@el4cteo/rbx-studio-mcp 0.1.6 → 0.2.7

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.
Files changed (43) hide show
  1. package/README.md +67 -5
  2. package/dist/bridge/api.js +29 -0
  3. package/dist/bridge/api.js.map +1 -1
  4. package/dist/bridge/remote.js +35 -0
  5. package/dist/bridge/remote.js.map +1 -1
  6. package/dist/bridge/rpc.js +81 -2
  7. package/dist/bridge/rpc.js.map +1 -1
  8. package/dist/bridge/server.js +60 -3
  9. package/dist/bridge/server.js.map +1 -1
  10. package/dist/index.js +86 -2
  11. package/dist/index.js.map +1 -1
  12. package/dist/lib/protocol.js +23 -0
  13. package/dist/lib/protocol.js.map +1 -1
  14. package/dist/tools/discover.js +7 -1
  15. package/dist/tools/discover.js.map +1 -1
  16. package/dist/tools/input.js +63 -1
  17. package/dist/tools/input.js.map +1 -1
  18. package/dist/tools/instances.js +9 -1
  19. package/dist/tools/instances.js.map +1 -1
  20. package/dist/tools/perf.js +73 -16
  21. package/dist/tools/perf.js.map +1 -1
  22. package/dist/tools/playtest.js +8 -1
  23. package/dist/tools/playtest.js.map +1 -1
  24. package/dist/tools/session.js +32 -6
  25. package/dist/tools/session.js.map +1 -1
  26. package/dist/tools/world.js +9 -3
  27. package/dist/tools/world.js.map +1 -1
  28. package/package.json +2 -2
  29. package/plugin/src/Config.luau +1 -1
  30. package/plugin/src/Console.luau +204 -4
  31. package/plugin/src/Net.luau +23 -1
  32. package/plugin/src/Phrase.luau +158 -5
  33. package/plugin/src/Transport.luau +84 -19
  34. package/plugin/src/Visuals.luau +217 -20
  35. package/plugin/src/handlers/Debug.luau +63 -6
  36. package/plugin/src/handlers/Geometry.luau +20 -1
  37. package/plugin/src/handlers/Input.luau +41 -1
  38. package/plugin/src/handlers/Perf.luau +662 -645
  39. package/plugin/src/handlers/World.luau +19 -0
  40. package/plugin/src/init.server.luau +48 -11
  41. package/scripts/check-plugin.mjs +29 -1
  42. package/scripts/test-bridge.mjs +104 -0
  43. package/scripts/test-transport.mjs +58 -0
