@el4cteo/rbx-studio-mcp 0.7.6 → 0.7.8

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.
@@ -67,13 +67,18 @@ local function editModeActive(): boolean?
67
67
  end
68
68
 
69
69
  local function state(): { [string]: any }
70
+ local players = {}
71
+ for _, player in game:GetService("Players"):GetPlayers() do
72
+ table.insert(players, { name = player.Name, userId = player.UserId })
73
+ end
70
74
  return {
71
75
  playtestsAllowed = Playtest.isAllowed(),
72
76
  isEdit = RunService:IsEdit(),
73
77
  isRunning = RunService:IsRunning(),
74
78
  isRunMode = RunService:IsRunMode(),
75
79
  editModeActive = editModeActive(),
76
- playerCount = #game:GetService("Players"):GetPlayers(),
80
+ playerCount = #players,
81
+ players = if #players > 0 then players else nil,
77
82
  testPending = pending,
78
83
  -- Kept from the previous test rather than discarded, since whatever
79
84
  -- EndTest returned is the only thing a test session can hand back.
@@ -17,6 +17,7 @@ local Console = require(script.Console)
17
17
  local Dispatch = require(script.Dispatch)
18
18
  local History = require(script.History)
19
19
  local LogBuffer = require(script.LogBuffer)
20
+ local ClientRelay = require(script.ClientRelay)
20
21
  local Mirror = require(script.Mirror)
21
22
  local Phrase = require(script.Phrase)
22
23
  local ScriptEdit = require(script.ScriptEdit)
@@ -130,6 +131,7 @@ end
130
131
  -- is unrecoverable. This runs even in a client session that will never connect,
131
132
  -- since the console tool reads it and connectivity is a separate question.
132
133
  LogBuffer.start()
134
+ ClientRelay.startDiagnostics()
133
135
 
134
136
  local storedPort = plugin:GetSetting(SETTING_PORT)
135
137
  if typeof(storedPort) == "number" then
@@ -887,6 +889,7 @@ button.Click:Connect(function()
887
889
  end)
888
890
 
889
891
  plugin.Unloading:Connect(function()
892
+ ClientRelay.stopDiagnostics()
890
893
  Transport.stop()
891
894
  end)
892
895
 
@@ -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, 150));
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
@@ -402,4 +443,41 @@ function twoStudios() {
402
443
  }
403
444
  }
404
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
+
405
483
  process.stdout.write("bridge: ok\n");
@@ -12,7 +12,7 @@
12
12
  * Everything it makes is named `__mcp_live_*` under ServerScriptService and is
13
13
  * deleted before it exits, pass or fail.
14
14
  *
15
- * Usage: node scripts/test-live.mjs [--port 44755] [--sizes 1000,16000,64000,256000] [--client] [--remotes] [--playtests-off] [--studio-id ID] [--player NAME]
15
+ * Usage: node scripts/test-live.mjs [--port 44755] [--sizes 1000,16000,64000,256000] [--client] [--remotes] [--playtest-start] [--playtest-op play|multiplayer] [--stale-cursor CURSOR] [--playtests-off] [--studio-id ID] [--player NAME]
16
16
  */
17
17
  import { expectedPluginBuildId } from "../dist/lib/pluginbuild.js";
18
18
 
@@ -42,6 +42,16 @@ async function call(op, params, timeoutMs = 20_000) {
42
42
  return body.data;
43
43
  }
44
44
 
45
+ async function callTo(studioId, op, params, timeoutMs = 20_000) {
46
+ const response = await fetch(`${base}/call`, {
47
+ method: "POST", headers: HEADERS,
48
+ body: JSON.stringify({ op, params, timeoutMs, studioId }),
49
+ });
50
+ const body = await response.json();
51
+ if (!body.ok) throw new Error(`${op}: [${body.error?.code}] ${body.error?.message}`);
52
+ return body.data;
53
+ }
54
+
45
55
  /** Luau source of about `size` bytes, with quotes and non-ASCII so escaping is exercised. */
