@chrrxs/robloxstudio-mcp 2.11.1 → 2.11.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.
@@ -0,0 +1,87 @@
1
+ // Cross-DM stop_playtest signaling via plugin:SetSetting.
2
+ //
3
+ // `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":
7
+ //
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).
14
+ //
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).
24
+
25
+ const StudioTestService = game.GetService("StudioTestService");
26
+
27
+ const SETTING_KEY = "MCP_STOP_PLAY_SIGNAL";
28
+ const POLL_INTERVAL_SEC = 1;
29
+ const WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 2.5;
30
+ const WAIT_POLL_SEC = 0.1;
31
+
32
+ let pluginRef: Plugin | undefined;
33
+
34
+ function init(p: Plugin): void {
35
+ pluginRef = p;
36
+ }
37
+
38
+ function startMonitor(): void {
39
+ if (!pluginRef) {
40
+ warn("[MCP] StopPlayMonitor.startMonitor called before init; skipping");
41
+ return;
42
+ }
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));
47
+ task.spawn(() => {
48
+ while (true) {
49
+ const [okGet, val] = pcall(() => pluginRef!.GetSetting(SETTING_KEY));
50
+ if (okGet && val === true) {
51
+ pcall(() => pluginRef!.SetSetting(SETTING_KEY, false));
52
+ pcall(() => StudioTestService.EndTest("stopped_by_mcp"));
53
+ }
54
+ task.wait(POLL_INTERVAL_SEC);
55
+ }
56
+ });
57
+ }
58
+
59
+ function requestStop(): boolean {
60
+ if (!pluginRef) return false;
61
+ const [ok] = pcall(() => pluginRef!.SetSetting(SETTING_KEY, true));
62
+ return ok;
63
+ }
64
+
65
+ function waitForConsumption(): boolean {
66
+ if (!pluginRef) return false;
67
+ const start = tick();
68
+ while (tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC) {
69
+ const [okGet, val] = pcall(() => pluginRef!.GetSetting(SETTING_KEY));
70
+ if (okGet && val !== true) return true;
71
+ task.wait(WAIT_POLL_SEC);
72
+ }
73
+ return false;
74
+ }
75
+
76
+ function clearPending(): void {
77
+ if (!pluginRef) return;
78
+ pcall(() => pluginRef!.SetSetting(SETTING_KEY, false));
79
+ }
80
+
81
+ export = {
82
+ init,
83
+ startMonitor,
84
+ requestStop,
85
+ waitForConsumption,
86
+ clearPending,
87
+ };
@@ -263,48 +263,59 @@ function executeLuau(requestData: Record<string, unknown>) {
263
263
  const code = requestData.code as string;
264
264
  if (!code || code === "") return { error: "Code is required" };
265
265
 
266
- const output: string[] = [];
267
- const oldPrint = print;
268
- const oldWarn = warn;
269
-
270
- const env = getfenv(0) as unknown as Record<string, unknown>;
271
- env["print"] = (...args: defined[]) => {
272
- const parts: string[] = [];
273
- for (const a of args) parts.push(tostring(a));
274
- output.push(parts.join("\t"));
275
- oldPrint(...(args as [defined, ...defined[]]));
276
- };
277
- env["warn"] = (...args: defined[]) => {
278
- const parts: string[] = [];
279
- for (const a of args) parts.push(tostring(a));
280
- output.push(`[warn] ${parts.join("\t")}`);
281
- oldWarn(...(args as [defined, ...defined[]]));
282
- };
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
+ }
283
315
 