@@ -147,6 +147,25 @@ function World.collision(params: { [string]: any }): { [string]: any }
147
147
  return { group = name, assigned = #assigned, parts = assigned, undoable = undoable }
148
148
  end
149
149
 
150
+ if action == "remove" then
151
+ --[[
152
+ Registering a group is one line and nothing here undid it: create,
153
+ assign and collidable all had a way back through the ordinary undo
154
+ stack, and this did not, because nothing called it at all. Over a
155
+ session that experiments -- try a group, decide against it -- that
156
+ leaves the place permanently carrying a group nobody wants, with no
157
+ way to remove it short of `execute_luau`. `UnregisterCollisionGroup`
158
+ has existed the whole time; it was just never wired up.
159
+ ]]
160
+ local ok, err = pcall(function()
161
+ PhysicsService:UnregisterCollisionGroup(name)
162
+ end)
163
+ if not ok then
164
+ Dispatch.fail("COLLISION_FAILED", string.format("Could not remove %q: %s", name, tostring(err)))
165
+ end
166
+ return { group = name, removed = true }
167
+ end
168
+
150
169
  if action == "collidable" then
151
170
  local other = params.with
152
171
  if typeof(other) ~= "string" or other == "" then
@@ -1,6 +1,6 @@
1
1
  --!strict
2
2
  --[[
3
- Studio MCP -- plugin entry point.
3
+ rbx-studio -- plugin entry point.
4
4
 
5
5
  Owns the toolbar UI, this window's Studio identity, and the command loop.
6
6
  Handlers do the actual work; this file only wires them to the transport and
@@ -121,10 +121,10 @@ Input.register()
121
121
  Device.register()
122
122
  Api.register()
123
123
 
124
- local toolbar = plugin:CreateToolbar("Studio MCP")
124
+ local toolbar = plugin:CreateToolbar("rbx-studio")
125
125
  local button = toolbar:CreateButton(
126
- "Studio MCP",
127
- "Show the Studio MCP console",
126
+ "rbx-studio",
127
+ "Show the rbx-studio console",
128
128
  "rbxasset://textures/ui/common/robux.png"
129
129
  )
130
130
  button.ClickableWhenViewportHidden = true
@@ -136,7 +136,7 @@ local widget = plugin:CreateDockWidgetPluginGuiAsync(
136
136
  "StudioMCP_Console",
137
137
  DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Float, true, false, 560, 320, 360, 200)
138
138
  )
139
- widget.Title = "Studio MCP"
139
+ widget.Title = "rbx-studio"
140
140
 
141
141
  local currentStatus: Transport.Status = "disconnected"
142
142
 
@@ -167,7 +167,7 @@ Console.mount(widget, {
167
167
  end,
168
168
  })
169
169
 
170
- Console.log("info", string.format("Studio MCP v%s", Config.PLUGIN_VERSION), "build " .. Config.BUILD_ID)
170
+ Console.log("info", string.format("rbx-studio v%s", Config.PLUGIN_VERSION), "build " .. Config.BUILD_ID)
171
171
  Console.log("dim", string.format("place: %s (%d)", game.Name, game.PlaceId))
172
172
 
173
173
  local function onStatus(status: Transport.Status, detail: string?)
@@ -210,12 +210,36 @@ local function onCommand(id: string, op: string, params: { [string]: any }?)
210
210
  something needs debugging.
211
211
  ]]
212
212
  local title = Phrase.of(op, params)
213
+ --[[
214
+ Announced synchronously, unlike the logging below.
215
+
216
+ Deferring this looked like free latency and was not. `beginCall` sets
217
+ two labels and some fields -- it never touches the RichText log, which
218
+ is where the cost actually is -- and deferring it let a call that
219
+ finished inside one frame record its result before the call had been
220
+ announced, so the bar on the trace lost the name it was supposed to
221
+ carry. A microsecond is not worth an ordering hazard.
222
+ ]]
213
223
  Console.beginCall(title, Phrase.kindOf(op))
214
224
 
215
225
  local result = Dispatch.invoke(id, op, params)
216
- local elapsed = string.format("%.0fms", (os.clock() - startedAt) * 1000)
226
+ local milliseconds = (os.clock() - startedAt) * 1000
217
227
 
218
- Console.recordCall(result.ok, (os.clock() - startedAt) * 1000)
228
+ --[[
229
+ The answer goes out before the console hears about it.
230
+
231
+ This used to be the last line of the function, which put a
232
+ `table.concat` of three hundred strings, a RichText relayout of the
233
+ whole log, two `Instance.new` calls and a forty-bar relayout in front
234
+ of the reply on its way back to the agent. None of that is work the
235
+ caller asked for, and all of it was being billed to the round trip
236
+ this project measures. Drawing happens below, on time the agent is no
237
+ longer waiting for.
238
+ ]]
239
+ Transport.sendResult(result)
240
+
241
+ local elapsed = string.format("%.0fms", milliseconds)
242
+ Console.recordCall(result.ok, milliseconds)
219
243
 
220
244
  if result.ok then
221
245
  Console.log("reply", title, elapsed)
@@ -230,11 +254,24 @@ local function onCommand(id: string, op: string, params: { [string]: any }?)
230
254
  Console.log("dim", " " .. err.message)
231
255
  end
232
256
  end
233
-
234
- Transport.sendResult(result)
235
257
  end)
236
258
  end
237
259
 
