@chrrxs/robloxstudio-mcp 2.11.4 → 2.12.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.
@@ -1,32 +1,40 @@
1
- // Cross-DM stop_playtest signaling via plugin:SetSetting.
1
+ // Cross-DM stop_playtest signaling via plugin:SetSetting, scoped by
2
+ // per-instance setting key so the same Studio process can host playtests
3
+ // for multiple places without one place's stop_playtest yanking another's.
2
4
  //
3
5
  // `plugin:SetSetting` / `plugin:GetSetting` is a per-plugin persistent store
4
- // that's shared across every DataModel the plugin runs in (edit, play-server,
5
- // play-clients). We use it as a one-bit flag for "please call EndTest in the
6
- // play-server DM":
6
+ // shared across every DataModel the plugin runs in (edit DMs, play-server
7
+ // DMs, play-client DMs). For each connected place we use a dedicated key
8
+ // "MCP_STOP_PLAY_<instanceId>" as a single-bit mailbox:
7
9
  //
8
- // * The edit DM's stopPlaytest handler writes the flag (requestStop).
9
- // * A monitor loop running inside the play-server DM polls the flag at 1Hz
10
- // and calls StudioTestService:EndTest when it flips true, then resets it.
11
- // * The edit DM then waits up to ~2.5s for the flag to be reset, which
12
- // tells us a play-server actually consumed the request (no false-positive
13
- // success when nothing was running).
10
+ // * The edit DM's stopPlaytest handler writes `true` into its own key
11
+ // (computed from its placeId / ServerStorage anon UUID).
12
+ // * Each play-server DM's monitor loop polls the key matching its own
13
+ // instanceId at 0.1Hz; on `true` it clears the key and calls
14
+ // StudioTestService:EndTest. Play-server DMs for other places never
15
+ // touch this key.
16
+ // * The edit DM waits up to ~8s for its key to be cleared, confirming a
17
+ // matching play-server actually consumed the request.
14
18
  //
15
- // Why this is simpler than the previous edit-proxy registration:
16
- // * Doesn't depend on the MCP server tracking peer roles at all.
17
- // * Survives MCP server restarts: monitor loop is local to the play-server
18
- // plugin lifetime, not to any HTTP/registration state.
19
- // * No need for cross-DM LogService.MessageOut reflection (which we verified
20
- // does not work edit -> play-server anyway).
21
- //
22
- // Pattern mirrors the official Roblox Studio MCP
23
- // (Roblox/studio-rust-mcp-server, plugin/src/Utils/GameStopUtil.luau).
19
+ // Earlier versions used a single shared boolean flag, which let any
20
+ // play-server DM in the same Studio process consume any place's stop
21
+ // request silently yanking teammates' playtests. The per-key scoping
22
+ // below is the fix.
23
+
24
+ import { HttpService, ServerStorage } from "@rbxts/services";
24
25
 
25
26
  const StudioTestService = game.GetService("StudioTestService");
26
27
 
27
- const SETTING_KEY = "MCP_STOP_PLAY_SIGNAL";
28
- const POLL_INTERVAL_SEC = 1;
29
- const WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 2.5;
28
+ const SETTING_KEY_PREFIX = "MCP_STOP_PLAY_";
29
+ // Monitor checks the key at this cadence. 0.1s keeps worst-case detection
30
+ // lag tight so the consumption-confirmation window doesn't have to absorb
31
+ // polling jitter on top of EndTest's teardown time.
32
+ const POLL_INTERVAL_SEC = 0.1;
33
+ // Total time we wait for the matching play-server DM to consume the
34
+ // signal. Must cover: monitor detection (<= POLL_INTERVAL_SEC) +
35
+ // StudioTestService:EndTest teardown (several seconds on heavier places).
36
+ // 8s is comfortable; the tighter poll above keeps real cases well under.
37
+ const WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 8.0;
30
38
  const WAIT_POLL_SEC = 0.1;
31
39
 
32
40
  let pluginRef: Plugin | undefined;
@@ -35,20 +43,45 @@ function init(p: Plugin): void {
35
43
  pluginRef = p;
36
44
  }
37
45
 
