@el4cteo/rbx-studio-mcp 0.7.0 → 0.7.2

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.
@@ -322,6 +322,46 @@ function twoStudios() {
322
322
  second.destroy();
323
323
  await settle();
324
324
  assert.equal(await count(), 0, "a stream that dies without a goodbye is dropped");
325
+
326
+ // A command far bigger than one socket read goes down the stream as ONE
327
+ // event, and its answer comes back. The plugin half of this -- joining the
328
+ // reads back together -- is covered by the frameReader cases in
329
+ // tests/sseframes.luau; this pins the bridge half over real sockets.
330
+ const source = "local x = 1\n".repeat(100_000);
331
+ const events = [];
332
+ const plugin = await new Promise((resolve) => {
333
+ const req = request(
334
+ { host: "127.0.0.1", port, path: "/events", method: "POST", headers: { "x-roblox-studio-mcp": "test" } },
335
+ (res) => {
336
+ let pending = "";
337
+ res.setEncoding("utf8");
338
+ res.on("data", (chunk) => {
339
+ pending += chunk;
340
+ let cut;
341
+ while ((cut = pending.indexOf("\n\n")) !== -1) {
342
+ const block = pending.slice(0, cut);
343
+ pending = pending.slice(cut + 2);
344
+ if (!block.startsWith("data: ")) continue;
345
+ const frame = JSON.parse(block.slice(6));
346
+ if (typeof frame.id !== "string") continue;
347
+ events.push(frame);
348
+ void fetch(`http://127.0.0.1:${port}/result?studioId=big`, {
349
+ method: "POST",
350
+ headers: { "x-roblox-studio-mcp": "test" },
351
+ body: JSON.stringify({ id: frame.id, ok: true, data: { length: frame.params.source.length } }),
352
+ });
353
+ }
354
+ });
355
+ resolve(req);
356
+ },
357
+ );
358
+ req.end(JSON.stringify({ studioId: "big", placeName: "p", placeId: 1 }));
359
+ });
360
+ const answer = await server.bridge.call("script.create", { source }, { studioId: "big" });
361
+ assert.equal(answer.length, source.length, "a 1.2MB command arrives whole and is answered");
362
+ assert.equal(events.length, 1, "as exactly one event");
363
+ plugin.destroy();
364
+
325
365
  await server.close();
326
366
  }
327
367
 
@@ -20,7 +20,15 @@ import { startBridgeServer } from "../dist/bridge/server.js";
20
20
  import { CLIENT_HEADER } from "../dist/lib/protocol.js";
21
21
  import { PEER_HEADER } from "../dist/bridge/remote.js";
22
22
 
23
- const PORT = 44799;
23
+ // A free port, not a fixed one: a fixed port fails the whole suite whenever
24
+ // anything else on the machine happens to hold it.
25
+ const PORT = await new Promise((resolve, reject) => {
26
+ const probe = createServer().listen(0, "127.0.0.1", () => {
27
+ const { port } = probe.address();
28
+ probe.close(() => resolve(port));
29
+ });
30
+ probe.once("error", reject);
31
+ });
24
32
 
25
33
  /** Waits for `check` to hold, or gives up loudly rather than hanging the suite. */
