@el4cteo/rbx-studio-mcp 0.7.5 → 0.7.7
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/dist/bridge/harness.js +6 -2
- package/dist/bridge/harness.js.map +1 -1
- package/dist/bridge/remote.js +35 -8
- package/dist/bridge/remote.js.map +1 -1
- package/dist/bridge/rpc.js +68 -11
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/bridge/server.js +28 -8
- package/dist/bridge/server.js.map +1 -1
- package/dist/lib/credentials.js +8 -3
- package/dist/lib/credentials.js.map +1 -1
- package/dist/lib/opencloud.js +3 -1
- package/dist/lib/opencloud.js.map +1 -1
- package/dist/lib/timeout.js +13 -0
- package/dist/lib/timeout.js.map +1 -0
- package/dist/tools/exec.js +3 -1
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/input.js +2 -1
- package/dist/tools/input.js.map +1 -1
- package/package.json +1 -1
- package/plugin/src/Config.luau +65 -65
- package/plugin/src/Net.luau +1 -1
- package/plugin/src/Transport.luau +65 -12
- package/scripts/test-bridge.mjs +118 -3
- package/scripts/test-failover.mjs +69 -0
- package/scripts/test-tools.mjs +11 -0
package/scripts/test-bridge.mjs
CHANGED
|
@@ -297,7 +297,7 @@ function twoStudios() {
|
|
|
297
297
|
probe.close(() => resolve(free));
|
|
298
298
|
});
|
|
299
299
|
});
|
|
300
|
-
const server = await startBridgeServer({ port });
|
|
300
|
+
const server = await startBridgeServer({ port, bridge: new Bridge({ reconnectGraceMs: 100 }) });
|
|
301
301
|
const open = () =>
|
|
302
302
|
new Promise((resolve) => {
|
|
303
303
|
const req = request(
|
|
@@ -312,7 +312,7 @@ function twoStudios() {
|
|
|
312
312
|
const count = async () =>
|
|
313
313
|
(await (await fetch(`http://127.0.0.1:${port}/sessions`, { headers: { "x-roblox-studio-mcp": "test" } })).json())
|
|
314
314
|
.list.length;
|
|
315
|
-
const settle = () => new Promise((resolve) => setTimeout(resolve,
|
|
315
|
+
const settle = () => new Promise((resolve) => setTimeout(resolve, 250));
|
|
316
316
|
|
|
317
317
|
const first = await open();
|
|
318
318
|
const second = await open();
|
|
@@ -321,7 +321,48 @@ function twoStudios() {
|
|
|
321
321
|
assert.equal(await count(), 1, "the old stream closing leaves the new session alone");
|
|
322
322
|
second.destroy();
|
|
323
323
|
await settle();
|
|
324
|
-
assert.equal(await count(), 0, "a stream that dies without a goodbye is dropped");
|
|
324
|
+
assert.equal(await count(), 0, "a stream that dies without a goodbye is dropped after the grace");
|
|
325
|
+
|
|
326
|
+
// A stream that closes and redials inside the grace keeps its session, its
|
|
327
|
+
// agent's chosen target, and a call issued during the gap.
|
|
328
|
+
{
|
|
329
|
+
const before = await open();
|
|
330
|
+
await server.bridge.setActive("crashy");
|
|
331
|
+
before.destroy();
|
|
332
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
333
|
+
const during = server.bridge.call("studio.ping", {}, { timeoutMs: 2_000 });
|
|
334
|
+
const events = [];
|
|
335
|
+
const after = await new Promise((resolve) => {
|
|
336
|
+
const req = request(
|
|
337
|
+
{ host: "127.0.0.1", port, path: "/events", method: "POST", headers: { "x-roblox-studio-mcp": "test" } },
|
|
338
|
+
(res) => {
|
|
339
|
+
res.setEncoding("utf8");
|
|
340
|
+
res.on("data", (chunk) => {
|
|
341
|
+
for (const block of chunk.split("\n\n")) {
|
|
342
|
+
if (!block.startsWith("data: ")) continue;
|
|
343
|
+
const frame = JSON.parse(block.slice(6));
|
|
344
|
+
if (typeof frame.id !== "string") continue;
|
|
345
|
+
events.push(frame);
|
|
346
|
+
void fetch(`http://127.0.0.1:${port}/result?studioId=crashy`, {
|
|
347
|
+
method: "POST",
|
|
348
|
+
headers: { "x-roblox-studio-mcp": "test" },
|
|
349
|
+
body: JSON.stringify({ id: frame.id, ok: true, data: "pong" }),
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
resolve(req);
|
|
354
|
+
},
|
|
355
|
+
);
|
|
356
|
+
req.end(JSON.stringify({ studioId: "crashy", placeName: "p", placeId: 1 }));
|
|
357
|
+
});
|
|
358
|
+
assert.equal(await during, "pong", "a call made while redialling is delivered on the new stream");
|
|
359
|
+
assert.equal(events.length, 1);
|
|
360
|
+
await settle();
|
|
361
|
+
const view = await server.bridge.sessions();
|
|
362
|
+
assert.equal(view.list.length, 1, "the redialled session is still listed after the grace");
|
|
363
|
+
assert.equal(view.activeIsChosen, true, "and the agent's chosen target survived");
|
|
364
|
+
after.destroy();
|
|
365
|
+
}
|
|
325
366
|
|
|
326
367
|
// A command far bigger than one socket read goes down the stream as ONE
|
|
327
368
|
// event, and its answer comes back. The plugin half of this -- joining the
|
|
@@ -365,4 +406,78 @@ function twoStudios() {
|
|
|
365
406
|
await server.close();
|
|
366
407
|
}
|
|
367
408
|
|
|
409
|
+
// Normalize deadlines at the owner, even when the caller isn't input or a current peer.
|
|
410
|
+
{
|
|
411
|
+
const { normalizeTimeoutMs } = await import("../dist/lib/timeout.js");
|
|
412
|
+
assert.equal(normalizeTimeoutMs(78394.99999999999), 78395);
|
|
413
|
+
assert.equal(normalizeTimeoutMs(91613.1, 10000), 101614);
|
|
414
|
+
assert.equal(normalizeTimeoutMs(0.05), 1);
|
|
415
|
+
assert.equal(normalizeTimeoutMs(2 ** 31 - 1), 2 ** 31 - 1);
|
|
416
|
+
assert.throws(() => normalizeTimeoutMs(2 ** 31 - 1, 10000), {code:"BAD_TIMEOUT"});
|
|
417
|
+
const bridge = new Bridge();
|
|
418
|
+
bridge.attach(identity("timeout-test", 1), null);
|
|
419
|
+
const originalTimer = globalThis.setTimeout;
|
|
420
|
+
const delays = [];
|
|
421
|
+
try {
|
|
422
|
+
globalThis.setTimeout = (callback, delay, ...args) => {
|
|
423
|
+
delays.push(delay);
|
|
424
|
+
return originalTimer(callback, delay, ...args);
|
|
425
|
+
};
|
|
426
|
+
for (const timeoutMs of [78394.99999999999, 91613.1, 95762.99999999994, 15000, undefined]) {
|
|
427
|
+
const result = bridge.call("studio.ping", {}, {clientId:"test",studioId:"timeout-test",timeoutMs});
|
|
428
|
+
assert.equal(delays.at(-1), Math.ceil(timeoutMs ?? 15000));
|
|
429
|
+
const command = await bridge.waitForCommand("timeout-test");
|
|
430
|
+
bridge.settle("timeout-test", {id:command.id,ok:true,data:"ok"});
|
|
431
|
+
assert.equal(await result, "ok");
|
|
432
|
+
}
|
|
433
|
+
const before = delays.length;
|
|
434
|
+
for (const timeoutMs of [NaN, Infinity, -Infinity, -1, 0, null, "1000", {}, 2 ** 31, Number.MAX_SAFE_INTEGER]) {
|
|
435
|
+
assert.throws(() => bridge.call("studio.ping", {}, {clientId:"test",timeoutMs}), {code:"BAD_TIMEOUT"});
|
|
436
|
+
}
|
|
437
|
+
assert.equal(delays.length, before, "invalid deadlines never create timers");
|
|
438
|
+
assert.equal(bridge.sessions.get("timeout-test").pending.size, 0);
|
|
439
|
+
assert.equal(bridge.sessions.get("timeout-test").queue.length, 0);
|
|
440
|
+
} finally {
|
|
441
|
+
globalThis.setTimeout = originalTimer;
|
|
442
|
+
bridge.detach("timeout-test");
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// A poll whose socket closed while parked must not swallow the next command.
|
|
447
|
+
{
|
|
448
|
+
const bridge = new Bridge();
|
|
449
|
+
bridge.attach(identity("poll-drop", 1), null);
|
|
450
|
+
const released = new AbortController();
|
|
451
|
+
const parked = bridge.waitForCommand("poll-drop", released.signal);
|
|
452
|
+
released.abort();
|
|
453
|
+
assert.equal(await parked, null, "an abandoned poll resolves empty");
|
|
454
|
+
assert.equal(bridge.sessions.get("poll-drop").waiter, null, "and leaves no waiter behind");
|
|
455
|
+
|
|
456
|
+
const result = bridge.call("studio.ping", {}, { clientId: "test", studioId: "poll-drop" });
|
|
457
|
+
assert.equal(bridge.sessions.get("poll-drop").queue.length, 1, "the command waits in the queue");
|
|
458
|
+
|
|
459
|
+
// One handed to a poll that died anyway goes back to the front.
|
|
460
|
+
const command = await bridge.waitForCommand("poll-drop");
|
|
461
|
+
bridge.requeue("poll-drop", command);
|
|
462
|
+
const again = await bridge.waitForCommand("poll-drop");
|
|
463
|
+
assert.equal(again.id, command.id, "the requeued command is picked up next");
|
|
464
|
+
bridge.settle("poll-drop", { id: again.id, ok: true, data: "pong" });
|
|
465
|
+
assert.equal(await result, "pong");
|
|
466
|
+
|
|
467
|
+
// Nothing is requeued for a call that already settled.
|
|
468
|
+
bridge.requeue("poll-drop", command);
|
|
469
|
+
assert.equal(bridge.sessions.get("poll-drop").queue.length, 0, "settled calls are not resent");
|
|
470
|
+
bridge.detach("poll-drop");
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// A resolved published name survives the plugin reconnecting.
|
|
474
|
+
{
|
|
475
|
+
const bridge = new Bridge();
|
|
476
|
+
bridge.attach(identity("named", 1), null);
|
|
477
|
+
bridge.notePlaceName("named", "My Game");
|
|
478
|
+
bridge.attach({ ...identity("named", 1), placeName: "Place3" }, null);
|
|
479
|
+
assert.equal(bridge.list()[0].placeName, "My Game", "reconnect keeps the published name");
|
|
480
|
+
bridge.detach("named");
|
|
481
|
+
}
|
|
482
|
+
|
|
368
483
|
process.stdout.write("bridge: ok\n");
|
|
@@ -239,4 +239,73 @@ function handshake(port, studioId) {
|
|
|
239
239
|
await new Promise((resolve) => halfDead.close(resolve));
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
// Issue #4: the real input tool crosses peer -> owner -> fake Studio over HTTP.
|
|
243
|
+
{
|
|
244
|
+
const { z } = await import("zod");
|
|
245
|
+
const { registerInputTools } = await import("../dist/tools/input.js");
|
|
246
|
+
const owner = await startBridgeServer({port:PORT});
|
|
247
|
+
const peer = await startBridgeServer({port:PORT});
|
|
248
|
+
const headers = {[CLIENT_HEADER]:"test", "Content-Type":"application/json"};
|
|
249
|
+
const originalSignal = AbortSignal.timeout;
|
|
250
|
+
const signalDelays = [];
|
|
251
|
+
const base = `http://127.0.0.1:${PORT}`;
|
|
252
|
+
const reply = async () => {
|
|
253
|
+
const response = await fetch(`${base}/poll?studioId=decimal`, {headers,signal:originalSignal(5000)});
|
|
254
|
+
const {command} = await response.json();
|
|
255
|
+
assert.ok(command, "the command reaches Studio");
|
|
256
|
+
await fetch(`${base}/result?studioId=decimal`, {method:"POST",headers,
|
|
257
|
+
body:JSON.stringify({id:command.id,ok:true,data:{delivered:true,steps:command.params.steps?.length ?? 0,player:"Test"}})});
|
|
258
|
+
return command;
|
|
259
|
+
};
|
|
260
|
+
try {
|
|
261
|
+
await handshake(PORT,"decimal");
|
|
262
|
+
assert.equal(peer.owner,false);
|
|
263
|
+
AbortSignal.timeout = delay => { signalDelays.push(delay); return originalSignal(delay); };
|
|
264
|
+
let tool;
|
|
265
|
+
registerInputTools({bridge:peer.bridge,server:{registerTool(name,spec,handler) {tool={spec,handler};}}});
|
|
266
|
+
const steps = [{kind:"key",key:"E",hold:0.1,after:4.1}, ...Array.from({length:24}, () => ({kind:"key",key:"Left",hold:0.05,after:1.065}))];
|
|
267
|
+
const [result, command] = await Promise.all([
|
|
268
|
+
tool.handler(z.object(tool.spec.inputSchema).parse({steps,studioId:"decimal"})), reply(),
|
|
269
|
+
]);
|
|
270
|
+
assert.ok(!result.isError, JSON.stringify(result));
|
|
271
|
+
assert.deepEqual(command.params.steps,steps);
|
|
272
|
+
assert.ok(signalDelays.includes(88460), "input budget plus existing 10s peer padding");
|
|
273
|
+
|
|
274
|
+
const [other] = await Promise.all([
|
|
275
|
+
peer.bridge.call("another.tool", {}, {studioId:"decimal",timeoutMs:91613.1}), reply(),
|
|
276
|
+
]);
|
|
277
|
+
assert.ok(other.delivered);
|
|
278
|
+
assert.ok(signalDelays.includes(101614), "other tools are normalized at the peer boundary");
|
|
279
|
+
|
|
280
|
+
// Older/non-normalizing peers may send decimals directly to the owner.
|
|
281
|
+
const [raw] = await Promise.all([
|
|
282
|
+
fetch(`${base}/call`, {method:"POST",headers,body:JSON.stringify({op:"another.tool",studioId:"decimal",timeoutMs:95762.99999999994})}).then(res => res.json()), reply(),
|
|
283
|
+
]);
|
|
284
|
+
assert.equal(raw.ok,true);
|
|
285
|
+
for (const timeoutMs of [null, "1000", 0, -1, 2 ** 31]) {
|
|
286
|
+
const response = await fetch(`${base}/call`, {method:"POST",headers,body:JSON.stringify({op:"studio.ping",studioId:"decimal",timeoutMs})});
|
|
287
|
+
const error = await response.json();
|
|
288
|
+
assert.equal(error.error.code,"BAD_TIMEOUT", "owner rejects invalid wire values without crashing");
|
|
289
|
+
}
|
|
290
|
+
for (const timeoutMs of [NaN, Infinity, -1, 0, 2 ** 31 - 1]) {
|
|
291
|
+
await assert.rejects(() => peer.bridge.call("studio.ping", {}, {studioId:"decimal",timeoutMs}), {code:"BAD_TIMEOUT"});
|
|
292
|
+
}
|
|
293
|
+
// A locally thrown AbortSignal error must not trigger failover or blame the owner.
|
|
294
|
+
const localError = new RangeError("local AbortSignal setup failure");
|
|
295
|
+
AbortSignal.timeout = delay => { if (delay === 10001) throw localError; return originalSignal(delay); };
|
|
296
|
+
await assert.rejects(() => peer.bridge.call("studio.ping", {}, {studioId:"decimal",timeoutMs:1}), cause => cause === localError);
|
|
297
|
+
AbortSignal.timeout = originalSignal;
|
|
298
|
+
const [healthy] = await Promise.all([
|
|
299
|
+
peer.bridge.call("studio.ping", {}, {studioId:"decimal",timeoutMs:1000}), reply(),
|
|
300
|
+
]);
|
|
301
|
+
assert.ok(healthy.delivered, "owner still answers after every rejected call");
|
|
302
|
+
assert.equal(owner.owner,true);
|
|
303
|
+
assert.equal(peer.owner,false);
|
|
304
|
+
} finally {
|
|
305
|
+
AbortSignal.timeout = originalSignal;
|
|
306
|
+
await peer.close();
|
|
307
|
+
await owner.close();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
242
311
|
process.stdout.write("failover: ok\n");
|
package/scripts/test-tools.mjs
CHANGED
|
@@ -67,6 +67,17 @@ for (const step of [{kind:"click",target:"PlayerGui.HUD.BuyButton"},{kind:"text"
|
|
|
67
67
|
await input.handler(z.object(input.spec.inputSchema).parse({steps:[step]}));
|
|
68
68
|
assert.deepEqual(calls.at(-1).params.steps[0], step);
|
|
69
69
|
}
|
|
70
|
+
// Issue #4: ordinary decimal holds/waits retain padding and yield whole milliseconds.
|
|
71
|
+
for (const steps of [
|
|
72
|
+
[{kind:"key",key:"E",hold:0.1,after:4.1}, ...Array.from({length:24}, () => ({kind:"key",key:"Left",hold:0.05,after:1.065}))],
|
|
73
|
+
[{kind:"key",key:"E",hold:0.05,after:1.0651}],
|
|
74
|
+
]) {
|
|
75
|
+
await input.handler(z.object(input.spec.inputSchema).parse({steps}));
|
|
76
|
+
const raw = (35 + steps.reduce((sum, step) => sum + step.hold + step.after + 0.5, 0)) * 1000;
|
|
77
|
+
assert.equal(calls.at(-1).options.timeoutMs, Math.ceil(raw));
|
|
78
|
+
assert.ok(Number.isInteger(calls.at(-1).options.timeoutMs));
|
|
79
|
+
assert.deepEqual(calls.at(-1).params.steps, steps, "step timing itself is unchanged");
|
|
80
|
+
}
|
|
70
81
|
assert.deepEqual([...registered.keys()].sort(), ["execute_luau", "input", "viewport"]);
|
|
71
82
|
process.stdout.write("client tool schemas: ok\n");
|
|
72
83
|
|