260
+ --[[
261
+ Bridge news that is not a command.
262
+
263
+ Kept deliberately narrow: an unknown event is ignored rather than logged,
264
+ because a newer server talking to an older plugin is a supported situation
265
+ and "unknown event" rows would be the only symptom of it working correctly.
266
+ ]]
267
+ local function onEvent(event: { [string]: any })
268
+ if event.event == "clients" and typeof(event.count) == "number" then
269
+ Console.setClients(event.count)
270
+ elseif event.event == "agent" and event.state == "finished" then
271
+ Console.agentFinished()
272
+ end
273
+ end
274
+
238
275
  function connect()
239
276
  local allowed, reason = canConnect()
240
277
  if not allowed then
@@ -249,7 +286,7 @@ function connect()
249
286
  Console.setCaption("switch to the Server view to watch this playtest")
250
287
  return
251
288
  end
252
- Transport.start(studioId(), { onCommand = onCommand, onStatus = onStatus })
289
+ Transport.start(studioId(), { onCommand = onCommand, onStatus = onStatus, onEvent = onEvent })
253
290
  plugin:SetSetting(SETTING_AUTOCONNECT, true)
254
291
  end
255
292
 
@@ -72,14 +72,34 @@ for (const file of files) {
72
72
  * filtering to this one diagnostic gets the signal without needing a
73
73
  * definitions file that would then have to be kept current with the engine.
74
74
  */
75
+ /*
76
+ * Diagnostics about a table this file declares its own type for.
77
+ *
78
+ * The LocalShadow filter was the only thing let through, and that let a whole
79
+ * class of error ship: a field read or written on a `--!strict` table type that
80
+ * does not declare it. The plugin failed to load on the first line it logged --
81
+ * "attempt to perform arithmetic on nil" -- because a batch of edits added five
82
+ * uses of `state.entries` and the edit declaring it never landed. The analyser
83
+ * had said so, four times, and this script threw it away.
84
+ *
85
+ * These two patterns are safe to surface where the rest is not. Without Roblox
86
+ * type definitions the analyser cannot know what `Instance` or `Color3` are, so
87
+ * it reports engine globals in their hundreds -- but those come out as unknown
88
+ * *globals* and unknown *types*. A key missing from a named table type can only
89
+ * be a table this file declared itself.
90
+ */
91
+ const TYPE_PATTERNS = [/Key '[^']+' not found in table/, /Cannot add property '[^']+' to table/];
92
+
75
93
  const analyser = locateLuau("LUAU_ANALYZE", ["luau-analyze.exe", "luau-analyze"]);
76
94
  const shadowed = [];
95
+ const mistyped = [];
77
96
  if (analyser !== null) {
78
97
  for (const file of files) {
79
98
  const result = spawnSync(analyser, [file], { encoding: "utf8" });
80
99
  const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
81
100
  for (const line of output.split("\n")) {
82
101
  if (line.includes("LocalShadow:")) shadowed.push(line.trim());
102
+ else if (TYPE_PATTERNS.some((pattern) => pattern.test(line))) mistyped.push(line.trim());
83
103
  }
84
104
  }
85
105
  }
@@ -94,4 +114,12 @@ if (shadowed.length > 0) {
94
114
  `${shadowed.join("\n")}\n`,
95
115
  );
96
116
  }
97
- process.exit(failures.length > 0 || shadowed.length > 0 ? 1 : 0);
117
+ if (mistyped.length > 0) {
118
+ process.stderr.write(
119
+ "\nA field is used on a table whose type does not declare it. It is nil at " +
120
+ "runtime, and the plugin fails the first time that line runs:\n" +
121
+ `${mistyped.join("\n")}
122
+ `,
123
+ );
124
+ }
125
+ process.exit(failures.length > 0 || shadowed.length > 0 || mistyped.length > 0 ? 1 : 0);
@@ -20,6 +20,7 @@ const identity = (studioId, placeId) => ({
20
20
  placeId,
21
21
  pluginVersion: "test",
22
22
  buildId: "test",
23
+ protocolVersion: 1,
23
24
  transport: "poll",
24
25
  context: "edit",
25
26
  });