26
34
  async function until(check, what, timeoutMs = 20_000) {
@@ -133,102 +141,102 @@ function handshake(port, studioId) {
133
141
  await owner.close();
134
142
  }
135
143
 
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
-
183
- // An owner that dies WHILE answering is still OWNER_GONE, not a raw Node error.
184
- //
185
- // The shipped bug: only the fetch was guarded, so a socket cut after the
186
- // headers threw "TypeError: fetch failed / SocketError: other side closed"
187
- // straight past the catch. It surfaced right under the takeover notice, which
188
- // is the exact moment a handover cuts a live response.
189
- //
190
- // The fake owner declares a Content-Length it never delivers, then hangs up.
191
- // That distinction is the whole test: a socket destroyed BEFORE the headers
192
- // makes Node reject the fetch itself, which the old code already caught -- the
193
- // bug only shows when the reply has started and stops halfway. Two earlier
194
- // versions of this test cut the socket too early, passed with the bug present,
195
- // and proved nothing.
196
- {
197
- const halfDead = createServer((req, res) => {
198
- if (req.url === "/identity") {
199
- res.writeHead(200, { "Content-Type": "application/json" });
200
- res.end(JSON.stringify({ server: "roblox-studio-mcp", protocolVersion: 1, pid: 1 }));
201
- return;
202
- }
203
- res.writeHead(200, { "Content-Type": "application/json", "Content-Length": "5000" });
204
- res.write('{"ok":true,"data":"' + "x".repeat(100));
205
- setTimeout(() => res.socket.destroy(), 150);
206
- });
207
- await new Promise((resolve) => halfDead.listen(PORT, "127.0.0.1", resolve));
208
-
209
- const peer = await startBridgeServer({ port: PORT });
210
- assert.equal(peer.owner, false, "the peer proxies to whatever holds the port");
211
-
212
- let raised;
213
- try {
214
- await peer.bridge.call("studio.status", {});
215
- } catch (cause) {
216
- raised = cause;
217
- }
218
-
219
- assert.ok(raised, "a truncated answer must not resolve as success");
220
- assert.equal(
221
- raised.code,
222
- "OWNER_GONE",
223
- `a body cut short is the owner going away, not a raw ${raised?.name}`,
224
- );
225
- assert.ok(
226
- /try the call again/.test(raised.hint ?? ""),
227
- "and says what to do about it, which a raw socket error never does",
228
- );
229
-
230
- await peer.close();
231
- await new Promise((resolve) => halfDead.close(resolve));
232
- }
233
-
144
+ // POST /hello carries the panel's spawned marker all the way to the roster.
145
+ //
146
+ // The shipped bug: every layer marked the agent the panel starts -- the spawn's
147
+ // environment, the MCP config handed to the agent, the hello body -- and this
148
+ // route typed the body with three fields and dropped the fourth. So the panel
149
+ // said "2 MCP clients connected" on every prompt and kept a stopped agent in
150
+ // `clients` until the stale sweep. Tested at the route, because the route is
151
+ // the layer that was wrong while the bridge underneath it was right.
152
+ {
153
+ const owner = await startBridgeServer({ port: PORT });
154
+
155
+ const hello = async (id, body) => {
156
+ const sent = await fetch(`http://127.0.0.1:${PORT}/hello`, {
157
+ method: "POST",
158
+ headers: {
159
+ [CLIENT_HEADER]: "test",
160
+ [PEER_HEADER]: id,
161
+ "Content-Type": "application/json",
162
+ },
163
+ body: JSON.stringify(body),
164
+ });
165
+ return (await sent.json()).clients;
166
+ };
167
+
168
+ const alone = await hello("a-stranger", { name: "codex", version: "1", pid: 1 });
169
+ const withAgent = await hello("panel-agent", {
170
+ name: "claude-code",
171
+ version: "1",
172
+ pid: 2,
173
+ spawned: true,
174
+ });
175
+ assert.equal(withAgent, alone, "an agent the panel started is not another client");
176
+ // A second stranger does count, or the filter would be hiding everyone.
177
+ const withStranger = await hello("another-stranger", { name: "opencode", pid: 3 });
178
+ assert.equal(withStranger, alone + 1, "a client nobody asked for still counts");
179
+
180
+ // A keepalive that says nothing must not erase what the first hello said, and
181
+ // must not re-announce the client as if it had just arrived.
182
+ assert.equal(
183
+ await hello("a-stranger", { pid: 1 }),
184
+ withStranger,
185
+ "a nameless keepalive is not a new client",
186
+ );
187
+
188
+ await owner.close();
189
+ }
190
+
191
+ // An owner that dies WHILE answering is still OWNER_GONE, not a raw Node error.
192
+ //
193
+ // The shipped bug: only the fetch was guarded, so a socket cut after the
194
+ // headers threw "TypeError: fetch failed / SocketError: other side closed"
195
+ // straight past the catch. It surfaced right under the takeover notice, which
196
+ // is the exact moment a handover cuts a live response.
197
+ //
198
+ // The fake owner declares a Content-Length it never delivers, then hangs up.
199
+ // That distinction is the whole test: a socket destroyed BEFORE the headers
200
+ // makes Node reject the fetch itself, which the old code already caught -- the
201
+ // bug only shows when the reply has started and stops halfway. Two earlier
202
+ // versions of this test cut the socket too early, passed with the bug present,
203
+ // and proved nothing.
204
+ {
205
+ const halfDead = createServer((req, res) => {
206
+ if (req.url === "/identity") {
207
+ res.writeHead(200, { "Content-Type": "application/json" });
208
+ res.end(JSON.stringify({ server: "roblox-studio-mcp", protocolVersion: 1, pid: 1 }));
209
+ return;
210
+ }
211
+ res.writeHead(200, { "Content-Type": "application/json", "Content-Length": "5000" });
212
+ res.write('{"ok":true,"data":"' + "x".repeat(100));
213
+ setTimeout(() => res.socket.destroy(), 150);
214
+ });
215
+ await new Promise((resolve) => halfDead.listen(PORT, "127.0.0.1", resolve));
216
+
217
+ const peer = await startBridgeServer({ port: PORT });
218
+ assert.equal(peer.owner, false, "the peer proxies to whatever holds the port");
219
+
220
+ let raised;
221
+ try {
222
+ await peer.bridge.call("studio.status", {});
223
+ } catch (cause) {
224
+ raised = cause;
225
+ }
226
+
227
+ assert.ok(raised, "a truncated answer must not resolve as success");
228
+ assert.equal(
229
+ raised.code,
230
+ "OWNER_GONE",
231
+ `a body cut short is the owner going away, not a raw ${raised?.name}`,
232
+ );
233
+ assert.ok(
234
+ /try the call again/.test(raised.hint ?? ""),
235
+ "and says what to do about it, which a raw socket error never does",
236
+ );
237
+
238
+ await peer.close();
239
+ await new Promise((resolve) => halfDead.close(resolve));
240
+ }
241
+
234
242
  process.stdout.write("failover: ok\n");
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Drives a real, connected Studio through the running bridge and checks the
4
+ * paths no fake can: big payloads over the actual stream, and scripts that
5
+ * survive a create -> read -> edit -> delete round trip.
6
+ *
7
+ * Not part of `npm test`, because it needs Studio open with the plugin. It
8
+ * exists because the worst transport bug this project had -- any command the
9
+ * stream delivered in more than one piece was dropped, so a big `script_create`
10
+ * timed out -- passed every offline test and only showed up against Studio.
11
+ *
12
+ * Everything it makes is named `__mcp_live_*` under ServerScriptService and is
13
+ * deleted before it exits, pass or fail.
14
+ *
15
+ * Usage: node scripts/test-live.mjs [--port 44755] [--sizes 1000,16000,64000,256000]
16
+ */
17
+ import { expectedPluginBuildId } from "../dist/lib/pluginbuild.js";
18
+
19
+ const args = process.argv.slice(2);
20
+ const flag = (name, fallback) => {
21
+ const at = args.indexOf(`--${name}`);
22
+ return at === -1 ? fallback : args[at + 1];
23
+ };
24
+ const port = Number.parseInt(flag("port", "44755"), 10);
25
+ const sizes = flag("sizes", "1000,16000,64000,256000").split(",").map(Number);
26
+ const base = `http://127.0.0.1:${port}`;
27
+ const HEADERS = {
28
+ "x-roblox-studio-mcp": "test-live",
29
+ "x-roblox-studio-mcp-peer": `test-live-${process.pid}`,
30
+ "content-type": "application/json",
31
+ };
32
+ const PREFIX = "__mcp_live_";
33
+
34
+ async function call(op, params, timeoutMs = 20_000) {
35
+ const response = await fetch(`${base}/call`, {
36
+ method: "POST",
37
+ headers: HEADERS,
38
+ body: JSON.stringify({ op, params, timeoutMs }),
39
+ });
40
+ const body = await response.json();
41
+ if (!body.ok) throw new Error(`${op}: [${body.error?.code}] ${body.error?.message}`);
42
+ return body.data;
43
+ }
44
+
45
+ /** Luau source of about `size` bytes, with quotes and non-ASCII so escaping is exercised. */
46
+ function sourceOf(size) {
47
+ const lines = [];
48
+ let length = 0;
49
+ for (let index = 0; length < size; index += 1) {
50
+ const line = `local value${index} = "line ${index}: \\"quoted\\" ünïcode ✓"`;
51
+ lines.push(line);
52
+ length += line.length + 1;
53
+ }
54
+ return lines.join("\n");
55
+ }
56
+
57
+ let sessions;
58
+ try {
59
+ sessions = await (await fetch(`${base}/sessions`, { headers: HEADERS })).json();
60
+ } catch {
61
+ process.stderr.write(`Nothing is listening on port ${port}. Start the MCP server and open Studio.\n`);
62
+ process.exit(1);
63
+ }
64
+ const studio = sessions.list[0];
65
+ if (studio === undefined) {
66
+ process.stderr.write("The bridge is up but no Studio is connected.\n");
67
+ process.exit(1);
68
+ }
69
+ if (studio.buildId !== expectedPluginBuildId()) {
70
+ process.stderr.write(
71
+ `warning: Studio runs plugin build ${studio.buildId}, this checkout is ` +
72
+ `${expectedPluginBuildId()}. Results describe the plugin Studio has loaded.\n`,
73
+ );
74
+ }
75
+ process.stdout.write(`Studio: ${studio.placeName} (${studio.transport})\n`);
76
+
77
+ const created = [];
78
+ let failures = 0;
79
+ const check = (ok, what) => {
80
+ process.stdout.write(`${ok ? "ok " : "FAIL"} ${what}\n`);
81
+ if (!ok) failures += 1;
82
+ };
83
+
84
+ try {
85
+ for (const size of sizes) {
86
+ const name = `${PREFIX}${size}`;
87
+ const source = sourceOf(size);
88
+ const started = Date.now();
89
+ try {
90
+ await call("script.create", {
91
+ scripts: [{ parent: "ServerScriptService", name, className: "ModuleScript", source }],
92
+ });
93
+ created.push(`ServerScriptService.${name}`);
94
+ const read = await call("script.read", { paths: [`ServerScriptService.${name}`] });
95
+ const item = read.items[0];
96
+ check(item?.source === source, `${size} bytes: create + read back identical (${Date.now() - started}ms)`);
97
+
98
+ const edited = await call("script.edit", {
99
+ edits: [{ path: `ServerScriptService.${name}`, find: "local value0 =", replace: "local first =", revision: item.revision }],
100
+ });
101
+ check(edited.items[0]?.edits === 1, `${size} bytes: conditional edit applied`);
102
+ } catch (cause) {
103
+ check(false, `${size} bytes: ${cause.message}`);
104
+ }
105
+ }
106
+ } finally {
107
+ if (created.length > 0) {
108
+ await call("instances.delete", { paths: created }).catch((cause) => {
109
+ process.stderr.write(`cleanup failed, delete ${created.join(", ")} by hand: ${cause.message}\n`);
110
+ });
111
+ }
112
+ }
113
+
114
+ process.stdout.write(failures === 0 ? "live: ok\n" : `live: ${failures} failure(s)\n`);
115
+ process.exit(failures === 0 ? 0 : 1);
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Offline checks for tool output shaping that needs no Studio.
3
+ *
4
+ * Usage: node scripts/test-tools.mjs
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import { CHARACTER_LIMIT } from "../dist/lib/format.js";
8
+ import { clipListing, numbered } from "../dist/tools/scripts.js";
9
+
10
+ const item = (path, lineCount) => ({ path, className: "ModuleScript", lineCount, startLine: 1, source: "" });
11
+ const listing = (path, lines) =>
12
+ `${path} (ModuleScript, ${lines} lines)\n` +
13
+ numbered(Array.from({ length: lines }, (_, index) => `local v${index} = ${index}`).join("\n"), 1);
14
+
15
+ // Small reads pass through untouched.
16
+ {
17
+ const blocks = [listing("A", 3)];
18
+ assert.equal(clipListing(blocks, [item("A", 3)], null), blocks[0]);
19
+ }
20
+
21
+ // A big read is cut on a whole line and names the exact next window.
22
+ {
23
+ const out = clipListing([listing("Big", 5000), listing("After", 5)], [item("Big", 5000), item("After", 5)], null);
24
+ assert.ok(out.length <= CHARACTER_LIMIT, "fits the limit");
25
+ const match = /Big stops at line (\d+) of 5000\. Continue with \{ path: "Big", startLine: (\d+) \}/.exec(out);
26
+ assert.ok(match, "names where to continue");
27
+ assert.equal(Number(match[2]), Number(match[1]) + 1);
28
+ const lastShown = out.split("\n\n[clipped")[0].split("\n").pop();
29
+ assert.match(lastShown, new RegExp(`^\s*${match[1]}│ local v${Number(match[1]) - 1} = ${Number(match[1]) - 1}$`), "last line is whole");
30
+ assert.match(out, /1 more script\(s\) after it were not shown/);
31
+ }
32
+
33
+ // Failures are never the part that gets clipped away.
34
+ {
35
+ const out = clipListing([listing("Big", 5000)], [item("Big", 5000)], "Could not read 1 path(s):\n - Nope");
36
+ assert.ok(out.startsWith("Could not read 1 path(s)"));
37
+ }
38
+
39
+ process.stdout.write("tools: ok\n");
@@ -1,58 +1,71 @@
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);
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
+ /** Lifts one top-level `local NAME = ...` constant, so a lifted function can see it. */
50
+ function extractConstant(source, name) {
51
+ const match = new RegExp(`^local ${name}\\b[^\\r\\n]*`, "m").exec(source);
52
+ if (match === null) throw new Error(`${name} is not in Transport.luau any more`);
53
+ return match[0];
54
+ }
55
+
56
+ const bundle = [
57
+ stub,
58
+ extractConstant(transport, "MAX_PENDING"),
59
+ extractFunction(transport, "parseFrames"),
60
+ extractFunction(transport, "frameReader"),
61
+ cases,
62
+ ].join("\n");
63
+ const bundlePath = join(mkdtempSync(join(tmpdir(), "studio-mcp-sse-")), "bundle.luau");
64
+ writeFileSync(bundlePath, bundle, "utf8");
65
+
66
+ const result = spawnSync(luau, [bundlePath], { stdio: "inherit" });
67
+ if (result.error) {
68
+ process.stderr.write(`could not run '${luau}': ${result.error.message}\n`);
69
+ process.exit(1);
70
+ }
71
+ process.exit(result.status ?? 1);