@el4cteo/rbx-studio-mcp 0.1.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.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -0
  3. package/dist/bridge/rpc.js +243 -0
  4. package/dist/bridge/rpc.js.map +1 -0
  5. package/dist/bridge/server.js +281 -0
  6. package/dist/bridge/server.js.map +1 -0
  7. package/dist/index.js +104 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/lib/apidump.js +269 -0
  10. package/dist/lib/apidump.js.map +1 -0
  11. package/dist/lib/errors.js +38 -0
  12. package/dist/lib/errors.js.map +1 -0
  13. package/dist/lib/format.js +191 -0
  14. package/dist/lib/format.js.map +1 -0
  15. package/dist/lib/pluginbuild.js +83 -0
  16. package/dist/lib/pluginbuild.js.map +1 -0
  17. package/dist/lib/png.js +84 -0
  18. package/dist/lib/png.js.map +1 -0
  19. package/dist/lib/protocol.js +22 -0
  20. package/dist/lib/protocol.js.map +1 -0
  21. package/dist/lib/tool.js +27 -0
  22. package/dist/lib/tool.js.map +1 -0
  23. package/dist/resources.js +70 -0
  24. package/dist/resources.js.map +1 -0
  25. package/dist/tools/api.js +78 -0
  26. package/dist/tools/api.js.map +1 -0
  27. package/dist/tools/character.js +94 -0
  28. package/dist/tools/character.js.map +1 -0
  29. package/dist/tools/debug.js +211 -0
  30. package/dist/tools/debug.js.map +1 -0
  31. package/dist/tools/device.js +74 -0
  32. package/dist/tools/device.js.map +1 -0
  33. package/dist/tools/discover.js +217 -0
  34. package/dist/tools/discover.js.map +1 -0
  35. package/dist/tools/exec.js +191 -0
  36. package/dist/tools/exec.js.map +1 -0
  37. package/dist/tools/input.js +96 -0
  38. package/dist/tools/input.js.map +1 -0
  39. package/dist/tools/instances.js +261 -0
  40. package/dist/tools/instances.js.map +1 -0
  41. package/dist/tools/perf.js +367 -0
  42. package/dist/tools/perf.js.map +1 -0
  43. package/dist/tools/playtest.js +153 -0
  44. package/dist/tools/playtest.js.map +1 -0
  45. package/dist/tools/screenshot.js +75 -0
  46. package/dist/tools/screenshot.js.map +1 -0
  47. package/dist/tools/scripts.js +316 -0
  48. package/dist/tools/scripts.js.map +1 -0
  49. package/dist/tools/session.js +152 -0
  50. package/dist/tools/session.js.map +1 -0
  51. package/dist/tools/world.js +281 -0
  52. package/dist/tools/world.js.map +1 -0
  53. package/package.json +62 -0
  54. package/plugin/default.project.json +6 -0
  55. package/plugin/src/Config.luau +59 -0
  56. package/plugin/src/Console.luau +657 -0
  57. package/plugin/src/Context.luau +35 -0
  58. package/plugin/src/Dispatch.luau +90 -0
  59. package/plugin/src/Editor.luau +142 -0
  60. package/plugin/src/Emulation.luau +151 -0
  61. package/plugin/src/LogBuffer.luau +277 -0
  62. package/plugin/src/Net.luau +102 -0
  63. package/plugin/src/Paths.luau +255 -0
  64. package/plugin/src/Phrase.luau +465 -0
  65. package/plugin/src/Png.luau +238 -0
  66. package/plugin/src/Scope.luau +78 -0
  67. package/plugin/src/ScriptEdit.luau +100 -0
  68. package/plugin/src/Serialize.luau +287 -0
  69. package/plugin/src/TextEdit.luau +296 -0
  70. package/plugin/src/Transport.luau +328 -0
  71. package/plugin/src/Undo.luau +72 -0
  72. package/plugin/src/Visuals.luau +710 -0
  73. package/plugin/src/handlers/Api.luau +242 -0
  74. package/plugin/src/handlers/Assets.luau +145 -0
  75. package/plugin/src/handlers/Capture.luau +187 -0
  76. package/plugin/src/handlers/Character.luau +361 -0
  77. package/plugin/src/handlers/Debug.luau +391 -0
  78. package/plugin/src/handlers/Device.luau +119 -0
  79. package/plugin/src/handlers/Discover.luau +289 -0
  80. package/plugin/src/handlers/Exec.luau +270 -0
  81. package/plugin/src/handlers/Geometry.luau +261 -0
  82. package/plugin/src/handlers/Input.luau +287 -0
  83. package/plugin/src/handlers/Instances.luau +389 -0
  84. package/plugin/src/handlers/Perf.luau +645 -0
  85. package/plugin/src/handlers/Playtest.luau +205 -0
  86. package/plugin/src/handlers/Scripts.luau +387 -0
  87. package/plugin/src/handlers/Session.luau +168 -0
  88. package/plugin/src/handlers/Viewport.luau +302 -0
  89. package/plugin/src/handlers/World.luau +176 -0
  90. package/plugin/src/init.server.luau +317 -0
  91. package/scripts/build-plugin.mjs +157 -0
  92. package/scripts/check-plugin.mjs +97 -0
  93. package/scripts/install-plugin.mjs +39 -0
  94. package/scripts/latency.mjs +201 -0
  95. package/scripts/locate-luau.mjs +51 -0
  96. package/scripts/sourcemap.mjs +58 -0
  97. package/scripts/test-plugin.mjs +82 -0
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Measures round-trip latency against a connected Studio, per transport.
4
+ *
5
+ * This exists because the README's central claim -- that pushing over SSE beats
6
+ * the long-polling every other Studio MCP server uses -- was written from first
7
+ * principles and never measured. A performance claim nobody has timed is a
8
+ * guess with a confident tone, which is exactly what this project criticises
9
+ * other servers for.
10
+ *
11
+ * Method: send `studio.ping` N times sequentially against whichever transport
12
+ * the plugin currently holds, and report the distribution. Sequential rather
13
+ * than concurrent on purpose -- an agent waits for each answer before deciding
14
+ * what to ask next, so serial round trips are the number that matters, and
15
+ * running them in parallel would measure throughput instead.
16
+ *
17
+ * `--compare` measures both transports in one run: it times whichever is in
18
+ * use, switches the plugin to the other, times that, and puts it back. Doing it
19
+ * in one run matters -- the two numbers are only comparable if the same machine
20
+ * measured them minutes apart rather than the favourable one being kept.
21
+ *
22
+ * Usage: node scripts/latency.mjs [--port 44755] [--count 50] [--compare]
23
+ */
24
+
25
+ const args = process.argv.slice(2);
26
+
27
+ function flag(name, fallback) {
28
+ const at = args.indexOf(`--${name}`);
29
+ return at === -1 ? fallback : args[at + 1];
30
+ }
31
+
32
+ const port = Number.parseInt(flag("port", "44755"), 10);
33
+ const count = Number.parseInt(flag("count", "50"), 10);
34
+ const compare = args.includes("--compare");
35
+ const base = `http://127.0.0.1:${port}`;
36
+ const HEADERS = { "x-roblox-studio-mcp": "latency" };
37
+
38
+ /** Percentile from a sorted array, nearest-rank. */
39
+ function percentile(sorted, fraction) {
40
+ if (sorted.length === 0) return 0;
41
+ const rank = Math.max(0, Math.ceil(fraction * sorted.length) - 1);
42
+ return sorted[rank];
43
+ }
44
+
45
+ /**
46
+ * Asks a server that is already running to do the timing.
47
+ *
48
+ * The common case is that one is: the point of measuring is to check the claim
49
+ * while actually using the thing, and the alternative -- shutting down the
50
+ * editor's own connection so this script can take the port -- is a measurement
51
+ * nobody runs twice. Returns null when nothing is listening, so the caller can
52
+ * fall back to starting its own.
53
+ */
54
+ async function askRunningServer() {
55
+ let response;
56
+ try {
57
+ response = await fetch(`${base}/latency?count=${count}`, { headers: HEADERS });
58
+ } catch {
59
+ return null;
60
+ }
61
+ if (response.status === 404) {
62
+ // Something is listening but has no /latency route, which means it is an
63
+ // older build of this server still in memory. Worth naming, because the
64
+ // 404 body says only "unknown route".
65
+ process.stderr.write(
66
+ `The server on port ${port} predates this script — it has no /latency route.\n` +
67
+ "Restart it (in Claude Code, /mcp and reconnect) and run this again.\n",
68
+ );
69
+ process.exit(1);
70
+ }
71
+ if (!response.ok) {
72
+ const body = await response.json().catch(() => ({}));
73
+ process.stderr.write(
74
+ `${body.error ?? `The server on port ${port} refused: HTTP ${response.status}.`}\n`,
75
+ );
76
+ process.exit(1);
77
+ }
78
+ return response.json();
79
+ }
80
+
81
+ /**
82
+ * Moves the plugin to a transport and waits for it to actually get there.
83
+ *
84
+ * The switch is asynchronous by necessity -- the plugin answers first and then
85
+ * tears its connection down, because replying over a stream it is about to
86
+ * close would strand the answer. So this polls until a round trip succeeds on
87
+ * the new transport rather than assuming a fixed delay is long enough.
88
+ */
89
+ async function switchTransport(mode) {
90
+ const response = await fetch(`${base}/transport?mode=${mode}`, {
91
+ method: "POST",
92
+ headers: HEADERS,
93
+ });
94
+ if (!response.ok) {
95
+ const body = await response.json().catch(() => ({}));
96
+ throw new Error(body.error ?? `switching to ${mode} failed: HTTP ${response.status}`);
97
+ }
98
+
99
+ const deadline = Date.now() + 30_000;
100
+ while (Date.now() < deadline) {
101
+ await new Promise((resolve) => setTimeout(resolve, 500));
102
+ const probe = await fetch(`${base}/latency?count=1`, { headers: HEADERS });
103
+ if (probe.ok) {
104
+ const reading = await probe.json();
105
+ if (reading.transport === mode) return;
106
+ }
107
+ }
108
+ throw new Error(`the plugin did not reach ${mode} within 30s`);
109
+ }
110
+
111
+ async function main() {
112
+ if (compare) {
113
+ const first = await askRunningServer();
114
+ if (first === null) {
115
+ process.stderr.write(
116
+ "--compare needs a server already running, since it drives the plugin " +
117
+ "between transports.\n",
118
+ );
119
+ process.exit(1);
120
+ }
121
+
122
+ const other = first.transport === "sse" ? "poll" : "sse";
123
+ await switchTransport(other);
124
+ const second = await askRunningServer();
125
+ // Put it back the way it was found. Leaving someone's editor on the slow
126
+ // path as a side effect of measuring is not a trade this script gets to
127
+ // make for them.
128
+ await switchTransport(first.transport);
129
+
130
+ const readings = { [first.transport]: first, [other]: second };
131
+ process.stdout.write(`${JSON.stringify(readings, null, 2)}\n`);
132
+ return;
133
+ }
134
+
135
+ const live = await askRunningServer();
136
+ if (live !== null) {
137
+ process.stdout.write(`${JSON.stringify(live, null, 2)}\n`);
138
+ return;
139
+ }
140
+
141
+ // Nothing listening, so stand a bridge up and drive it directly.
142
+ const { startBridgeServer } = await import("../dist/bridge/server.js");
143
+ const server = await startBridgeServer({ port });
144
+ const { bridge } = server;
145
+
146
+ const deadline = Date.now() + 30_000;
147
+ let studios = [];
148
+ while (Date.now() < deadline) {
149
+ studios = bridge.list();
150
+ if (studios.length > 0) break;
151
+ await new Promise((resolve) => setTimeout(resolve, 250));
152
+ }
153
+
154
+ if (studios.length === 0) {
155
+ await server.close();
156
+ // Written to stderr and exited non-zero: this is a measurement tool, and
157
+ // reporting "0ms" for a run that never happened would be worse than failing.
158
+ process.stderr.write(
159
+ `No Studio connected on port ${port} within 30s.\n` +
160
+ "Open Studio with the plugin installed, then run this again.\n",
161
+ );
162
+ process.exit(1);
163
+ }
164
+
165
+ const target = studios[0];
166
+ const samples = [];
167
+
168
+ // One warm-up that is not recorded: the first call after an idle period pays
169
+ // for TCP and stream wake-up, which is real but is not what an agent's tenth
170
+ // call costs, and including it would flatter or penalise nothing usefully.
171
+ await bridge.call("studio.ping", {}, { studioId: target.studioId });
172
+
173
+ for (let index = 0; index < count; index += 1) {
174
+ const started = process.hrtime.bigint();
175
+ await bridge.call("studio.ping", {}, { studioId: target.studioId });
176
+ samples.push(Number(process.hrtime.bigint() - started) / 1e6);
177
+ }
178
+
179
+ await server.close();
180
+
181
+ const sorted = [...samples].sort((a, b) => a - b);
182
+ const mean = samples.reduce((sum, value) => sum + value, 0) / samples.length;
183
+
184
+ const report = {
185
+ transport: target.transport,
186
+ place: target.placeName,
187
+ samples: samples.length,
188
+ meanMs: Number(mean.toFixed(2)),
189
+ medianMs: Number(percentile(sorted, 0.5).toFixed(2)),
190
+ p95Ms: Number(percentile(sorted, 0.95).toFixed(2)),
191
+ minMs: Number(sorted[0].toFixed(2)),
192
+ maxMs: Number(sorted[sorted.length - 1].toFixed(2)),
193
+ };
194
+
195
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
196
+ }
197
+
198
+ main().catch((cause) => {
199
+ process.stderr.write(`${cause instanceof Error ? cause.stack : String(cause)}\n`);
200
+ process.exit(1);
201
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Finds a Luau binary, the same way for every script that needs one.
3
+ *
4
+ * This is shared because it once was not. `check-plugin.mjs` looked in ./tools
5
+ * and `test-plugin.mjs` looked only on PATH, so on a machine with the binaries
6
+ * unpacked into ./tools -- which is what the README tells you to do -- one of
7
+ * them worked and the other reported the interpreter missing. The tests were
8
+ * not failing; they were not running, and saying so in a way that read like a
9
+ * setup problem the user had already solved.
10
+ *
11
+ * Order is: explicit environment variable, then ./tools, then PATH. The env var
12
+ * wins because someone who set it means it.
13
+ */
14
+ import { spawnSync } from "node:child_process";
15
+ import { existsSync } from "node:fs";
16
+ import { dirname, join, resolve } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
20
+
21
+ /**
22
+ * @param {string} envVar name of the environment variable that overrides the search
23
+ * @param {string[]} names candidate executable names, most specific first
24
+ * @returns {string | null} a command that runs, or null
25
+ */
26
+ export function locateLuau(envVar, names) {
27
+ const fromEnv = process.env[envVar];
28
+ if (fromEnv) return fromEnv;
29
+
30
+ for (const name of names) {
31
+ const local = join(root, "tools", name);
32
+ if (existsSync(local)) return local;
33
+ }
34
+
35
+ for (const name of names) {
36
+ // `--help` rather than `--version`: every one of these supports it, and a
37
+ // binary that is present but refuses to start should not count as found.
38
+ const probe = spawnSync(name, ["--help"], { encoding: "utf8" });
39
+ if (!probe.error) return name;
40
+ }
41
+
42
+ return null;
43
+ }
44
+
45
+ /** The message to print when `locateLuau` comes back empty. */
46
+ export function missingLuau(what, envVar) {
47
+ return (
48
+ `No ${what} found. Put it on PATH or in ./tools, or set ${envVar}.\n` +
49
+ "Download: https://github.com/luau-lang/luau/releases\n"
50
+ );
51
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Emits a luau-lsp sourcemap for plugin/src.
3
+ *
4
+ * luau-lsp needs one to resolve `script.Parent.Foo` requires. Maintaining it by
5
+ * hand meant every new module was invisible to the type checker until someone
6
+ * noticed, so it is generated from the same naming rules build-plugin.mjs uses.
7
+ *
8
+ * Usage: node scripts/sourcemap.mjs [outputPath]
9
+ */
10
+ import { readdirSync, writeFileSync } from "node:fs";
11
+ import { dirname, join, relative, resolve } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
15
+ const sourceDir = join(root, "plugin", "src");
16
+ const outputPath = resolve(process.argv[2] ?? join(root, "sourcemap.json"));
17
+
18
+ /**
19
+ * luau-lsp resolves a sourcemap's filePaths against the working directory
20
+ * rather than against the sourcemap file, so they are written relative to the
21
+ * repo root, and always with forward slashes.
22
+ */
23
+ const relativePath = (path) => relative(root, path).split("\\").join("/");
24
+
25
+ function buildTree(dir, name) {
26
+ const node = { name, className: "Folder", filePaths: [], children: [] };
27
+
28
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
29
+ a.name.localeCompare(b.name),
30
+ )) {
31
+ const path = join(dir, entry.name);
32
+
33
+ if (entry.isDirectory()) {
34
+ node.children.push(buildTree(path, entry.name));
35
+ continue;
36
+ }
37
+ if (!entry.name.endsWith(".luau")) continue;
38
+
39
+ if (entry.name === "init.server.luau") {
40
+ node.className = "Script";
41
+ node.filePaths.push(relativePath(path));
42
+ } else if (entry.name === "init.luau") {
43
+ node.className = "ModuleScript";
44
+ node.filePaths.push(relativePath(path));
45
+ } else {
46
+ node.children.push({
47
+ name: entry.name.replace(/\.luau$/, ""),
48
+ className: "ModuleScript",
49
+ filePaths: [relativePath(path)],
50
+ children: [],
51
+ });
52
+ }
53
+ }
54
+
55
+ return node;
56
+ }
57
+
58
+ writeFileSync(outputPath, JSON.stringify(buildTree(sourceDir, "StudioMCP"), null, 2) + "\n", "utf8");
@@ -0,0 +1,82 @@
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);