46
56
  function sourceOf(size) {
47
57
  const lines = [];
@@ -125,6 +135,40 @@ if (args.includes("--client")) {
125
135
  try {
126
136
  const identity = await client('return game:GetService("RunService"):IsClient(), game:GetService("Players").LocalPlayer.Name');
127
137
  check(identity.ok && identity.returned[0] === true && typeof identity.returned[1] === "string", "exec runs in the actual client VM");
138
+ const playerName = identity.returned[1];
139
+ const baseline = await call("perf.console", {target:"client",player:playerName,limit:10});
140
+ check(baseline.capturing === true && typeof baseline.nextCursor === "string", "persistent client diagnostics relay is ready");
141
+ const diagnosticPath = `Players.${playerName}.PlayerGui.__mcp_live_diagnostics`;
142
+ try {
143
+ await call("script.create", {scripts:[{parent:`Players.${playerName}.PlayerGui`,name:"__mcp_live_diagnostics",className:"LocalScript",source:'print("mcp live client print"); warn("mcp live client warning"); error("mcp live client error")'}]});
144
+ let captured;
145
+ for (let attempt = 0; attempt < 20; attempt += 1) {
146
+ captured = await call("perf.console", {target:"client",player:playerName,since:baseline.nextCursor,limit:20});
147
+ if (captured.items.some(item => item.message.includes("mcp live client error"))) break;
148
+ await new Promise(resolve => setTimeout(resolve, 200));
149
+ }
150
+ check(captured.items.some(item => item.message.includes("mcp live client print") && item.level === "print") &&
151
+ captured.items.some(item => item.message.includes("mcp live client warning") && item.level === "warning") &&
152
+ captured.items.some(item => item.message.includes("mcp live client error") && item.level === "error"),
153
+ "console captures LocalScript output outside execute_luau");
154
+ check(captured.items.some(item => item.level === "error" && item.stack && item.source), "client errors include stack and source");
155
+ const incremental = await call("perf.console", {target:"client",player:playerName,since:captured.nextCursor,limit:20});
156
+ check(incremental.items.length === 0, "client console cursor does not repeat old output");
157
+ const runtimeState = await call("playtest.control", {op:"state"});
158
+ const other = runtimeState.state.players?.find(player => player.name !== playerName);
159
+ if (other) {
160
+ const second = await call("perf.console", {target:"client",player:other.name,limit:20});
161
+ check(!second.items.some(item => item.message.includes("mcp live client error")), "multiplayer client logs stay with their player");
162
+ try {
163
+ await call("perf.console", {target:"client",player:other.name,since:captured.nextCursor});
164
+ check(false, "a cursor from another player is rejected");
165
+ } catch (cause) {
166
+ check(cause.message.includes("BAD_CURSOR"), "a cursor from another player is rejected");
167
+ }
168
+ }
169
+ } finally {
170
+ await call("instances.delete", {paths:[diagnosticPath]}).catch(() => {});
171
+ }
128
172
  const values = await client('print("client print"); warn("client warning"); local t = {}; t.self = t; return 1, nil, t, Vector3.new(1,2,3)');
129
173
  check(values.ok && values.returned[1] === "nil" && values.returned[2].self === "<circular reference>", "client structured values preserve nil and cycles");
130
174
  check(values.output.some(v => v.message.includes("client print")) && values.output.some(v => v.level === "warning"), "client prints and warnings captured");
@@ -279,5 +323,66 @@ if (args.includes("--playtests-off")) {
279
323
  }
280
324
  }
281
325
 
326
+ // Starts a real playtest through the public tool handler, proving its returned
327
+ // ID is the attached server session. Run only from an edit session, opt in.
328
+ if (args.includes("--playtest-start")) {
329
+ const { z } = await import("zod");
330
+ const { registerPlaytestTools } = await import("../dist/tools/playtest.js");
331
+ let handler, spec;
332
+ const bridge = {
333
+ sessions: async () => (await (await fetch(`${base}/sessions`, {headers:HEADERS})).json()),
334
+ call: (op, params, options = {}) => callTo(options.studioId ?? studio.studioId, op, params, options.timeoutMs),
335
+ notePlaceName: async () => {},
336
+ };
337
+ registerPlaytestTools({bridge,server:{registerTool(_name, definition, invoke) {spec = definition; handler = invoke;}}});
338
+ let startedId;
339
+ let started = false;
340
+ const startOp = flag("playtest-op", "play");
341
+ if (!["play", "multiplayer"].includes(startOp)) throw new Error("--playtest-op must be play or multiplayer");
342
+ try {
343
+ const before = await callTo(studio.studioId, "playtest.control", {op:"state"});
344
+ if (!before.state.isEdit || before.state.playtestsAllowed === false) {
345
+ check(false, "--playtest-start needs an edit session with playtests enabled");
346
+ } else {
347
+ started = true;
348
+ const result = await handler(z.object(spec.inputSchema).parse({op:startOp,players:2,studioId:studio.studioId}));
349
+ const id = /"studioId": "([^"]+)"/.exec(result.content[0].text)?.[1];
350
+ startedId = id;
351
+ const sessions = await bridge.sessions();
352
+ check(Boolean(id && id !== studio.studioId && sessions.list.some(s => s.studioId === id && s.context?.includes("playtest"))), "playtest returns its connected server studioId directly");
353
+ const state = id ? await callTo(id, "playtest.control", {op:"state"}) : null;
354
+ if (state?.state.players?.length && !result.content[0].text.includes("players are still joining"))
355
+ check(/"players": \[/.test(result.content[0].text), "playtest includes available player identities");
356
+ if (startOp === "multiplayer") {
357
+ const reply = result.content[0].text;
358
+ const named = state?.state.players?.length === 2 && state.state.players.every(player => reply.includes(`"name": "${player.name}"`));
359
+ check(named || reply.includes("players are still joining"), "multiplayer returns player names when connected, or identifies late joins");
360
+ }
361
+ const staleCursor = flag("stale-cursor", undefined);
362
+ if (id && staleCursor) {
363
+ let players = state?.state.players;
364
+ for (let attempt = 0; !players?.length && attempt < 20; attempt += 1) {
365
+ await new Promise(resolve => setTimeout(resolve, 250));
366
+ players = (await callTo(id, "playtest.control", {op:"state"})).state.players;
367
+ }
368
+ if (players?.length) {
369
+ try {
370
+ await callTo(id, "perf.console", {target:"client",player:players[0].name,since:staleCursor});
371
+ check(false, "a client cursor from the prior playtest is rejected");
372
+ } catch (cause) {
373
+ check(cause.message.includes("BAD_CURSOR"), "a client cursor from the prior playtest is rejected");
374
+ }
375
+ } else check(false, "a player must join to check the prior playtest cursor");
376
+ }
377
+ }
378
+ } catch (cause) {
379
+ check(false, `playtest returned ID: ${cause.message}`);
380
+ } finally {
381
+ const sessions = await bridge.sessions().catch(() => ({list:[]}));
382
+ const target = startedId ?? (started ? sessions.list.find(s => s.studioId !== studio.studioId && s.context?.includes("playtest"))?.studioId : undefined);
383
+ if (target) await callTo(target, "playtest.control", {op:"endTest",value:"live check complete"}).catch(() => {});
384
+ }
385
+ }
386
+
282
387
  process.stdout.write(failures === 0 ? "live: ok\n" : `live: ${failures} failure(s)\n`);
283
388
  process.exit(failures === 0 ? 0 : 1);
@@ -33,6 +33,8 @@ const suites = [
33
33
  { module: "plugin/src/RemoteTrace.luau", test: "tests/remote-trace.luau", prelude: "tests/remote-trace-stub.luau" },
34
34
  { module: "plugin/src/ExecRuntime.luau", test: "tests/exec-runtime.luau", prelude: "tests/exec-runtime-stub.luau" },
35
35
  { module: "plugin/src/ClientRelay.luau", test: "tests/client-relay.luau", prelude: "tests/client-relay-stub.luau" },
36
+ { module: "plugin/src/LogBuffer.luau", test: "tests/log-buffer.luau", prelude: "tests/log-buffer-stub.luau" },
37
+ { module: "plugin/src/handlers/Perf.luau", test: "tests/perf-console.luau", prelude: "tests/perf-console-stub.luau" },
36
38
  { module: "plugin/src/TextEdit.luau", test: "tests/textedit.luau" },
37
39
  { module: "plugin/src/Format.luau", test: "tests/format.luau" },
38
40
  ];
@@ -46,7 +46,7 @@ const registered = new Map();
46
46
  const calls = [];
47
47
  const context = {
48
48
  server: { registerTool(name, spec, handler) { registered.set(name, { spec, handler }); } },
49
- bridge: { async call(op, params, options) {
49
+ bridge: { async sessions() { return {list:[]}; }, async call(op, params, options) {
50
50
  calls.push({op, params, options});
51
51
  return op === "exec.run" ? {ok:true,returned:["nil",{answer:42}],output:[],milliseconds:1} : {delivered:true,steps:1,player:"Alice"};
52
52
  } },
@@ -118,6 +118,36 @@ assert.ok(!stateResult.isError);
118
118
  context.bridge.call = normalCall;
119
119
  process.stdout.write("playtest lock errors and instruction precedence: ok\n");
120
120
 
121
+ // A successful start includes the newly attached runtime session and player
122
+ // names, using the same discovery path as stop. The editor ID is excluded.
123
+ {
124
+ let started = false;
125
+ let operation = "play";
126
+ const originalSessions = context.bridge.sessions;
127
+ const originalCall = context.bridge.call;
128
+ context.bridge.sessions = async () => ({list: started
129
+ ? [{studioId:"editor",context:"edit"},{studioId:"runtime",context:"playtest server"}]
130
+ : [{studioId:"editor",context:"edit"}]});
131
+ context.bridge.call = async (op, params, options) => {
132
+ assert.equal(op, "playtest.control");
133
+ if (params.op === operation) {
134
+ started = true;
135
+ return {changed:true,state:{testPending:true,isRunning:false}};
136
+ }
137
+ assert.equal(options.studioId, "runtime");
138
+ return {changed:false,state:{players:operation === "multiplayer" ? [{name:"Alice",userId:123},{name:"Bob",userId:456}] : [{name:"Alice",userId:123}]}};
139
+ };
140
+ for (operation of ["play", "multiplayer"]) {
141
+ started = false;
142
+ const result = await playtest.handler(z.object(playtest.spec.inputSchema).parse({op:operation,studioId:"editor"}));
143
+ assert.match(result.content[0].text, /"studioId": "runtime"/);
144
+ assert.match(result.content[0].text, /"name": "Alice"/);
145
+ }
146
+ context.bridge.sessions = originalSessions;
147
+ context.bridge.call = originalCall;
148
+ }
149
+ process.stdout.write("playtest runtime identity: ok\n");
150
+
121
151
  // OFF affects simulation starts, not the ordinary edit-mode tool paths.
122
152
  const { registerDiscoverTools } = await import("../dist/tools/discover.js");
123
153
  const { registerScriptTools } = await import("../dist/tools/scripts.js");
@@ -160,3 +190,27 @@ for (const [name, args, expected] of [
160
190
  assert.ok(!editOps.includes("playtest.control"), "edit-mode tools never start or probe a simulation");
161
191
  context.bridge.call = normalCall;
162
192
  process.stdout.write("playtest lock leaves edit-mode tools available: ok\n");
193
+
194
+ const { registerPerfTools } = await import("../dist/tools/perf.js");
195
+ registerPerfTools(context);
196
+ const consoleTool = registered.get("console");
197
+ const consoleArgs = z.object(consoleTool.spec.inputSchema).parse({target:"client",player:"Alice",studioId:"runtime",since:"cursor-1",limit:10});
198
+ context.bridge.call = async (op, params, options) => {
199
+ assert.equal(op, "perf.console");
200
+ assert.equal(params.target, "client");
201
+ assert.equal(params.player, "Alice");
202
+ assert.equal(params.since, "cursor-1");
203
+ assert.equal(options.studioId, "runtime");
204
+ return {items:[],total:0,dropped:0,evicted:2,nextCursor:"cursor-2",capturing:true};
205
+ };
206
+ const consoleResult = await consoleTool.handler(consoleArgs);
207
+ assert.match(consoleResult.content[0].text, /2 older lines were evicted/);
208
+ assert.match(consoleResult.content[0].text, /nextCursor: cursor-2/);
209
+ context.bridge.call = async () => ({
210
+ items: Array.from({length:60}, (_, index) => ({level:"print",message:`${index} ${"x".repeat(1000)}`})),
211
+ total:60,dropped:0,nextCursor:"cursor-3",
212
+ });
213
+ const boundedConsole = await consoleTool.handler(z.object(consoleTool.spec.inputSchema).parse({}));
214
+ assert.ok(boundedConsole.content[0].text.length < 25_000);
215
+ assert.match(boundedConsole.content[0].text, /older matching lines omitted/);
216
+ process.stdout.write("client console schema and cursor forwarding: ok\n");