@el4cteo/rbx-studio-mcp 0.8.2 → 0.8.4

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 +7 -4
  2. package/dist/bridge/api.js +2 -2
  3. package/dist/bridge/api.js.map +1 -1
  4. package/dist/bridge/failover.js +12 -4
  5. package/dist/bridge/failover.js.map +1 -1
  6. package/dist/bridge/harness.js +48 -6
  7. package/dist/bridge/harness.js.map +1 -1
  8. package/dist/bridge/remote.js +4 -2
  9. package/dist/bridge/remote.js.map +1 -1
  10. package/dist/bridge/rpc.js +43 -5
  11. package/dist/bridge/rpc.js.map +1 -1
  12. package/dist/bridge/server.js +26 -1
  13. package/dist/bridge/server.js.map +1 -1
  14. package/dist/lib/apidump.js +58 -13
  15. package/dist/lib/apidump.js.map +1 -1
  16. package/dist/lib/cloudassets.js +2 -2
  17. package/dist/lib/cloudassets.js.map +1 -1
  18. package/dist/lib/png.js +19 -31
  19. package/dist/lib/png.js.map +1 -1
  20. package/dist/tools/discover.js +78 -4
  21. package/dist/tools/discover.js.map +1 -1
  22. package/dist/tools/instances.js +3 -1
  23. package/dist/tools/instances.js.map +1 -1
  24. package/dist/tools/session.js +11 -3
  25. package/dist/tools/session.js.map +1 -1
  26. package/package.json +3 -3
  27. package/plugin/src/Commands.luau +72 -5
  28. package/plugin/src/Config.luau +65 -65
  29. package/plugin/src/Console.luau +5 -0
  30. package/plugin/src/Prompt.luau +23 -3
  31. package/plugin/src/handlers/Debug.luau +42 -0
  32. package/plugin/src/handlers/Exec.luau +5 -1
  33. package/plugin/src/handlers/Spatial.luau +34 -0
  34. package/plugin/src/handlers/Terrain.luau +10 -0
  35. package/plugin/src/handlers/Viewport.luau +33 -1
  36. package/plugin/src/handlers/World.luau +21 -1
  37. package/plugin/src/init.server.luau +25 -0
  38. package/scripts/check-plugin.mjs +54 -0
  39. package/scripts/test-apidump.mjs +170 -0
  40. package/scripts/test-bridge.mjs +111 -4
  41. package/scripts/test-live-tools.mjs +382 -0
  42. package/scripts/test-plugin.mjs +1 -0
  43. package/scripts/test-tools.mjs +44 -0
