@el4cteo/rbx-studio-mcp 0.7.2 → 0.7.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bridge/console.js +28 -0
- package/dist/bridge/console.js.map +1 -1
- package/dist/bridge/remote.js +9 -3
- package/dist/bridge/remote.js.map +1 -1
- package/dist/bridge/rpc.js +2 -1
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/lib/timeout.js +13 -0
- package/dist/lib/timeout.js.map +1 -0
- package/dist/tools/debug.js +15 -4
- package/dist/tools/debug.js.map +1 -1
- package/dist/tools/exec.js +13 -6
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/input.js +8 -1
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/playtest.js +11 -1
- package/dist/tools/playtest.js.map +1 -1
- package/package.json +75 -75
- package/plugin/src/ClientRelay.luau +88 -0
- package/plugin/src/Commands.luau +26 -0
- package/plugin/src/Config.luau +65 -65
- package/plugin/src/ExecRuntime.luau +161 -0
- package/plugin/src/Phrase.luau +3 -0
- package/plugin/src/RemoteTrace.luau +143 -0
- package/plugin/src/handlers/Debug.luau +574 -504
- package/plugin/src/handlers/Exec.luau +36 -161
- package/plugin/src/handlers/Input.luau +54 -75
- package/plugin/src/handlers/Playtest.luau +245 -205
- package/plugin/src/init.server.luau +946 -939
- package/scripts/test-bridge.mjs +37 -0
- package/scripts/test-console.mjs +25 -0
- package/scripts/test-failover.mjs +69 -0
- package/scripts/test-live.mjs +283 -115
- package/scripts/test-plugin.mjs +103 -85
- package/scripts/test-tools.mjs +162 -39
package/package.json
CHANGED
|
@@ -1,75 +1,75 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@el4cteo/rbx-studio-mcp",
|
|
3
|
-
"version": "0.7.
|
|
4
|
-
"description": "MCP server for Roblox Studio. 35 tools, push-based SSE bridge, editor-safe script edits, one-step undo.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"license": "MIT",
|
|
7
|
-
"bin": {
|
|
8
|
-
"rbx-studio-mcp": "dist/index.js"
|
|
9
|
-
},
|
|
10
|
-
"files": [
|
|
11
|
-
"dist",
|
|
12
|
-
"plugin",
|
|
13
|
-
"scripts",
|
|
14
|
-
"config/dsh.cordis.yml",
|
|
15
|
-
"README.md",
|
|
16
|
-
"LICENSE"
|
|
17
|
-
],
|
|
18
|
-
"dsh": {
|
|
19
|
-
"bundle": {
|
|
20
|
-
"patch": "./config/dsh.cordis.yml"
|
|
21
|
-
}
|
|
22
|
-
},
|
|
23
|
-
"engines": {
|
|
24
|
-
"node": ">=22.15"
|
|
25
|
-
},
|
|
26
|
-
"scripts": {
|
|
27
|
-
"build": "tsc",
|
|
28
|
-
"watch": "tsc --watch",
|
|
29
|
-
"typecheck": "tsc --noEmit",
|
|
30
|
-
"start": "node dist/index.js",
|
|
31
|
-
"build:plugin": "node scripts/check-plugin.mjs && node scripts/build-plugin.mjs",
|
|
32
|
-
"sourcemap": "node scripts/sourcemap.mjs",
|
|
33
|
-
"test:live": "node scripts/test-live.mjs",
|
|
34
|
-
"test": "node scripts/test-plugin.mjs && node scripts/test-transport.mjs && node scripts/test-server.mjs && node scripts/test-bridge.mjs && node scripts/test-console.mjs && node scripts/test-failover.mjs && node scripts/test-tools.mjs",
|
|
35
|
-
"build:all": "npm run build && npm run build:plugin",
|
|
36
|
-
"install:plugin": "node scripts/install-plugin.mjs",
|
|
37
|
-
"check:plugin": "node scripts/check-plugin.mjs",
|
|
38
|
-
"doctor": "node dist/index.js doctor",
|
|
39
|
-
"prepack": "npm run build"
|
|
40
|
-
},
|
|
41
|
-
"keywords": [
|
|
42
|
-
"mcp",
|
|
43
|
-
"mcp-server",
|
|
44
|
-
"model-context-protocol",
|
|
45
|
-
"roblox",
|
|
46
|
-
"roblox-studio",
|
|
47
|
-
"roblox-development",
|
|
48
|
-
"luau",
|
|
49
|
-
"claude",
|
|
50
|
-
"claude-code",
|
|
51
|
-
"cursor",
|
|
52
|
-
"ai",
|
|
53
|
-
"ai-agent"
|
|
54
|
-
],
|
|
55
|
-
"dependencies": {
|
|
56
|
-
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
57
|
-
"zod": "^4.6.5"
|
|
58
|
-
},
|
|
59
|
-
"devDependencies": {
|
|
60
|
-
"@types/node": "^26.6.2",
|
|
61
|
-
"typescript": "^7.0.2"
|
|
62
|
-
},
|
|
63
|
-
"author": "EL4CTEO",
|
|
64
|
-
"repository": {
|
|
65
|
-
"type": "git",
|
|
66
|
-
"url": "git+https://github.com/EL4CTEO/rbx-studio-mcp.git"
|
|
67
|
-
},
|
|
68
|
-
"homepage": "https://github.com/EL4CTEO/rbx-studio-mcp#readme",
|
|
69
|
-
"bugs": {
|
|
70
|
-
"url": "https://github.com/EL4CTEO/rbx-studio-mcp/issues"
|
|
71
|
-
},
|
|
72
|
-
"publishConfig": {
|
|
73
|
-
"access": "public"
|
|
74
|
-
}
|
|
75
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@el4cteo/rbx-studio-mcp",
|
|
3
|
+
"version": "0.7.6",
|
|
4
|
+
"description": "MCP server for Roblox Studio. 35 tools, push-based SSE bridge, editor-safe script edits, one-step undo.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"rbx-studio-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"plugin",
|
|
13
|
+
"scripts",
|
|
14
|
+
"config/dsh.cordis.yml",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"dsh": {
|
|
19
|
+
"bundle": {
|
|
20
|
+
"patch": "./config/dsh.cordis.yml"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=22.15"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc",
|
|
28
|
+
"watch": "tsc --watch",
|
|
29
|
+
"typecheck": "tsc --noEmit",
|
|
30
|
+
"start": "node dist/index.js",
|
|
31
|
+
"build:plugin": "node scripts/check-plugin.mjs && node scripts/build-plugin.mjs",
|
|
32
|
+
"sourcemap": "node scripts/sourcemap.mjs",
|
|
33
|
+
"test:live": "node scripts/test-live.mjs",
|
|
34
|
+
"test": "node scripts/test-plugin.mjs && node scripts/test-transport.mjs && node scripts/test-server.mjs && node scripts/test-bridge.mjs && node scripts/test-console.mjs && node scripts/test-failover.mjs && node scripts/test-tools.mjs",
|
|
35
|
+
"build:all": "npm run build && npm run build:plugin",
|
|
36
|
+
"install:plugin": "node scripts/install-plugin.mjs",
|
|
37
|
+
"check:plugin": "node scripts/check-plugin.mjs",
|
|
38
|
+
"doctor": "node dist/index.js doctor",
|
|
39
|
+
"prepack": "npm run build"
|
|
40
|
+
},
|
|
41
|
+
"keywords": [
|
|
42
|
+
"mcp",
|
|
43
|
+
"mcp-server",
|
|
44
|
+
"model-context-protocol",
|
|
45
|
+
"roblox",
|
|
46
|
+
"roblox-studio",
|
|
47
|
+
"roblox-development",
|
|
48
|
+
"luau",
|
|
49
|
+
"claude",
|
|
50
|
+
"claude-code",
|
|
51
|
+
"cursor",
|
|
52
|
+
"ai",
|
|
53
|
+
"ai-agent"
|
|
54
|
+
],
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
57
|
+
"zod": "^4.6.5"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@types/node": "^26.6.2",
|
|
61
|
+
"typescript": "^7.0.2"
|
|
62
|
+
},
|
|
63
|
+
"author": "EL4CTEO",
|
|
64
|
+
"repository": {
|
|
65
|
+
"type": "git",
|
|
66
|
+
"url": "git+https://github.com/EL4CTEO/rbx-studio-mcp.git"
|
|
67
|
+
},
|
|
68
|
+
"homepage": "https://github.com/EL4CTEO/rbx-studio-mcp#readme",
|
|
69
|
+
"bugs": {
|
|
70
|
+
"url": "https://github.com/EL4CTEO/rbx-studio-mcp/issues"
|
|
71
|
+
},
|
|
72
|
+
"publishConfig": {
|
|
73
|
+
"access": "public"
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
-- One temporary client relay lifecycle for input and execution.
|
|
3
|
+
local Players = game:GetService("Players")
|
|
4
|
+
local RunService = game:GetService("RunService")
|
|
5
|
+
local Dispatch = require(script.Parent.Dispatch)
|
|
6
|
+
local ClientRelay = {}
|
|
7
|
+
function ClientRelay.playerFor(name: string?): Player
|
|
8
|
+
if not RunService:IsRunning() then
|
|
9
|
+
Dispatch.fail("NOT_RUNNING", "Client access needs a running playtest; this is an edit session.")
|
|
10
|
+
end
|
|
11
|
+
if not RunService:IsServer() then
|
|
12
|
+
Dispatch.fail("WRONG_CONTEXT", "Address the playtest server's studioId for client access.")
|
|
13
|
+
end
|
|
14
|
+
local players = Players:GetPlayers()
|
|
15
|
+
if #players == 0 then
|
|
16
|
+
Dispatch.fail(
|
|
17
|
+
"NO_PLAYER",
|
|
18
|
+
"No player is in this session.",
|
|
19
|
+
"Client access needs a running playtest. Use `playtest op=\"play\"`, "
|
|
20
|
+
.. "then address this at the playtest's studioId."
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
if name == nil or name == "" then
|
|
24
|
+
if #players > 1 then
|
|
25
|
+
Dispatch.fail(
|
|
26
|
+
"AMBIGUOUS_PLAYER",
|
|
27
|
+
string.format("%d players are in this session.", #players),
|
|
28
|
+
"Name one with `player`."
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
return players[1]
|
|
32
|
+
end
|
|
33
|
+
for _, player in players do
|
|
34
|
+
if player.Name == name then
|
|
35
|
+
return player
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
Dispatch.fail("NO_PLAYER", string.format("No player named %q is in this session.", name))
|
|
39
|
+
return players[1]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
function ClientRelay.run(player: Player, name: string, source: string, attributes: { [string]: any }, timeout: number, prepare: ((LocalScript) -> ())?, ready: (() -> ())?): { [string]: any }
|
|
44
|
+
local gui = player:FindFirstChildOfClass("PlayerGui")
|
|
45
|
+
if not gui then Dispatch.fail("NO_PLAYER", player.Name .. " has no PlayerGui yet.") end
|
|
46
|
+
local relay = Instance.new("LocalScript")
|
|
47
|
+
local connection: RBXScriptConnection? = nil
|
|
48
|
+
local answer: any = nil
|
|
49
|
+
local ok, failure = pcall(function()
|
|
50
|
+
relay.Name = name
|
|
51
|
+
relay:SetAttribute("MCPClientRelay", true)
|
|
52
|
+
relay.Source = source
|
|
53
|
+
for key, value in attributes do relay:SetAttribute(key, value) end
|
|
54
|
+
local remote = Instance.new("RemoteEvent")
|
|
55
|
+
remote.Name = "Report"
|
|
56
|
+
remote.Parent = relay
|
|
57
|
+
if prepare then prepare(relay) end
|
|
58
|
+
local started = false
|
|
59
|
+
connection = remote.OnServerEvent:Connect(function(from, payload)
|
|
60
|
+
if from ~= player or answer ~= nil or typeof(payload) ~= "table" then return end
|
|
61
|
+
-- A capture can wait until its client relay is ready before starting
|
|
62
|
+
-- the server window. Existing single-response relays are unchanged.
|
|
63
|
+
if ready and payload.ready == true then
|
|
64
|
+
if started then return end
|
|
65
|
+
started = true
|
|
66
|
+
local prepared, reason = pcall(ready)
|
|
67
|
+
if not prepared then
|
|
68
|
+
answer = { ok = false, reason = tostring(reason) }
|
|
69
|
+
return
|
|
70
|
+
end
|
|
71
|
+
remote:FireClient(player, true)
|
|
72
|
+
else
|
|
73
|
+
answer = payload
|
|
74
|
+
end
|
|
75
|
+
end)
|
|
76
|
+
relay.Parent = gui
|
|
77
|
+
local deadline = os.clock() + timeout
|
|
78
|
+
while answer == nil and os.clock() < deadline and player.Parent == Players do task.wait(0.05) end
|
|
79
|
+
end)
|
|
80
|
+
if connection then connection:Disconnect() end
|
|
81
|
+
relay:Destroy()
|
|
82
|
+
if not ok then Dispatch.fail("CLIENT_FAILED", tostring(failure)) end
|
|
83
|
+
if answer == nil then
|
|
84
|
+
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.")
|
|
85
|
+
end
|
|
86
|
+
return answer
|
|
87
|
+
end
|
|
88
|
+
return ClientRelay
|
package/plugin/src/Commands.luau
CHANGED
|
@@ -22,9 +22,11 @@
|
|
|
22
22
|
|
|
23
23
|
local ScriptEditorService = game:GetService("ScriptEditorService")
|
|
24
24
|
local Selection = game:GetService("Selection")
|
|
25
|
+
local RunService = game:GetService("RunService")
|
|
25
26
|
local ServerStorage = game:GetService("ServerStorage")
|
|
26
27
|
|
|
27
28
|
local Config = require(script.Parent.Config)
|
|
29
|
+
local Playtest = require(script.Parent.handlers.Playtest)
|
|
28
30
|
local Console = require(script.Parent.Console)
|
|
29
31
|
local Net = require(script.Parent.Net)
|
|
30
32
|
local Secret = require(script.Parent.Secret)
|
|
@@ -251,6 +253,30 @@ define({
|
|
|
251
253
|
end,
|
|
252
254
|
})
|
|
253
255
|
|
|
256
|
+
define({
|
|
257
|
+
name = "playtests",
|
|
258
|
+
usage = "playtests [on|off]",
|
|
259
|
+
summary = "allow or block MCP playtests; ON permits, OFF enforces",
|
|
260
|
+
run = function(args)
|
|
261
|
+
local wanted = args[1]
|
|
262
|
+
if #args > 1 or (wanted ~= nil and wanted ~= "on" and wanted ~= "off") then
|
|
263
|
+
Console.log("error", "usage: playtests [on|off]")
|
|
264
|
+
return
|
|
265
|
+
end
|
|
266
|
+
if wanted ~= nil then Playtest.setAllowed(wanted == "on") end
|
|
267
|
+
Console.log("info", if Playtest.isAllowed() then "Playtests: ON" else "Playtests: OFF")
|
|
268
|
+
if wanted == "off" then
|
|
269
|
+
-- Persist the lock BEFORE stopping, because stopping can destroy this VM.
|
|
270
|
+
-- The bridge sees other connected test sessions; the client view cannot
|
|
271
|
+
-- make HTTP calls and stops its own session locally instead.
|
|
272
|
+
if not (RunService:IsClient() and not RunService:IsServer()) then
|
|
273
|
+
task.spawn(function() remote("playtests", { "off" }, "playtests off") end)
|
|
274
|
+
end
|
|
275
|
+
Playtest.stopLocal()
|
|
276
|
+
end
|
|
277
|
+
end,
|
|
278
|
+
})
|
|
279
|
+
|
|
254
280
|
define({
|
|
255
281
|
name = "autoopen",
|
|
256
282
|
usage = "autoopen [on|off]",
|
package/plugin/src/Config.luau
CHANGED
|
@@ -1,65 +1,65 @@
|
|
|
1
|
-
--!strict
|
|
2
|
-
--[[
|
|
3
|
-
Connection settings for the Studio MCP plugin.
|
|
4
|
-
|
|
5
|
-
The port must match the one the Node server was started with (`--port`, or
|
|
6
|
-
the ROBLOX_STUDIO_MCP_PORT environment variable). It is stored as a plugin
|
|
7
|
-
setting so a user running on a non-default port only has to set it once.
|
|
8
|
-
]]
|
|
9
|
-
|
|
10
|
-
local Config = {}
|
|
11
|
-
|
|
12
|
-
Config.PLUGIN_VERSION = "0.7.
|
|
13
|
-
|
|
14
|
-
-- Fingerprint of plugin/src, stamped in by scripts/build-plugin.mjs. The server
|
|
15
|
-
-- computes the same hash from its own copy of the sources and compares, so a
|
|
16
|
-
-- plugin left running from an older build is reported rather than silently
|
|
17
|
-
-- answering with stale handlers. Stays "dev" when the tree is loaded unbuilt.
|
|
18
|
-
Config.BUILD_ID = "dev"
|
|
19
|
-
Config.PROTOCOL_VERSION = 1
|
|
20
|
-
Config.DEFAULT_PORT = 44755
|
|
21
|
-
|
|
22
|
-
-- Header the bridge requires on every request. A web page cannot set it
|
|
23
|
-
-- cross-origin without a preflight the bridge never answers, which is what
|
|
24
|
-
-- keeps a malicious site from driving Studio through the loopback port.
|
|
25
|
-
Config.CLIENT_HEADER = "x-roblox-studio-mcp"
|
|
26
|
-
|
|
27
|
-
-- How long to wait before retrying after the stream drops. Studio closes SSE
|
|
28
|
-
-- connections at the 30 minute mark, so reconnects are routine, not exceptional.
|
|
29
|
-
Config.RECONNECT_DELAY = 1
|
|
30
|
-
Config.MAX_RECONNECT_DELAY = 30
|
|
31
|
-
|
|
32
|
-
-- How long a connection has to last before it counts as having worked, rather
|
|
33
|
-
-- than as one more failure to back off from. Comfortably longer than a failed
|
|
34
|
-
-- handshake against a dead port, which resolves in milliseconds, and far shorter
|
|
35
|
-
-- than any connection that carried a single command.
|
|
36
|
-
Config.HEALTHY_CONNECTION = 10
|
|
37
|
-
|
|
38
|
-
-- How long a healthy long-poll session runs before standing aside so streaming
|
|
39
|
-
-- can be tried again. Long enough that a Studio which genuinely cannot stream
|
|
40
|
-
-- is not reconnecting constantly, short enough that a session downgraded by one
|
|
41
|
-
-- unlucky attempt does not spend the rest of its life slower than it should be.
|
|
42
|
-
Config.RESTREAM_INTERVAL = 60
|
|
43
|
-
|
|
44
|
-
local port: number? = nil
|
|
45
|
-
|
|
46
|
-
function Config.setPort(value: number)
|
|
47
|
-
port = value
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
function Config.getPort(): number
|
|
51
|
-
return port or Config.DEFAULT_PORT
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
function Config.baseUrl(): string
|
|
55
|
-
return string.format("http://127.0.0.1:%d", Config.getPort())
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
function Config.headers(): { [string]: string }
|
|
59
|
-
return {
|
|
60
|
-
[Config.CLIENT_HEADER] = tostring(Config.PROTOCOL_VERSION),
|
|
61
|
-
["Content-Type"] = "application/json",
|
|
62
|
-
}
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
return Config
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Connection settings for the Studio MCP plugin.
|
|
4
|
+
|
|
5
|
+
The port must match the one the Node server was started with (`--port`, or
|
|
6
|
+
the ROBLOX_STUDIO_MCP_PORT environment variable). It is stored as a plugin
|
|
7
|
+
setting so a user running on a non-default port only has to set it once.
|
|
8
|
+
]]
|
|
9
|
+
|
|
10
|
+
local Config = {}
|
|
11
|
+
|
|
12
|
+
Config.PLUGIN_VERSION = "0.7.6"
|
|
13
|
+
|
|
14
|
+
-- Fingerprint of plugin/src, stamped in by scripts/build-plugin.mjs. The server
|
|
15
|
+
-- computes the same hash from its own copy of the sources and compares, so a
|
|
16
|
+
-- plugin left running from an older build is reported rather than silently
|
|
17
|
+
-- answering with stale handlers. Stays "dev" when the tree is loaded unbuilt.
|
|
18
|
+
Config.BUILD_ID = "dev"
|
|
19
|
+
Config.PROTOCOL_VERSION = 1
|
|
20
|
+
Config.DEFAULT_PORT = 44755
|
|
21
|
+
|
|
22
|
+
-- Header the bridge requires on every request. A web page cannot set it
|
|
23
|
+
-- cross-origin without a preflight the bridge never answers, which is what
|
|
24
|
+
-- keeps a malicious site from driving Studio through the loopback port.
|
|
25
|
+
Config.CLIENT_HEADER = "x-roblox-studio-mcp"
|
|
26
|
+
|
|
27
|
+
-- How long to wait before retrying after the stream drops. Studio closes SSE
|
|
28
|
+
-- connections at the 30 minute mark, so reconnects are routine, not exceptional.
|
|
29
|
+
Config.RECONNECT_DELAY = 1
|
|
30
|
+
Config.MAX_RECONNECT_DELAY = 30
|
|
31
|
+
|
|
32
|
+
-- How long a connection has to last before it counts as having worked, rather
|
|
33
|
+
-- than as one more failure to back off from. Comfortably longer than a failed
|
|
34
|
+
-- handshake against a dead port, which resolves in milliseconds, and far shorter
|
|
35
|
+
-- than any connection that carried a single command.
|
|
36
|
+
Config.HEALTHY_CONNECTION = 10
|
|
37
|
+
|
|
38
|
+
-- How long a healthy long-poll session runs before standing aside so streaming
|
|
39
|
+
-- can be tried again. Long enough that a Studio which genuinely cannot stream
|
|
40
|
+
-- is not reconnecting constantly, short enough that a session downgraded by one
|
|
41
|
+
-- unlucky attempt does not spend the rest of its life slower than it should be.
|
|
42
|
+
Config.RESTREAM_INTERVAL = 60
|
|
43
|
+
|
|
44
|
+
local port: number? = nil
|
|
45
|
+
|
|
46
|
+
function Config.setPort(value: number)
|
|
47
|
+
port = value
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
function Config.getPort(): number
|
|
51
|
+
return port or Config.DEFAULT_PORT
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
function Config.baseUrl(): string
|
|
55
|
+
return string.format("http://127.0.0.1:%d", Config.getPort())
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
function Config.headers(): { [string]: string }
|
|
59
|
+
return {
|
|
60
|
+
[Config.CLIENT_HEADER] = tostring(Config.PROTOCOL_VERSION),
|
|
61
|
+
["Content-Type"] = "application/json",
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
return Config
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
-- Shared bounded execution results in the VM that calls run.
|
|
3
|
+
local LogService = game:GetService("LogService")
|
|
4
|
+
local Serialize = require(script.Parent.Serialize)
|
|
5
|
+
-- A chunk that prints in a loop would otherwise return a response no context
|
|
6
|
+
-- window can hold.
|
|
7
|
+
local MAX_OUTPUT_LINES = 200
|
|
8
|
+
local MAX_RETURN_VALUES = 10
|
|
9
|
+
|
|
10
|
+
-- Returned tables are walked rather than counted, but not without limit: a
|
|
11
|
+
-- chunk that returns a whole config tree, or one with a cycle in it, must not be
|
|
12
|
+
-- able to produce a response nothing can read.
|
|
13
|
+
local MAX_TABLE_ENTRIES = 50
|
|
14
|
+
local MAX_TABLE_DEPTH = 4
|
|
15
|
+
|
|
16
|
+
local function describe(value: any, depth: number?, seen: { [any]: boolean }?): any
|
|
17
|
+
local level = depth or 0
|
|
18
|
+
local visited = seen or {}
|
|
19
|
+
local kind = typeof(value)
|
|
20
|
+
|
|
21
|
+
if kind == "function" or kind == "thread" then
|
|
22
|
+
return string.format("<%s>", kind)
|
|
23
|
+
end
|
|
24
|
+
if kind ~= "table" then
|
|
25
|
+
return Serialize.value(value)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
local source = value :: { [any]: any }
|
|
29
|
+
-- Named separately from the depth cap, because they mean opposite things to
|
|
30
|
+
-- a reader. The depth cap says "there is more below this"; a cycle says
|
|
31
|
+
-- "this is the table you are already inside". Reporting the second as the
|
|
32
|
+
-- first printed `t.self.self.self` as three distinct nested tables, which
|
|
33
|
+
-- reads as real structure that does not exist.
|
|
34
|
+
if visited[source] then
|
|
35
|
+
return "<circular reference>"
|
|
36
|
+
end
|
|
37
|
+
if level >= MAX_TABLE_DEPTH then
|
|
38
|
+
return "<nested table>"
|
|
39
|
+
end
|
|
40
|
+
visited[source] = true
|
|
41
|
+
|
|
42
|
+
local count = 0
|
|
43
|
+
for _ in source do
|
|
44
|
+
count += 1
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
-- Arrays keep their shape so they read as lists on the other side; anything
|
|
48
|
+
-- with non-sequential keys becomes a map with stringified keys.
|
|
49
|
+
if count == #source then
|
|
50
|
+
local list: { any } = {}
|
|
51
|
+
for index, item in ipairs(source) do
|
|
52
|
+
if index > MAX_TABLE_ENTRIES then
|
|
53
|
+
table.insert(list, string.format("<%d more>", count - MAX_TABLE_ENTRIES))
|
|
54
|
+
break
|
|
55
|
+
end
|
|
56
|
+
table.insert(list, describe(item, level + 1, visited))
|
|
57
|
+
end
|
|
58
|
+
-- Cleared on the way out so the same table appearing twice side by side
|
|
59
|
+
-- still renders twice; only a table containing itself is a cycle.
|
|
60
|
+
visited[source] = nil
|
|
61
|
+
return list
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
local map: { [string]: any } = {}
|
|
65
|
+
local shown = 0
|
|
66
|
+
for key, item in source do
|
|
67
|
+
shown += 1
|
|
68
|
+
if shown > MAX_TABLE_ENTRIES then
|
|
69
|
+
map["..."] = string.format("<%d more>", count - MAX_TABLE_ENTRIES)
|
|
70
|
+
break
|
|
71
|
+
end
|
|
72
|
+
map[tostring(key)] = describe(item, level + 1, visited)
|
|
73
|
+
end
|
|
74
|
+
visited[source] = nil
|
|
75
|
+
return map
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
local Runtime = {}
|
|
79
|
+
Runtime.describe = describe
|
|
80
|
+
function Runtime.run(chunk: () -> ...any, timeout: number?): { [string]: any }
|
|
81
|
+
-- Captured rather than inferred: a chunk's prints are usually the point, and
|
|
82
|
+
-- reading them back from the log afterwards would also pick up whatever else
|
|
83
|
+
-- the place logged in the meantime.
|
|
84
|
+
local output: { { [string]: any } } = {}
|
|
85
|
+
local connection = LogService.MessageOut:Connect(function(message, messageType)
|
|
86
|
+
if #output < MAX_OUTPUT_LINES then
|
|
87
|
+
table.insert(output, {
|
|
88
|
+
message = message,
|
|
89
|
+
level = if messageType == Enum.MessageType.MessageError
|
|
90
|
+
then "error"
|
|
91
|
+
elseif messageType == Enum.MessageType.MessageWarning then "warning"
|
|
92
|
+
else "print",
|
|
93
|
+
})
|
|
94
|
+
end
|
|
95
|
+
end)
|
|
96
|
+
|
|
97
|
+
local started = os.clock()
|
|
98
|
+
local results: any = nil
|
|
99
|
+
if timeout then
|
|
100
|
+
local worker = task.spawn(function() results = table.pack(pcall(chunk :: any)) end)
|
|
101
|
+
local deadline = started + timeout
|
|
102
|
+
while results == nil and os.clock() < deadline do task.wait() end
|
|
103
|
+
if results == nil then
|
|
104
|
+
task.cancel(worker)
|
|
105
|
+
results = table.pack(false, "CLIENT_TIMEOUT: execution exceeded " .. tostring(timeout) .. "s")
|
|
106
|
+
end
|
|
107
|
+
else
|
|
108
|
+
results = table.pack(pcall(chunk :: () -> ...any))
|
|
109
|
+
end
|
|
110
|
+
local elapsed = os.clock() - started
|
|
111
|
+
|
|
112
|
+
--[[
|
|
113
|
+
MessageOut is deferred: `print` returns before the event fires, so
|
|
114
|
+
disconnecting straight after the chunk finished captured nothing at all.
|
|
115
|
+
A short settle picks the lines up.
|
|
116
|
+
|
|
117
|
+
It is bounded and stops as soon as the flow stops, rather than waiting a
|
|
118
|
+
fixed period, so a chunk that logged nothing costs almost no time and one
|
|
119
|
+
that logged plenty is not truncated.
|
|
120
|
+
]]
|
|
121
|
+
local settleDeadline = os.clock() + 0.5
|
|
122
|
+
local seen = #output
|
|
123
|
+
local quietFrames = 0
|
|
124
|
+
while os.clock() < settleDeadline and quietFrames < 3 do
|
|
125
|
+
task.wait()
|
|
126
|
+
if #output == seen then
|
|
127
|
+
quietFrames += 1
|
|
128
|
+
else
|
|
129
|
+
seen = #output
|
|
130
|
+
quietFrames = 0
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
connection:Disconnect()
|
|
135
|
+
|
|
136
|
+
if not results[1] then
|
|
137
|
+
return {
|
|
138
|
+
ok = false,
|
|
139
|
+
error = tostring(results[2]),
|
|
140
|
+
output = output,
|
|
141
|
+
milliseconds = math.floor(elapsed * 1000 + 0.5),
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
local returned: { any } = {}
|
|
146
|
+
for index = 2, math.min(results.n, MAX_RETURN_VALUES + 1) do
|
|
147
|
+
-- A nil return is written out rather than dropped. `table.insert` of nil
|
|
148
|
+
-- does nothing, so `return a, nil, b` came back as two values and every
|
|
149
|
+
-- one after the nil shifted into the wrong position.
|
|
150
|
+
local value = results[index]
|
|
151
|
+
table.insert(returned, if value == nil then "nil" else describe(value))
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
ok = true,
|
|
156
|
+
returned = returned,
|
|
157
|
+
output = output,
|
|
158
|
+
milliseconds = math.floor(elapsed * 1000 + 0.5),
|
|
159
|
+
}
|
|
160
|
+
end
|
|
161
|
+
return Runtime
|
package/plugin/src/Phrase.luau
CHANGED
|
@@ -288,6 +288,9 @@ local DESCRIBERS: { [string]: Describer } = {
|
|
|
288
288
|
local name = leaf(params.path)
|
|
289
289
|
return if name ~= nil then "Clear breakpoints in " .. name else "Clear all breakpoints"
|
|
290
290
|
end,
|
|
291
|
+
["debug.remotes"] = function()
|
|
292
|
+
return "Observe playtest RemoteEvent traffic"
|
|
293
|
+
end,
|
|
291
294
|
["debug.snapshots"] = function()
|
|
292
295
|
return "Read what the breakpoints caught"
|
|
293
296
|
end,
|