@el4cteo/rbx-studio-mcp 0.7.1 → 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.
@@ -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);