@@ -0,0 +1,382 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Drives the tools through the real MCP layer against a connected Studio.
4
+ *
5
+ * `test-live.mjs` talks to the plugin over the bridge, which proves the transport
6
+ * and the handlers. This starts an actual server, connects an MCP client to it and
7
+ * calls tools the way an agent does -- so schema validation, output shaping and
8
+ * the plugin are all in the path. It exists because that is where the last two
9
+ * real faults hid: `execute_luau target="client"` had been broken for a release
10
+ * because a module the relay needed was never copied to the client (every offline
11
+ * test stubs that out), and `collision remove` answered "removed" for a group that
12
+ * never existed. Neither shows in a unit test.
13
+ *
14
+ * Nothing here needs a place in any particular state. Everything it makes is under
15
+ * Workspace.__mcp_live_tools and is deleted before it exits, pass or fail, and what
16
+ * it moves (the camera, device emulation, network shaping, a playtest) is put back.
17
+ *
18
+ * The server it starts finds the bridge port taken and proxies to the running one,
19
+ * so it appears in the Studio panel as one more MCP client for the length of a run.
20
+ *
21
+ * Usage: node scripts/test-live-tools.mjs [--port 44755] [--studio-id ID] [--playtest] [--no-camera]
22
+ * --playtest also start a playtest and exercise character, input and the client tools
23
+ * --no-camera leave the viewport camera alone (skips the focus and camera checks)
24
+ */
25
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
26
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
27
+ import { dirname, join, resolve } from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
+
30
+ const args = process.argv.slice(2);
31
+ const flag = (name, fallback) => {
32
+ const at = args.indexOf(`--${name}`);
33
+ return at === -1 ? fallback : args[at + 1];
34
+ };
35
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
36
+ const FIXTURE = "Workspace.__mcp_live_tools";
37
+ const GROUP = "MCPLiveToolsGroup";
38
+ const TERRAIN_AT = "5000, 0, 5000";
39
+
40
+ const client = new Client({ name: "test-live-tools", version: "0" });
41
+ await client.connect(
42
+ new StdioClientTransport({
43
+ command: process.execPath,
44
+ args: [join(root, "dist", "index.js"), ...(flag("port") ? ["--port", flag("port")] : [])],
45
+ stderr: "inherit",
46
+ }),
47
+ );
48
+
49
+ let failures = 0;
50
+ let studioId = flag("studio-id");
51
+
52
+ async function call(name, params = {}) {
53
+ const started = performance.now();
54
+ try {
55
+ const result = await client.callTool(
56
+ { name, arguments: { ...(studioId && !("studioId" in params) ? { studioId } : {}), ...params } },
57
+ undefined,
58
+ { timeout: 180_000 },
59
+ );
60
+ const text = (result.content ?? []).filter((part) => part.type === "text").map((part) => part.text).join("\n");
61
+ return { text, isError: result.isError === true, ms: Math.round(performance.now() - started) };
62
+ } catch (cause) {
63
+ // Schema validation is a protocol error rather than an isError result.
64
+ return { text: cause.message, isError: true, ms: Math.round(performance.now() - started) };
65
+ }
66
+ }
67
+
68
+ function report(label, good, { text, ms }) {
69
+ if (!good) failures += 1;
70
+ const brief = text.replace(/\s+/g, " ").slice(0, good ? 84 : 360);
71
+ process.stdout.write(`${good ? "ok " : "FAIL"} ${label.padEnd(50)} ${String(ms).padStart(5)}ms ${brief}\n`);
72
+ }
73
+
74
+ /** `expect`: "ok" (any success), "refused" (any error), or a RegExp the reply must match. */
75
+ async function check(label, name, params, expect = "ok", { refused = false } = {}) {
76
+ const reply = await call(name, params);
77
+ const matched = expect instanceof RegExp ? expect.test(reply.text) : true;
78
+ report(label, refused ? reply.isError && matched : !reply.isError && matched, reply);
79
+ return reply.text;
80
+ }
81
+ const ok = (label, name, params, expect) => check(label, name, params, expect);
82
+ const refuses = (label, name, params, expect) => check(label, name, params, expect, { refused: true });
83
+ const section = (title) => process.stdout.write(`\n-- ${title}\n`);
84
+ const paths = (text) => text.split("\n").filter((line) => /^\S+ \| /.test(line) && !line.startsWith("path |")).map((line) => line.split(" | ")[0]);
85
+
86
+ const cleanup = [];
87
+ try {
88
+ // ---- which Studio ---------------------------------------------------------------
89
+ const listed = JSON.parse((await call("list_studios")).text.split("\n\nWARNING")[0].split("\n\n")[0]);
90
+ const edits = (listed.studios ?? []).filter((entry) => entry.context === "edit");
91
+ if (studioId === undefined) {
92
+ if (edits.length !== 1) {
93
+ process.stderr.write(
94
+ edits.length === 0
95
+ ? "No Studio in edit mode is connected. Open one with the plugin, or stop the playtest.\n"
96
+ : "Several edit sessions are connected: pass --studio-id.\n",
97
+ );
98
+ await client.close();
99
+ process.exit(1);
100
+ }
101
+ studioId = edits[0].studioId;
102
+ }
103
+ process.stdout.write(`Studio: ${edits.find((entry) => entry.studioId === studioId)?.placeName ?? studioId}\n`);
104
+
105
+ // ---- fixture ------------------------------------------------------------------------
106
+ section("fixture");
107
+ await ok("create: nested, typed, tagged", "create", { instances: [{ parent: "Workspace", className: "Folder", name: "__mcp_live_tools", children: [
108
+ { className: "Part", name: "A", properties: { Anchored: true, Size: "4, 1, 2", Position: "0, 50, 0", Color: "#FF8800", Material: "Neon" },
109
+ attributes: { hp: 5, pos: { type: "Vector3", value: "1, 2, 3" } }, tags: { add: ["MCPLive"] } },
110
+ { className: "Part", name: "B", properties: { Anchored: true, Size: "4, 1, 2", Position: "1, 50, 0" } },
111
+ { className: "Model", name: "M", children: [
112
+ { className: "Part", name: "P1", properties: { Anchored: true, Position: "20, 50, 0" } },
113
+ { className: "Part", name: "P2", properties: { Anchored: true, Position: "24, 50, 0" } } ] },
114
+ { className: "Folder", name: "Rows", children: Array.from({ length: 7 }, (_, index) => (
115
+ { className: "Part", name: `Row${index + 1}`, properties: { Anchored: true, Position: `${index * 6}, 70, 0` } })) },
116
+ ...["U1:40", "U2:43", "S1:60", "S2:60", "I1:80", "I2:83", "F1:100", "W1:120"].map((entry) => {
117
+ const [name, x] = entry.split(":");
118
+ return { className: "Part", name, properties: { Anchored: true, Size: name === "W1" ? "1, 8, 8" : name === "S2" ? "4, 4, 4" : name === "S1" ? "8, 8, 8" : "6, 6, 6", Position: `${x}, 50, 0` } };
119
+ }),
120
+ ] }] }, /__mcp_live_tools/);
121
+ await ok("script_create: a module and a disabled script", "script_create", { scripts: [
122
+ { parent: FIXTURE, name: "Mod", className: "ModuleScript", source: "local M = {}\nfunction M.add(a, b)\n\treturn a + b\nend\nreturn M\n" },
123
+ { parent: FIXTURE, name: "Srv", className: "Script", runContext: "Server", disabled: true, source: "print('x')\n" } ] }, /__mcp_live_tools\.Mod/);
124
+
125
+ // ---- reading --------------------------------------------------------------------------
126
+ section("reading");
127
+ await ok("studio_status", "studio_status", {}, /"placeName"/);
128
+ await ok("list_studios", "list_studios", {}, /studioId/);
129
+ await ok("tree", "tree", { path: FIXTURE, depth: 2 }, /__mcp_live_tools\.M/);
130
+ await ok("tree: concise", "tree", { path: FIXTURE, depth: 1, detail: "concise" }, /className/);
131
+ await ok("find: by class", "find", { className: "BasePart", path: FIXTURE, limit: 5 }, /__mcp_live_tools\.A/);
132
+ await ok("find: tags", "find", { op: "tags" }, /MCPLive/);
133
+ await ok("find: selector", "find", { selector: "Part", path: FIXTURE, limit: 3 }, /Part/);
134
+ await ok("find: nameContains", "find", { nameContains: "row", path: FIXTURE, limit: 3 }, /Row/);
135
+ await ok("inspect: standard", "inspect", { paths: [`${FIXTURE}.A`] }, /Anchored/);
136
+ await ok("inspect: full with physics", "inspect", { paths: [`${FIXTURE}.A`], detail: "full", physics: true }, /density/);
137
+ await ok("inspect: concise", "inspect", { paths: [`${FIXTURE}.A`], detail: "concise" }, /attributes/);
138
+ await ok("inspect: named properties", "inspect", { paths: [`${FIXTURE}.A`], properties: ["Name", "Position"] }, /Position/);
139
+ await ok("script_read", "script_read", { paths: [`${FIXTURE}.Mod`] }, /rev [0-9a-f]+/);
140
+ await ok("script_read: a window", "script_read", { paths: [{ path: `${FIXTURE}.Mod`, startLine: 2, endLine: 3 }] }, /lines 2-3 of 5/);
141
+ await ok("script_grep", "script_grep", { pattern: "add", literal: true, path: FIXTURE }, /Mod:2/);
142
+ await ok("api: describe", "api", { op: "describe", className: "Part" }, /superclass/);
143
+ await ok("api: classes", "api", { op: "classes", contains: "Constraint" }, /HingeConstraint/);
144
+ await ok("console", "console", { limit: 5 });
145
+ await ok("performance: snapshot", "performance", { op: "snapshot" }, /frame/);
146
+ await ok("performance: scene", "performance", { op: "scene", section: "composition" }, /composition/);
147
+ await ok("performance: coverage", "performance", { op: "coverage" });
148
+ await ok("device: list", "device", { op: "list", form: "Phone" }, /Phone/);
149
+ await ok("device: state", "device", { op: "state" });
150
+ await ok("collision: list", "collision", { action: "list" }, /Default/);
151
+ await ok("terrain: stats", "terrain", { op: "stats" }, /cells/);
152
+ await ok("undo: status", "undo", { action: "status" }, /canUndo/);
153
+ await ok("viewport: raycast down onto the fixture", "viewport", { op: "raycast", origin: "0, 120, 0", direction: "0, -1, 0" }, /"hit": ?true/);
154
+ await ok("viewport: textbounds", "viewport", { op: "textbounds", text: "Hello world", textSize: 20 }, /px/);
155
+ await ok("viewport: ui audit", "viewport", { op: "ui" });
156
+ await ok("screenshot", "screenshot", { width: 480 }, /Studio viewport/);
157
+ await ok("screenshot: zoom to a rect", "screenshot", { width: 480, rect: "100, 100, 200, 100" }, /rect/);
158
+ await refuses("screenshot: a bad rect", "screenshot", { rect: "not a rect" }, /BAD_PARAMS/);
159
+ await ok("debug: snapshots", "debug", { op: "snapshots" });
160
+ await ok("playtest: state", "playtest", { op: "state" }, /isEdit/);
161
+
162
+ section("paging and cursors");
163
+ const everyRow = paths(await ok("find: all rows", "find", { className: "BasePart", path: `${FIXTURE}.Rows`, limit: 500 }));
164
+ const seen = [];
165
+ let cursor;
166
+ for (let page = 0; page < 10; page += 1) {
167
+ const got = await call("find", { className: "BasePart", path: `${FIXTURE}.Rows`, limit: 3, ...(cursor ? { cursor } : {}) });
168
+ seen.push(...paths(got.text));
169
+ cursor = /cursor: "([^"]+)"/.exec(got.text)?.[1];
170
+ if (!cursor) break;
171
+ }
172
+ const paged = everyRow.length === 7 && JSON.stringify(seen) === JSON.stringify(everyRow);
173
+ if (!paged) failures += 1;
174
+ process.stdout.write(`${paged ? "ok " : "FAIL"} paging returns every row once, in order (${seen.length} of ${everyRow.length})\n`);
175
+ await refuses("find: a cursor this server did not issue", "find", { className: "Part", path: FIXTURE, cursor: "nope" }, /BAD_CURSOR/);
176
+ const before = await call("console", { limit: 2 });
177
+ const since = /nextCursor: ([^\]\s]+)/.exec(before.text)?.[1];
178
+ await ok("execute_luau: print and warn", "execute_luau", { source: "print('live-tools-marker'); warn('live-tools-warning')" }, /live-tools-marker/);
179
+ await ok("console: only what is newer than the cursor", "console", { since, limit: 50 }, /live-tools-marker/);
180
+ await ok("console: filtered by level and pattern", "console", { level: "warning", pattern: "live%-tools", limit: 10 }, /live-tools-warning/);
181
+
182
+ // ---- writing --------------------------------------------------------------------------------
183
+ section("writing");
184
+ await ok("modify: properties, attributes, tags", "modify", { targets: [{ paths: [`${FIXTURE}.A`], properties: { Transparency: 0.5, Color: "0.2, 0.6, 1" },
185
+ attributes: { hp: "", note: "x" }, tags: { add: ["Extra"], remove: ["MCPLive"] } }] }, /one undo step/);
186
+ await ok("find: the new tag", "find", { tag: "Extra" }, /__mcp_live_tools\.A/);
187
+ await refuses("modify: a value that is not a number", "modify", { targets: [{ paths: [`${FIXTURE}.A`], properties: { Transparency: "nope" } }] }, /BAD_PROPERTY/);
188
+ await ok("move: clone with a new name", "move", { items: [{ path: `${FIXTURE}.A`, to: `${FIXTURE}.M`, mode: "clone", name: "A2" }] }, /A2/);
189
+ await ok("move: reparent", "move", { items: [{ path: `${FIXTURE}.B`, to: `${FIXTURE}.M` }] }, /M\.B/);
190
+ await refuses("move: into itself", "move", { items: [{ path: `${FIXTURE}.M`, to: `${FIXTURE}.M.P1` }] }, /BAD_MOVE/);
191
+ await refuses("create: a class that does not exist", "create", { instances: [{ parent: FIXTURE, className: "Nope" }] }, /UNKNOWN_CLASS/);
192
+ await refuses("create: a property that does not exist", "create", { instances: [{ parent: FIXTURE, className: "Part", properties: { Sizee: "1, 1, 1" } }] }, /Did you mean: Size/);
193
+
194
+ const revision = /rev ([0-9a-f-]+)/.exec(await call("script_read", { paths: [`${FIXTURE}.Mod`] }).then((reply) => reply.text))?.[1];
195
+ await ok("script_edit: find and replace with a revision", "script_edit", { edits: [{ path: `${FIXTURE}.Mod`, find: "return a + b", replace: "return a + b + 0", revision }] });
196
+ await ok("script_edit: a line range", "script_edit", { edits: [{ path: `${FIXTURE}.Mod`, startLine: 2, endLine: 2, replacement: "function M.add(a, b) -- edited" }] });
197
+ await refuses("script_edit: a stale revision", "script_edit", { edits: [{ path: `${FIXTURE}.Mod`, source: "return {}", revision }] }, /STALE_SCRIPT/);
198
+ await refuses("script_edit: text that is not there", "script_edit", { edits: [{ path: `${FIXTURE}.Mod`, find: "zzz-absent", replace: "x" }] }, /NO_MATCH/);
199
+ await refuses("script_edit: a line past the end", "script_edit", { edits: [{ path: `${FIXTURE}.Mod`, startLine: 99, endLine: 99, replacement: "x" }] }, /BAD_RANGE/);
200
+ await ok("script_read: sees the edit", "script_read", { paths: [`${FIXTURE}.Mod`] }, /edited/);
201
+
202
+ await ok("geometry: union", "geometry", { op: "union", path: `${FIXTURE}.U1`, with: [`${FIXTURE}.U2`], name: "Unioned" }, /Unioned/);
203
+ await ok("geometry: subtract", "geometry", { op: "subtract", path: `${FIXTURE}.S1`, with: [`${FIXTURE}.S2`], name: "Subtracted" }, /Subtracted/);
204
+ await ok("geometry: intersect", "geometry", { op: "intersect", path: `${FIXTURE}.I1`, with: [`${FIXTURE}.I2`], name: "Intersected" }, /Intersected/);
205
+ await ok("geometry: fragment", "geometry", { op: "fragment", path: `${FIXTURE}.F1`, pieces: 4 }, /F1_1/);
206
+ await ok("geometry: sweep", "geometry", { op: "sweep", path: `${FIXTURE}.W1`, to: "120, 60, 0", steps: 4, keep: false, checkAgainst: [] }, /sweep/i);
207
+ await ok("geometry: mirror", "geometry", { op: "mirror", paths: [`${FIXTURE}.A`], axis: "X", copy: true }, /copied/);
208
+
209
+ cleanup.push(() => call("collision", { action: "remove", group: GROUP }));
210
+ await ok("collision: create", "collision", { action: "create", group: GROUP }, /created/);
211
+ await ok("collision: assign", "collision", { action: "assign", group: GROUP, paths: [`${FIXTURE}.A`] }, /assigned/);
212
+ await ok("collision: pass through Default", "collision", { action: "collidable", group: GROUP, with: "Default", collidable: false }, /collidable/);
213
+ await ok("collision: listed", "collision", { action: "list" }, new RegExp(GROUP));
214
+ await ok("collision: cast", "collision", { action: "cast", from: "0, 120, 0", direction: "0, -1, 0", distance: 200 }, /hit/);
215
+ await ok("collision: overlap a box", "collision", { action: "overlap", region: "box", at: "0, 50, 0", size: "10, 10, 10" }, /__mcp_live_tools/);
216
+ await ok("collision: overlap a radius", "collision", { action: "overlap", region: "radius", at: "0, 50, 0", radius: 6 }, /__mcp_live_tools/);
217
+ await ok("collision: remove", "collision", { action: "remove", group: GROUP }, /"removed": ?true/);
218
+
219
+ cleanup.push(() => call("terrain", { op: "clear", position: TERRAIN_AT, size: "40, 40, 40" }));
220
+ await ok("terrain: fill a ball", "terrain", { op: "fill", shapes: [{ shape: "ball", position: TERRAIN_AT, radius: 8, material: "Grass" }] }, /voxels/);
221
+ await ok("terrain: replace a material", "terrain", { op: "replace", position: TERRAIN_AT, size: "40, 40, 40", from: "Grass", to: "Snow" }, /Snow/);
222
+ await ok("terrain: clear the region", "terrain", { op: "clear", position: TERRAIN_AT, size: "40, 40, 40" }, /cleared/);
223
+
224
+ await ok("viewport: select", "viewport", { op: "select", paths: [`${FIXTURE}.A`] }, /1 selected/);
225
+ await ok("viewport: clear the selection", "viewport", { op: "select", paths: [] }, /cleared/i);
226
+ await ok("audio: a whole graph", "audio", { op: "graph", kind: "ui", parent: FIXTURE, name: "LiveAudio", effects: ["AudioFader"] }, /LiveAudio/);
227
+ await ok("audio: read it back", "audio", { op: "inspect", path: FIXTURE }, /LiveAudio/);
228
+ // Breakpoints need "Debugger Luau API" (File > Beta Features), which is off by
229
+ // default -- a setting of the machine, not a fault of the tool.
230
+ const armed = await call("debug", { op: "set", breakpoints: [{ path: `${FIXTURE}.Mod`, line: 3 }] });
231
+ if (/NO_DEBUGGER|NO_BREAKPOINTS_SET/.test(armed.text)) {
232
+ process.stdout.write('skip debug: "Debugger Luau API" is off in this Studio\n');
233
+ } else {
234
+ report("debug: set a breakpoint", !armed.isError && /Verified/.test(armed.text), armed);
235
+ await ok("debug: clear it", "debug", { op: "clear", path: `${FIXTURE}.Mod` }, /removed/);
236
+ }
237
+ await ok("execute_luau: returns values", "execute_luau", { source: "return 1 + 1, {a = 1}" }, /Returned/);
238
+ await refuses("execute_luau: a runtime error", "execute_luau", { source: "error('boom')" }, /boom/);
239
+ await refuses("execute_luau: a syntax error", "execute_luau", { source: "local =" }, /COMPILE_ERROR/);
240
+ await ok("assets: peek inside a model", "assets", { op: "peek", assetId: 6903238241 }, /nothing inserted/);
241
+ await ok("assets: insert without its scripts", "assets", { op: "insert", assetId: 6903238241, parent: FIXTURE, stripScripts: true }, /Glass Door/);
242
+
243
+ const undoable = await call("create", { instances: [{ parent: FIXTURE, className: "Part", name: "UndoMe" }] });
244
+ if (/one undo step/.test(undoable.text)) {
245
+ // Only when the create says it opened a recording: without one, `undo` would
246
+ // reach past this run into somebody else's work.
247
+ await ok("undo: takes the create back", "undo", { action: "undo", steps: 1 }, /"applied": ?1/);
248
+ await ok("find: it is gone", "find", { nameContains: "UndoMe", path: FIXTURE }, /No matches/);
249
+ await ok("redo: brings it back", "undo", { action: "redo", steps: 1 }, /"applied": ?1/);
250
+ await ok("find: it is back", "find", { nameContains: "UndoMe", path: FIXTURE }, /UndoMe/);
251
+ } else {
252
+ process.stdout.write("skip undo: the create opened no recording\n");
253
+ }
254
+
255
+ // ---- things that must be refused, or say plainly they did nothing ---------------------------------
256
+ section("refusals");
257
+ const GONE = "Workspace.__mcp_live_tools_absent";
258
+ for (const [label, tool, params, expect] of [
259
+ ["delete: a path that is not there", "delete", { paths: [GONE] }, /NOT_FOUND/],
260
+ ["delete: a service", "delete", { paths: ["Workspace"] }, /PROTECTED/],
261
+ ["modify: a path that is not there", "modify", { targets: [{ paths: [GONE], attributes: { a: 1 } }] }, /NOT_FOUND/],
262
+ ["move: to a parent that is not there", "move", { items: [{ path: `${FIXTURE}.A`, to: GONE }] }, /NOT_FOUND/],
263
+ ["create: under a parent that is not there", "create", { instances: [{ parent: GONE, className: "Part" }] }, /NOT_FOUND/],
264
+ ["tree: a path that is not there", "tree", { path: GONE }, /NOT_FOUND/],
265
+ ["script_edit: something that is not a script", "script_edit", { edits: [{ path: `${FIXTURE}.A`, find: "a", replace: "b" }] }, /NOT_A_SCRIPT/],
266
+ ["viewport: select what is not there", "viewport", { op: "select", paths: [GONE] }, /NOT_FOUND/],
267
+ ["device: a device that does not exist", "device", { op: "set", device: "no-such-device" }, /NO_SUCH_DEVICE/],
268
+ ["terrain: clear everything without confirm", "terrain", { op: "clear" }, /CONFIRM_REQUIRED/],
269
+ ["terrain: a material that does not exist", "terrain", { op: "fill", shapes: [{ shape: "ball", position: TERRAIN_AT, radius: 4, material: "NotAMaterial" }] }, /BAD_PARAMS/],
270
+ ["collision: assign to a group that does not exist", "collision", { action: "assign", group: "NoSuchGroupZ", paths: [`${FIXTURE}.A`] }, /NO_SUCH_GROUP/],
271
+ ["geometry: subtract with nothing to subtract", "geometry", { op: "subtract", path: `${FIXTURE}.A` }, /BAD_PARAMS/],
272
+ ["character: needs a playtest", "character", { op: "state" }, /NOT_RUNNING/],
273
+ ["input: needs a playtest", "input", { steps: [{ kind: "key", key: "E" }] }, /NOT_RUNNING/],
274
+ ["execute_luau client: needs a playtest", "execute_luau", { source: "return 1", target: "client" }, /NOT_RUNNING/],
275
+ ["playtest: add players to none", "playtest", { op: "addPlayers", players: 1 }, /No playtest is running/],
276
+ ["universe: restart needs confirm", "universe", { op: "restart" }, /NEEDS_CONFIRM|NO_CREDENTIALS/],
277
+ ["universe: a ban needs confirm", "universe", { op: "ban", userId: "1" }, /NEEDS_CONFIRM|NO_CREDENTIALS/],
278
+ ["execute_luau live: needs confirm", "execute_luau", { source: "return 1", target: "live" }, /NEEDS_CONFIRM/],
279
+ ["script_edit live: needs confirm", "script_edit", { target: "live", path: "ServerScriptService.X", source: "x" }, /NEEDS_CONFIRM/],
280
+ ["datastore live: a write needs confirm", "datastore", { op: "set", target: "live", store: "s", key: "k", value: "1" }, /NEEDS_CONFIRM/],
281
+ ["assets: publish needs a file", "assets", { op: "publish" }, /BAD_PARAMS/],
282
+ ]) await refuses(label, tool, params, expect);
283
+ await ok("inspect: says a path could not be resolved", "inspect", { paths: [GONE] }, /Could not resolve/);
284
+ await ok("script_read: says a path could not be read", "script_read", { paths: [GONE] }, /Could not read/);
285
+ await ok("collision: removing a missing group says so", "collision", { action: "remove", group: "NoSuchGroupZ" }, /"removed": ?false/);
286
+ await ok("inspect: a mistyped property is named", "inspect", { paths: [`${FIXTURE}.A`], properties: ["Name", "Sizee"] }, /Sizee \(not a property of Part; did you mean Size\?\)/);
287
+ await ok("find: a mistyped class is named", "find", { className: "Prat", path: FIXTURE }, /not a Roblox class/);
288
+
289
+ section("zero-length and empty inputs");
290
+ const rayGuard = await refuses("viewport raycast: a zero direction", "viewport", { op: "raycast", origin: "0, 100, 0", direction: "0, 0, 0" }, /BAD_PARAMS/);
291
+ await refuses("collision cast: from a point to itself", "collision", { action: "cast", from: "0, 80, 0", to: "0, 80, 0" }, /no length/);
292
+ await refuses("collision cast: distance 0", "collision", { action: "cast", from: "0, 80, 0", direction: "0, -1, 0", distance: 0 }, /no length/);
293
+ await refuses("collision cast: a zero direction", "collision", { action: "cast", from: "0, 80, 0", direction: "0, 0, 0" }, /no length/);
294
+ await refuses("collision overlap: a box with no volume", "collision", { action: "overlap", region: "box", at: "0, 80, 0", size: "0, 0, 0" }, /no volume/);
295
+ await refuses("collision overlap: a radius of 0", "collision", { action: "overlap", region: "radius", at: "0, 80, 0", radius: 0 }, /no volume/);
296
+ await refuses("terrain fill: a block with no size", "terrain", { op: "fill", shapes: [{ shape: "block", position: TERRAIN_AT, size: "0, 0, 0", material: "Grass" }] }, /empty/);
297
+ await refuses("terrain replace: a region with no size", "terrain", { op: "replace", position: TERRAIN_AT, size: "0, 4, 4", from: "Grass", to: "Snow" }, /empty/);
298
+ await refuses("debug: a line the script does not have", "debug", { op: "set", breakpoints: [{ path: `${FIXTURE}.Mod`, line: 999 }] }, /BAD_LINE/);
299
+
300
+ // ---- the camera, which is the user's ----------------------------------------------------------------
301
+ section("camera");
302
+ if (args.includes("--no-camera")) {
303
+ process.stdout.write("skip --no-camera\n");
304
+ } else if (!/BAD_PARAMS/.test(rayGuard)) {
305
+ // Without the guard a zero `from` gives the camera a NaN position, so the
306
+ // probes are not sent to a plugin that has not got it.
307
+ failures += 1;
308
+ process.stdout.write("FAIL the plugin does not refuse zero-length input; camera checks skipped to protect the viewport\n");
309
+ } else {
310
+ const original = await call("viewport", { op: "camera" });
311
+ let camera;
312
+ try { camera = JSON.parse(original.text); } catch { camera = undefined; }
313
+ cleanup.push(async () => {
314
+ if (!camera) return;
315
+ const [px, py, pz] = camera.position.split(",").map(Number);
316
+ const [lx, ly, lz] = camera.lookVector.split(",").map(Number);
317
+ await call("viewport", { op: "camera", position: camera.position, lookAt: `${px + lx}, ${py + ly}, ${pz + lz}`, fieldOfView: camera.fieldOfView });
318
+ });
319
+ await refuses("camera: position equal to lookAt", "viewport", { op: "camera", position: "0, 90, 0", lookAt: "0, 90, 0" }, /same point/);
320
+ await refuses("focus: a zero `from`", "viewport", { op: "focus", path: FIXTURE, from: "0, 0, 0" }, /BAD_PARAMS/);
321
+ const unmoved = (await call("viewport", { op: "camera" })).text === original.text;
322
+ if (!unmoved) failures += 1;
323
+ process.stdout.write(`${unmoved ? "ok " : "FAIL"} the refused requests left the camera where it was\n`);
324
+ await ok("focus: on the fixture", "viewport", { op: "focus", path: FIXTURE }, /cameraPosition/);
325
+ await ok("focus: from above", "viewport", { op: "focus", path: FIXTURE, from: "0, 1, 0" }, /cameraPosition/);
326
+ await ok("camera: set it directly", "viewport", { op: "camera", position: "0, 90, 30", lookAt: "0, 50, 0", fieldOfView: 60 }, /position/);
327
+ }
328
+
329
+ // ---- emulation, put back --------------------------------------------------------------------------------
330
+ section("device and network");
331
+ cleanup.push(() => call("device", { op: "stop" }));
332
+ const phone = /^(\S+) \| /m.exec((await call("device", { op: "list", form: "Phone" })).text.split("\n").slice(1).join("\n"))?.[1];
333
+ if (phone) {
334
+ await ok("device: emulate a phone", "device", { op: "set", device: phone });
335
+ await ok("screenshot: through the emulation", "screenshot", { width: 320 }, /Emulating/);
336
+ await ok("viewport ui: against the phone", "viewport", { op: "ui" }, /emulating/);
337
+ await ok("device: stop", "device", { op: "stop" }, /"emulating": ?false/);
338
+ }
339
+ await ok("device: a 3g connection", "device", { op: "network", preset: "3g" }, /"shaping": ?true|shaping/);
340
+ await ok("device: back to normal", "device", { op: "network", preset: "clear" }, /"shaping": ?false|normal/);
341
+
342
+ // ---- a running game ---------------------------------------------------------------------------------------
343
+ if (args.includes("--playtest")) {
344
+ section("playtest");
345
+ const started = await call("playtest", { op: "play" });
346
+ const playing = /"studioId": "([^"]+)"/.exec(started.text)?.[1];
347
+ const good = Boolean(playing);
348
+ if (!good) failures += 1;
349
+ process.stdout.write(`${good ? "ok " : "FAIL"} playtest: play returns the server's studioId ${started.text.replace(/\s+/g, " ").slice(0, 100)}\n`);
350
+ if (playing) {
351
+ cleanup.push(() => call("playtest", { op: "stop", studioId }));
352
+ await new Promise((resolve) => setTimeout(resolve, 3500));
353
+ const on = (params) => ({ ...params, studioId: playing });
354
+ await ok("character: state", "character", on({ op: "state" }), /Humanoid/);
355
+ await ok("character: path to a point", "character", on({ op: "path", to: "20, 3, 20" }), /Reachable/);
356
+ await ok("character: walk to a point", "character", on({ op: "moveTo", to: "10, 3, 10" }), /"arrived": ?true/);
357
+ await ok("character: jump", "character", on({ op: "act", action: "jump" }), /jump/);
358
+ await ok("input: keys", "input", on({ cursor: false, steps: [{ kind: "key", key: "W", hold: 0.3 }, { kind: "key", key: "Space" }] }), /delivered/);
359
+ await ok("screenshot: the player's own view", "screenshot", on({ width: 480 }), /playtest client/);
360
+ await ok("console: the server's log", "console", on({ limit: 5 }));
361
+ await ok("console: the client's log", "console", on({ target: "client", limit: 5 }));
362
+ await ok("performance: snapshot", "performance", on({ op: "snapshot" }), /frame/);
363
+ await ok("execute_luau: in the client VM", "execute_luau", on({ target: "client", source: "return game:GetService('Players').LocalPlayer.Name" }), /Returned/);
364
+ await ok("execute_luau: in the server VM", "execute_luau", on({ source: "return #game.Workspace:GetChildren()" }), /Returned/);
365
+ await ok("debug: trace remote traffic", "debug", on({ op: "remotes", seconds: 1 }));
366
+ await ok("playtest: stop", "playtest", { op: "stop" }, /editModeActive": ?true/);
367
+ }
368
+ }
369
+ } finally {
370
+ // Reversed, so what was made last is undone first.
371
+ for (const step of cleanup.reverse()) await step().catch(() => {});
372
+ await call("delete", { paths: [FIXTURE] });
373
+ const gone = await call("find", { nameContains: "__mcp_live_tools", path: "Workspace" });
374
+ if (!/No matches/.test(gone.text)) {
375
+ failures += 1;
376
+ process.stderr.write(`cleanup left the fixture behind; delete ${FIXTURE} by hand\n`);
377
+ }
378
+ await client.close();
379
+ }
380
+
381
+ process.stdout.write(failures === 0 ? "\nlive tools: ok\n" : `\nlive tools: ${failures} failure(s)\n`);
382
+ process.exit(failures === 0 ? 0 : 1);
@@ -30,6 +30,7 @@ if (luau === null) {
30
30
  const suites = [
31
31
  { module: "plugin/src/handlers/Playtest.luau", test: "tests/playtests.luau", prelude: "tests/playtests-stub.luau" },
32
32
  { module: "plugin/src/Commands.luau", test: "tests/playtests-commands.luau", prelude: "tests/playtests-stub.luau", dependency: "plugin/src/handlers/Playtest.luau" },
33
+ { module: "plugin/src/Commands.luau", test: "tests/chat-commands.luau", prelude: "tests/playtests-stub.luau", dependency: "plugin/src/handlers/Playtest.luau" },
33
34
  { module: "plugin/src/RemoteTrace.luau", test: "tests/remote-trace.luau", prelude: "tests/remote-trace-stub.luau" },
34
35
  { module: "plugin/src/ExecRuntime.luau", test: "tests/exec-runtime.luau", prelude: "tests/exec-runtime-stub.luau" },
35
36
  { module: "plugin/src/ClientRelay.luau", test: "tests/client-relay.luau", prelude: "tests/client-relay-stub.luau" },
@@ -283,6 +283,50 @@ process.stdout.write("client console schema and cursor forwarding: ok\n");
283
283
  process.stdout.write("screenshot scaling: ok\n");
284
284
  }
285
285
 
286
+ // The PNG writer: every chunk carries the right checksum and the pixels survive.
287
+ // A wrong CRC still yields a file, and a file every decoder refuses with no hint
288
+ // which byte was at fault -- so the checksums are recomputed here by a separate
289
+ // implementation rather than by the one under test.
290
+ {
291
+ const { encodePng } = await import("../dist/lib/png.js");
292
+ const { inflateSync } = await import("node:zlib");
293
+ const table = new Int32Array(256).map((_, n) => {
294
+ let c = n;
295
+ for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
296
+ return c;
297
+ });
298
+ const reference = (bytes) => {
299
+ let c = -1;
300
+ for (const byte of bytes) c = table[(c ^ byte) & 0xff] ^ (c >>> 8);
301
+ return (c ^ -1) >>> 0;
302
+ };
303
+ const width = 3, height = 2;
304
+ const rgb = Buffer.from([255,0,0, 0,255,0, 0,0,255, 10,20,30, 40,50,60, 70,80,90]);
305
+ const png = encodePng(rgb, width, height);
306
+ assert.deepEqual([...png.subarray(0, 8)], [0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a], "PNG signature");
307
+ let offset = 8, idat;
308
+ const kinds = [];
309
+ while (offset < png.length) {
310
+ const length = png.readUInt32BE(offset);
311
+ const kind = png.toString("ascii", offset + 4, offset + 8);
312
+ assert.equal(png.readUInt32BE(offset + 8 + length), reference(png.subarray(offset + 4, offset + 8 + length)), `${kind} checksum`);
313
+ if (kind === "IHDR") {
314
+ assert.equal(png.readUInt32BE(offset + 8), width, "IHDR width");
315
+ assert.equal(png.readUInt32BE(offset + 12), height, "IHDR height");
316
+ }
317
+ if (kind === "IDAT") idat = png.subarray(offset + 8, offset + 8 + length);
318
+ kinds.push(kind);
319
+ offset += 12 + length;
320
+ }
321
+ assert.deepEqual(kinds, ["IHDR", "IDAT", "IEND"]);
322
+ const raw = inflateSync(idat);
323
+ assert.equal(raw.length, (width * 3 + 1) * height, "one filter byte per row");
324
+ assert.deepEqual([...raw.subarray(1, 10)], [...rgb.subarray(0, 9)], "first row survives");
325
+ assert.deepEqual([...raw.subarray(11)], [...rgb.subarray(9)], "second row survives");
326
+ assert.throws(() => encodePng(Buffer.alloc(5), 2, 2), /short of the 12 needed/, "short pixel data is refused");
327
+ process.stdout.write("png encoding: ok\n");
328
+ }
329
+
286
330
  // scene: a category of bare counts renders on one line; owned entries keep theirs.
287
331
  {
288
332
  const perf = registered.get("performance");