@el4cteo/rbx-studio-mcp 0.4.6 → 0.5.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.
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Checks the console panel's command line and the agents it starts.
3
+ *
4
+ * Everything here is a pure function or a call against a real Bridge with fake
5
+ * Studio sessions -- no sockets, no Studio, and above all no agent processes.
6
+ * That last one is the constraint that shapes the file: the interesting code is
7
+ * "start a coding agent and stream it back", and a test that actually did so
8
+ * would cost money, need an API key, and take a minute. So the seam is
9
+ * `harness.read`, which turns one line of a harness's output into console rows
10
+ * and is where every adapter's real work lives.
11
+ *
12
+ * The event shapes below are recorded from live runs of each CLI, not invented.
13
+ * A test built on a guessed envelope passes forever and proves nothing.
14
+ */
15
+ import assert from "node:assert/strict";
16
+ import { readFileSync } from "node:fs";
17
+ import { Bridge } from "../dist/bridge/rpc.js";
18
+ import { LocalBridge } from "../dist/bridge/api.js";
19
+ import { frame, handleConsole } from "../dist/bridge/console.js";
20
+ import { find, installed } from "../dist/bridge/harness.js";
21
+
22
+ let checks = 0;
23
+ const ok = (condition, what) => {
24
+ assert.ok(condition, what);
25
+ checks += 1;
26
+ };
27
+
28
+ /** Every row a harness produces for one line of its output. */
29
+ const readAll = (id, lines) => {
30
+ const harness = find(id);
31
+ const rows = [];
32
+ let session = null;
33
+ for (const line of lines) {
34
+ const reading = harness.read(line);
35
+ if (reading.session !== undefined) session = reading.session;
36
+ rows.push(...reading.lines);
37
+ }
38
+ return { rows, session };
39
+ };
40
+
41
+ // --- Claude Code -----------------------------------------------------------
42
+ {
43
+ const { rows, session } = readAll("claude", [
44
+ JSON.stringify({ type: "system", subtype: "init", session_id: "abc-123" }),
45
+ JSON.stringify({
46
+ type: "assistant",
47
+ message: {
48
+ content: [
49
+ { type: "text", text: "Looking at the place." },
50
+ {
51
+ type: "tool_use",
52
+ name: "mcp__rbx-studio__create",
53
+ input: { instances: [], parent: "Workspace" },
54
+ },
55
+ ],
56
+ },
57
+ }),
58
+ "not json at all",
59
+ JSON.stringify({ type: "result", duration_ms: 5600, total_cost_usd: 0.1282 }),
60
+ ]);
61
+
62
+ ok(session === "abc-123", "claude: session id is learned from the init event");
63
+ ok(rows.length === 3, "claude: init contributes no row, junk lines are ignored");
64
+ ok(rows[0].level === "reply" && rows[0].message === "Looking at the place.", "claude: prose");
65
+ ok(rows[1].level === "call" && rows[1].message === "create", "claude: server prefix is stripped");
66
+ ok(rows[2].level === "ok" && rows[2].message === "agent done", "claude: result row");
67
+ ok(rows[2].detail === "5.6s $0.1282", "claude: duration and cost ride the detail column");
68
+
69
+ // A failure must not be reported as a completion. This is the one row a user
70
+ // reads to decide whether to trust what just happened to their place.
71
+ const failed = readAll("claude", [
72
+ JSON.stringify({ type: "result", is_error: true, duration_ms: 100 }),
73
+ ]);
74
+ ok(failed.rows[0].level === "error", "claude: is_error becomes an error row");
75
+ }
76
+
77
+ // --- Tool arguments --------------------------------------------------------
78
+ {
79
+ // Regression: a Luau snippet passed to execute_luau is multi-line, and 32
80
+ // characters of it used to carry a newline into a log whose rows are lines.
81
+ // The visible symptom was a stray "m" sitting at column 0 under the entry.
82
+ const { rows } = readAll("claude", [
83
+ JSON.stringify({
84
+ type: "assistant",
85
+ message: {
86
+ content: [
87
+ {
88
+ type: "tool_use",
89
+ name: "mcp__rbx-studio__execute_luau",
90
+ input: { source: "local m = workspace.SmallHouse\nm.Parent = nil\nprint(m)" },
91
+ },
92
+ ],
93
+ },
94
+ }),
95
+ ]);
96
+ ok(!rows[0].detail.includes("\n"), "tool detail never contains a newline");
97
+ ok(rows[0].message === "execute_luau", "tool name keeps its own underscores");
98
+ }
99
+
100
+ // --- Codex and opencode ----------------------------------------------------
101
+ {
102
+ const codex = readAll("codex", [
103
+ JSON.stringify({ type: "session.created", session_id: "cx-1" }),
104
+ JSON.stringify({ type: "agent_message", text: "Done." }),
105
+ ]);
106
+ ok(codex.session === "cx-1", "codex: session id");
107
+ ok(codex.rows[0].level === "reply" && codex.rows[0].message === "Done.", "codex: prose");
108
+
109
+ const oc = readAll("opencode", [
110
+ JSON.stringify({
111
+ type: "message.part.updated",
112
+ properties: { part: { type: "text", text: "Placed the model." } },
113
+ }),
114
+ ]);
115
+ ok(oc.rows[0].message === "Placed the model.", "opencode: prose");
116
+ }
117
+
118
+ // --- DeepSeek Harness ------------------------------------------------------
119
+ {
120
+ const dsh = find("dsh");
121
+ const argv = dsh.argv("add a spawn point", null);
122
+
123
+ // `dsh [options] [command] [args...]`: --patch is a launcher option and the
124
+ // prompt is a positional argument, so the flag has to come first. Getting
125
+ // this backwards is silent -- dsh reads the prompt as the patch path.
126
+ const patchAt = argv.indexOf("--patch");
127
+ ok(patchAt !== -1, "dsh: the overlay is passed");
128
+ ok(
129
+ patchAt < argv.indexOf("add a spawn point"),
130
+ "dsh: --patch comes before the prompt, or dsh reads the prompt as a path",
131
+ );
132
+ ok(argv[argv.length - 1] === "add a spawn point", "dsh: the prompt is last");
133
+ ok(dsh.mcpFlag === undefined, "dsh: builds its own argv rather than appending flags");
134
+
135
+ const { rows } = readAll("dsh", ["The spawn point is placed.", " ", ""]);
136
+ ok(rows.length === 1, "dsh: blank lines are not rows");
137
+ ok(rows[0].level === "reply", "dsh: headless prints prose, not events");
138
+ }
139
+
140
+ // --- The registry ----------------------------------------------------------
141
+ {
142
+ ok(find("claude") !== undefined && find("nonesuch") === undefined, "registry: lookup by id");
143
+ ok(
144
+ installed().every((entry) => typeof entry.id === "string" && entry.id.length > 0),
145
+ "registry: every detected harness is named",
146
+ );
147
+ }
148
+
149
+ // --- Panel-started agents are not other people's clients -------------------
150
+ {
151
+ const bridge = new Bridge();
152
+ const announcements = [];
153
+ bridge.watchClients((count) => announcements.push(count));
154
+
155
+ const mine = new LocalBridge(bridge);
156
+ ok(bridge.clientCount() === 1, "an ordinary client counts");
157
+
158
+ // What a spawned agent's own server reports when it says hello. Counting it
159
+ // flashed the badge to 2 and logged an arrival and a departure around every
160
+ // single prompt -- around output the user was trying to read.
161
+ bridge.noteClient("spawned-agent", { name: "claude-code", pid: 42, spawned: true });
162
+ ok(bridge.clientCount() === 1, "a panel-started agent is not counted");
163
+ ok(
164
+ bridge.clientList().every((client) => client.name !== "claude-code"),
165
+ "a panel-started agent is not in the roster",
166
+ );
167
+ ok(
168
+ announcements.every((count) => count <= 1),
169
+ "a panel-started agent never announces an arrival",
170
+ );
171
+
172
+ mine.goodbye();
173
+ }
174
+
175
+ // --- Command routing -------------------------------------------------------
176
+ {
177
+ const bridge = new Bridge();
178
+ const identity = (studioId, placeId, placeName) => ({
179
+ studioId,
180
+ placeName,
181
+ placeId,
182
+ pluginVersion: "test",
183
+ buildId: "test",
184
+ protocolVersion: 1,
185
+ transport: "poll",
186
+ context: "edit",
187
+ });
188
+ bridge.attach(identity("studio-a", 111, "Alpha"), null);
189
+ bridge.attach(identity("studio-b", 222, "Beta"), null);
190
+
191
+ const run = (command, args = []) =>
192
+ handleConsole(bridge, 44755, { studioId: "studio-a", command, args, line: command });
193
+
194
+ const studios = await run("studios");
195
+ ok(studios.length === 3, "studios: a heading and one row per Studio");
196
+ ok(
197
+ studios.some((row) => row.message.includes("(this panel)")),
198
+ "studios: the asking panel is marked, so two rows with one place name are told apart",
199
+ );
200
+
201
+ // `use` takes the number printed by `studios`, because requiring the id would
202
+ // mean reading a hex string off one line to type it into the next.
203
+ const used = await run("use", ["2"]);
204
+ ok(used[0].level === "ok" && used[0].message.includes("Beta"), "use: resolves a list number");
205
+ ok(bridge.activeId("nobody-in-particular") === "studio-b", "use: applies to clients too");
206
+
207
+ const bad = await run("use", ["nope"]);
208
+ ok(bad[0].level === "error", "use: an unknown target is an error, not a silent no-op");
209
+ ok(bridge.activeId("nobody") === "studio-b", "use: a failed switch changes nothing");
210
+
211
+ const noArg = await run("use");
212
+ ok(noArg[0].message.startsWith("usage:"), "use: says how to use it");
213
+
214
+ const stopped = await run("stop");
215
+ ok(stopped[0].message === "nothing is running", "stop: honest when idle");
216
+
217
+ const agents = await run("agent");
218
+ ok(agents.length > 0, "agent: always answers, installed or not");
219
+
220
+ const unknown = await run("wat");
221
+ ok(unknown[0].level === "error", "an unknown command is reported, not guessed at");
222
+ ok(
223
+ unknown[0].detail.includes("different builds"),
224
+ "an unknown command names the likely cause, since the plugin filters first",
225
+ );
226
+ }
227
+
228
+ // --- doctor ----------------------------------------------------------------
229
+ {
230
+ const bridge = new Bridge();
231
+ // A port nothing is listening on: doctor must still answer, because "why is
232
+ // nothing working" is exactly when it is run.
233
+ const rows = await handleConsole(bridge, 45999, {
234
+ studioId: "studio-a",
235
+ command: "doctor",
236
+ args: [],
237
+ line: "doctor",
238
+ });
239
+ ok(rows.length > 1, "doctor: reports against a dead port rather than failing");
240
+ ok(
241
+ rows.every((row) => typeof row.message === "string" && !row.message.includes("\n")),
242
+ "doctor: every row is one line",
243
+ );
244
+ const summary = rows[rows.length - 1];
245
+ ok(/passed.*warning.*failure/.test(summary.message), "doctor: ends with a tally");
246
+ }
247
+
248
+ // The framing preamble --------------------------------------------------------
249
+ //
250
+ // The shipped bug: "create a simple script, then edit it" sent the agent to the
251
+ // filesystem, because that is where a coding agent spawned in a repo assumes a
252
+ // script lives. It tried Bash, was refused, and reported the test impossible.
253
+ {
254
+ const framed = frame("make the door open");
255
+ ok(framed.endsWith("make the door open"), "frame: the user's words come last and unaltered");
256
+ ok(/Roblox Studio/.test(framed), "frame: says where the prompt came from");
257
+ ok(/script_create|script_edit/.test(framed), "frame: names the tools that reach the place");
258
+ ok(!framed.includes(String.fromCharCode(13)), "frame: no stray carriage returns");
259
+ // A framing that swallows an empty prompt would send the agent a wall of
260
+ // instructions and no request.
261
+ ok(frame("").trim().length > 0, "frame: survives an empty prompt");
262
+ }
263
+
264
+ // The spawned marker reaches the server the agent starts ----------------------
265
+ //
266
+ // The shipped bug: the agent inherited RBX_STUDIO_MCP_SPAWNED, but the agent is
267
+ // not what connects -- it launches its own copy of this server, and Claude did
268
+ // not pass its environment down. So the spawned server announced itself as a
269
+ // stranger: "2 MCP clients connected" on every prompt, and a stopped agent sat
270
+ // in `clients` until the stale timeout swept it.
271
+ {
272
+ const claude = find("claude");
273
+ ok(claude !== undefined, "registry: claude is registered");
274
+ const flag = claude.mcpFlag();
275
+ ok(flag[0] === "--mcp-config", "claude: passes an mcp config file");
276
+ const written = JSON.parse(readFileSync(flag[1], "utf8"));
277
+ const server = written.mcpServers["rbx-studio"];
278
+ ok(server !== undefined, "claude config: names this server");
279
+ ok(
280
+ server.env?.RBX_STUDIO_MCP_SPAWNED === "1",
281
+ "claude config: marks the server it starts as spawned by the panel",
282
+ );
283
+
284
+ const dsh = find("dsh");
285
+ const argv = dsh.argv("hello");
286
+ const patch = argv[argv.indexOf("--patch") + 1];
287
+ ok(
288
+ readFileSync(patch, "utf8").includes("RBX_STUDIO_MCP_SPAWNED: '1'"),
289
+ "dsh overlay: carries the same marker",
290
+ );
291
+ }
292
+
293
+ process.stdout.write(`console: ${checks} checks pass\n`);
@@ -133,4 +133,51 @@ function handshake(port, studioId) {
133
133
  await owner.close();
134
134
  }
