@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/package.json CHANGED
@@ -1,75 +1,75 @@
1
- {
2
- "name": "@el4cteo/rbx-studio-mcp",
3
- "version": "0.7.2",
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
@@ -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]",
@@ -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.2"
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
@@ -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,