@lolofuk123/rbx-mcp 0.2.0

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/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # rbx-mcp (server)
2
+
3
+ The Node/TS package published to npm as **`@lolofuk123/rbx-mcp`**. One process, two faces:
4
+
5
+ - **MCP side** (stdio) — exposes `execute_lua`, `read_studio_state`, `get_errors`
6
+ to Claude.
7
+ - **HTTP side** (`127.0.0.1:30700`) — the Studio plugin long-polls it; correlates
8
+ results back to the awaiting tool call.
9
+
10
+ On startup it also **auto-installs** the bundled Studio plugin into the local
11
+ Plugins folder. See the repo [`README`](../README.md) for the big picture.
12
+
13
+ ## Use with Claude
14
+
15
+ ```json
16
+ {
17
+ "mcpServers": {
18
+ "rbx-mcp": { "command": "npx", "args": ["-y", "@lolofuk123/rbx-mcp"] }
19
+ }
20
+ }
21
+ ```
22
+
23
+ Claude launches the process; you never run a `start` command. Then start/restart
24
+ Studio so it loads the auto-installed plugin.
25
+
26
+ ## Environment variables
27
+
28
+ | Var | Default | Meaning |
29
+ |-----|---------|---------|
30
+ | `RBXMCP_HOST` | `127.0.0.1` | Bind/connect host (loopback). |
31
+ | `RBXMCP_PORT` | `30700` | HTTP port (must match the plugin's Port field). |
32
+ | `RBXMCP_TOKEN` | _(unset)_ | Shared secret; if set, the plugin must send it (`X-RbxMcp-Token`). |
33
+ | `RBXMCP_POLL_HOLD_MS` | `25000` | Long-poll hold window. |
34
+ | `RBXMCP_CMD_TIMEOUT_MS` | `30000` | Default per-command execution budget. |
35
+ | `RBXMCP_MAX_RESULT_BYTES` | `1048576` | Max result body size. |
36
+ | `RBXMCP_MAX_QUEUE_DEPTH` | `16` | Reject new work past this (fail-fast "busy"). |
37
+ | `RBXMCP_AUTOINSTALL` | `on` | Set `off` to skip writing the plugin into the Plugins folder. |
38
+ | `RBXMCP_LOG` | `info` | `error` \| `warn` \| `info` \| `debug` (stderr only). |
39
+ | `RBXMCP_DEV` | _(unset)_ | `1` enables the dev-only `POST /v1/_dev/enqueue` route. |
40
+
41
+ ## Develop
42
+
43
+ ```bash
44
+ npm install
45
+ npm test # vitest (52 tests)
46
+ npm run typecheck
47
+ npm run build # bundle plugin -> assets/, then tsc -> dist/
48
+ ```
49
+
50
+ ### Prove the pipeline without Studio (Milestone A)
51
+
52
+ Terminal 1 — run the server with the dev route enabled:
53
+
54
+ ```bash
55
+ RBXMCP_DEV=1 RBXMCP_AUTOINSTALL=off npm run dev
56
+ ```
57
+
58
+ Terminal 2 — enqueue a command (a real plugin, or a fake poller, answers it):
59
+
60
+ ```bash
61
+ npm run dev-enqueue -- "return 1 + 1"
62
+ ```
63
+
64
+ `GET http://127.0.0.1:30700/v1/health` shows live status (`pluginConnected`,
65
+ `queueDepth`, …).
66
+
67
+ ## Manual plugin install
68
+
69
+ Auto-install covers Windows and macOS. To install by hand (or with
70
+ `RBXMCP_AUTOINSTALL=off`), copy [`../plugin/src/rbx-mcp.server.luau`](../plugin/src/rbx-mcp.server.luau)
71
+ into the local Plugins folder and restart Studio. See
72
+ [`../plugin/README.md`](../plugin/README.md).
73
+
74
+ ## Troubleshooting
75
+
76
+ - **Plugin not connected** — Studio open? plugin enabled + **Start**ed? HttpService
77
+ allowed? Host/Port/Token match the server?
78
+ - **Port in use** — set `RBXMCP_PORT` (and the plugin's Port field) to a free port.
79
+ - **Linux** — there's no standard Studio Plugins path; install manually.
@@ -0,0 +1,427 @@
1
+ --!nocheck
2
+ --[[
3
+ rbx-mcp — Roblox Studio plugin (single file)
4
+
5
+ Polls the local rbx-mcp server for Lua commands, runs each via loadstring(),
6
+ and reports back captured output, return values, and errors (with traceback).
7
+
8
+ Deliberately dumb and universal: it executes whatever Lua it's handed, so it
9
+ never needs updating for new tasks. All intelligence lives on the AI side.
10
+
11
+ Talks only to the local server over loopback HTTP. Requires HttpService.
12
+ Spec: work/02-studio-plugin.md · wire contract: work/01-wire-protocol.md
13
+ ]]
14
+
15
+ local HttpService = game:GetService("HttpService")
16
+
17
+ local PLUGIN_VERSION = "0.2.0"
18
+ local HOLD_MS = 25000 -- long-poll hold; keep under HttpService's request timeout
19
+ local MAX_RESULT_BYTES = 900 * 1024 -- stay under the server's 1 MiB default
20
+ local SETTING_PREFIX = "rbx-mcp."
21
+
22
+ -- ============================================================ [Config]
23
+ local Config = {}
24
+ local DEFAULTS = { host = "127.0.0.1", port = 30700, token = "", enabled = false }
25
+
26
+ local function getSetting(key, default)
27
+ local ok, value = pcall(function()
28
+ return plugin:GetSetting(SETTING_PREFIX .. key)
29
+ end)
30
+ if not ok or value == nil then
31
+ return default
32
+ end
33
+ return value
34
+ end
35
+
36
+ local function setSetting(key, value)
37
+ pcall(function()
38
+ plugin:SetSetting(SETTING_PREFIX .. key, value)
39
+ end)
40
+ end
41
+
42
+ function Config.load()
43
+ return {
44
+ host = getSetting("host", DEFAULTS.host),
45
+ port = tonumber(getSetting("port", DEFAULTS.port)) or DEFAULTS.port,
46
+ token = getSetting("token", DEFAULTS.token),
47
+ enabled = getSetting("enabled", DEFAULTS.enabled) == true,
48
+ }
49
+ end
50
+
51
+ function Config.base(cfg)
52
+ return ("http://%s:%d"):format(cfg.host, cfg.port)
53
+ end
54
+
55
+ -- ============================================================ [Serialize]
56
+ -- Total: never throws. Produces a JSON-safe string for any value.
57
+ local Serialize = {}
58
+ local MAX_KEYS, MAX_DEPTH = 50, 3
59
+
60
+ function Serialize.value(v, depth, seen)
61
+ depth = depth or 0
62
+ local t = typeof(v)
63
+ if t == "nil" then
64
+ return "nil"
65
+ elseif t == "string" then
66
+ return v
67
+ elseif t == "number" or t == "boolean" then
68
+ return tostring(v)
69
+ elseif t == "Instance" then
70
+ local ok, full = pcall(function()
71
+ return v:GetFullName()
72
+ end)
73
+ return ("%s (%s)"):format(ok and full or v.Name, v.ClassName)
74
+ elseif t == "table" then
75
+ seen = seen or {}
76
+ if seen[v] then
77
+ return "<cycle>"
78
+ end
79
+ if depth >= MAX_DEPTH then
80
+ return "{…}"
81
+ end
82
+ seen[v] = true
83
+ local parts, count = {}, 0
84
+ for key, val in pairs(v) do
85
+ count += 1
86
+ if count > MAX_KEYS then
87
+ table.insert(parts, "… (truncated)")
88
+ break
89
+ end
90
+ table.insert(parts, ("%s = %s"):format(tostring(key), Serialize.value(val, depth + 1, seen)))
91
+ end
92
+ seen[v] = nil
93
+ return "{" .. table.concat(parts, ", ") .. "}"
94
+ elseif t == "function" then
95
+ return "<function>"
96
+ else
97
+ local ok, s = pcall(tostring, v)
98
+ return ok and s or ("<" .. t .. ">")
99
+ end
100
+ end
101
+
102
+ -- ============================================================ [Executor]
103
+ -- loadstring + captured environment + xpcall/traceback. Verified by the spike.
104
+ local Executor = {}
105
+
106
+ function Executor.run(code)
107
+ local chunk, compileErr = loadstring(code, "=rbx-mcp")
108
+ if not chunk then
109
+ return {
110
+ ok = false,
111
+ output = "",
112
+ error = { message = tostring(compileErr), traceback = tostring(compileErr), phase = "compile" },
113
+ durationMs = 0,
114
+ }
115
+ end
116
+
117
+ local buffer = {}
118
+ local function capture(...)
119
+ local parts = {}
120
+ for i = 1, select("#", ...) do
121
+ parts[i] = tostring(select(i, ...))
122
+ end
123
+ table.insert(buffer, table.concat(parts, "\t"))
124
+ end
125
+
126
+ local env = setmetatable({
127
+ print = capture,
128
+ warn = function(...)
129
+ capture("[warn]", ...)
130
+ end,
131
+ }, { __index = getfenv() })
132
+ setfenv(chunk, env)
133
+
134
+ local t0 = os.clock()
135
+ local results = table.pack(xpcall(chunk, function(err)
136
+ return debug.traceback(tostring(err), 2)
137
+ end))
138
+ local durationMs = math.floor((os.clock() - t0) * 1000 + 0.5)
139
+ local output = table.concat(buffer, "\n")
140
+
141
+ if results[1] then
142
+ local returnValues = nil
143
+ if results.n >= 2 then
144
+ returnValues = {}
145
+ for i = 2, results.n do
146
+ returnValues[i - 1] = Serialize.value(results[i])
147
+ end
148
+ end
149
+ return { ok = true, output = output, returnValues = returnValues, error = nil, durationMs = durationMs }
150
+ else
151
+ local tb = tostring(results[2])
152
+ local message = tb:match("^[^\n]*") or tb
153
+ return {
154
+ ok = false,
155
+ output = output,
156
+ error = { message = message, traceback = tb, phase = "runtime" },
157
+ durationMs = durationMs,
158
+ }
159
+ end
160
+ end
161
+
162
+ -- Enforce the wire size cap: trim output (then drop return values) if too big.
163
+ local function capResult(result)
164
+ if #result.output > MAX_RESULT_BYTES then
165
+ result.output = result.output:sub(1, MAX_RESULT_BYTES) .. "\n… (truncated)"
166
+ result.truncated = true
167
+ end
168
+ return result
169
+ end
170
+
171
+ -- ============================================================ [Transport]
172
+ local Transport = {}
173
+
174
+ local function headers(cfg)
175
+ local h = { ["Content-Type"] = "application/json", ["X-RbxMcp-Plugin"] = "plugin/" .. PLUGIN_VERSION }
176
+ if cfg.token and cfg.token ~= "" then
177
+ h["X-RbxMcp-Token"] = cfg.token
178
+ end
179
+ return h
180
+ end
181
+
182
+ function Transport.poll(cfg, clientId)
183
+ local url = ("%s/v1/poll?clientId=%s&wait=%d"):format(Config.base(cfg), HttpService:UrlEncode(clientId), HOLD_MS)
184
+ local res = HttpService:RequestAsync({ Url = url, Method = "GET", Headers = headers(cfg) })
185
+ if res.StatusCode == 200 then
186
+ return "command", HttpService:JSONDecode(res.Body).command
187
+ elseif res.StatusCode == 204 then
188
+ return "idle"
189
+ elseif res.StatusCode == 401 then
190
+ return "auth"
191
+ end
192
+ return "error", res.StatusCode
193
+ end
194
+
195
+ function Transport.postResult(cfg, result)
196
+ local res = HttpService:RequestAsync({
197
+ Url = Config.base(cfg) .. "/v1/result",
198
+ Method = "POST",
199
+ Headers = headers(cfg),
200
+ Body = HttpService:JSONEncode(result),
201
+ })
202
+ if res.StatusCode == 200 then
203
+ return "accepted"
204
+ elseif res.StatusCode == 404 then
205
+ return "unknown"
206
+ end
207
+ return "error", res.StatusCode
208
+ end
209
+
210
+ -- ============================================================ [Ui]
211
+ local Ui = {}
212
+ local widget, statusLabel, toggleButton, lastLabel
213
+ local fields = {}
214
+
215
+ local COLORS = {
216
+ ok = Color3.fromRGB(120, 220, 130),
217
+ warn = Color3.fromRGB(240, 200, 90),
218
+ error = Color3.fromRGB(240, 110, 110),
219
+ idle = Color3.fromRGB(200, 200, 200),
220
+ }
221
+
222
+ local function makeLabel(text, parent, order)
223
+ local label = Instance.new("TextLabel")
224
+ label.BackgroundTransparency = 1
225
+ label.Size = UDim2.new(1, -16, 0, 18)
226
+ label.Font = Enum.Font.Gotham
227
+ label.TextSize = 13
228
+ label.TextXAlignment = Enum.TextXAlignment.Left
229
+ label.TextColor3 = COLORS.idle
230
+ label.Text = text
231
+ label.LayoutOrder = order
232
+ label.Parent = parent
233
+ return label
234
+ end
235
+
236
+ local function makeField(labelText, settingKey, value, order, parent, onSave)
237
+ makeLabel(labelText, parent, order)
238
+ local box = Instance.new("TextBox")
239
+ box.Size = UDim2.new(1, -16, 0, 24)
240
+ box.BackgroundColor3 = Color3.fromRGB(30, 30, 30)
241
+ box.TextColor3 = Color3.fromRGB(230, 230, 230)
242
+ box.Font = Enum.Font.Code
243
+ box.TextSize = 13
244
+ box.ClearTextOnFocus = false
245
+ box.Text = tostring(value)
246
+ box.LayoutOrder = order + 1
247
+ box.Parent = parent
248
+ box.FocusLost:Connect(function()
249
+ setSetting(settingKey, box.Text)
250
+ if onSave then
251
+ onSave()
252
+ end
253
+ end)
254
+ fields[settingKey] = box
255
+ return box
256
+ end
257
+
258
+ function Ui.build(onToggle)
259
+ local info = DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Float, false, false, 300, 300, 260, 260)
260
+ widget = plugin:CreateDockWidgetPluginGui("rbxMcpWidget", info)
261
+ widget.Title = "rbx-mcp"
262
+
263
+ local root = Instance.new("Frame")
264
+ root.Size = UDim2.fromScale(1, 1)
265
+ root.BackgroundColor3 = Color3.fromRGB(46, 46, 46)
266
+ root.BorderSizePixel = 0
267
+ root.Parent = widget
268
+
269
+ local layout = Instance.new("UIListLayout")
270
+ layout.SortOrder = Enum.SortOrder.LayoutOrder
271
+ layout.Padding = UDim.new(0, 6)
272
+ layout.HorizontalAlignment = Enum.HorizontalAlignment.Center
273
+ layout.Parent = root
274
+
275
+ local pad = Instance.new("UIPadding")
276
+ pad.PaddingTop = UDim.new(0, 10)
277
+ pad.PaddingLeft = UDim.new(0, 8)
278
+ pad.Parent = root
279
+
280
+ statusLabel = makeLabel("Disconnected", root, 1)
281
+ statusLabel.TextSize = 14
282
+ statusLabel.TextWrapped = true
283
+ statusLabel.AutomaticSize = Enum.AutomaticSize.Y
284
+
285
+ local cfg = Config.load()
286
+ makeField("Host", "host", cfg.host, 10, root)
287
+ makeField("Port", "port", cfg.port, 20, root)
288
+ makeField("Token (optional)", "token", cfg.token, 30, root)
289
+
290
+ toggleButton = Instance.new("TextButton")
291
+ toggleButton.Size = UDim2.new(1, -16, 0, 30)
292
+ toggleButton.BackgroundColor3 = Color3.fromRGB(64, 110, 180)
293
+ toggleButton.TextColor3 = Color3.fromRGB(255, 255, 255)
294
+ toggleButton.Font = Enum.Font.GothamMedium
295
+ toggleButton.TextSize = 14
296
+ toggleButton.Text = "Start"
297
+ toggleButton.LayoutOrder = 40
298
+ toggleButton.Parent = root
299
+ toggleButton.Activated:Connect(onToggle)
300
+
301
+ lastLabel = makeLabel("", root, 50)
302
+ lastLabel.TextSize = 12
303
+ lastLabel.TextColor3 = COLORS.idle
304
+ end
305
+
306
+ function Ui.setStatus(text, kind)
307
+ if statusLabel then
308
+ statusLabel.Text = text
309
+ statusLabel.TextColor3 = COLORS[kind] or COLORS.idle
310
+ end
311
+ end
312
+
313
+ function Ui.setRunning(running)
314
+ if toggleButton then
315
+ toggleButton.Text = running and "Stop" or "Start"
316
+ toggleButton.BackgroundColor3 = running and Color3.fromRGB(150, 70, 70) or Color3.fromRGB(64, 110, 180)
317
+ end
318
+ end
319
+
320
+ function Ui.setLastCommand(commandId, ok, durationMs)
321
+ if lastLabel then
322
+ lastLabel.Text = ("last: %s %s %dms"):format(commandId:sub(1, 8), ok and "ok" or "ERR", durationMs)
323
+ end
324
+ end
325
+
326
+ function Ui.toggleVisible()
327
+ if widget then
328
+ widget.Enabled = not widget.Enabled
329
+ end
330
+ end
331
+
332
+ -- ============================================================ [Loop]
333
+ local Loop = {}
334
+ local active = false
335
+ local clientId = HttpService:GenerateGUID(false)
336
+
337
+ function Loop.isActive()
338
+ return active
339
+ end
340
+
341
+ function Loop.start()
342
+ if active then
343
+ return
344
+ end
345
+ active = true
346
+ Ui.setRunning(true)
347
+ Ui.setStatus("Connecting…", "warn")
348
+
349
+ task.spawn(function()
350
+ local backoff = 0.5
351
+ while active do
352
+ -- Plugins can't enable HttpService themselves (it needs a higher security
353
+ -- identity than plugin context); guide the user — it recovers automatically.
354
+ if not HttpService.HttpEnabled then
355
+ Ui.setStatus("HTTP service disabled - enable it in Experience settings → Security → Allow HTTP Requests", "error")
356
+ task.wait(2)
357
+ continue
358
+ end
359
+
360
+ local cfg = Config.load()
361
+ local ok, kind, data = pcall(Transport.poll, cfg, clientId)
362
+
363
+ if not active then
364
+ break
365
+ elseif not ok then
366
+ Ui.setStatus("Disconnected — is the server running?", "warn")
367
+ task.wait(backoff)
368
+ backoff = math.min(backoff * 2, 5)
369
+ else
370
+ backoff = 0.5
371
+ if kind == "idle" then
372
+ Ui.setStatus("Connected (idle)", "ok")
373
+ elseif kind == "auth" then
374
+ Ui.setStatus("Auth failed — check token", "error")
375
+ task.wait(2)
376
+ elseif kind == "error" then
377
+ Ui.setStatus("Server error " .. tostring(data), "warn")
378
+ task.wait(1)
379
+ elseif kind == "command" then
380
+ Ui.setStatus("Executing…", "ok")
381
+ local result = capResult(Executor.run(data.code))
382
+ result.commandId = data.commandId
383
+ local pok, outcome = pcall(Transport.postResult, cfg, result)
384
+ Ui.setLastCommand(data.commandId, result.ok, result.durationMs)
385
+ if pok and outcome == "accepted" then
386
+ Ui.setStatus("Connected (idle)", "ok")
387
+ else
388
+ Ui.setStatus("Result not accepted (" .. tostring(outcome) .. ")", "warn")
389
+ end
390
+ end
391
+ end
392
+ end
393
+ end)
394
+ end
395
+
396
+ function Loop.stop()
397
+ active = false
398
+ Ui.setRunning(false)
399
+ Ui.setStatus("Disconnected", "idle")
400
+ end
401
+
402
+ -- ============================================================ [main]
403
+ local toolbar = plugin:CreateToolbar("rbx-mcp")
404
+ local button = toolbar:CreateButton("rbx-mcp", "Show/hide the rbx-mcp panel", "")
405
+ button.ClickableWhenViewportHidden = true
406
+
407
+ local function onToggle()
408
+ if Loop.isActive() then
409
+ Loop.stop()
410
+ setSetting("enabled", false)
411
+ else
412
+ Loop.start()
413
+ setSetting("enabled", true)
414
+ end
415
+ end
416
+
417
+ Ui.build(onToggle)
418
+ button.Click:Connect(Ui.toggleVisible)
419
+
420
+ plugin.Unloading:Connect(function()
421
+ active = false
422
+ end)
423
+
424
+ -- Auto-start if it was running last session.
425
+ if Config.load().enabled then
426
+ Loop.start()
427
+ end
package/dist/bridge.js ADDED
@@ -0,0 +1,157 @@
1
+ import { randomUUID } from "node:crypto";
2
+ const RECENT_MAX = 20;
3
+ export function createBridge(config, log) {
4
+ const queue = [];
5
+ const inFlight = new Map();
6
+ const waiters = [];
7
+ const recentErrors = [];
8
+ let lastPollAt = null;
9
+ let stopped = false;
10
+ function pushRecent(r) {
11
+ recentErrors.push(r);
12
+ while (recentErrors.length > RECENT_MAX)
13
+ recentErrors.shift();
14
+ }
15
+ function clampTimeout(ms) {
16
+ return Math.min(config.cmdTimeoutMsMax, Math.max(config.cmdTimeoutMsMin, Math.floor(ms)));
17
+ }
18
+ function timeoutResult(command) {
19
+ return {
20
+ commandId: command.commandId,
21
+ ok: false,
22
+ output: "",
23
+ returnValues: null,
24
+ error: {
25
+ message: `Execution timed out after ${command.timeoutMs} ms (no result from the Studio plugin).`,
26
+ traceback: "",
27
+ phase: "timeout",
28
+ },
29
+ durationMs: command.timeoutMs,
30
+ };
31
+ }
32
+ function armTimeout(pending) {
33
+ pending.timer = setTimeout(() => {
34
+ if (!inFlight.has(pending.command.commandId))
35
+ return;
36
+ inFlight.delete(pending.command.commandId);
37
+ const r = timeoutResult(pending.command);
38
+ pushRecent(r);
39
+ log.warn("command timed out", { commandId: pending.command.commandId, timeoutMs: pending.command.timeoutMs });
40
+ pending.resolve(r);
41
+ dispatch();
42
+ }, pending.command.timeoutMs + 1000); // small transport grace beyond the plugin budget
43
+ }
44
+ /** Hand the head of the queue to a parked waiter — but only one command in-flight at a time. */
45
+ function dispatch() {
46
+ while (inFlight.size === 0 && queue.length > 0 && waiters.length > 0) {
47
+ const pending = queue.shift();
48
+ const waiter = waiters.shift();
49
+ clearTimeout(waiter.timer);
50
+ inFlight.set(pending.command.commandId, pending);
51
+ armTimeout(pending);
52
+ log.debug("dispatch", { commandId: pending.command.commandId });
53
+ waiter.resolve(pending.command);
54
+ }
55
+ }
56
+ function enqueueAndAwait(input) {
57
+ if (stopped)
58
+ return Promise.reject(new Error("bridge is shutting down"));
59
+ if (queue.length >= config.maxQueueDepth) {
60
+ return Promise.reject(new Error(`bridge busy: command queue is full (${config.maxQueueDepth})`));
61
+ }
62
+ const command = {
63
+ commandId: randomUUID(),
64
+ kind: "execute_lua",
65
+ code: input.code,
66
+ timeoutMs: clampTimeout(input.timeoutMs ?? config.cmdTimeoutMs),
67
+ meta: input.meta,
68
+ };
69
+ log.info("enqueue", { commandId: command.commandId, codeLen: input.code.length, label: input.meta?.label });
70
+ return new Promise((resolve) => {
71
+ queue.push({ command, resolve, enqueuedAt: Date.now() });
72
+ dispatch();
73
+ });
74
+ }
75
+ function waitForCommand(waitMs, clientId) {
76
+ markPoll(clientId);
77
+ if (stopped)
78
+ return Promise.resolve(null);
79
+ const hold = Math.min(Math.max(waitMs, 0), config.pollHoldMs);
80
+ return new Promise((resolve) => {
81
+ const waiter = {
82
+ resolve,
83
+ timer: setTimeout(() => {
84
+ const i = waiters.indexOf(waiter);
85
+ if (i >= 0)
86
+ waiters.splice(i, 1);
87
+ resolve(null);
88
+ }, hold),
89
+ };
90
+ waiters.push(waiter);
91
+ dispatch();
92
+ });
93
+ }
94
+ function submitResult(result) {
95
+ const pending = inFlight.get(result.commandId);
96
+ if (!pending)
97
+ return "unknown";
98
+ if (pending.timer)
99
+ clearTimeout(pending.timer);
100
+ inFlight.delete(result.commandId);
101
+ if (!result.ok)
102
+ pushRecent(result);
103
+ log.info("result", { commandId: result.commandId, ok: result.ok, durationMs: result.durationMs });
104
+ pending.resolve(result);
105
+ dispatch();
106
+ return "accepted";
107
+ }
108
+ function markPoll(clientId) {
109
+ lastPollAt = Date.now();
110
+ void clientId; // reserved for future multi-client tracking
111
+ }
112
+ function getStatus() {
113
+ const connected = lastPollAt !== null && Date.now() - lastPollAt <= 2 * config.pollHoldMs;
114
+ return {
115
+ status: "ok",
116
+ bridgeVersion: config.version,
117
+ queueDepth: queue.length,
118
+ inFlight: inFlight.size,
119
+ pluginConnected: connected,
120
+ lastPollAt: lastPollAt !== null ? new Date(lastPollAt).toISOString() : null,
121
+ };
122
+ }
123
+ function getRecentErrors(limit = 5) {
124
+ const n = Math.max(0, Math.min(limit, recentErrors.length));
125
+ return recentErrors.slice(recentErrors.length - n).reverse();
126
+ }
127
+ function shutdown(reason = "shutting down") {
128
+ stopped = true;
129
+ for (const w of waiters.splice(0)) {
130
+ clearTimeout(w.timer);
131
+ w.resolve(null);
132
+ }
133
+ const pending = [...queue.splice(0), ...inFlight.values()];
134
+ inFlight.clear();
135
+ for (const p of pending) {
136
+ if (p.timer)
137
+ clearTimeout(p.timer);
138
+ p.resolve({
139
+ commandId: p.command.commandId,
140
+ ok: false,
141
+ output: "",
142
+ returnValues: null,
143
+ error: { message: `bridge ${reason}`, traceback: "", phase: "internal" },
144
+ durationMs: 0,
145
+ });
146
+ }
147
+ }
148
+ return {
149
+ enqueueAndAwait,
150
+ waitForCommand,
151
+ submitResult,
152
+ markPoll,
153
+ getStatus,
154
+ getRecentErrors,
155
+ shutdown,
156
+ };
157
+ }
package/dist/config.js ADDED
@@ -0,0 +1,32 @@
1
+ export const VERSION = "0.1.0";
2
+ const LOG_LEVELS = ["error", "warn", "info", "debug"];
3
+ function clampInt(raw, def, min, max) {
4
+ if (raw === undefined || raw.trim() === "")
5
+ return def;
6
+ const n = Number(raw);
7
+ if (!Number.isFinite(n))
8
+ return def;
9
+ return Math.min(max, Math.max(min, Math.floor(n)));
10
+ }
11
+ /** Read, validate, and clamp configuration from the environment. */
12
+ export function loadConfig(env = process.env, version = VERSION) {
13
+ const logRaw = (env.RBXMCP_LOG ?? "info").toLowerCase();
14
+ const logLevel = LOG_LEVELS.includes(logRaw) ? logRaw : "info";
15
+ const token = env.RBXMCP_TOKEN && env.RBXMCP_TOKEN.length > 0 ? env.RBXMCP_TOKEN : null;
16
+ const cfg = {
17
+ host: env.RBXMCP_HOST && env.RBXMCP_HOST.length > 0 ? env.RBXMCP_HOST : "127.0.0.1",
18
+ port: clampInt(env.RBXMCP_PORT, 30700, 0, 65535),
19
+ pollHoldMs: clampInt(env.RBXMCP_POLL_HOLD_MS, 25000, 0, 60000),
20
+ cmdTimeoutMs: clampInt(env.RBXMCP_CMD_TIMEOUT_MS, 30000, 1000, 600000),
21
+ cmdTimeoutMsMin: 1000,
22
+ cmdTimeoutMsMax: 600000,
23
+ maxResultBytes: clampInt(env.RBXMCP_MAX_RESULT_BYTES, 1024 * 1024, 1024, 64 * 1024 * 1024),
24
+ maxQueueDepth: clampInt(env.RBXMCP_MAX_QUEUE_DEPTH, 16, 1, 1024),
25
+ token,
26
+ logLevel,
27
+ dev: env.RBXMCP_DEV === "1" || env.RBXMCP_DEV === "true",
28
+ autoInstall: env.RBXMCP_AUTOINSTALL !== "off",
29
+ version,
30
+ };
31
+ return cfg;
32
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Dev CLI: enqueue one Lua command against a running server and print the result.
3
+ *
4
+ * RBXMCP_DEV=1 npm run dev # in one terminal (starts the server)
5
+ * npm run dev-enqueue -- "return 1 + 1" # in another
6
+ *
7
+ * Talks to the dev-only POST /v1/_dev/enqueue route (only present when RBXMCP_DEV is set).
8
+ * This is a standalone process, not the MCP server, so writing to stdout is fine here.
9
+ */
10
+ const code = process.argv[2];
11
+ if (!code) {
12
+ console.error('usage: dev-enqueue "<lua code>"');
13
+ process.exit(1);
14
+ }
15
+ const host = process.env.RBXMCP_HOST ?? "127.0.0.1";
16
+ const port = process.env.RBXMCP_PORT ?? "30700";
17
+ const token = process.env.RBXMCP_TOKEN;
18
+ const headers = { "content-type": "application/json" };
19
+ if (token)
20
+ headers["x-rbxmcp-token"] = token;
21
+ try {
22
+ const res = await fetch(`http://${host}:${port}/v1/_dev/enqueue`, {
23
+ method: "POST",
24
+ headers,
25
+ body: JSON.stringify({ code }),
26
+ });
27
+ const text = await res.text();
28
+ let pretty = text;
29
+ try {
30
+ pretty = JSON.stringify(JSON.parse(text), null, 2);
31
+ }
32
+ catch {
33
+ /* leave as-is */
34
+ }
35
+ console.log(`HTTP ${res.status}\n${pretty}`);
36
+ process.exit(res.ok ? 0 : 1);
37
+ }
38
+ catch (err) {
39
+ console.error(`Failed to reach the server at http://${host}:${port}. ` +
40
+ `Is it running with RBXMCP_DEV=1?\n${err.message}`);
41
+ process.exit(1);
42
+ }
43
+ export {};
package/dist/http.js ADDED
@@ -0,0 +1,171 @@
1
+ import { createServer } from "node:http";
2
+ import { timingSafeEqual } from "node:crypto";
3
+ import { isValidResultBody } from "./types.js";
4
+ const BODY_TOO_LARGE = Symbol("too-large");
5
+ export function createHttpServer(bridge, config, log) {
6
+ const server = createServer((req, res) => {
7
+ handle(req, res).catch((err) => {
8
+ log.error("unhandled request error", { message: err.message });
9
+ if (!res.headersSent)
10
+ fail(res, 500, "internal error");
11
+ });
12
+ });
13
+ async function handle(req, res) {
14
+ const url = new URL(req.url ?? "/", `http://${config.host}`);
15
+ const path = url.pathname;
16
+ const method = req.method ?? "GET";
17
+ // Defense-in-depth: only serve loopback Host headers.
18
+ if (!hostAllowed(req.headers.host))
19
+ return fail(res, 400, "bad host");
20
+ // /health is always reachable (no command data, no auth) so setup debugging works.
21
+ if (method === "GET" && path === "/v1/health") {
22
+ return json(res, 200, bridge.getStatus());
23
+ }
24
+ if (config.token !== null && !checkToken(req, config.token)) {
25
+ return fail(res, 401, "unauthorized");
26
+ }
27
+ if (method === "GET" && path === "/v1/poll") {
28
+ const clientId = url.searchParams.get("clientId") ?? undefined;
29
+ const waitRaw = Number(url.searchParams.get("wait"));
30
+ const wait = Number.isFinite(waitRaw) ? waitRaw : config.pollHoldMs;
31
+ const command = await bridge.waitForCommand(wait, clientId);
32
+ if (command === null) {
33
+ res.writeHead(204).end();
34
+ return;
35
+ }
36
+ return json(res, 200, { type: "command", command });
37
+ }
38
+ if (method === "POST" && path === "/v1/result") {
39
+ const body = await readBody(req, config.maxResultBytes);
40
+ if (body === BODY_TOO_LARGE)
41
+ return fail(res, 413, "result too large");
42
+ let parsed;
43
+ try {
44
+ parsed = JSON.parse(body);
45
+ }
46
+ catch {
47
+ return fail(res, 400, "invalid json");
48
+ }
49
+ if (!isValidResultBody(parsed))
50
+ return fail(res, 400, "invalid result body");
51
+ // Normalize omitted (undefined) fields to null so downstream Result objects are well-formed.
52
+ const outcome = bridge.submitResult({
53
+ commandId: parsed.commandId,
54
+ ok: parsed.ok,
55
+ output: parsed.output,
56
+ returnValues: parsed.returnValues ?? null,
57
+ error: parsed.error ?? null,
58
+ durationMs: parsed.durationMs,
59
+ truncated: parsed.truncated,
60
+ });
61
+ if (outcome === "unknown")
62
+ return fail(res, 404, "unknown commandId");
63
+ return json(res, 200, { accepted: true });
64
+ }
65
+ // Dev-only enqueue route — present solely when RBXMCP_DEV is set. Stripped from prod.
66
+ if (config.dev && method === "POST" && path === "/v1/_dev/enqueue") {
67
+ const body = await readBody(req, config.maxResultBytes);
68
+ if (body === BODY_TOO_LARGE)
69
+ return fail(res, 413, "too large");
70
+ let parsed;
71
+ try {
72
+ parsed = JSON.parse(body);
73
+ }
74
+ catch {
75
+ return fail(res, 400, "invalid json");
76
+ }
77
+ const obj = parsed;
78
+ if (typeof obj?.code !== "string")
79
+ return fail(res, 400, "missing code");
80
+ try {
81
+ const result = await bridge.enqueueAndAwait({
82
+ code: obj.code,
83
+ timeoutMs: typeof obj.timeoutMs === "number" ? obj.timeoutMs : undefined,
84
+ meta: { label: typeof obj.label === "string" ? obj.label : "dev-enqueue" },
85
+ });
86
+ return json(res, 200, result);
87
+ }
88
+ catch (err) {
89
+ return fail(res, 503, err.message);
90
+ }
91
+ }
92
+ return fail(res, 404, "not found");
93
+ }
94
+ function start() {
95
+ return new Promise((resolve, reject) => {
96
+ const onError = (err) => {
97
+ if (err.code === "EADDRINUSE") {
98
+ reject(new Error(`port ${config.port} on ${config.host} is already in use. ` +
99
+ `Set RBXMCP_PORT to a free port (and match it in the Studio plugin).`));
100
+ }
101
+ else {
102
+ reject(err);
103
+ }
104
+ };
105
+ server.once("error", onError);
106
+ server.listen(config.port, config.host, () => {
107
+ server.removeListener("error", onError);
108
+ const addr = server.address();
109
+ log.info("http listening", { host: config.host, port: addr.port });
110
+ resolve({ host: config.host, port: addr.port });
111
+ });
112
+ });
113
+ }
114
+ function stop() {
115
+ return new Promise((resolve) => {
116
+ server.close(() => resolve());
117
+ // Don't let lingering keep-alive sockets block close.
118
+ server.closeAllConnections?.();
119
+ });
120
+ }
121
+ return { start, stop };
122
+ }
123
+ function hostAllowed(host) {
124
+ if (!host)
125
+ return false;
126
+ const name = host.replace(/:\d+$/, "").replace(/^\[|\]$/g, "");
127
+ return name === "127.0.0.1" || name === "localhost" || name === "::1";
128
+ }
129
+ function checkToken(req, expected) {
130
+ const got = req.headers["x-rbxmcp-token"];
131
+ const value = Array.isArray(got) ? got[0] : got;
132
+ if (typeof value !== "string")
133
+ return false;
134
+ const a = Buffer.from(value);
135
+ const b = Buffer.from(expected);
136
+ if (a.length !== b.length)
137
+ return false;
138
+ return timingSafeEqual(a, b);
139
+ }
140
+ async function readBody(req, maxBytes) {
141
+ return new Promise((resolve, reject) => {
142
+ const chunks = [];
143
+ let size = 0;
144
+ let done = false;
145
+ req.on("data", (chunk) => {
146
+ size += chunk.length;
147
+ if (!done && size > maxBytes) {
148
+ done = true;
149
+ resolve(BODY_TOO_LARGE);
150
+ }
151
+ // Keep consuming (to drain the socket) but stop buffering once over the cap.
152
+ if (!done)
153
+ chunks.push(chunk);
154
+ });
155
+ req.on("end", () => {
156
+ if (!done) {
157
+ done = true;
158
+ resolve(Buffer.concat(chunks).toString("utf8"));
159
+ }
160
+ });
161
+ req.on("error", reject);
162
+ });
163
+ }
164
+ function json(res, status, payload) {
165
+ const body = JSON.stringify(payload);
166
+ res.writeHead(status, { "content-type": "application/json" });
167
+ res.end(body);
168
+ }
169
+ function fail(res, status, message) {
170
+ json(res, status, { error: message });
171
+ }
package/dist/index.js ADDED
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { loadConfig } from "./config.js";
4
+ import { createLogger } from "./log.js";
5
+ import { createBridge } from "./bridge.js";
6
+ import { createHttpServer } from "./http.js";
7
+ import { createMcpServer } from "./mcp/server.js";
8
+ import { installPlugin } from "./install.js";
9
+ async function main() {
10
+ const config = loadConfig();
11
+ const log = createLogger(config.logLevel);
12
+ log.info("starting rbx-mcp", {
13
+ version: config.version,
14
+ host: config.host,
15
+ port: config.port,
16
+ dev: config.dev,
17
+ token: config.token ? "set" : "none",
18
+ });
19
+ const bridge = createBridge(config, log);
20
+ const http = createHttpServer(bridge, config, log);
21
+ await http.start();
22
+ if (config.autoInstall) {
23
+ const res = await installPlugin({ log });
24
+ log.info("plugin auto-install", res);
25
+ }
26
+ else {
27
+ log.info("plugin auto-install disabled (RBXMCP_AUTOINSTALL=off)");
28
+ }
29
+ const mcp = createMcpServer(bridge, config, log);
30
+ const transport = new StdioServerTransport();
31
+ await mcp.connect(transport);
32
+ log.info("MCP server connected over stdio");
33
+ let shuttingDown = false;
34
+ const shutdown = async (sig) => {
35
+ if (shuttingDown)
36
+ return;
37
+ shuttingDown = true;
38
+ log.info("shutting down", { sig });
39
+ bridge.shutdown("shutting down");
40
+ await http.stop();
41
+ try {
42
+ await mcp.close();
43
+ }
44
+ catch {
45
+ /* best-effort */
46
+ }
47
+ process.exit(0);
48
+ };
49
+ for (const sig of ["SIGINT", "SIGTERM"]) {
50
+ process.on(sig, () => void shutdown(sig));
51
+ }
52
+ }
53
+ main().catch((err) => {
54
+ process.stderr.write(`[rbx-mcp] fatal: ${err.stack ?? String(err)}\n`);
55
+ process.exit(1);
56
+ });
@@ -0,0 +1,86 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ const PLUGIN_FILE = "rbx-mcp.lua";
7
+ /** Path to the plugin asset bundled in the package (../assets relative to this module). */
8
+ export function bundledPluginPath() {
9
+ return fileURLToPath(new URL("../assets/rbx-mcp.lua", import.meta.url));
10
+ }
11
+ /** Local Roblox Studio Plugins folder for the current OS, or null if there's no standard one. */
12
+ export function resolvePluginsDir(platform = process.platform, env = process.env) {
13
+ if (platform === "win32") {
14
+ const local = env.LOCALAPPDATA;
15
+ return local ? join(local, "Roblox", "Plugins") : null;
16
+ }
17
+ if (platform === "darwin") {
18
+ return join(homedir(), "Documents", "Roblox", "Plugins");
19
+ }
20
+ return null; // Linux / other: no standard Studio install path
21
+ }
22
+ function parseVersion(content) {
23
+ return content.match(/PLUGIN_VERSION\s*=\s*"([^"]+)"/)?.[1] ?? "0.0.0";
24
+ }
25
+ function compareVersions(a, b) {
26
+ const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
27
+ const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
28
+ const len = Math.max(pa.length, pb.length);
29
+ for (let i = 0; i < len; i++) {
30
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
31
+ if (d !== 0)
32
+ return d < 0 ? -1 : 1;
33
+ }
34
+ return 0;
35
+ }
36
+ /**
37
+ * Install/refresh the bundled plugin into the local Plugins folder.
38
+ *
39
+ * - missing → write
40
+ * - identical → skip
41
+ * - bundled is newer → overwrite (update)
42
+ * - same version, diff → skip (don't clobber a user-modified copy)
43
+ * - bundled is older → skip (don't downgrade)
44
+ *
45
+ * Never throws: returns a structured result so the bootstrap can log and continue.
46
+ */
47
+ export async function installPlugin(opts) {
48
+ const sourcePath = opts.sourcePath ?? bundledPluginPath();
49
+ const pluginsDir = opts.pluginsDir !== undefined ? opts.pluginsDir : resolvePluginsDir();
50
+ const fileName = opts.fileName ?? PLUGIN_FILE;
51
+ if (pluginsDir === null) {
52
+ return {
53
+ action: "no-plugins-dir",
54
+ message: "no standard Roblox Plugins folder for this OS — install the plugin manually (see plugin/README.md)",
55
+ };
56
+ }
57
+ let bundled;
58
+ try {
59
+ bundled = await readFile(sourcePath, "utf8");
60
+ }
61
+ catch (err) {
62
+ return { action: "error", message: `cannot read bundled plugin: ${err.message}` };
63
+ }
64
+ const dest = join(pluginsDir, fileName);
65
+ try {
66
+ await mkdir(pluginsDir, { recursive: true });
67
+ if (!existsSync(dest)) {
68
+ await writeFile(dest, bundled, "utf8");
69
+ return { action: "written", path: dest };
70
+ }
71
+ const existing = await readFile(dest, "utf8");
72
+ if (existing === bundled)
73
+ return { action: "skipped-same", path: dest };
74
+ const cmp = compareVersions(parseVersion(bundled), parseVersion(existing));
75
+ if (cmp > 0) {
76
+ await writeFile(dest, bundled, "utf8");
77
+ return { action: "updated", path: dest };
78
+ }
79
+ if (cmp === 0)
80
+ return { action: "skipped-usermodified", path: dest };
81
+ return { action: "skipped-downgrade", path: dest };
82
+ }
83
+ catch (err) {
84
+ return { action: "error", path: dest, message: err.message };
85
+ }
86
+ }
package/dist/log.js ADDED
@@ -0,0 +1,33 @@
1
+ const RANK = { error: 0, warn: 1, info: 2, debug: 3 };
2
+ function fmt(v) {
3
+ if (typeof v === "string")
4
+ return v;
5
+ try {
6
+ return JSON.stringify(v);
7
+ }
8
+ catch {
9
+ return String(v);
10
+ }
11
+ }
12
+ /**
13
+ * Leveled logger that writes ONLY to stderr.
14
+ *
15
+ * stdout is reserved for MCP stdio framing — writing log text there would
16
+ * corrupt the protocol stream. Every path here goes to process.stderr.
17
+ */
18
+ export function createLogger(level) {
19
+ const threshold = RANK[level];
20
+ function emit(l, msg, args) {
21
+ if (RANK[l] > threshold)
22
+ return;
23
+ const head = `[rbx-mcp] ${new Date().toISOString()} ${l.toUpperCase()} ${msg}`;
24
+ const line = args.length > 0 ? `${head} ${args.map(fmt).join(" ")}` : head;
25
+ process.stderr.write(`${line}\n`);
26
+ }
27
+ return {
28
+ error: (m, ...a) => emit("error", m, a),
29
+ warn: (m, ...a) => emit("warn", m, a),
30
+ info: (m, ...a) => emit("info", m, a),
31
+ debug: (m, ...a) => emit("debug", m, a),
32
+ };
33
+ }
@@ -0,0 +1,8 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { registerTools } from "./tools.js";
3
+ /** Build the MCP server (tools wired to the bridge). Transport is connected by the caller. */
4
+ export function createMcpServer(bridge, config, log) {
5
+ const server = new McpServer({ name: "rbx-mcp", version: config.version });
6
+ registerTools(server, bridge, config, log);
7
+ return server;
8
+ }
@@ -0,0 +1,82 @@
1
+ /** A Studio path is a dotted name like "Workspace.Model.Part" — restrict to safe chars. */
2
+ export function isSafePath(path) {
3
+ return typeof path === "string" && path.trim().length > 0 && /^[A-Za-z0-9_. ]+$/.test(path);
4
+ }
5
+ const SELECTION = `
6
+ local sel = game:GetService("Selection"):Get()
7
+ if #sel == 0 then return "(nothing selected)" end
8
+ local lines = {}
9
+ for _, inst in ipairs(sel) do
10
+ table.insert(lines, inst:GetFullName() .. " (" .. inst.ClassName .. ")")
11
+ end
12
+ return table.concat(lines, "\\n")
13
+ `.trim();
14
+ const SERVICES_SUMMARY = `
15
+ local names = {"Workspace","Lighting","ReplicatedStorage","ReplicatedFirst","ServerStorage","ServerScriptService","StarterGui","StarterPack","StarterPlayer","SoundService","Teams"}
16
+ local out = {}
17
+ for _, name in ipairs(names) do
18
+ local ok, svc = pcall(function() return game:GetService(name) end)
19
+ if ok and svc then
20
+ table.insert(out, ("%s: %d children"):format(name, #svc:GetChildren()))
21
+ end
22
+ end
23
+ return table.concat(out, "\\n")
24
+ `.trim();
25
+ function explorerTree(depth) {
26
+ return `
27
+ local DEPTH = ${depth}
28
+ local function walk(inst, d, prefix, out)
29
+ for _, child in ipairs(inst:GetChildren()) do
30
+ table.insert(out, prefix .. child.Name .. " (" .. child.ClassName .. ")")
31
+ if d < DEPTH then
32
+ walk(child, d + 1, prefix .. " ", out)
33
+ end
34
+ end
35
+ end
36
+ local out = {}
37
+ walk(workspace, 1, "", out)
38
+ if #out == 0 then return "(workspace is empty)" end
39
+ return table.concat(out, "\\n")
40
+ `.trim();
41
+ }
42
+ function instanceDump(path) {
43
+ // `path` MUST be pre-validated by isSafePath before reaching here.
44
+ return `
45
+ local parts = string.split("${path}", ".")
46
+ local node = game
47
+ for _, p in ipairs(parts) do
48
+ if p ~= "" and node then
49
+ node = node:FindFirstChild(p)
50
+ end
51
+ end
52
+ if not node then return "(not found: ${path})" end
53
+ local children = {}
54
+ for _, c in ipairs(node:GetChildren()) do
55
+ table.insert(children, " " .. c.Name .. " (" .. c.ClassName .. ")")
56
+ end
57
+ return table.concat({
58
+ "Instance: " .. node:GetFullName(),
59
+ "ClassName: " .. node.ClassName,
60
+ "Children (" .. #children .. "):",
61
+ table.concat(children, "\\n"),
62
+ }, "\\n")
63
+ `.trim();
64
+ }
65
+ /** Build the server-owned Lua snippet for a read_studio_state query. */
66
+ export function buildStateSnippet(query, params) {
67
+ switch (query) {
68
+ case "selection":
69
+ return SELECTION;
70
+ case "services_summary":
71
+ return SERVICES_SUMMARY;
72
+ case "explorer_tree":
73
+ return explorerTree(clampDepth(params.depth));
74
+ case "instance":
75
+ return instanceDump(params.path ?? "");
76
+ }
77
+ }
78
+ function clampDepth(depth) {
79
+ if (depth === undefined || !Number.isFinite(depth))
80
+ return 2;
81
+ return Math.min(6, Math.max(1, Math.floor(depth)));
82
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Map an execution Result into an MCP tool result.
3
+ *
4
+ * Failures set `isError: true` and lead with phase + message + traceback so
5
+ * Claude can read the error and fix it — that feedback is the whole point.
6
+ */
7
+ export function toToolResult(r) {
8
+ if (r.ok) {
9
+ const lines = ["✅ ok"];
10
+ if (r.output)
11
+ lines.push(`output:\n${r.output}`);
12
+ if (r.returnValues && r.returnValues.length > 0)
13
+ lines.push(`returns: ${r.returnValues.join(", ")}`);
14
+ lines.push(`(${r.durationMs} ms${r.truncated ? ", truncated" : ""})`);
15
+ return { content: [{ type: "text", text: lines.join("\n") }], isError: false };
16
+ }
17
+ const e = r.error;
18
+ const lines = [`❌ ${e?.phase ?? "internal"} error: ${e?.message ?? "unknown error"}`];
19
+ if (r.output)
20
+ lines.push(`output before error:\n${r.output}`);
21
+ if (e?.traceback)
22
+ lines.push(`traceback:\n${e.traceback}`);
23
+ return { content: [{ type: "text", text: lines.join("\n") }], isError: true };
24
+ }
25
+ export function textResult(text, isError = false) {
26
+ return { content: [{ type: "text", text }], isError };
27
+ }
@@ -0,0 +1,69 @@
1
+ import { z } from "zod";
2
+ import { toToolResult, textResult } from "./toToolResult.js";
3
+ import { buildStateSnippet, isSafePath } from "./snippets.js";
4
+ const NOT_CONNECTED = "The Roblox Studio plugin isn't connected. Open Studio, make sure the rbx-mcp " +
5
+ "plugin is enabled and Started, and that HttpService is allowed (Game Settings → Security).";
6
+ export function registerTools(server, bridge, config, log) {
7
+ server.registerTool("execute_lua", {
8
+ title: "Execute Lua in Roblox Studio",
9
+ description: "Run a Luau code string inside Roblox Studio (via loadstring) and return its captured " +
10
+ "output, return values, and any error with traceback. Use this to build, script, inspect, " +
11
+ "or modify anything in Studio; iterate by reading the error and re-running.",
12
+ inputSchema: {
13
+ code: z.string().describe("Luau code to execute in Studio."),
14
+ timeoutMs: z.number().int().min(1000).max(600000).optional().describe("Execution budget in ms (default 30000)."),
15
+ label: z.string().optional().describe("Short human label for logs/UI."),
16
+ },
17
+ }, async ({ code, timeoutMs, label }) => {
18
+ if (!bridge.getStatus().pluginConnected)
19
+ return textResult(NOT_CONNECTED, true);
20
+ try {
21
+ const r = await bridge.enqueueAndAwait({ code, timeoutMs, meta: { label } });
22
+ return toToolResult(r);
23
+ }
24
+ catch (err) {
25
+ return textResult(`❌ ${err.message}`, true);
26
+ }
27
+ });
28
+ server.registerTool("read_studio_state", {
29
+ title: "Read Roblox Studio state",
30
+ description: "Inspect Studio state in a clean form: 'selection' (current selection), 'explorer_tree' " +
31
+ "(workspace tree to a depth), 'instance' (one instance by dotted path, e.g. 'Workspace.Model.Part'), " +
32
+ "or 'services_summary'. Runs a server-owned snippet — no arbitrary code.",
33
+ inputSchema: {
34
+ query: z.enum(["selection", "explorer_tree", "instance", "services_summary"]),
35
+ path: z.string().optional().describe("For query=instance: dotted path like 'Workspace.Model.Part'."),
36
+ depth: z.number().int().min(1).max(6).optional().describe("For query=explorer_tree: max depth (default 2)."),
37
+ },
38
+ }, async ({ query, path, depth }) => {
39
+ if (query === "instance" && !isSafePath(path)) {
40
+ return textResult("`path` is required for query=instance and must look like 'Workspace.Model.Part'.", true);
41
+ }
42
+ if (!bridge.getStatus().pluginConnected)
43
+ return textResult(NOT_CONNECTED, true);
44
+ const code = buildStateSnippet(query, { path, depth });
45
+ try {
46
+ const r = await bridge.enqueueAndAwait({ code, meta: { label: `read_studio_state:${query}` } });
47
+ return toToolResult(r);
48
+ }
49
+ catch (err) {
50
+ return textResult(`❌ ${err.message}`, true);
51
+ }
52
+ });
53
+ server.registerTool("get_errors", {
54
+ title: "Recent execution errors",
55
+ description: "Return the most recent failed executions (message + traceback). Useful for recovering context.",
56
+ inputSchema: {
57
+ limit: z.number().int().min(1).max(20).optional().describe("How many recent errors (default 5)."),
58
+ },
59
+ }, async ({ limit }) => {
60
+ const errs = bridge.getRecentErrors(limit ?? 5);
61
+ if (errs.length === 0)
62
+ return textResult("No recent errors.");
63
+ const text = errs
64
+ .map((e, i) => `#${i + 1} [${e.error?.phase ?? "?"}] ${e.error?.message ?? ""}\n${e.error?.traceback ?? ""}`.trimEnd())
65
+ .join("\n\n");
66
+ return textResult(text);
67
+ });
68
+ log.debug("registered tools", { tools: ["execute_lua", "read_studio_state", "get_errors"] });
69
+ }
package/dist/types.js ADDED
@@ -0,0 +1,25 @@
1
+ /** Structural validation of a result body posted by the plugin (defensive — the wire is untyped). */
2
+ export function isValidResultBody(v) {
3
+ if (typeof v !== "object" || v === null)
4
+ return false;
5
+ const r = v;
6
+ if (typeof r.commandId !== "string")
7
+ return false;
8
+ if (typeof r.ok !== "boolean")
9
+ return false;
10
+ if (typeof r.output !== "string")
11
+ return false;
12
+ // Be liberal: the plugin omits nil fields, so `error`/`returnValues` may be absent (undefined).
13
+ if (r.returnValues !== null && r.returnValues !== undefined && !Array.isArray(r.returnValues))
14
+ return false;
15
+ if (typeof r.durationMs !== "number")
16
+ return false;
17
+ if (r.error !== null && r.error !== undefined) {
18
+ if (typeof r.error !== "object")
19
+ return false;
20
+ const e = r.error;
21
+ if (typeof e.message !== "string")
22
+ return false;
23
+ }
24
+ return true;
25
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@lolofuk123/rbx-mcp",
3
+ "version": "0.2.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "Bridge that lets an AI agent execute Lua live inside Roblox Studio over MCP.",
8
+ "type": "module",
9
+ "bin": {
10
+ "rbx-mcp": "dist/index.js"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "assets"
15
+ ],
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "scripts": {
20
+ "bundle:plugin": "node scripts/bundle-plugin.mjs",
21
+ "build": "npm run bundle:plugin && tsc",
22
+ "dev": "tsx src/index.ts",
23
+ "dev-enqueue": "tsx src/dev-enqueue.ts",
24
+ "test": "vitest run",
25
+ "typecheck": "tsc --noEmit",
26
+ "prepublishOnly": "npm run build && npm test"
27
+ },
28
+ "keywords": [
29
+ "roblox",
30
+ "roblox-studio",
31
+ "mcp",
32
+ "model-context-protocol",
33
+ "claude",
34
+ "lua",
35
+ "luau"
36
+ ],
37
+ "license": "MIT",
38
+ "dependencies": {
39
+ "@modelcontextprotocol/sdk": "^1.12.0",
40
+ "zod": "^3.23.8"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^24.0.0",
44
+ "tsx": "^4.19.0",
45
+ "typescript": "^5.6.0",
46
+ "vitest": "^3.0.0"
47
+ }
48
+ }