@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 +79 -0
- package/assets/rbx-mcp.lua +427 -0
- package/dist/bridge.js +157 -0
- package/dist/config.js +32 -0
- package/dist/dev-enqueue.js +43 -0
- package/dist/http.js +171 -0
- package/dist/index.js +56 -0
- package/dist/install.js +86 -0
- package/dist/log.js +33 -0
- package/dist/mcp/server.js +8 -0
- package/dist/mcp/snippets.js +82 -0
- package/dist/mcp/toToolResult.js +27 -0
- package/dist/mcp/tools.js +69 -0
- package/dist/types.js +25 -0
- package/package.json +48 -0
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
|
+
});
|
package/dist/install.js
ADDED
|
@@ -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
|
+
}
|