@el4cteo/rbx-studio-mcp 0.8.2 → 0.8.3
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.
- package/README.md +3 -1
- package/dist/bridge/api.js +2 -2
- package/dist/bridge/api.js.map +1 -1
- package/dist/bridge/failover.js +12 -4
- package/dist/bridge/failover.js.map +1 -1
- package/dist/bridge/harness.js +48 -6
- package/dist/bridge/harness.js.map +1 -1
- package/dist/bridge/remote.js +4 -2
- package/dist/bridge/remote.js.map +1 -1
- package/dist/bridge/rpc.js +43 -5
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/bridge/server.js +26 -1
- package/dist/bridge/server.js.map +1 -1
- package/dist/lib/apidump.js +58 -13
- package/dist/lib/apidump.js.map +1 -1
- package/dist/lib/cloudassets.js +2 -2
- package/dist/lib/cloudassets.js.map +1 -1
- package/dist/lib/png.js +19 -31
- package/dist/lib/png.js.map +1 -1
- package/dist/tools/discover.js +78 -4
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/instances.js +3 -1
- package/dist/tools/instances.js.map +1 -1
- package/dist/tools/session.js +11 -3
- package/dist/tools/session.js.map +1 -1
- package/package.json +3 -3
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/handlers/Debug.luau +42 -0
- package/plugin/src/handlers/Exec.luau +5 -1
- package/plugin/src/handlers/Spatial.luau +34 -0
- package/plugin/src/handlers/Terrain.luau +10 -0
- package/plugin/src/handlers/Viewport.luau +33 -1
- package/plugin/src/handlers/World.luau +21 -1
- package/plugin/src/init.server.luau +11 -0
- package/scripts/check-plugin.mjs +54 -0
- package/scripts/test-apidump.mjs +170 -0
- package/scripts/test-bridge.mjs +111 -4
- package/scripts/test-live-tools.mjs +382 -0
- package/scripts/test-tools.mjs +44 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The API dump: its cache, how a long-lived server keeps it current, and what
|
|
3
|
+
* the discovery tools say from it.
|
|
4
|
+
*
|
|
5
|
+
* Run against a tiny fixture in a private temp directory, so nothing here reads
|
|
6
|
+
* the real cache or touches the network -- and so it cannot pass or fail on
|
|
7
|
+
* whether Roblox happened to be reachable.
|
|
8
|
+
*
|
|
9
|
+
* Usage: node scripts/test-apidump.mjs
|
|
10
|
+
*/
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
// Redirected before anything is imported: the cache path is built from
|
|
17
|
+
// os.tmpdir() at call time, and this keeps every read and write in the sandbox.
|
|
18
|
+
const sandbox = mkdtempSync(join(tmpdir(), "rbx-apidump-test-"));
|
|
19
|
+
for (const name of ["TMPDIR", "TEMP", "TMP"]) process.env[name] = sandbox;
|
|
20
|
+
const cacheDir = join(sandbox, "roblox-studio-mcp");
|
|
21
|
+
const cacheFile = join(cacheDir, "api-dump.json");
|
|
22
|
+
|
|
23
|
+
const property = (name, type, extra = {}) => ({
|
|
24
|
+
MemberType: "Property",
|
|
25
|
+
Name: name,
|
|
26
|
+
ValueType: { Name: type, Category: "Primitive" },
|
|
27
|
+
...extra,
|
|
28
|
+
});
|
|
29
|
+
const dumpOf = (extraClasses = []) => ({
|
|
30
|
+
Classes: [
|
|
31
|
+
{ Name: "Instance", Superclass: "", Members: [property("Name", "string"), property("Archivable", "bool")] },
|
|
32
|
+
{ Name: "Model", Superclass: "Instance", Members: [property("PrimaryPart", "BasePart")] },
|
|
33
|
+
{
|
|
34
|
+
Name: "Part",
|
|
35
|
+
Superclass: "Instance",
|
|
36
|
+
Members: [
|
|
37
|
+
property("Size", "Vector3"),
|
|
38
|
+
property("Anchored", "bool"),
|
|
39
|
+
// Above plugin identity: present in the dump, not reachable from here.
|
|
40
|
+
property("Secret", "string", { Security: { Read: "RobloxScriptSecurity", Write: "RobloxScriptSecurity" } }),
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
...extraClasses,
|
|
44
|
+
],
|
|
45
|
+
Enums: [],
|
|
46
|
+
});
|
|
47
|
+
const widget = { Name: "Widget", Superclass: "Instance", Members: [property("Gizmo", "number")] };
|
|
48
|
+
const seed = (dump, fetchedAt = Date.now()) => {
|
|
49
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
50
|
+
writeFileSync(cacheFile, JSON.stringify({ fetchedAt, dump }));
|
|
51
|
+
};
|
|
52
|
+
const until = async (test, what) => {
|
|
53
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
54
|
+
if (await test()) return;
|
|
55
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
56
|
+
}
|
|
57
|
+
assert.fail(`timed out waiting for ${what}`);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const realFetch = globalThis.fetch;
|
|
61
|
+
const realNow = Date.now;
|
|
62
|
+
try {
|
|
63
|
+
// ---- what the dump answers ----------------------------------------------------
|
|
64
|
+
seed(dumpOf());
|
|
65
|
+
const dump = await import("../dist/lib/apidump.js");
|
|
66
|
+
const names = (await dump.propertiesOf("Part")).map((entry) => entry.name);
|
|
67
|
+
assert.deepEqual([...names].sort(), ["Anchored", "Archivable", "Name", "Size"], "own and inherited, nothing above plugin identity");
|
|
68
|
+
assert.ok((await dump.restrictionsOf("Part")).has("Secret"), "a restricted property is known about, separately");
|
|
69
|
+
assert.deepEqual(await dump.suggestProperty("Part", "size"), ["Size"], "a wrong-case name is suggested");
|
|
70
|
+
assert.deepEqual(await dump.suggestClass("Prat"), ["Part"], "a mistyped class is suggested");
|
|
71
|
+
|
|
72
|
+
// ---- what the tools say from it ----------------------------------------------------
|
|
73
|
+
const { z } = await import("zod");
|
|
74
|
+
const { registerDiscoverTools } = await import("../dist/tools/discover.js");
|
|
75
|
+
const registered = new Map();
|
|
76
|
+
const replies = {};
|
|
77
|
+
registerDiscoverTools({
|
|
78
|
+
server: { registerTool: (name, spec, handler) => registered.set(name, { spec, handler }) },
|
|
79
|
+
bridge: {
|
|
80
|
+
sessions: async () => ({ list: [], activeId: null, activeIsChosen: false }),
|
|
81
|
+
call: async (op) => replies[op],
|
|
82
|
+
notePlaceName: async () => {},
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
const run = (name, args) => {
|
|
86
|
+
const tool = registered.get(name);
|
|
87
|
+
return tool.handler(z.object(tool.spec.inputSchema).parse(args));
|
|
88
|
+
};
|
|
89
|
+
const textOf = (result) => result.content.map((part) => part.text ?? "").join("\n");
|
|
90
|
+
|
|
91
|
+
replies["discover.inspect"] = {
|
|
92
|
+
items: [{ path: "Workspace.P", className: "Part", childCount: 0, properties: { Name: "P", Size: "1, 1, 1" } }],
|
|
93
|
+
failures: [],
|
|
94
|
+
};
|
|
95
|
+
const inspected = textOf(await run("inspect", {
|
|
96
|
+
paths: ["Workspace.P"],
|
|
97
|
+
properties: ["Name", "Size", "Sizee", "Anchored", "Secret"],
|
|
98
|
+
}));
|
|
99
|
+
assert.match(inspected, /Requested but not returned:/);
|
|
100
|
+
assert.match(inspected, /Sizee \(not a property of Part; did you mean Size\?\)/, "a typo is named as one");
|
|
101
|
+
assert.match(inspected, /Anchored \(unset, or not readable in this session\)/, "a real property with no value is not called a typo");
|
|
102
|
+
assert.match(inspected, /Secret \(exists, but a plugin cannot read it\)/, "a restricted one says so");
|
|
103
|
+
assert.doesNotMatch(inspected.split("Requested but not returned:")[1], /\bName\b|\bSize \(/, "what did come back is not listed");
|
|
104
|
+
|
|
105
|
+
const complete = textOf(await run("inspect", { paths: ["Workspace.P"], properties: ["Name", "Size"] }));
|
|
106
|
+
assert.doesNotMatch(complete, /Requested but not returned/, "nothing to say when everything came back");
|
|
107
|
+
|
|
108
|
+
replies["discover.find"] = { items: [], total: 0, offset: 0, searched: 12 };
|
|
109
|
+
const misspelt = textOf(await run("find", { className: "Prat" }));
|
|
110
|
+
assert.match(misspelt, /"Prat" is not a Roblox class/);
|
|
111
|
+
assert.match(misspelt, /Did you mean: Part\?/);
|
|
112
|
+
const genuine = textOf(await run("find", { className: "Part" }));
|
|
113
|
+
assert.doesNotMatch(genuine, /is not a Roblox class/, "a real class that simply matched nothing is not accused");
|
|
114
|
+
const bare = textOf(await run("find", { nameContains: "zzz" }));
|
|
115
|
+
assert.doesNotMatch(bare, /is not a Roblox class/);
|
|
116
|
+
|
|
117
|
+
replies["discover.tree"] = { items: [], total: 0, offset: 0 };
|
|
118
|
+
assert.match(textOf(await run("tree", { className: "Prat" })), /"Prat" is not a Roblox class/);
|
|
119
|
+
|
|
120
|
+
// ---- a stale cache is served at once, then replaced in the running process ------------
|
|
121
|
+
// Each case gets its own copy of the module: the state under test is per process.
|
|
122
|
+
const fresh = (tag) => import(`../dist/lib/apidump.js?${tag}`);
|
|
123
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
124
|
+
let downloads = 0;
|
|
125
|
+
const serve = (value) => {
|
|
126
|
+
globalThis.fetch = async () => {
|
|
127
|
+
downloads += 1;
|
|
128
|
+
return { ok: true, json: async () => value };
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
seed(dumpOf(), Date.now() - 3 * dayMs);
|
|
133
|
+
serve(dumpOf([widget]));
|
|
134
|
+
const stale = await fresh("stale");
|
|
135
|
+
assert.equal((await stale.propertiesOf("Widget")).length, 0, "the stale copy is what answers first");
|
|
136
|
+
await until(async () => (await stale.propertiesOf("Widget")).length > 0, "the refreshed dump to replace the stale one");
|
|
137
|
+
assert.equal(downloads, 1, "one download, however many calls were made meanwhile");
|
|
138
|
+
assert.ok((await stale.propertiesOf("Widget")).some((entry) => entry.name === "Gizmo"), "and the answers come from the new one");
|
|
139
|
+
assert.deepEqual(readdirSync(cacheDir), ["api-dump.json"], "the cache was replaced whole, leaving no staging file behind");
|
|
140
|
+
assert.ok(Date.now() - JSON.parse(readFileSync(cacheFile, "utf8")).fetchedAt < dayMs, "and is stamped as fresh");
|
|
141
|
+
|
|
142
|
+
// A fresh cache is not refreshed... until the process outlives it.
|
|
143
|
+
seed(dumpOf());
|
|
144
|
+
downloads = 0;
|
|
145
|
+
serve(dumpOf([widget]));
|
|
146
|
+
const longLived = await fresh("long-lived");
|
|
147
|
+
assert.equal((await longLived.propertiesOf("Widget")).length, 0);
|
|
148
|
+
assert.equal(downloads, 0, "a fresh cache needs no download");
|
|
149
|
+
Date.now = () => realNow() + 2 * dayMs;
|
|
150
|
+
await longLived.loadApiDump();
|
|
151
|
+
await until(async () => (await longLived.propertiesOf("Widget")).length > 0, "a process that outlived the cache to refresh it");
|
|
152
|
+
Date.now = realNow;
|
|
153
|
+
assert.equal(downloads, 1);
|
|
154
|
+
|
|
155
|
+
// A failed refresh leaves the old dump answering rather than emptying it.
|
|
156
|
+
seed(dumpOf(), Date.now() - 3 * dayMs);
|
|
157
|
+
globalThis.fetch = async () => {
|
|
158
|
+
throw new Error("offline");
|
|
159
|
+
};
|
|
160
|
+
const offline = await fresh("offline");
|
|
161
|
+
assert.ok((await offline.propertiesOf("Part")).length > 0, "no network, still answers from the stale copy");
|
|
162
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
163
|
+
assert.ok((await offline.propertiesOf("Part")).length > 0, "and still does after the refresh failed");
|
|
164
|
+
|
|
165
|
+
process.stdout.write("apidump: ok\n");
|
|
166
|
+
} finally {
|
|
167
|
+
globalThis.fetch = realFetch;
|
|
168
|
+
Date.now = realNow;
|
|
169
|
+
rmSync(sandbox, { recursive: true, force: true });
|
|
170
|
+
}
|
package/scripts/test-bridge.mjs
CHANGED
|
@@ -273,10 +273,16 @@ function twoStudios() {
|
|
|
273
273
|
bridge.setActiveForAll("studio-b");
|
|
274
274
|
const late = new LocalBridge(bridge);
|
|
275
275
|
assert.equal((await late.sessions()).activeId, "studio-b");
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
276
|
+
const routed = late.call("studio.ping", {}, { timeoutMs: 50 });
|
|
277
|
+
// Read while the call is outstanding: once it times out the command is taken
|
|
278
|
+
// back off the queue, so the queue is only evidence of routing until then.
|
|
279
279
|
assert.equal(bridge.sessions.get("studio-b").queue.length, 1, "the call went to the pick");
|
|
280
|
+
assert.equal(bridge.sessions.get("studio-a").queue.length, 0, "and not to the other one");
|
|
281
|
+
await assert.rejects(
|
|
282
|
+
routed,
|
|
283
|
+
(cause) => cause.code === "TIMEOUT",
|
|
284
|
+
"the user's pick routes the call, so it fails by timing out and not as ambiguous",
|
|
285
|
+
);
|
|
280
286
|
|
|
281
287
|
await assert.rejects(
|
|
282
288
|
async () => late.call("studio.ping", {}, { studioId: "gone", timeoutMs: 50 }),
|
|
@@ -285,6 +291,83 @@ function twoStudios() {
|
|
|
285
291
|
);
|
|
286
292
|
}
|
|
287
293
|
|
|
294
|
+
/**
|
|
295
|
+
* A call that has timed out must not be run afterwards.
|
|
296
|
+
*
|
|
297
|
+
* The timeout answered the agent with a failure and dropped the request from the
|
|
298
|
+
* in-flight table, but left the command on the queue. The plugin's next poll --
|
|
299
|
+
* or the next stream to connect -- then picked it up and ran it: a create or a
|
|
300
|
+
* delete nobody was waiting for any more, usually after the agent had retried it.
|
|
301
|
+
*/
|
|
302
|
+
{
|
|
303
|
+
const bridge = new Bridge();
|
|
304
|
+
bridge.attach(identity("studio-a", 111), null);
|
|
305
|
+
|
|
306
|
+
await assert.rejects(
|
|
307
|
+
bridge.call("studio.ping", {}, { clientId: "c", timeoutMs: 20 }),
|
|
308
|
+
(cause) => cause.code === "TIMEOUT" && /never reached Studio/.test(cause.message),
|
|
309
|
+
"nothing collected it, and the error says so",
|
|
310
|
+
);
|
|
311
|
+
assert.equal(bridge.sessions.get("studio-a").queue.length, 0, "the abandoned command is gone");
|
|
312
|
+
|
|
313
|
+
// A poll arriving now has nothing to take, rather than the command that failed.
|
|
314
|
+
const held = new AbortController();
|
|
315
|
+
const waiting = bridge.waitForCommand("studio-a", held.signal);
|
|
316
|
+
setTimeout(() => held.abort(), 20);
|
|
317
|
+
assert.equal(await waiting, null, "a later poll is not handed the dead command");
|
|
318
|
+
|
|
319
|
+
// The same for a stream that connects after the deadline: nothing to flush.
|
|
320
|
+
const written = [];
|
|
321
|
+
const stream = {
|
|
322
|
+
writableEnded: false,
|
|
323
|
+
write(chunk) {
|
|
324
|
+
written.push(chunk);
|
|
325
|
+
return true;
|
|
326
|
+
},
|
|
327
|
+
end() {
|
|
328
|
+
this.writableEnded = true;
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
bridge.attach(identity("studio-a", 111), stream);
|
|
332
|
+
assert.deepEqual(written, [], "a reconnecting stream is not sent the dead command");
|
|
333
|
+
|
|
334
|
+
// And a live one is still delivered, so the queue is not simply being dropped.
|
|
335
|
+
const live = bridge.call("studio.ping", {}, { clientId: "c", timeoutMs: 1000 });
|
|
336
|
+
assert.equal(written.length, 1, "a command issued to an open stream is written at once");
|
|
337
|
+
const frame = JSON.parse(written[0].replace(/^data: /, ""));
|
|
338
|
+
bridge.settle("studio-a", { id: frame.id, ok: true, data: "pong" });
|
|
339
|
+
assert.equal(await live, "pong");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* `call` returns a promise, so every way it can fail is a rejection.
|
|
344
|
+
*
|
|
345
|
+
* NO_STUDIO and UNKNOWN_STUDIO used to be thrown from inside `call`, before a
|
|
346
|
+
* promise existed. `bridge.call(...).catch(...)` -- how `playtest stop` shrugs
|
|
347
|
+
* off a session that closed under it -- never got the chance to catch them.
|
|
348
|
+
*/
|
|
349
|
+
{
|
|
350
|
+
const bridge = new Bridge();
|
|
351
|
+
let pending;
|
|
352
|
+
assert.doesNotThrow(() => {
|
|
353
|
+
pending = bridge.call("studio.ping", {}, { clientId: "c" });
|
|
354
|
+
}, "no Studio connected does not throw synchronously");
|
|
355
|
+
await assert.rejects(pending, (cause) => cause.code === "NO_STUDIO");
|
|
356
|
+
|
|
357
|
+
bridge.attach(identity("studio-a", 111), null);
|
|
358
|
+
assert.doesNotThrow(() => {
|
|
359
|
+
pending = bridge.call("studio.ping", {}, { clientId: "c", timeoutMs: -1 });
|
|
360
|
+
}, "a bad timeout does not throw synchronously");
|
|
361
|
+
await assert.rejects(pending, (cause) => cause.code === "BAD_TIMEOUT");
|
|
362
|
+
|
|
363
|
+
const local = new LocalBridge(bridge);
|
|
364
|
+
let caught = false;
|
|
365
|
+
await local.call("studio.ping", {}, { studioId: "gone" }).catch(() => {
|
|
366
|
+
caught = true;
|
|
367
|
+
});
|
|
368
|
+
assert.equal(caught, true, "a `.catch` on the local bridge sees an unknown Studio");
|
|
369
|
+
}
|
|
370
|
+
|
|
288
371
|
// A stream that dies without /bye (a crashed Studio) removes its session, and a
|
|
289
372
|
// replaced stream closing late does not remove the session that replaced it.
|
|
290
373
|
{
|
|
@@ -314,6 +397,28 @@ function twoStudios() {
|
|
|
314
397
|
.list.length;
|
|
315
398
|
const settle = () => new Promise((resolve) => setTimeout(resolve, 250));
|
|
316
399
|
|
|
400
|
+
// A page that rebinds its own hostname to 127.0.0.1 is same-origin as far as
|
|
401
|
+
// the browser is concerned: it can send the custom header without a preflight,
|
|
402
|
+
// and a same-origin GET carries no Origin to reject. What it cannot do is make
|
|
403
|
+
// the Host header say 127.0.0.1, so that is what is checked.
|
|
404
|
+
const statusFor = (host) =>
|
|
405
|
+
new Promise((resolve, reject) => {
|
|
406
|
+
const req = request(
|
|
407
|
+
{ host: "127.0.0.1", port, path: "/sessions", method: "GET", headers: { host, "x-roblox-studio-mcp": "test" } },
|
|
408
|
+
(res) => {
|
|
409
|
+
res.resume();
|
|
410
|
+
resolve(res.statusCode);
|
|
411
|
+
},
|
|
412
|
+
);
|
|
413
|
+
req.on("error", reject);
|
|
414
|
+
req.end();
|
|
415
|
+
});
|
|
416
|
+
assert.equal(await statusFor(`127.0.0.1:${port}`), 200, "the address the plugin uses is served");
|
|
417
|
+
assert.equal(await statusFor(`localhost:${port}`), 200, "and so is localhost");
|
|
418
|
+
assert.equal(await statusFor(`evil.example:${port}`), 403, "a rebound hostname is refused");
|
|
419
|
+
assert.equal(await statusFor(`127.0.0.1.evil.example:${port}`), 403, "even one that merely starts like loopback");
|
|
420
|
+
assert.equal(await statusFor("127.0.0.1"), 403, "and a Host that names no port is not this server");
|
|
421
|
+
|
|
317
422
|
const first = await open();
|
|
318
423
|
const second = await open();
|
|
319
424
|
first.destroy();
|
|
@@ -432,7 +537,9 @@ function twoStudios() {
|
|
|
432
537
|
}
|
|
433
538
|
const before = delays.length;
|
|
434
539
|
for (const timeoutMs of [NaN, Infinity, -Infinity, -1, 0, null, "1000", {}, 2 ** 31, Number.MAX_SAFE_INTEGER]) {
|
|
435
|
-
|
|
540
|
+
// A rejection rather than a throw: `call` returns a promise, so a caller's
|
|
541
|
+
// `.catch` has to be able to see this one too.
|
|
542
|
+
await assert.rejects(bridge.call("studio.ping", {}, {clientId:"test",timeoutMs}), {code:"BAD_TIMEOUT"});
|
|
436
543
|
}
|
|
437
544
|
assert.equal(delays.length, before, "invalid deadlines never create timers");
|
|
438
545
|
assert.equal(bridge.sessions.get("timeout-test").pending.size, 0);
|