@@ -105,3 +106,106 @@ function twoStudios() {
105
106
  "but the survivor was never chosen",
106
107
  );
107
108
  }
109
+
110
+ /**
111
+ * Counting the agents that share one bridge, and noticing when one leaves.
112
+ *
113
+ * The Studio console shows this count and announces the departure, so getting
114
+ * it wrong is not a cosmetic fault -- it either claims a second agent is
115
+ * editing the user's place when none is, or stays silent when one really has
116
+ * gone. Both are the kind of wrong that is only visible from outside the
117
+ * process, which is what this covers.
118
+ */
119
+ {
120
+ const bridge = new Bridge();
121
+ const seen = [];
122
+ bridge.watchClients((count) => seen.push(count));
123
+
124
+ assert.equal(bridge.clientCount(), 0, "a fresh bridge has no clients");
125
+
126
+ const alice = new LocalBridge(bridge);
127
+ assert.equal(bridge.clientCount(), 1, "constructing a local bridge registers it");
128
+
129
+ bridge.noteClient("peer-1");
130
+ assert.equal(bridge.clientCount(), 2, "a peer counts too");
131
+
132
+ bridge.noteClient("peer-1");
133
+ assert.equal(bridge.clientCount(), 2, "and saying hello twice is not two peers");
134
+
135
+ assert.deepEqual(seen, [1, 2], "only real changes are announced");
136
+
137
+ assert.equal(bridge.forgetClient("peer-1"), true, "goodbye drops the peer");
138
+ assert.equal(bridge.clientCount(), 1, "leaving one behind");
139
+ assert.equal(
140
+ bridge.forgetClient("peer-1"),
141
+ false,
142
+ "a repeated goodbye is not a second departure",
143
+ );
144
+ assert.deepEqual(seen, [1, 2, 1], "and is not announced twice");
145
+
146
+ // A client's chosen Studio goes with it, which is the leak forgetClient was
147
+ // written for and never called to fix.
148
+ bridge.attach(identity("studio-a", 111), null);
149
+ bridge.attach(identity("studio-b", 222), null);
150
+ await alice.setActive("studio-b");
151
+ assert.equal((await alice.sessions()).activeIsChosen, true, "alice chose one");
152
+ alice.goodbye();
153
+ assert.equal(bridge.clientCount(), 0, "and left");
154
+ assert.equal(
155
+ (await alice.sessions()).activeIsChosen,
156
+ false,
157
+ "taking her choice with her",
158
+ );
159
+ }
160
+
161
+ /** A client killed rather than closed is swept by the reaper. */
162
+ {
163
+ const bridge = new Bridge();
164
+ bridge.noteClient("ghost");
165
+ assert.equal(bridge.clientCount(), 1, "the ghost registered");
166
+
167
+ bridge.reapStale();
168
+ assert.equal(bridge.clientCount(), 1, "a fresh client survives a sweep");
169
+
170
+ // Reach past the clock rather than wait ninety seconds for it.
171
+ const original = Date.now;
172
+ Date.now = () => original() + 120_000;
173
+ try {
174
+ bridge.reapStale();
175
+ } finally {
176
+ Date.now = original;
177
+ }
178
+ assert.equal(bridge.clientCount(), 0, "a silent one does not");
179
+ }
180
+
181
+ /**
182
+ * The bridge must not sweep away the process it is running inside.
183
+ *
184
+ * It did. `LocalBridge` announced itself once at construction and nothing ever
185
+ * refreshed it, so ninety seconds later the reaper -- which cannot tell an
186
+ * absent client from a quiet one -- dropped the owner from its own client list
187
+ * while it was actively serving. The count then read one short, and the drop
188
+ * was broadcast to the Studio console as an agent having finished. Found by a
189
+ * user asking why the panel said three clients when there was one.
190
+ */
191
+ {
192
+ const bridge = new Bridge();
193
+ const owner = new LocalBridge(bridge);
194
+ bridge.attach(identity("studio-a", 111), null);
195
+ assert.equal(bridge.clientCount(), 1, "the owner counts as a client");
196
+
197
+ const real = Date.now;
198
+ Date.now = () => real() + 120_000;
199
+ try {
200
+ // Working is what proves it is here, and the owner works constantly.
201
+ await owner.sessions();
202
+ bridge.reapStale();
203
+ } finally {
204
+ Date.now = real;
205
+ }
206
+ assert.equal(bridge.clientCount(), 1, "an owner that is working is not stale");
207
+
208
+ // And it still goes when it actually goes.
209
+ owner.goodbye();
210
+ assert.equal(bridge.clientCount(), 0, "goodbye still drops it");
211
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Exercises the plugin's SSE frame parser outside Studio.
3
+ *
4
+ * `Transport.luau` cannot be loaded whole by the interpreter -- it reaches for
5
+ * HttpService at module scope -- and `parseFrames` is a local, so neither the
6
+ * existing bundling harness nor a plain require can get at it. It is lifted out
7
+ * of the real file by name instead, so this cannot quietly drift from what ships:
8
+ * rename or delete the function and the extraction fails the test run.
9
+ *
10
+ * The bug it covers cost a session to find and would have been invisible in
11
+ * review. Two SSE writes in the same tick arrive as one chunk, and the old
12
+ * parser JSON-decoded the whole chunk, so BOTH frames were dropped. It showed up
13
+ * as a client-count badge that would not come back down; the part that mattered
14
+ * is that a command sharing a chunk with a keepalive would have vanished the
15
+ * same way, losing a tool call with no error at either end.
16
+ *
17
+ * Usage: node scripts/test-transport.mjs
18
+ */
19
+ import { spawnSync } from "node:child_process";
20
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { dirname, join, resolve } from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+ import { locateLuau, missingLuau } from "./locate-luau.mjs";
25
+
26
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
27
+ const luau = locateLuau("LUAU", ["luau.exe", "luau"]);
28
+ if (luau === null) {
29
+ process.stderr.write(missingLuau("luau", "LUAU"));
30
+ process.exit(1);
31
+ }
32
+
33
+ /** Lifts one top-level `local function` out of a module, body and all. */
34
+ function extractFunction(source, name) {
35
+ // Normalised first: the plugin sources are CRLF and every anchor here is LF.
36
+ const text = source.split(String.fromCharCode(13, 10)).join(String.fromCharCode(10));
37
+ const opener = `local function ${name}(`;
38
+ const start = text.indexOf(opener);
39
+ if (start === -1) throw new Error(`${name} is not in Transport.luau any more`);
40
+ const closer = String.fromCharCode(10) + 'end' + String.fromCharCode(10);
41
+ const end = text.indexOf(closer, start);
42
+ if (end === -1) throw new Error(`could not find the end of ${name}`);
43
+ return text.slice(start, end + closer.length).replace(opener, `function ${name}(`);
44
+ }
45
+ const transport = readFileSync(join(root, "plugin", "src", "Transport.luau"), "utf8");
46
+ const stub = readFileSync(join(root, "tests", "jsonstub.luau"), "utf8").replace(/^return Net$/m, "");
47
+ const cases = readFileSync(join(root, "tests", "sseframes.luau"), "utf8");
48
+
49
+ const bundle = [stub, extractFunction(transport, "parseFrames"), cases].join("\n");
50
+ const bundlePath = join(mkdtempSync(join(tmpdir(), "studio-mcp-sse-")), "bundle.luau");
51
+ writeFileSync(bundlePath, bundle, "utf8");
52
+
53
+ const result = spawnSync(luau, [bundlePath], { stdio: "inherit" });
54
+ if (result.error) {
55
+ process.stderr.write(`could not run '${luau}': ${result.error.message}\n`);
56
+ process.exit(1);
57
+ }
58
+ process.exit(result.status ?? 1);