--!strict -- Shared client relay lifecycles for temporary calls and persistent diagnostics. local Players = game:GetService("Players") local RunService = game:GetService("RunService") local Dispatch = require(script.Parent.Dispatch) local LogBuffer = require(script.Parent.LogBuffer) local ClientRelay = {} -- Long-lived diagnostics use the same LocalScript/RemoteEvent replication path -- as temporary client calls. The server owns the buffers and validates the -- sender; the client never needs HTTP access. local DIAGNOSTIC_SOURCE = [==[ local LogService = game:GetService("LogService") local ScriptContext = game:GetService("ScriptContext") local report = script:WaitForChild("Report", 10) if not report then return end local open = true local connections = {} local levels = { [Enum.MessageType.MessageOutput] = "print", [Enum.MessageType.MessageInfo] = "info", [Enum.MessageType.MessageWarning] = "warning", [Enum.MessageType.MessageError] = "error", } local function send(payload) if open and report.Parent then report:FireServer(payload) end end local HttpService = game:GetService("HttpService") table.insert(connections, LogService.MessageOut:Connect(function(message, kind, context) -- A structured log's context table, as JSON; plain prints pass an empty one. -- Values JSON has no shape for (Vector3, Instance) become text, not null. local function plain(value, depth) local kind = typeof(value) if kind == "string" or kind == "number" or kind == "boolean" then return value end if kind == "Instance" then return value:GetFullName() end if kind ~= "table" then return tostring(value) end if depth >= 4 then return "" end local copy = {} for key, item in value do copy[if type(key) == "number" then key else tostring(key)] = plain(item, depth + 1) end return copy end local encoded if type(context) == "table" and next(context) ~= nil then local ok, json = pcall(function() return HttpService:JSONEncode(plain(context, 0)) end) encoded = if ok then string.sub(json, 1, 1000) else nil end send({level = levels[kind] or "print", message = string.sub(message, 1, 2000), timestamp = os.time(), context = encoded}) end)) pcall(function() table.insert(connections, ScriptContext.Error:Connect(function(message, stack, source) local path if typeof(source) == "Instance" then local ok, full = pcall(function() return source:GetFullName() end) path = if ok then full else source.Name end send({kind = "trace", message = string.sub(message, 1, 2000), stack = string.sub(stack, 1, 4000), source = path}) end)) end) script.Destroying:Connect(function() open = false for _, connection in connections do connection:Disconnect() end end) send({kind = "ready"}) ]==] local diagnosticsStarted = false local diagnosticConnections: { [Player]: RBXScriptConnection } = {} local diagnosticRelays: { [Player]: LocalScript } = {} local diagnosticInstalling: { [Player]: boolean } = {} local characterConnections: { [Player]: RBXScriptConnection } = {} local playerAddedConnection: RBXScriptConnection? = nil local playerRemovingConnection: RBXScriptConnection? = nil local function installDiagnostics(player: Player) if diagnosticInstalling[player] or diagnosticConnections[player] then return end diagnosticInstalling[player] = true task.spawn(function() -- PlayerGui is server-visible (PlayerScripts is created on the client). local gui = player:FindFirstChildOfClass("PlayerGui") or player:WaitForChild("PlayerGui", 15) if not diagnosticsStarted or not gui or player.Parent ~= Players then diagnosticInstalling[player] = nil; return end local old = gui:FindFirstChild("MCPDiagnosticsRelay") if old then old:Destroy() end LogBuffer.startClient(player) local relay = Instance.new("LocalScript") local remote = Instance.new("RemoteEvent") local ok = pcall(function() relay.Name = "MCPDiagnosticsRelay" relay:SetAttribute("MCPClientRelay", true) relay.Source = DIAGNOSTIC_SOURCE remote.Name = "Report" remote.Parent = relay diagnosticConnections[player] = remote.OnServerEvent:Connect(function(from, payload) if from == player then LogBuffer.pushClient(player, payload) end end) relay.Parent = gui diagnosticRelays[player] = relay end) if not ok then if diagnosticConnections[player] then diagnosticConnections[player]:Disconnect() end diagnosticConnections[player] = nil relay:Destroy() LogBuffer.stopClient(player) end diagnosticInstalling[player] = nil if not characterConnections[player] then characterConnections[player] = player.CharacterAdded:Connect(function() task.delay(0.5, function() if not diagnosticsStarted or player.Parent ~= Players then return end local currentGui = player:FindFirstChildOfClass("PlayerGui") local current = diagnosticRelays[player] if current and current.Parent == currentGui then return end if diagnosticConnections[player] then diagnosticConnections[player]:Disconnect() end diagnosticConnections[player], diagnosticRelays[player] = nil, nil installDiagnostics(player) end) end) end end) end function ClientRelay.startDiagnostics() if diagnosticsStarted or not RunService:IsRunning() or not RunService:IsServer() then return end diagnosticsStarted = true playerAddedConnection = Players.PlayerAdded:Connect(installDiagnostics) playerRemovingConnection = Players.PlayerRemoving:Connect(function(player) if diagnosticConnections[player] then diagnosticConnections[player]:Disconnect() end diagnosticConnections[player] = nil if diagnosticRelays[player] then diagnosticRelays[player]:Destroy() end diagnosticRelays[player] = nil if characterConnections[player] then characterConnections[player]:Disconnect() end characterConnections[player] = nil LogBuffer.stopClient(player) end) for _, player in Players:GetPlayers() do installDiagnostics(player) end end function ClientRelay.stopDiagnostics() if not diagnosticsStarted then return end diagnosticsStarted = false if playerAddedConnection then playerAddedConnection:Disconnect() end if playerRemovingConnection then playerRemovingConnection:Disconnect() end playerAddedConnection, playerRemovingConnection = nil, nil for player, connection in diagnosticConnections do connection:Disconnect() if diagnosticRelays[player] then diagnosticRelays[player]:Destroy() end LogBuffer.stopClient(player) end for _, connection in characterConnections do connection:Disconnect() end table.clear(diagnosticConnections) table.clear(diagnosticRelays) table.clear(characterConnections) end function ClientRelay.playerFor(name: string?): Player if not RunService:IsRunning() then Dispatch.fail("NOT_RUNNING", "Client access needs a running playtest; this is an edit session.") end if not RunService:IsServer() then Dispatch.fail("WRONG_CONTEXT", "Address the playtest server's studioId for client access.") end local players = Players:GetPlayers() if #players == 0 then Dispatch.fail( "NO_PLAYER", "No player is in this session.", "Client access needs a running playtest. Use `playtest op=\"play\"`, " .. "then address this at the playtest's studioId." ) end if name == nil or name == "" then if #players > 1 then Dispatch.fail( "AMBIGUOUS_PLAYER", string.format("%d players are in this session.", #players), "Name one with `player`." ) end return players[1] end for _, player in players do if player.Name == name then return player end end Dispatch.fail("NO_PLAYER", string.format("No player named %q is in this session.", name)) return players[1] end function ClientRelay.run(player: Player, name: string, source: string, attributes: { [string]: any }, timeout: number, prepare: ((LocalScript) -> ())?, ready: (() -> ())?): { [string]: any } local gui = player:FindFirstChildOfClass("PlayerGui") if not gui then Dispatch.fail("NO_PLAYER", player.Name .. " has no PlayerGui yet.") end local relay = Instance.new("LocalScript") local connection: RBXScriptConnection? = nil local answer: any = nil local ok, failure = pcall(function() relay.Name = name relay:SetAttribute("MCPClientRelay", true) relay.Source = source for key, value in attributes do relay:SetAttribute(key, value) end local remote = Instance.new("RemoteEvent") remote.Name = "Report" remote.Parent = relay if prepare then prepare(relay) end local started = false connection = remote.OnServerEvent:Connect(function(from, payload) if from ~= player or answer ~= nil or typeof(payload) ~= "table" then return end -- A capture can wait until its client relay is ready before starting -- the server window. Existing single-response relays are unchanged. if ready and payload.ready == true then if started then return end started = true local prepared, reason = pcall(ready) if not prepared then answer = { ok = false, reason = tostring(reason) } return end remote:FireClient(player, true) else answer = payload end end) relay.Parent = gui local deadline = os.clock() + timeout while answer == nil and os.clock() < deadline and player.Parent == Players do task.wait(0.05) end end) if connection then connection:Disconnect() end relay:Destroy() if not ok then Dispatch.fail("CLIENT_FAILED", tostring(failure)) end if answer == nil then Dispatch.fail("NO_ACK", string.format("The client did not respond within %gs (or disconnected).", timeout), "The temporary relay was removed; code may have made changes before timing out.") end return answer end -- Read modules stay in this player's runtime only. Their Lua tables hold -- session handles and bounded result snapshots across calls, with no place -- attributes or editor writes. Respawning/replacing PlayerGui starts a new -- scope; old handles then fail explicitly. local readRoots: { [Player]: Folder } = setmetatable({}, { __mode = "k" }) :: any function ClientRelay.readRoot(player: Player): Folder local gui = player:FindFirstChildOfClass("PlayerGui") if not gui then Dispatch.fail("NO_PLAYER", player.Name .. " has no PlayerGui yet.") end local current = readRoots[player] if current and current.Parent == gui then return current end if current then current:Destroy() end local root = Instance.new("Folder") root.Name = "__rbxStudioMcpRead" root.Archivable = false root:SetAttribute("MCPClientRelay", true) for _, name in { "ClientRead", "Dispatch", "Handles", "Paths", "Scope", "Serialize", "LogBuffer", "ExecRuntime", "Results", "ViewRead", "ScreenRegion", "PerfSnapshot", "Watch", "Collisions" } do local copy = script.Parent[name]:Clone() copy.Parent = root end local handlers = Instance.new("Folder") handlers.Name = "Handlers" handlers.Parent = root script.Parent.handlers.Discover:Clone().Parent = handlers root.Parent = gui readRoots[player] = root return root end function ClientRelay.attachRuntime(relay: LocalScript, player: Player) local reference = Instance.new("ObjectValue") reference.Name = "Runtime" reference.Value = ClientRelay.readRoot(player).ClientRead reference.Parent = relay end function ClientRelay.read(params: { [string]: any }): { [string]: any } local player = ClientRelay.playerFor(params.player) local arguments = params.params or {} local seconds = if params.op == "debug.watch" or params.op == "debug.collisions" then math.clamp(tonumber(arguments.seconds) or 5, 1, 15) else 0 local result = ClientRelay.run(player, "MCPReadRelay", [==[ local report = script:WaitForChild("Report", 10) if not report then return end local ok, data = pcall(function() local reference = script:WaitForChild("Runtime", 10) assert(reference, "Runtime reference did not replicate") local deadline = os.clock() + 10 while not reference.Value and os.clock() < deadline do task.wait() end assert(reference.Value, "Runtime module did not replicate") local Reader = require(reference.Value) local arguments = game:GetService("HttpService"):JSONDecode(script:GetAttribute("Arguments")) return Reader.call(script:GetAttribute("Operation"), arguments) end) report:FireServer(if ok then {ok = true, data = data} else {ok = false, failure = if typeof(data) == "table" then data else nil, reason = tostring(data)}) ]==], { Operation = params.op, Arguments = game:GetService("HttpService"):JSONEncode(arguments) }, seconds + 25, function(relay) ClientRelay.attachRuntime(relay, player) end) if not result.ok then local failure = result.failure if typeof(failure) == "table" and typeof(failure.code) == "string" then Dispatch.fail(failure.code, tostring(failure.message), failure.hint) end Dispatch.fail("CLIENT_READ_FAILED", tostring(result.reason)) end local data = result.data if typeof(data) ~= "table" then Dispatch.fail("CLIENT_READ_FAILED", "The client returned an invalid read response.") end data.context = "client" data.player = player.Name return data end function ClientRelay.register() Dispatch.register("client.read", ClientRelay.read) end function ClientRelay.stopReads() for player, root in readRoots do root:Destroy() readRoots[player] = nil end end return ClientRelay