135
135
 
136
+ // POST /hello carries the panel's spawned marker all the way to the roster.
137
+ //
138
+ // The shipped bug: every layer marked the agent the panel starts -- the spawn's
139
+ // environment, the MCP config handed to the agent, the hello body -- and this
140
+ // route typed the body with three fields and dropped the fourth. So the panel
141
+ // said "2 MCP clients connected" on every prompt and kept a stopped agent in
142
+ // `clients` until the stale sweep. Tested at the route, because the route is
143
+ // the layer that was wrong while the bridge underneath it was right.
144
+ {
145
+ const owner = await startBridgeServer({ port: PORT });
146
+
147
+ const hello = async (id, body) => {
148
+ const sent = await fetch(`http://127.0.0.1:${PORT}/hello`, {
149
+ method: "POST",
150
+ headers: {
151
+ [CLIENT_HEADER]: "test",
152
+ [PEER_HEADER]: id,
153
+ "Content-Type": "application/json",
154
+ },
155
+ body: JSON.stringify(body),
156
+ });
157
+ return (await sent.json()).clients;
158
+ };
159
+
160
+ const alone = await hello("a-stranger", { name: "codex", version: "1", pid: 1 });
161
+ const withAgent = await hello("panel-agent", {
162
+ name: "claude-code",
163
+ version: "1",
164
+ pid: 2,
165
+ spawned: true,
166
+ });
167
+ assert.equal(withAgent, alone, "an agent the panel started is not another client");
168
+ // A second stranger does count, or the filter would be hiding everyone.
169
+ const withStranger = await hello("another-stranger", { name: "opencode", pid: 3 });
170
+ assert.equal(withStranger, alone + 1, "a client nobody asked for still counts");
171
+
172
+ // A keepalive that says nothing must not erase what the first hello said, and
173
+ // must not re-announce the client as if it had just arrived.
174
+ assert.equal(
175
+ await hello("a-stranger", { pid: 1 }),
176
+ withStranger,
177
+ "a nameless keepalive is not a new client",
178
+ );
179
+
180
+ await owner.close();
181
+ }
182
+
136
183
  process.stdout.write("failover: ok\n");