46
+ // Mirror of Communication.computeInstanceId(). Duplicated here because
47
+ // StopPlayMonitor runs in both edit and play-server DMs, and both must
48
+ // agree on the place identifier (published places: placeId; unpublished:
49
+ // UUID on ServerStorage's __MCPPlaceId attribute, travels with the .rbxl
50
+ // into the play DM).
51
+ function computeInstanceId(): string {
52
+ if (game.PlaceId !== 0) {
53
+ return `place:${tostring(game.PlaceId)}`;
54
+ }
55
+ const existing = ServerStorage.GetAttribute("__MCPPlaceId");
56
+ if (typeIs(existing, "string") && existing !== "") {
57
+ return `anon:${existing as string}`;
58
+ }
59
+ const fresh = HttpService.GenerateGUID(false);
60
+ pcall(() => ServerStorage.SetAttribute("__MCPPlaceId", fresh));
61
+ return `anon:${fresh}`;
62
+ }
63
+
64
+ function settingKey(instanceId: string): string {
65
+ return SETTING_KEY_PREFIX + instanceId;
66
+ }
67
+
38
68
  function startMonitor(): void {
39
69
  if (!pluginRef) {
40
70
  warn("[MCP] StopPlayMonitor.startMonitor called before init; skipping");
41
71
  return;
42
72
  }
43
- // Clear any stale value left from a prior session. If a real stop request
44
- // is in-flight when this runs, the requesting edit DM will set it again
45
- // within its 2.5s wait window.
46
- pcall(() => pluginRef!.SetSetting(SETTING_KEY, false));
73
+ const myKey = settingKey(computeInstanceId());
74
+ // Clear any stale value left from a prior session. If a real stop
75
+ // request is in-flight when this runs, the requesting edit DM will
76
+ // write again within its consumption-confirmation window.
77
+ pcall(() => pluginRef!.SetSetting(myKey, false));
47
78
  task.spawn(() => {
48
79
  while (true) {
49
- const [okGet, val] = pcall(() => pluginRef!.GetSetting(SETTING_KEY));
80
+ const [okGet, val] = pcall(() => pluginRef!.GetSetting(myKey));
50
81
  if (okGet && val === true) {
51
- pcall(() => pluginRef!.SetSetting(SETTING_KEY, false));
82
+ // Consume the flag first so requestStop's
83
+ // waitForConsumption returns success, then end the test.
84
+ pcall(() => pluginRef!.SetSetting(myKey, false));
52
85
  pcall(() => StudioTestService.EndTest("stopped_by_mcp"));
53
86
  }
54
87
  task.wait(POLL_INTERVAL_SEC);
@@ -58,15 +91,17 @@ function startMonitor(): void {
58
91
 
59
92
  function requestStop(): boolean {
60
93
  if (!pluginRef) return false;
61
- const [ok] = pcall(() => pluginRef!.SetSetting(SETTING_KEY, true));
94
+ const myKey = settingKey(computeInstanceId());
95
+ const [ok] = pcall(() => pluginRef!.SetSetting(myKey, true));
62
96
  return ok;
63
97
  }
64
98
 
65
99
  function waitForConsumption(): boolean {
66
100
  if (!pluginRef) return false;
101
+ const myKey = settingKey(computeInstanceId());
67
102
  const start = tick();
68
103
  while (tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC) {
69
- const [okGet, val] = pcall(() => pluginRef!.GetSetting(SETTING_KEY));
104
+ const [okGet, val] = pcall(() => pluginRef!.GetSetting(myKey));
70
105
  if (okGet && val !== true) return true;
71
106
  task.wait(WAIT_POLL_SEC);
72
107
  }
@@ -75,7 +110,8 @@ function waitForConsumption(): boolean {
75
110
 
76
111
  function clearPending(): void {
77
112
  if (!pluginRef) return;
78
- pcall(() => pluginRef!.SetSetting(SETTING_KEY, false));
113
+ const myKey = settingKey(computeInstanceId());
114
+ pcall(() => pluginRef!.SetSetting(myKey, false));
79
115
  }
80
116
 
81
117
  export = {
@@ -1,6 +1,7 @@
1
- import { CollectionService, LogService } from "@rbxts/services";
1
+ import { CollectionService } from "@rbxts/services";
2
2
  import Utils from "../Utils";
3
3
  import Recording from "../Recording";
4
+ import LuauExec from "../LuauExec";
4
5
 
5
6
  const ChangeHistoryService = game.GetService("ChangeHistoryService");
6
7
  const Selection = game.GetService("Selection");
@@ -262,151 +263,11 @@ function getSelection(_requestData: Record<string, unknown>) {
262
263
  function executeLuau(requestData: Record<string, unknown>) {
263
264
  const code = requestData.code as string;
264
265
  if (!code || code === "") return { error: "Code is required" };
265
-
266
- // Both execution paths (loadstring + ModuleScript-require fallback) run
267
- // the SAME wrapped source so they return a uniform { ok, value, output }
268
- // shape. Two problems the wrapper solves at once:
269
- //
270
- // 1. pcall(require, m) swallows the real error and returns Roblox's
271
- // generic "Requested module experienced an error while loading"
272
- // message. Wrapping user code in xpcall INSIDE the IIFE keeps the
273
- // ModuleScript itself returning successfully — the real error +
274
- // traceback live in the returned table.
275
- //
276
- // 2. The ModuleScript path runs in its own environment, so a plugin-
277
- // side getfenv print/warn override never reached user prints. A
278
- // lexical local print/warn inside the IIFE captures user prints
279
- // regardless of which path executes. We also call the real global
280
- // print/warn so messages still flow to Studio's output and
281
- // LogService.MessageOut (which powers get_runtime_logs).
282
- //
283
- // Prints from required sub-modules don't reach this capture (they have
284
- // their own env) — those go through the runtime log buffer.
285
- const wrapped = `return ((function()
286
- \tlocal __mcp_output = {}
287
- \tlocal __mcp_real_print = print
288
- \tlocal __mcp_real_warn = warn
289
- \tlocal print = function(...)
290
- \t\t__mcp_real_print(...)
291
- \t\tlocal args = {...}
292
- \t\tlocal parts = table.create(#args)
293
- \t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end
294
- \t\ttable.insert(__mcp_output, table.concat(parts, "\\t"))
295
- \tend
296
- \tlocal warn = function(...)
297
- \t\t__mcp_real_warn(...)
298
- \t\tlocal args = {...}
299
- \t\tlocal parts = table.create(#args)
300
- \t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end
301
- \t\ttable.insert(__mcp_output, "[warn] " .. table.concat(parts, "\\t"))
302
- \tend
303
- \tlocal function __mcp_run()
304
- ${code}
305
- \tend
306
- \tlocal ok, errOrValue = xpcall(__mcp_run, debug.traceback)
307
- \treturn { ok = ok, value = errOrValue, output = __mcp_output }
308
- end)())`;
309
-
310
- interface WrapperResult {
311
- ok?: boolean;
312
- value?: unknown;
313
- output?: defined;
314
- }
315
-
316
- const runViaModuleScript = (): WrapperResult => {
317
- const m = new Instance("ModuleScript");
318
- m.Name = "__MCPExecLuauPayload";
319
- const [okSet, setErr] = pcall(() => {
320
- (m as unknown as { Source: string }).Source = wrapped;
321
- });
322
- if (!okSet) {
323
- m.Destroy();
324
- error(`ModuleScript Source set failed: ${tostring(setErr)}`);
325
- }
326
- m.Parent = game.GetService("Workspace");
327
- const [okReq, reqResult] = pcall(() => require(m));
328
- m.Destroy();
329
- if (!okReq) {
330
- let errMsg = tostring(reqResult);
331
- // pcall(require, m) collapses parse/compile failures into the
332
- // canned engine string below. Walk LogService backward for the
333
- // real diagnostic, which was emitted to MessageOut just before.
334
- if (errMsg === "Requested module experienced an error while loading") {
335
- // The parser diagnostic is emitted to LogService on the next
336
- // engine frame, not synchronously with pcall(require). task.wait(0)
337
- // yields too early; 50ms is enough to let the frame complete and
338
- // the message land in GetLogHistory.
339
- task.wait(0.05);
340
- const hist = LogService.GetLogHistory();
341
- for (let i = hist.size() - 1; i >= 0; i--) {
342
- const e = hist[i];
343
- if (
344
- e.messageType === Enum.MessageType.MessageError &&
345
- string.sub(e.message, 1, 31) === "Workspace.__MCPExecLuauPayload:"
346
- ) {
347
- errMsg = e.message;
348
- break;
349
- }
350
- }
351
- }
352
- error(errMsg);
353
- }
354
- return reqResult as unknown as WrapperResult;
355
- };
356
-
357
- const isLoadstringUnavailable = (err: unknown): boolean => {
358
- const errStr = tostring(err);
359
- const [matchStart] = string.find(errStr, "not available", 1, true);
360
- return matchStart !== undefined;
361
- };
362
-
363
- let [success, result] = pcall(() => {
364
- const [fn, compileError] = loadstring(wrapped);
365
- if (!fn) {
366
- if (isLoadstringUnavailable(compileError)) {
367
- return runViaModuleScript();
368
- }
369
- error(`Compile error: ${compileError}`);
370
- }
371
- return fn() as unknown as WrapperResult;
372
- });
373
-
374
- // loadstring throws (not returns nil) in some plugin contexts when
375
- // LoadStringEnabled=false. Catch that as a second-chance fallback.
376
- if (!success && isLoadstringUnavailable(result)) {
377
- [success, result] = pcall(runViaModuleScript);
378
- }
379
-
380
- if (!success) {
381
- // Outer pcall failed - the wrapper itself didn't even run (e.g. compile
382
- // error in the user code, or ModuleScript setup error). 'result' is the
383
- // raw error string from pcall.
384
- return {
385
- success: false,
386
- error: tostring(result),
387
- output: [],
388
- message: "Code execution failed",
389
- };
390
- }
391
-
392
- // Wrapper executed - unpack { ok, value, output }.
393
- const r = result as unknown as WrapperResult;
394
- const capturedOutput = r.output as unknown as string[] | undefined;
395
- const output = capturedOutput !== undefined ? capturedOutput : ([] as string[]);
396
- if (r.ok === true) {
397
- return {
398
- success: true,
399
- returnValue: r.value !== undefined ? tostring(r.value) : undefined,
400
- output,
401
- message: "Code executed successfully",
402
- };
403
- }
404
- return {
405
- success: false,
406
- error: r.value !== undefined ? tostring(r.value) : "(unknown error)",
407
- output,
408
- message: "Code execution failed",
409
- };
266
+ // All wrapping, print/warn capture, loadstring fallback, JSON-encoding
267
+ // of table returns, and parse-error recovery live in LuauExec so the
268
+ // edit/server (this handler) and the play-client (ClientBroker) take
269
+ // the same code path and produce identical output shapes.
270
+ return LuauExec.execute(code);
410
271
  }
411
272
 
412
273
  function undo(_requestData: Record<string, unknown>) {
@@ -235,9 +235,27 @@ function stopPlaytest(_requestData: Record<string, unknown>) {
235
235
  return { error: "Plugin not ready. Try again in a moment." };
236
236
  }
237
237
  if (!StopPlayMonitor.waitForConsumption()) {
238
- // Clean up the pending flag so a future playtest's monitor doesn't fire
239
- // EndTest on its own startup against a stale signal.
238
+ // Two distinct failure modes collapse here, distinguished by whether
239
+ // THIS edit DM has a playtest tracked:
240
+ //
241
+ // - testRunning=false: no playtest was running from this edit DM
242
+ // (true negative). Return "no active playtest" — fine to retry only
243
+ // after actually starting a playtest.
244
+ // - testRunning=true: a playtest IS running but the cross-DM signal
245
+ // didn't propagate within the consumption timeout (false negative
246
+ // from the caller's perspective — playtest may actually have ended).
247
+ // Tell the caller it's a timing issue and they can retry.
248
+ //
249
+ // Either way clean up the pending flag so a future playtest's monitor
250
+ // doesn't fire EndTest on startup against a stale signal.
240
251
  StopPlayMonitor.clearPending();
252
+ if (testRunning) {
253
+ return {
254
+ error:
255
+ "Playtest stop signal sent but consumption confirmation timed out. " +
256
+ "The playtest may have ended anyway; check get_connected_instances.",
257
+ };
258
+ }
241
259
  return { error: "No active playtest to stop." };
242
260
  }
243
261
  // Flag was consumed (EndTest called). ExecutePlayModeAsync in our
@@ -33,14 +33,17 @@ export interface PollResponse {
33
33
  request?: RequestPayload;
34
34
  requestId?: string;
35
35
  // Server signals knownInstance=false when its in-memory instances map
36
- // doesn't contain our instanceId (typically after an MCP process restart).
37
- // The plugin re-issues /ready when it sees this.
36
+ // doesn't contain our pluginSessionId (typically after an MCP process
37
+ // restart). The plugin re-issues /ready when it sees this.
38
38
  knownInstance?: boolean;
39
39
  }
40
40
 
41
41
  export interface ReadyResponse {
42
42
  success: boolean;
43
43
  assignedRole?: string;
44
+ instanceId?: string;
45
+ error?: string;
46
+ message?: string;
44
47
  }
45
48
 
46
49
  declare global {