284
- // Try loadstring first (preserves print/warn interception). When
285
- // ServerScriptService.LoadStringEnabled=false AND the plugin runs in a
286
- // peer where the engine respects that gate (notably the play-server DM
287
- // in some Studio configurations), loadstring either returns nil with a
288
- // "loadstring() is not available" message OR throws that same message
289
- // directly. Both paths must trigger the ModuleScript + require
290
- // fallback. The fallback can't intercept print/warn since the
291
- // ModuleScript runs in its own environment, so the output array stays
292
- // empty in that branch - the playtest log buffer already captures
293
- // prints separately via LogService.MessageOut.
294
- const runViaModuleScript = () => {
316
+ const runViaModuleScript = (): WrapperResult => {
295
317
  const m = new Instance("ModuleScript");
296
318
  m.Name = "__MCPExecLuauPayload";
297
- // Wrap user code in an IIFE so require() always gets exactly one
298
- // return value. Without this, code like `print("x")` errors with
299
- // "Module code did not return exactly one value" because top-level
300
- // ModuleScripts must return exactly one value.
301
- //
302
- // The DOUBLE parens around the call are load-bearing: in Luau,
303
- // `return f()` propagates whatever multi-value tuple f returns,
304
- // including zero values. Outer parens adjust the call to exactly
305
- // one value (the first, or nil). So `return ((f)())` always
306
- // returns exactly one value, regardless of what f does.
307
- const wrapped = `return ((function()\n${code}\nend)())`;
308
319
  const [okSet, setErr] = pcall(() => {
309
320
  (m as unknown as { Source: string }).Source = wrapped;
310
321
  });
@@ -316,7 +327,7 @@ function executeLuau(requestData: Record<string, unknown>) {
316
327
  const [okReq, reqResult] = pcall(() => require(m));
317
328
  m.Destroy();
318
329
  if (!okReq) error(tostring(reqResult));
319
- return reqResult;
330
+ return reqResult as unknown as WrapperResult;
320
331
  };
321
332
 
322
333
  const isLoadstringUnavailable = (err: unknown): boolean => {
@@ -326,40 +337,52 @@ function executeLuau(requestData: Record<string, unknown>) {
326
337
  };
327
338
 
328
339
  let [success, result] = pcall(() => {
329
- const [fn, compileError] = loadstring(code);
340
+ const [fn, compileError] = loadstring(wrapped);
330
341
  if (!fn) {
331
342
  if (isLoadstringUnavailable(compileError)) {
332
343
  return runViaModuleScript();
333
344
  }
334
345
  error(`Compile error: ${compileError}`);
335
346
  }
336
- return fn();
347
+ return fn() as unknown as WrapperResult;
337
348
  });
338
349
 
339
350
  // loadstring throws (not returns nil) in some plugin contexts when
340
- // LoadStringEnabled=false. Catch that here as a second-chance fallback.
351
+ // LoadStringEnabled=false. Catch that as a second-chance fallback.
341
352
  if (!success && isLoadstringUnavailable(result)) {
342
353
  [success, result] = pcall(runViaModuleScript);
343
354
  }
344
355
 
345
- env["print"] = oldPrint;
346
- env["warn"] = oldWarn;
356
+ if (!success) {
357
+ // Outer pcall failed - the wrapper itself didn't even run (e.g. compile
358
+ // error in the user code, or ModuleScript setup error). 'result' is the
359
+ // raw error string from pcall.
360
+ return {
361
+ success: false,
362
+ error: tostring(result),
363
+ output: [],
364
+ message: "Code execution failed",
365
+ };
366
+ }
347
367
 
348
- if (success) {
368
+ // Wrapper executed - unpack { ok, value, output }.
369
+ const r = result as unknown as WrapperResult;
370
+ const capturedOutput = r.output as unknown as string[] | undefined;
371
+ const output = capturedOutput !== undefined ? capturedOutput : ([] as string[]);
372
+ if (r.ok === true) {
349
373
  return {
350
374
  success: true,
351
- returnValue: result !== undefined ? tostring(result) : undefined,
375
+ returnValue: r.value !== undefined ? tostring(r.value) : undefined,
352
376
  output,
353
377
  message: "Code executed successfully",
354
378
  };
355
- } else {
356
- return {
357
- success: false,
358
- error: tostring(result),
359
- output,
360
- message: "Code execution failed",
361
- };
362
379
  }
380
+ return {
381
+ success: false,
382
+ error: r.value !== undefined ? tostring(r.value) : "(unknown error)",
383
+ output,
384
+ message: "Code execution failed",
385
+ };
363
386
  }
364
387
 
365
388
  function undo(_requestData: Record<string, unknown>) {
@@ -1,11 +1,15 @@
1
- import { HttpService, LogService } from "@rbxts/services";
1
+ import { HttpService, LogService, RunService } from "@rbxts/services";
2
2
  import { installBridges, cleanupBridges } from "../EvalBridges";
3
+ import StopPlayMonitor from "../StopPlayMonitor";
3
4
 
4
5
  const StudioTestService = game.GetService("StudioTestService");
5
6
  const ServerScriptService = game.GetService("ServerScriptService");
6
7
  const ScriptEditorService = game.GetService("ScriptEditorService");
7
8
 
8
- const STOP_SIGNAL = "__MCP_STOP__";
9
+ // NAV_SIGNAL flows from the edit DM to the play-server DM via the injected
10
+ // __MCP_CommandListener Script + LogService.MessageOut. Stop signaling moved
11
+ // off this path entirely (see StopPlayMonitor) because cross-DM MessageOut
12
+ // reflection from edit -> play-server does not work in practice.
9
13
  const NAV_SIGNAL = "__MCP_NAV__";
10
14
  const NAV_RESULT = "__MCP_NAV_RESULT__";
11
15
 
@@ -25,16 +29,13 @@ let navResultCallback: ((json: string) => void) | undefined;
25
29
 
26
30
  function buildCommandListenerSource(): string {
27
31
  return `local LogService = game:GetService("LogService")
28
- local StudioTestService = game:GetService("StudioTestService")
29
32
  local PathfindingService = game:GetService("PathfindingService")
30
33
  local Players = game:GetService("Players")
31
34
  local HttpService = game:GetService("HttpService")
32
35
  local NAV_SIG = "${NAV_SIGNAL}"
33
36
  local NAV_RES = "${NAV_RESULT}"
34
37
  LogService.MessageOut:Connect(function(msg)
35
- if msg == "${STOP_SIGNAL}" then
36
- pcall(function() StudioTestService:EndTest("stopped_by_mcp") end)
37
- elseif string.sub(msg, 1, #NAV_SIG + 1) == NAV_SIG .. ":" then
38
+ if string.sub(msg, 1, #NAV_SIG + 1) == NAV_SIG .. ":" then
38
39
  local json = string.sub(msg, #NAV_SIG + 2)
39
40
  task.spawn(function()
40
41
  local ok, d = pcall(function() return HttpService:JSONDecode(json) end)
@@ -123,6 +124,20 @@ function startPlaytest(requestData: Record<string, unknown>) {
123
124
  return { error: 'mode must be "play" or "run"' };
124
125
  }
125
126
 
127
+ // Self-heal: if testRunning is stuck true but Studio reports no active
128
+ // playtest, the previous start_playtest's task.spawn was orphaned
129
+ // (plugin reload mid-test, Studio entered some inconsistent state, etc).
130
+ // Reset it so subsequent starts don't hit a false "already running".
131
+ if (testRunning && !RunService.IsRunning()) {
132
+ testRunning = false;
133
+ if (logConnection) {
134
+ logConnection.Disconnect();
135
+ logConnection = undefined;
136
+ }
137
+ cleanupStopListener();
138
+ cleanupBridges();
139
+ }
140
+
126
141
  if (testRunning) {
127
142
  return { error: "A test is already running" };
128
143
  }
@@ -135,7 +150,6 @@ function startPlaytest(requestData: Record<string, unknown>) {
135
150
  cleanupStopListener();
136
151
 
137
152
  logConnection = LogService.MessageOut.Connect((message, messageType) => {
138
- if (message === STOP_SIGNAL) return;
139
153
  if (message.sub(1, NAV_SIGNAL.size()) === NAV_SIGNAL) return;
140
154
  if (message.sub(1, NAV_RESULT.size() + 1) === `${NAV_RESULT}:`) {
141
155
  if (navResultCallback) {
@@ -193,32 +207,58 @@ function startPlaytest(requestData: Record<string, unknown>) {
193
207
  });
194
208
 
195
209
  const msg = numPlayers !== undefined
196
- ? `Playtest started in ${mode} mode with ${numPlayers} player(s)`
197
- : `Playtest started in ${mode} mode`;
210
+ ? `Playtest started in ${mode} mode with ${numPlayers} player(s).`
211
+ : `Playtest started in ${mode} mode.`;
198
212
 
199
213
  const response: Record<string, unknown> = {
200
214
  success: true,
201
215
  message: msg,
202
- evalBridges: bridgeInstall.installed ? "installed" : `failed: ${bridgeInstall.error}`,
203
216
  };
217
+ // Only mention eval bridges when they failed — when they're fine, the
218
+ // detail is noise. eval_server_runtime / eval_client_runtime will surface
219
+ // their own clear errors if the caller tries to use them after a failed
220
+ // install.
221
+ if (!bridgeInstall.installed) {
222
+ response.evalBridgesError = bridgeInstall.error;
223
+ }
204
224
 
205
225
  return response;
206
226
  }
207
227
 
208
228
  function stopPlaytest(_requestData: Record<string, unknown>) {
209
- // Server-side routing (tools/index.ts:stopPlaytest) sends /api/stop-playtest
210
- // to the role="edit-proxy" instance whenever one is registered. This handler
211
- // is only reached when there's no edit-proxy - i.e. no active playtest, or
212
- // the play DMs haven't completed plugin auto-activation yet. Calling
213
- // StudioTestService:EndTest from the edit DM is illegal ("can only be
214
- // called from the server DataModel of a running Studio play session"), so
215
- // don't try - return a clean "no active playtest" response instead.
216
- return {
217
- error: "No active playtest to stop (edit-proxy not registered).",
218
- hint:
219
- "If a playtest is running, the play-server DM may not have completed plugin auto-activation yet. " +
220
- "Wait a moment and retry, or call execute_luau target=server with StudioTestService:EndTest as a manual fallback.",
221
- };
229
+ // Signal the play-server DM's StopPlayMonitor via plugin:SetSetting (a
230
+ // cross-DM persistent store). The monitor polls at 1Hz, sees the flag,
231
+ // calls StudioTestService:EndTest, then resets the flag. We wait up to
232
+ // 2.5s for the reset to confirm a play DM actually consumed the request,
233
+ // which avoids returning success when nothing is running.
234
+ if (!StopPlayMonitor.requestStop()) {
235
+ return { error: "Plugin not ready. Try again in a moment." };
236
+ }
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.
240
+ StopPlayMonitor.clearPending();
241
+ return { error: "No active playtest to stop." };
242
+ }
243
+ // Flag was consumed (EndTest called). ExecutePlayModeAsync in our
244
+ // startPlaytest task.spawn is still unwinding though — testRunning stays
245
+ // true until that yield completes and the post-block runs. Wait so
246
+ // back-to-back stop -> start sequences don't race against the prior
247
+ // teardown and get "A test is already running". 10s covers play-DM
248
+ // teardown on heavier places; if it still hasn't cleared we return
249
+ // anyway so users aren't stuck — but note that in the response so the
250
+ // caller knows a subsequent start may need a moment.
251
+ const deadline = tick() + 10;
252
+ while (testRunning && tick() < deadline) {
253
+ task.wait(0.1);
254
+ }
255
+ if (testRunning) {
256
+ return {
257
+ success: true,
258
+ message: "Playtest stop signal sent; teardown still in progress.",
259
+ };
260
+ }
261
+ return { success: true, message: "Playtest stopped." };
222
262
  }
223
263
 
224
264
  function getPlaytestOutput(_requestData: Record<string, unknown>) {
@@ -3,12 +3,18 @@ import UI from "../modules/UI";
3
3
  import Communication from "../modules/Communication";
4
4
  import ClientBroker from "../modules/ClientBroker";
5
5
  import RuntimeLogBuffer from "../modules/RuntimeLogBuffer";
6
+ import StopPlayMonitor from "../modules/StopPlayMonitor";
6
7
 
7
8
  // Attach the per-peer LogService.MessageOut listener as early as possible so
8
9
  // boot-time prints from the user's place scripts are captured. Powers the
9
10
  // get_runtime_logs MCP tool. Idempotent; safe to call before UI.init().
10
11
  RuntimeLogBuffer.install();
11
12
 
13
+ // Share the plugin reference with the stop-play signaling module so both the
14
+ // edit DM (write the flag) and the play-server DM (read+act on the flag) can
15
+ // access plugin:SetSetting/GetSetting.
16
+ StopPlayMonitor.init(plugin);
17
+
12
18
  UI.init(plugin);
13
19
  const elements = UI.getElements();
14
20
 
@@ -67,6 +73,10 @@ task.delay(2, () => {
67
73
  }
68
74
  if (role === "server") {
69
75
  ClientBroker.setupServerBroker();
76
+ // The play-server DM is the only one where StudioTestService:EndTest is
77
+ // legal, so the stop-play monitor lives here. Reads MCP_STOP_PLAY_SIGNAL
78
+ // at 1Hz and calls EndTest when the edit DM sets it.
79
+ StopPlayMonitor.startMonitor();
70
80
  } else if (role === "client") {
71
81
  ClientBroker.setupClientBroker();
72
82
  }