@@ -1,82 +1,85 @@
1
- /**
2
- * Runs the plugin's unit tests outside Roblox Studio.
3
- *
4
- * The modules under test import their dependencies with `require(script.Parent.X)`,
5
- * which only resolves inside Studio. Rather than mock the engine, this bundles
6
- * the real module source with a stub for its one dependency and runs the result
7
- * through the standalone Luau interpreter, so the tests exercise the shipped
8
- * code rather than a copy of it.
9
- *
10
- * Needs the `luau` binary on PATH, in ./tools, or named by the LUAU variable.
11
- * Get one from https://github.com/luau-lang/luau/releases.
12
- *
13
- * Usage: node scripts/test-plugin.mjs
14
- */
15
- import { spawnSync } from "node:child_process";
16
- import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
17
- import { tmpdir } from "node:os";
18
- import { dirname, join, resolve } from "node:path";
19
- import { fileURLToPath } from "node:url";
20
- import { locateLuau, missingLuau } from "./locate-luau.mjs";
21
-
22
- const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
23
- const luau = locateLuau("LUAU", ["luau.exe", "luau"]);
24
- if (luau === null) {
25
- process.stderr.write(missingLuau("luau", "LUAU"));
26
- process.exit(1);
27
- }
28
-
29
- /** Modules under test, paired with the test file that exercises each. */
30
- const suites = [{ module: "plugin/src/TextEdit.luau", test: "tests/textedit.luau" }];
31
-
32
- /**
33
- * The stub stands in for Dispatch. It has to raise the same structured table the
34
- * real one does, because the tests assert on `code` -- that contract is what the
35
- * MCP server turns into an actionable error for the agent.
36
- */
37
- const DISPATCH_STUB = `local Dispatch = {}
38
- function Dispatch.fail(code, message, hint)
39
- \terror({ code = code, message = message, hint = hint }, 0)
40
- end
41
- `;
42
-
43
- /** Drops the module's own requires; the stub above is already in scope. */
44
- const stripRequires = (source) =>
45
- source.replace(/^local \w+ = require\(script[^\n]*\n/gm, "");
46
-
47
- let failures = 0;
48
-
49
- for (const suite of suites) {
50
- const moduleSource = stripRequires(readFileSync(join(root, suite.module), "utf8"));
51
- const testSource = readFileSync(join(root, suite.test), "utf8");
52
-
53
- const bundle = [
54
- DISPATCH_STUB,
55
- "local Module = (function()",
56
- moduleSource,
57
- "end)()",
58
- "local run = function(...)",
59
- testSource,
60
- "end",
61
- "run(Module)",
62
- "",
63
- ].join("\n");
64
-
65
- const bundlePath = join(mkdtempSync(join(tmpdir(), "studio-mcp-test-")), "bundle.luau");
66
- writeFileSync(bundlePath, bundle, "utf8");
67
-
68
- const result = spawnSync(luau, [bundlePath], { stdio: "inherit" });
69
- if (result.error) {
70
- process.stderr.write(
71
- `could not run '${luau}': ${result.error.message}\n` +
72
- "Set LUAU to the path of a Luau interpreter.\n",
73
- );
74
- process.exit(1);
75
- }
76
- if (result.status !== 0) {
77
- process.stderr.write(`FAIL ${suite.test}\n`);
78
- failures += 1;
79
- }
80
- }
81
-
82
- process.exit(failures === 0 ? 0 : 1);
1
+ /**
2
+ * Runs the plugin's unit tests outside Roblox Studio.
3
+ *
4
+ * The modules under test import their dependencies with `require(script.Parent.X)`,
5
+ * which only resolves inside Studio. Rather than mock the engine, this bundles
6
+ * the real module source with a stub for its one dependency and runs the result
7
+ * through the standalone Luau interpreter, so the tests exercise the shipped
8
+ * code rather than a copy of it.
9
+ *
10
+ * Needs the `luau` binary on PATH, in ./tools, or named by the LUAU variable.
11
+ * Get one from https://github.com/luau-lang/luau/releases.
12
+ *
13
+ * Usage: node scripts/test-plugin.mjs
14
+ */
15
+ import { spawnSync } from "node:child_process";
16
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { dirname, join, resolve } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { locateLuau, missingLuau } from "./locate-luau.mjs";
21
+
22
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
23
+ const luau = locateLuau("LUAU", ["luau.exe", "luau"]);
24
+ if (luau === null) {
25
+ process.stderr.write(missingLuau("luau", "LUAU"));
26
+ process.exit(1);
27
+ }
28
+
29
+ /** Modules under test, paired with the test file that exercises each. */
30
+ const suites = [
31
+ { module: "plugin/src/TextEdit.luau", test: "tests/textedit.luau" },
32
+ { module: "plugin/src/Format.luau", test: "tests/format.luau" },
33
+ ];
34
+
35
+ /**
36
+ * The stub stands in for Dispatch. It has to raise the same structured table the
37
+ * real one does, because the tests assert on `code` -- that contract is what the
38
+ * MCP server turns into an actionable error for the agent.
39
+ */
40
+ const DISPATCH_STUB = `local Dispatch = {}
41
+ function Dispatch.fail(code, message, hint)
42
+ \terror({ code = code, message = message, hint = hint }, 0)
43
+ end
44
+ `;
45
+
46
+ /** Drops the module's own requires; the stub above is already in scope. */
47
+ const stripRequires = (source) =>
48
+ source.replace(/^local \w+ = require\(script[^\n]*\n/gm, "");
49
+
50
+ let failures = 0;
51
+
52
+ for (const suite of suites) {
53
+ const moduleSource = stripRequires(readFileSync(join(root, suite.module), "utf8"));
54
+ const testSource = readFileSync(join(root, suite.test), "utf8");
55
+
56
+ const bundle = [
57
+ DISPATCH_STUB,
58
+ "local Module = (function()",
59
+ moduleSource,
60
+ "end)()",
61
+ "local run = function(...)",
62
+ testSource,
63
+ "end",
64
+ "run(Module)",
65
+ "",
66
+ ].join("\n");
67
+
68
+ const bundlePath = join(mkdtempSync(join(tmpdir(), "studio-mcp-test-")), "bundle.luau");
69
+ writeFileSync(bundlePath, bundle, "utf8");
70
+
71
+ const result = spawnSync(luau, [bundlePath], { stdio: "inherit" });
72
+ if (result.error) {
73
+ process.stderr.write(
74
+ `could not run '${luau}': ${result.error.message}\n` +
75
+ "Set LUAU to the path of a Luau interpreter.\n",
76
+ );
77
+ process.exit(1);
78
+ }
79
+ if (result.status !== 0) {
80
+ process.stderr.write(`FAIL ${suite.test}\n`);
81
+ failures += 1;
82
+ }
83
+ }
84
+
85
+ process.exit(failures === 0 ? 0 : 1);