@chrrxs/robloxstudio-mcp-inspector 2.11.1 → 2.11.2

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/index.js CHANGED
@@ -1928,23 +1928,9 @@ ${code}`
1928
1928
  };
1929
1929
  }
1930
1930
  async stopPlaytest() {
1931
- let hasProxy = this.bridge.getInstances().some((i) => i.role === "edit-proxy");
1932
- if (!hasProxy) {
1933
- const deadline = Date.now() + 1500;
1934
- while (!hasProxy && Date.now() < deadline) {
1935
- await new Promise((r) => setTimeout(r, 150));
1936
- hasProxy = this.bridge.getInstances().some((i) => i.role === "edit-proxy");
1937
- }
1938
- }
1939
- const target = hasProxy ? "edit-proxy" : "edit";
1940
- const response = await this.client.request("/api/stop-playtest", {}, target);
1931
+ const response = await this.client.request("/api/stop-playtest", {}, "edit");
1941
1932
  return {
1942
- content: [
1943
- {
1944
- type: "text",
1945
- text: JSON.stringify(response)
1946
- }
1947
- ]
1933
+ content: [{ type: "text", text: JSON.stringify(response) }]
1948
1934
  };
1949
1935
  }
1950
1936
  async getPlaytestOutput(target) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrrxs/robloxstudio-mcp-inspector",
3
- "version": "2.11.1",
3
+ "version": "2.11.2",
4
4
  "description": "Read-only MCP Server for Roblox Studio (fork of boshyxd/robloxstudio-mcp-inspector with per-peer execute_luau fixes baked in)",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -11,10 +11,15 @@ local UI = TS.import(script, script, "modules", "UI")
11
11
  local Communication = TS.import(script, script, "modules", "Communication")
12
12
  local ClientBroker = TS.import(script, script, "modules", "ClientBroker")
13
13
  local RuntimeLogBuffer = TS.import(script, script, "modules", "RuntimeLogBuffer")
14
+ local StopPlayMonitor = TS.import(script, script, "modules", "StopPlayMonitor")
14
15
  -- Attach the per-peer LogService.MessageOut listener as early as possible so
15
16
  -- boot-time prints from the user's place scripts are captured. Powers the
16
17
  -- get_runtime_logs MCP tool. Idempotent; safe to call before UI.init().
17
18
  RuntimeLogBuffer.install()
19
+ -- Share the plugin reference with the stop-play signaling module so both the
20
+ -- edit DM (write the flag) and the play-server DM (read+act on the flag) can
21
+ -- access plugin:SetSetting/GetSetting.
22
+ StopPlayMonitor.init(plugin)
18
23
  UI.init(plugin)
19
24
  local elements = UI.getElements()
20
25
  local ICON_DISCONNECTED = "rbxassetid://125921838360800"
@@ -65,6 +70,10 @@ task.delay(2, function()
65
70
  end
66
71
  if role == "server" then
67
72
  ClientBroker.setupServerBroker()
73
+ -- The play-server DM is the only one where StudioTestService:EndTest is
74
+ -- legal, so the stop-play monitor lives here. Reads MCP_STOP_PLAY_SIGNAL
75
+ -- at 1Hz and calls EndTest when the edit DM sets it.
76
+ StopPlayMonitor.startMonitor()
68
77
  elseif role == "client" then
69
78
  ClientBroker.setupClientBroker()
70
79
  end
@@ -94,12 +103,10 @@ local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandl
94
103
  -- in ReplicatedStorage; each player gets a proxy "client" registration on the
95
104
  -- MCP side, polled and dispatched by the server peer.
96
105
  --
97
- -- The same server peer also registers an "edit" proxy that intercepts
98
- -- /api/stop-playtest specifically - StudioTestService:EndTest only works from
99
- -- the play server DM, so the real edit DM cannot satisfy stop requests on its
100
- -- own. MCP returns the same pending request to multiple pollers until someone
101
- -- /responds, so non-stop edit-targeted requests fall through to the actual
102
- -- edit DM untouched.
106
+ -- (Previously the server peer also registered an "edit-proxy" role to
107
+ -- intercept /api/stop-playtest and call StudioTestService:EndTest. That hack
108
+ -- is gone: stop now uses StopPlayMonitor with plugin:SetSetting cross-DM
109
+ -- signaling, which works regardless of MCP server state.)
103
110
  local MCP_URL = "http://localhost:58741"
104
111
  local BROKER_NAME = "__MCPClientBroker"
105
112
  -- Endpoints the server-peer broker is allowed to forward to the client peer.
@@ -329,59 +336,10 @@ local function registerProxy(player, rf)
329
336
  proxyByPlayer[_player_1] = _arg1
330
337
  task.spawn(pollProxy, proxyId, player, rf)
331
338
  end
332
- local function startEditProxyLoop()
333
- task.spawn(function()
334
- local proxyId = HttpService:GenerateGUID(false)
335
- local ok, res = postJson("/ready", {
336
- instanceId = proxyId,
337
- role = "edit-proxy",
338
- })
339
- if not ok or not res or not res.Success then
340
- warn("[MCPFork] edit-proxy register failed")
341
- return nil
342
- end
343
- while true do
344
- local okPoll, pollRes = pcall(function()
345
- return HttpService:RequestAsync({
346
- Url = `{MCP_URL}/poll?instanceId={proxyId}`,
347
- Method = "GET",
348
- Headers = {
349
- ["Content-Type"] = "application/json",
350
- },
351
- })
352
- end)
353
- if okPoll and pollRes and (pollRes.Success or pollRes.StatusCode == 503) then
354
- local okJson, body = pcall(function()
355
- return HttpService:JSONDecode(pollRes.Body)
356
- end)
357
- if okJson and body then
358
- -- Re-register if the server lost our edit-proxy registration.
359
- if body.knownInstance == false then
360
- reRegisterProxy(proxyId, "edit-proxy")
361
- end
362
- if body.request and body.request.endpoint == "/api/stop-playtest" and body.requestId ~= nil then
363
- local sts = game:GetService("StudioTestService")
364
- local endOk, endErr = pcall(function()
365
- return sts:EndTest("stopped_by_mcp")
366
- end)
367
- local response = if endOk then {
368
- success = true,
369
- message = "Playtest stopped via edit-proxy/EndTest",
370
- } else {
371
- success = false,
372
- error = `EndTest failed: {tostring(endErr)}`,
373
- }
374
- postJson("/response", {
375
- requestId = body.requestId,
376
- response = response,
377
- })
378
- end
379
- end
380
- end
381
- task.wait(0.15)
382
- end
383
- end)
384
- end
339
+ -- (Removed: startEditProxyLoop. The play-server DM no longer registers an
340
+ -- "edit-proxy" peer with the MCP server. stop_playtest now uses a cross-DM
341
+ -- plugin:SetSetting flag consumed by StopPlayMonitor in the play-server DM,
342
+ -- which doesn't depend on MCP server state or peer registration at all.)
385
343
  local function setupServerBroker()
386
344
  local rf = ReplicatedStorage:FindFirstChild(BROKER_NAME)
387
345
  if not rf then
@@ -415,7 +373,6 @@ local function setupServerBroker()
415
373
  end
416
374
  table.clear(proxyByPlayer)
417
375
  end)
418
- startEditProxyLoop()
419
376
  end
420
377
  return {
421
378
  MCP_URL = MCP_URL,
@@ -5689,10 +5646,14 @@ local LogService = _services.LogService
5689
5646
  local _EvalBridges = TS.import(script, script.Parent.Parent, "EvalBridges")
5690
5647
  local installBridges = _EvalBridges.installBridges
5691
5648
  local cleanupBridges = _EvalBridges.cleanupBridges
5649
+ local StopPlayMonitor = TS.import(script, script.Parent.Parent, "StopPlayMonitor")
5692
5650
  local StudioTestService = game:GetService("StudioTestService")
5693
5651
  local ServerScriptService = game:GetService("ServerScriptService")
5694
5652
  local ScriptEditorService = game:GetService("ScriptEditorService")
5695
- local STOP_SIGNAL = "__MCP_STOP__"
5653
+ -- NAV_SIGNAL flows from the edit DM to the play-server DM via the injected
5654
+ -- __MCP_CommandListener Script + LogService.MessageOut. Stop signaling moved
5655
+ -- off this path entirely (see StopPlayMonitor) because cross-DM MessageOut
5656
+ -- reflection from edit -> play-server does not work in practice.
5696
5657
  local NAV_SIGNAL = "__MCP_NAV__"
5697
5658
  local NAV_RESULT = "__MCP_NAV_RESULT__"
5698
5659
  local testRunning = false
@@ -5704,16 +5665,13 @@ local stopListenerScript
5704
5665
  local navResultCallback
5705
5666
  local function buildCommandListenerSource()
5706
5667
  return `local LogService = game:GetService("LogService")\
5707
- local StudioTestService = game:GetService("StudioTestService")\
5708
5668
  local PathfindingService = game:GetService("PathfindingService")\
5709
5669
  local Players = game:GetService("Players")\
5710
5670
  local HttpService = game:GetService("HttpService")\
5711
5671
  local NAV_SIG = "{NAV_SIGNAL}"\
5712
5672
  local NAV_RES = "{NAV_RESULT}"\
5713
5673
  LogService.MessageOut:Connect(function(msg)\
5714
- if msg == "{STOP_SIGNAL}" then\
5715
- pcall(function() StudioTestService:EndTest("stopped_by_mcp") end)\
5716
- elseif string.sub(msg, 1, #NAV_SIG + 1) == NAV_SIG .. ":" then\
5674
+ if string.sub(msg, 1, #NAV_SIG + 1) == NAV_SIG .. ":" then\
5717
5675
  local json = string.sub(msg, #NAV_SIG + 2)\
5718
5676
  task.spawn(function()\
5719
5677
  local ok, d = pcall(function() return HttpService:JSONDecode(json) end)\
@@ -5812,9 +5770,6 @@ local function startPlaytest(requestData)
5812
5770
  testError = nil
5813
5771
  cleanupStopListener()
5814
5772
  logConnection = LogService.MessageOut:Connect(function(message, messageType)
5815
- if message == STOP_SIGNAL then
5816
- return nil
5817
- end
5818
5773
  local _message = message
5819
5774
  local _arg1 = #NAV_SIGNAL
5820
5775
  if string.sub(_message, 1, _arg1) == NAV_SIGNAL then
@@ -5876,25 +5831,42 @@ local function startPlaytest(requestData)
5876
5831
  cleanupStopListener()
5877
5832
  cleanupBridges()
5878
5833
  end)
5879
- local msg = if numPlayers ~= nil then `Playtest started in {mode} mode with {numPlayers} player(s)` else `Playtest started in {mode} mode`
5834
+ local msg = if numPlayers ~= nil then `Playtest started in {mode} mode with {numPlayers} player(s).` else `Playtest started in {mode} mode.`
5880
5835
  local response = {
5881
5836
  success = true,
5882
5837
  message = msg,
5883
- evalBridges = if bridgeInstall.installed then "installed" else `failed: {bridgeInstall.error}`,
5884
5838
  }
5839
+ -- Only mention eval bridges when they failed — when they're fine, the
5840
+ -- detail is noise. eval_server_runtime / eval_client_runtime will surface
5841
+ -- their own clear errors if the caller tries to use them after a failed
5842
+ -- install.
5843
+ if not bridgeInstall.installed then
5844
+ response.evalBridgesError = bridgeInstall.error
5845
+ end
5885
5846
  return response
5886
5847
  end
5887
5848
  local function stopPlaytest(_requestData)
5888
- -- Server-side routing (tools/index.ts:stopPlaytest) sends /api/stop-playtest
5889
- -- to the role="edit-proxy" instance whenever one is registered. This handler
5890
- -- is only reached when there's no edit-proxy - i.e. no active playtest, or
5891
- -- the play DMs haven't completed plugin auto-activation yet. Calling
5892
- -- StudioTestService:EndTest from the edit DM is illegal ("can only be
5893
- -- called from the server DataModel of a running Studio play session"), so
5894
- -- don't try - return a clean "no active playtest" response instead.
5849
+ -- Signal the play-server DM's StopPlayMonitor via plugin:SetSetting (a
5850
+ -- cross-DM persistent store). The monitor polls at 1Hz, sees the flag,
5851
+ -- calls StudioTestService:EndTest, then resets the flag. We wait up to
5852
+ -- 2.5s for the reset to confirm a play DM actually consumed the request,
5853
+ -- which avoids returning success when nothing is running.
5854
+ if not StopPlayMonitor.requestStop() then
5855
+ return {
5856
+ error = "Plugin not ready. Try again in a moment.",
5857
+ }
5858
+ end
5859
+ if StopPlayMonitor.waitForConsumption() then
5860
+ return {
5861
+ success = true,
5862
+ message = "Playtest stopped.",
5863
+ }
5864
+ end
5865
+ -- Clean up the pending flag so a future playtest's monitor doesn't fire
5866
+ -- EndTest on its own startup against a stale signal.
5867
+ StopPlayMonitor.clearPending()
5895
5868
  return {
5896
- error = "No active playtest to stop (edit-proxy not registered).",
5897
- hint = "If a playtest is running, the play-server DM may not have completed plugin auto-activation yet. " .. "Wait a moment and retry, or call execute_luau target=server with StudioTestService:EndTest as a manual fallback.",
5869
+ error = "No active playtest to stop.",
5898
5870
  }
5899
5871
  end
5900
5872
  local function getPlaytestOutput(_requestData)
@@ -6203,7 +6175,7 @@ return {
6203
6175
  <Properties>
6204
6176
  <string name="Name">State</string>
6205
6177
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
6206
- local CURRENT_VERSION = "2.11.1"
6178
+ local CURRENT_VERSION = "2.11.2"
6207
6179
  local MAX_CONNECTIONS = 5
6208
6180
  local BASE_PORT = 58741
6209
6181
  local activeTabIndex = 0
@@ -6296,6 +6268,113 @@ return {
6296
6268
  </Properties>
6297
6269
  </Item>
6298
6270
  <Item class="ModuleScript" referent="22">
6271
+ <Properties>
6272
+ <string name="Name">StopPlayMonitor</string>
6273
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
6274
+ -- Cross-DM stop_playtest signaling via plugin:SetSetting.
6275
+ --
6276
+ -- `plugin:SetSetting` / `plugin:GetSetting` is a per-plugin persistent store
6277
+ -- that's shared across every DataModel the plugin runs in (edit, play-server,
6278
+ -- play-clients). We use it as a one-bit flag for "please call EndTest in the
6279
+ -- play-server DM":
6280
+ --
6281
+ -- * The edit DM's stopPlaytest handler writes the flag (requestStop).
6282
+ -- * A monitor loop running inside the play-server DM polls the flag at 1Hz
6283
+ -- and calls StudioTestService:EndTest when it flips true, then resets it.
6284
+ -- * The edit DM then waits up to ~2.5s for the flag to be reset, which
6285
+ -- tells us a play-server actually consumed the request (no false-positive
6286
+ -- success when nothing was running).
6287
+ --
6288
+ -- Why this is simpler than the previous edit-proxy registration:
6289
+ -- * Doesn't depend on the MCP server tracking peer roles at all.
6290
+ -- * Survives MCP server restarts: monitor loop is local to the play-server
6291
+ -- plugin lifetime, not to any HTTP/registration state.
6292
+ -- * No need for cross-DM LogService.MessageOut reflection (which we verified
6293
+ -- does not work edit -> play-server anyway).
6294
+ --
6295
+ -- Pattern mirrors the official Roblox Studio MCP
6296
+ -- (Roblox/studio-rust-mcp-server, plugin/src/Utils/GameStopUtil.luau).
6297
+ local StudioTestService = game:GetService("StudioTestService")
6298
+ local SETTING_KEY = "MCP_STOP_PLAY_SIGNAL"
6299
+ local POLL_INTERVAL_SEC = 1
6300
+ local WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 2.5
6301
+ local WAIT_POLL_SEC = 0.1
6302
+ local pluginRef
6303
+ local function init(p)
6304
+ pluginRef = p
6305
+ end
6306
+ local function startMonitor()
6307
+ if not pluginRef then
6308
+ warn("[MCP] StopPlayMonitor.startMonitor called before init; skipping")
6309
+ return nil
6310
+ end
6311
+ -- Clear any stale value left from a prior session. If a real stop request
6312
+ -- is in-flight when this runs, the requesting edit DM will set it again
6313
+ -- within its 2.5s wait window.
6314
+ pcall(function()
6315
+ return pluginRef:SetSetting(SETTING_KEY, false)
6316
+ end)
6317
+ task.spawn(function()
6318
+ while true do
6319
+ local okGet, val = pcall(function()
6320
+ return pluginRef:GetSetting(SETTING_KEY)
6321
+ end)
6322
+ if okGet and val == true then
6323
+ pcall(function()
6324
+ return pluginRef:SetSetting(SETTING_KEY, false)
6325
+ end)
6326
+ pcall(function()
6327
+ return StudioTestService:EndTest("stopped_by_mcp")
6328
+ end)
6329
+ end
6330
+ task.wait(POLL_INTERVAL_SEC)
6331
+ end
6332
+ end)
6333
+ end
6334
+ local function requestStop()
6335
+ if not pluginRef then
6336
+ return false
6337
+ end
6338
+ local ok = pcall(function()
6339
+ return pluginRef:SetSetting(SETTING_KEY, true)
6340
+ end)
6341
+ return ok
6342
+ end
6343
+ local function waitForConsumption()
6344
+ if not pluginRef then
6345
+ return false
6346
+ end
6347
+ local start = tick()
6348
+ while tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC do
6349
+ local okGet, val = pcall(function()
6350
+ return pluginRef:GetSetting(SETTING_KEY)
6351
+ end)
6352
+ if okGet and val ~= true then
6353
+ return true
6354
+ end
6355
+ task.wait(WAIT_POLL_SEC)
6356
+ end
6357
+ return false
6358
+ end
6359
+ local function clearPending()
6360
+ if not pluginRef then
6361
+ return nil
6362
+ end
6363
+ pcall(function()
6364
+ return pluginRef:SetSetting(SETTING_KEY, false)
6365
+ end)
6366
+ end
6367
+ return {
6368
+ init = init,
6369
+ startMonitor = startMonitor,
6370
+ requestStop = requestStop,
6371
+ waitForConsumption = waitForConsumption,
6372
+ clearPending = clearPending,
6373
+ }
6374
+ ]]></string>
6375
+ </Properties>
6376
+ </Item>
6377
+ <Item class="ModuleScript" referent="23">
6299
6378
  <Properties>
6300
6379
  <string name="Name">UI</string>
6301
6380
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7046,7 +7125,7 @@ return {
7046
7125
  ]]></string>
7047
7126
  </Properties>
7048
7127
  </Item>
7049
- <Item class="ModuleScript" referent="23">
7128
+ <Item class="ModuleScript" referent="24">
7050
7129
  <Properties>
7051
7130
  <string name="Name">Utils</string>
7052
7131
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7576,11 +7655,11 @@ return {
7576
7655
  </Properties>
7577
7656
  </Item>
7578
7657
  </Item>
7579
- <Item class="Folder" referent="27">
7658
+ <Item class="Folder" referent="28">
7580
7659
  <Properties>
7581
7660
  <string name="Name">include</string>
7582
7661
  </Properties>
7583
- <Item class="ModuleScript" referent="24">
7662
+ <Item class="ModuleScript" referent="25">
7584
7663
  <Properties>
7585
7664
  <string name="Name">Promise</string>
7586
7665
  <string name="Source"><![CDATA[--[[
@@ -9654,7 +9733,7 @@ return Promise
9654
9733
  ]]></string>
9655
9734
  </Properties>
9656
9735
  </Item>
9657
- <Item class="ModuleScript" referent="25">
9736
+ <Item class="ModuleScript" referent="26">
9658
9737
  <Properties>
9659
9738
  <string name="Name">RuntimeLib</string>
9660
9739
  <string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
@@ -9921,15 +10000,15 @@ return TS
9921
10000
  </Properties>
9922
10001
  </Item>
9923
10002
  </Item>
9924
- <Item class="Folder" referent="28">
10003
+ <Item class="Folder" referent="29">
9925
10004
  <Properties>
9926
10005
  <string name="Name">node_modules</string>
9927
10006
  </Properties>
9928
- <Item class="Folder" referent="29">
10007
+ <Item class="Folder" referent="30">
9929
10008
  <Properties>
9930
10009
  <string name="Name">@rbxts</string>
9931
10010
  </Properties>
9932
- <Item class="ModuleScript" referent="26">
10011
+ <Item class="ModuleScript" referent="27">
9933
10012
  <Properties>
9934
10013
  <string name="Name">services</string>
9935
10014
  <string name="Source"><![CDATA[return setmetatable({}, {
@@ -11,10 +11,15 @@ local UI = TS.import(script, script, "modules", "UI")
11
11
  local Communication = TS.import(script, script, "modules", "Communication")
12
12
  local ClientBroker = TS.import(script, script, "modules", "ClientBroker")
13
13
  local RuntimeLogBuffer = TS.import(script, script, "modules", "RuntimeLogBuffer")
14
+ local StopPlayMonitor = TS.import(script, script, "modules", "StopPlayMonitor")
14
15
  -- Attach the per-peer LogService.MessageOut listener as early as possible so
15
16
  -- boot-time prints from the user's place scripts are captured. Powers the
16
17
  -- get_runtime_logs MCP tool. Idempotent; safe to call before UI.init().
17
18
  RuntimeLogBuffer.install()
19
+ -- Share the plugin reference with the stop-play signaling module so both the
20
+ -- edit DM (write the flag) and the play-server DM (read+act on the flag) can
21
+ -- access plugin:SetSetting/GetSetting.
22
+ StopPlayMonitor.init(plugin)
18
23
  UI.init(plugin)
19
24
  local elements = UI.getElements()
20
25
  local ICON_DISCONNECTED = "rbxassetid://75876056391496"
@@ -65,6 +70,10 @@ task.delay(2, function()
65
70
  end
66
71
  if role == "server" then
67
72
  ClientBroker.setupServerBroker()
73
+ -- The play-server DM is the only one where StudioTestService:EndTest is
74
+ -- legal, so the stop-play monitor lives here. Reads MCP_STOP_PLAY_SIGNAL
75
+ -- at 1Hz and calls EndTest when the edit DM sets it.
76
+ StopPlayMonitor.startMonitor()
68
77
  elseif role == "client" then
69
78
  ClientBroker.setupClientBroker()
70
79
  end
@@ -94,12 +103,10 @@ local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandl
94
103
  -- in ReplicatedStorage; each player gets a proxy "client" registration on the
95
104
  -- MCP side, polled and dispatched by the server peer.
96
105
  --
97
- -- The same server peer also registers an "edit" proxy that intercepts
98
- -- /api/stop-playtest specifically - StudioTestService:EndTest only works from
99
- -- the play server DM, so the real edit DM cannot satisfy stop requests on its
100
- -- own. MCP returns the same pending request to multiple pollers until someone
101
- -- /responds, so non-stop edit-targeted requests fall through to the actual
102
- -- edit DM untouched.
106
+ -- (Previously the server peer also registered an "edit-proxy" role to
107
+ -- intercept /api/stop-playtest and call StudioTestService:EndTest. That hack
108
+ -- is gone: stop now uses StopPlayMonitor with plugin:SetSetting cross-DM
109
+ -- signaling, which works regardless of MCP server state.)
103
110
  local MCP_URL = "http://localhost:58741"
104
111
  local BROKER_NAME = "__MCPClientBroker"
105
112
  -- Endpoints the server-peer broker is allowed to forward to the client peer.
@@ -329,59 +336,10 @@ local function registerProxy(player, rf)
329
336
  proxyByPlayer[_player_1] = _arg1
330
337
  task.spawn(pollProxy, proxyId, player, rf)
331
338
  end
332
- local function startEditProxyLoop()
333
- task.spawn(function()
334
- local proxyId = HttpService:GenerateGUID(false)
335
- local ok, res = postJson("/ready", {
336
- instanceId = proxyId,
337
- role = "edit-proxy",
338
- })
339
- if not ok or not res or not res.Success then
340
- warn("[MCPFork] edit-proxy register failed")
341
- return nil
342
- end
343
- while true do
344
- local okPoll, pollRes = pcall(function()
345
- return HttpService:RequestAsync({
346
- Url = `{MCP_URL}/poll?instanceId={proxyId}`,
347
- Method = "GET",
348
- Headers = {
349
- ["Content-Type"] = "application/json",
350
- },
351
- })
352
- end)
353
- if okPoll and pollRes and (pollRes.Success or pollRes.StatusCode == 503) then
354
- local okJson, body = pcall(function()
355
- return HttpService:JSONDecode(pollRes.Body)
356
- end)
357
- if okJson and body then
358
- -- Re-register if the server lost our edit-proxy registration.
359
- if body.knownInstance == false then
360
- reRegisterProxy(proxyId, "edit-proxy")
361
- end
362
- if body.request and body.request.endpoint == "/api/stop-playtest" and body.requestId ~= nil then
363
- local sts = game:GetService("StudioTestService")
364
- local endOk, endErr = pcall(function()
365
- return sts:EndTest("stopped_by_mcp")
366
- end)
367
- local response = if endOk then {
368
- success = true,
369
- message = "Playtest stopped via edit-proxy/EndTest",
370
- } else {
371
- success = false,
372
- error = `EndTest failed: {tostring(endErr)}`,
373
- }
374
- postJson("/response", {
375
- requestId = body.requestId,
376
- response = response,
377
- })
378
- end
379
- end
380
- end
381
- task.wait(0.15)
382
- end
383
- end)
384
- end
339
+ -- (Removed: startEditProxyLoop. The play-server DM no longer registers an
340
+ -- "edit-proxy" peer with the MCP server. stop_playtest now uses a cross-DM
341
+ -- plugin:SetSetting flag consumed by StopPlayMonitor in the play-server DM,
342
+ -- which doesn't depend on MCP server state or peer registration at all.)
385
343
  local function setupServerBroker()
386
344
  local rf = ReplicatedStorage:FindFirstChild(BROKER_NAME)
387
345
  if not rf then
@@ -415,7 +373,6 @@ local function setupServerBroker()
415
373
  end
416
374
  table.clear(proxyByPlayer)
417
375
  end)
418
- startEditProxyLoop()
419
376
  end
420
377
  return {
421
378
  MCP_URL = MCP_URL,
@@ -5689,10 +5646,14 @@ local LogService = _services.LogService
5689
5646
  local _EvalBridges = TS.import(script, script.Parent.Parent, "EvalBridges")
5690
5647
  local installBridges = _EvalBridges.installBridges
5691
5648
  local cleanupBridges = _EvalBridges.cleanupBridges
5649
+ local StopPlayMonitor = TS.import(script, script.Parent.Parent, "StopPlayMonitor")
5692
5650
  local StudioTestService = game:GetService("StudioTestService")
5693
5651
  local ServerScriptService = game:GetService("ServerScriptService")
5694
5652
  local ScriptEditorService = game:GetService("ScriptEditorService")
5695
- local STOP_SIGNAL = "__MCP_STOP__"
5653
+ -- NAV_SIGNAL flows from the edit DM to the play-server DM via the injected
5654
+ -- __MCP_CommandListener Script + LogService.MessageOut. Stop signaling moved
5655
+ -- off this path entirely (see StopPlayMonitor) because cross-DM MessageOut
5656
+ -- reflection from edit -> play-server does not work in practice.
5696
5657
  local NAV_SIGNAL = "__MCP_NAV__"
5697
5658
  local NAV_RESULT = "__MCP_NAV_RESULT__"
5698
5659
  local testRunning = false
@@ -5704,16 +5665,13 @@ local stopListenerScript
5704
5665
  local navResultCallback
5705
5666
  local function buildCommandListenerSource()
5706
5667
  return `local LogService = game:GetService("LogService")\
5707
- local StudioTestService = game:GetService("StudioTestService")\
5708
5668
  local PathfindingService = game:GetService("PathfindingService")\
5709
5669
  local Players = game:GetService("Players")\
5710
5670
  local HttpService = game:GetService("HttpService")\
5711
5671
  local NAV_SIG = "{NAV_SIGNAL}"\
5712
5672
  local NAV_RES = "{NAV_RESULT}"\
5713
5673
  LogService.MessageOut:Connect(function(msg)\
5714
- if msg == "{STOP_SIGNAL}" then\
5715
- pcall(function() StudioTestService:EndTest("stopped_by_mcp") end)\
5716
- elseif string.sub(msg, 1, #NAV_SIG + 1) == NAV_SIG .. ":" then\
5674
+ if string.sub(msg, 1, #NAV_SIG + 1) == NAV_SIG .. ":" then\
5717
5675
  local json = string.sub(msg, #NAV_SIG + 2)\
5718
5676
  task.spawn(function()\
5719
5677
  local ok, d = pcall(function() return HttpService:JSONDecode(json) end)\
@@ -5812,9 +5770,6 @@ local function startPlaytest(requestData)
5812
5770
  testError = nil
5813
5771
  cleanupStopListener()
5814
5772
  logConnection = LogService.MessageOut:Connect(function(message, messageType)
5815
- if message == STOP_SIGNAL then
5816
- return nil
5817
- end
5818
5773
  local _message = message
5819
5774
  local _arg1 = #NAV_SIGNAL
5820
5775
  if string.sub(_message, 1, _arg1) == NAV_SIGNAL then
@@ -5876,25 +5831,42 @@ local function startPlaytest(requestData)
5876
5831
  cleanupStopListener()
5877
5832
  cleanupBridges()
5878
5833
  end)
5879
- local msg = if numPlayers ~= nil then `Playtest started in {mode} mode with {numPlayers} player(s)` else `Playtest started in {mode} mode`
5834
+ local msg = if numPlayers ~= nil then `Playtest started in {mode} mode with {numPlayers} player(s).` else `Playtest started in {mode} mode.`
5880
5835
  local response = {
5881
5836
  success = true,
5882
5837
  message = msg,
5883
- evalBridges = if bridgeInstall.installed then "installed" else `failed: {bridgeInstall.error}`,
5884
5838
  }
5839
+ -- Only mention eval bridges when they failed — when they're fine, the
5840
+ -- detail is noise. eval_server_runtime / eval_client_runtime will surface
5841
+ -- their own clear errors if the caller tries to use them after a failed
5842
+ -- install.
5843
+ if not bridgeInstall.installed then
5844
+ response.evalBridgesError = bridgeInstall.error
5845
+ end
5885
5846
  return response
5886
5847
  end
5887
5848
  local function stopPlaytest(_requestData)
5888
- -- Server-side routing (tools/index.ts:stopPlaytest) sends /api/stop-playtest
5889
- -- to the role="edit-proxy" instance whenever one is registered. This handler
5890
- -- is only reached when there's no edit-proxy - i.e. no active playtest, or
5891
- -- the play DMs haven't completed plugin auto-activation yet. Calling
5892
- -- StudioTestService:EndTest from the edit DM is illegal ("can only be
5893
- -- called from the server DataModel of a running Studio play session"), so
5894
- -- don't try - return a clean "no active playtest" response instead.
5849
+ -- Signal the play-server DM's StopPlayMonitor via plugin:SetSetting (a
5850
+ -- cross-DM persistent store). The monitor polls at 1Hz, sees the flag,
5851
+ -- calls StudioTestService:EndTest, then resets the flag. We wait up to
5852
+ -- 2.5s for the reset to confirm a play DM actually consumed the request,
5853
+ -- which avoids returning success when nothing is running.
5854
+ if not StopPlayMonitor.requestStop() then
5855
+ return {
5856
+ error = "Plugin not ready. Try again in a moment.",
5857
+ }
5858
+ end
5859
+ if StopPlayMonitor.waitForConsumption() then
5860
+ return {
5861
+ success = true,
5862
+ message = "Playtest stopped.",
5863
+ }
5864
+ end
5865
+ -- Clean up the pending flag so a future playtest's monitor doesn't fire
5866
+ -- EndTest on its own startup against a stale signal.
5867
+ StopPlayMonitor.clearPending()
5895
5868
  return {
5896
- error = "No active playtest to stop (edit-proxy not registered).",
5897
- hint = "If a playtest is running, the play-server DM may not have completed plugin auto-activation yet. " .. "Wait a moment and retry, or call execute_luau target=server with StudioTestService:EndTest as a manual fallback.",
5869
+ error = "No active playtest to stop.",
5898
5870
  }
5899
5871
  end
5900
5872
  local function getPlaytestOutput(_requestData)
@@ -6203,7 +6175,7 @@ return {
6203
6175
  <Properties>
6204
6176
  <string name="Name">State</string>
6205
6177
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
6206
- local CURRENT_VERSION = "2.11.1"
6178
+ local CURRENT_VERSION = "2.11.2"
6207
6179
  local MAX_CONNECTIONS = 5
6208
6180
  local BASE_PORT = 58741
6209
6181
  local activeTabIndex = 0
@@ -6296,6 +6268,113 @@ return {
6296
6268
  </Properties>
6297
6269
  </Item>
6298
6270
  <Item class="ModuleScript" referent="22">
6271
+ <Properties>
6272
+ <string name="Name">StopPlayMonitor</string>
6273
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
6274
+ -- Cross-DM stop_playtest signaling via plugin:SetSetting.
6275
+ --
6276
+ -- `plugin:SetSetting` / `plugin:GetSetting` is a per-plugin persistent store
6277
+ -- that's shared across every DataModel the plugin runs in (edit, play-server,
6278
+ -- play-clients). We use it as a one-bit flag for "please call EndTest in the
6279
+ -- play-server DM":
6280
+ --
6281
+ -- * The edit DM's stopPlaytest handler writes the flag (requestStop).
6282
+ -- * A monitor loop running inside the play-server DM polls the flag at 1Hz
6283
+ -- and calls StudioTestService:EndTest when it flips true, then resets it.
6284
+ -- * The edit DM then waits up to ~2.5s for the flag to be reset, which
6285
+ -- tells us a play-server actually consumed the request (no false-positive
6286
+ -- success when nothing was running).
6287
+ --
6288
+ -- Why this is simpler than the previous edit-proxy registration:
6289
+ -- * Doesn't depend on the MCP server tracking peer roles at all.
6290
+ -- * Survives MCP server restarts: monitor loop is local to the play-server
6291
+ -- plugin lifetime, not to any HTTP/registration state.
6292
+ -- * No need for cross-DM LogService.MessageOut reflection (which we verified
6293
+ -- does not work edit -> play-server anyway).
6294
+ --
6295
+ -- Pattern mirrors the official Roblox Studio MCP
6296
+ -- (Roblox/studio-rust-mcp-server, plugin/src/Utils/GameStopUtil.luau).
6297
+ local StudioTestService = game:GetService("StudioTestService")
6298
+ local SETTING_KEY = "MCP_STOP_PLAY_SIGNAL"
6299
+ local POLL_INTERVAL_SEC = 1
6300
+ local WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 2.5
6301
+ local WAIT_POLL_SEC = 0.1
6302
+ local pluginRef
6303
+ local function init(p)
6304
+ pluginRef = p
6305
+ end
6306
+ local function startMonitor()
6307
+ if not pluginRef then
6308
+ warn("[MCP] StopPlayMonitor.startMonitor called before init; skipping")
6309
+ return nil
6310
+ end
6311
+ -- Clear any stale value left from a prior session. If a real stop request
6312
+ -- is in-flight when this runs, the requesting edit DM will set it again
6313
+ -- within its 2.5s wait window.
6314
+ pcall(function()
6315
+ return pluginRef:SetSetting(SETTING_KEY, false)
6316
+ end)
6317
+ task.spawn(function()
6318
+ while true do
6319
+ local okGet, val = pcall(function()
6320
+ return pluginRef:GetSetting(SETTING_KEY)
6321
+ end)
6322
+ if okGet and val == true then
6323
+ pcall(function()
6324
+ return pluginRef:SetSetting(SETTING_KEY, false)
6325
+ end)
6326
+ pcall(function()
6327
+ return StudioTestService:EndTest("stopped_by_mcp")
6328
+ end)
6329
+ end
6330
+ task.wait(POLL_INTERVAL_SEC)
6331
+ end
6332
+ end)
6333
+ end
6334
+ local function requestStop()
6335
+ if not pluginRef then
6336
+ return false
6337
+ end
6338
+ local ok = pcall(function()
6339
+ return pluginRef:SetSetting(SETTING_KEY, true)
6340
+ end)
6341
+ return ok
6342
+ end
6343
+ local function waitForConsumption()
6344
+ if not pluginRef then
6345
+ return false
6346
+ end
6347
+ local start = tick()
6348
+ while tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC do
6349
+ local okGet, val = pcall(function()
6350
+ return pluginRef:GetSetting(SETTING_KEY)
6351
+ end)
6352
+ if okGet and val ~= true then
6353
+ return true
6354
+ end
6355
+ task.wait(WAIT_POLL_SEC)
6356
+ end
6357
+ return false
6358
+ end
6359
+ local function clearPending()
6360
+ if not pluginRef then
6361
+ return nil
6362
+ end
6363
+ pcall(function()
6364
+ return pluginRef:SetSetting(SETTING_KEY, false)
6365
+ end)
6366
+ end
6367
+ return {
6368
+ init = init,
6369
+ startMonitor = startMonitor,
6370
+ requestStop = requestStop,
6371
+ waitForConsumption = waitForConsumption,
6372
+ clearPending = clearPending,
6373
+ }
6374
+ ]]></string>
6375
+ </Properties>
6376
+ </Item>
6377
+ <Item class="ModuleScript" referent="23">
6299
6378
  <Properties>
6300
6379
  <string name="Name">UI</string>
6301
6380
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7046,7 +7125,7 @@ return {
7046
7125
  ]]></string>
7047
7126
  </Properties>
7048
7127
  </Item>
7049
- <Item class="ModuleScript" referent="23">
7128
+ <Item class="ModuleScript" referent="24">
7050
7129
  <Properties>
7051
7130
  <string name="Name">Utils</string>
7052
7131
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7576,11 +7655,11 @@ return {
7576
7655
  </Properties>
7577
7656
  </Item>
7578
7657
  </Item>
7579
- <Item class="Folder" referent="27">
7658
+ <Item class="Folder" referent="28">
7580
7659
  <Properties>
7581
7660
  <string name="Name">include</string>
7582
7661
  </Properties>
7583
- <Item class="ModuleScript" referent="24">
7662
+ <Item class="ModuleScript" referent="25">
7584
7663
  <Properties>
7585
7664
  <string name="Name">Promise</string>
7586
7665
  <string name="Source"><![CDATA[--[[
@@ -9654,7 +9733,7 @@ return Promise
9654
9733
  ]]></string>
9655
9734
  </Properties>
9656
9735
  </Item>
9657
- <Item class="ModuleScript" referent="25">
9736
+ <Item class="ModuleScript" referent="26">
9658
9737
  <Properties>
9659
9738
  <string name="Name">RuntimeLib</string>
9660
9739
  <string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
@@ -9921,15 +10000,15 @@ return TS
9921
10000
  </Properties>
9922
10001
  </Item>
9923
10002
  </Item>
9924
- <Item class="Folder" referent="28">
10003
+ <Item class="Folder" referent="29">
9925
10004
  <Properties>
9926
10005
  <string name="Name">node_modules</string>
9927
10006
  </Properties>
9928
- <Item class="Folder" referent="29">
10007
+ <Item class="Folder" referent="30">
9929
10008
  <Properties>
9930
10009
  <string name="Name">@rbxts</string>
9931
10010
  </Properties>
9932
- <Item class="ModuleScript" referent="26">
10011
+ <Item class="ModuleScript" referent="27">
9933
10012
  <Properties>
9934
10013
  <string name="Name">services</string>
9935
10014
  <string name="Source"><![CDATA[return setmetatable({}, {
@@ -9,12 +9,10 @@ import MemoryHandlers from "./handlers/MemoryHandlers";
9
9
  // in ReplicatedStorage; each player gets a proxy "client" registration on the
10
10
  // MCP side, polled and dispatched by the server peer.
11
11
  //
12
- // The same server peer also registers an "edit" proxy that intercepts
13
- // /api/stop-playtest specifically - StudioTestService:EndTest only works from
14
- // the play server DM, so the real edit DM cannot satisfy stop requests on its
15
- // own. MCP returns the same pending request to multiple pollers until someone
16
- // /responds, so non-stop edit-targeted requests fall through to the actual
17
- // edit DM untouched.
12
+ // (Previously the server peer also registered an "edit-proxy" role to
13
+ // intercept /api/stop-playtest and call StudioTestService:EndTest. That hack
14
+ // is gone: stop now uses StopPlayMonitor with plugin:SetSetting cross-DM
15
+ // signaling, which works regardless of MCP server state.)
18
16
 
19
17
  const MCP_URL = "http://localhost:58741";
20
18
  const BROKER_NAME = "__MCPClientBroker";
@@ -219,49 +217,10 @@ function registerProxy(player: Player, rf: RemoteFunction) {
219
217
  task.spawn(pollProxy, proxyId, player, rf);
220
218
  }
221
219
 
222
- function startEditProxyLoop() {
223
- task.spawn(() => {
224
- const proxyId = HttpService.GenerateGUID(false);
225
- const [ok, res] = postJson("/ready", { instanceId: proxyId, role: "edit-proxy" });
226
- if (!ok || !res || !res.Success) {
227
- warn("[MCPFork] edit-proxy register failed");
228
- return;
229
- }
230
- while (true) {
231
- const [okPoll, pollRes] = pcall(() =>
232
- HttpService.RequestAsync({
233
- Url: `${MCP_URL}/poll?instanceId=${proxyId}`,
234
- Method: "GET",
235
- Headers: { "Content-Type": "application/json" },
236
- }),
237
- );
238
- if (okPoll && pollRes && (pollRes.Success || pollRes.StatusCode === 503)) {
239
- const [okJson, body] = pcall(() => HttpService.JSONDecode(pollRes.Body) as PollResponseBody);
240
- if (okJson && body) {
241
- // Re-register if the server lost our edit-proxy registration.
242
- if (body.knownInstance === false) {
243
- reRegisterProxy(proxyId, "edit-proxy");
244
- }
245
- if (
246
- body.request &&
247
- body.request.endpoint === "/api/stop-playtest" &&
248
- body.requestId !== undefined
249
- ) {
250
- const sts = game.GetService("StudioTestService") as Instance & {
251
- EndTest(reason: string): void;
252
- };
253
- const [endOk, endErr] = pcall(() => sts.EndTest("stopped_by_mcp"));
254
- const response = endOk
255
- ? { success: true, message: "Playtest stopped via edit-proxy/EndTest" }
256
- : { success: false, error: `EndTest failed: ${tostring(endErr)}` };
257
- postJson("/response", { requestId: body.requestId, response });
258
- }
259
- }
260
- }
261
- task.wait(0.15);
262
- }
263
- });
264
- }
220
+ // (Removed: startEditProxyLoop. The play-server DM no longer registers an
221
+ // "edit-proxy" peer with the MCP server. stop_playtest now uses a cross-DM
222
+ // plugin:SetSetting flag consumed by StopPlayMonitor in the play-server DM,
223
+ // which doesn't depend on MCP server state or peer registration at all.)
265
224
 
266
225
  function setupServerBroker() {
267
226
  let rf = ReplicatedStorage.FindFirstChild(BROKER_NAME) as RemoteFunction | undefined;
@@ -288,7 +247,6 @@ function setupServerBroker() {
288
247
  }
289
248
  proxyByPlayer.clear();
290
249
  });
291
- startEditProxyLoop();
292
250
  }
293
251
 
294
252
  export = {
@@ -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
+ };
@@ -1,11 +1,15 @@
1
1
  import { HttpService, LogService } 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)
@@ -135,7 +136,6 @@ function startPlaytest(requestData: Record<string, unknown>) {
135
136
  cleanupStopListener();
136
137
 
137
138
  logConnection = LogService.MessageOut.Connect((message, messageType) => {
138
- if (message === STOP_SIGNAL) return;
139
139
  if (message.sub(1, NAV_SIGNAL.size()) === NAV_SIGNAL) return;
140
140
  if (message.sub(1, NAV_RESULT.size() + 1) === `${NAV_RESULT}:`) {
141
141
  if (navResultCallback) {
@@ -193,32 +193,40 @@ function startPlaytest(requestData: Record<string, unknown>) {
193
193
  });
194
194
 
195
195
  const msg = numPlayers !== undefined
196
- ? `Playtest started in ${mode} mode with ${numPlayers} player(s)`
197
- : `Playtest started in ${mode} mode`;
196
+ ? `Playtest started in ${mode} mode with ${numPlayers} player(s).`
197
+ : `Playtest started in ${mode} mode.`;
198
198
 
199
199
  const response: Record<string, unknown> = {
200
200
  success: true,
201
201
  message: msg,
202
- evalBridges: bridgeInstall.installed ? "installed" : `failed: ${bridgeInstall.error}`,
203
202
  };
203
+ // Only mention eval bridges when they failed — when they're fine, the
204
+ // detail is noise. eval_server_runtime / eval_client_runtime will surface
205
+ // their own clear errors if the caller tries to use them after a failed
206
+ // install.
207
+ if (!bridgeInstall.installed) {
208
+ response.evalBridgesError = bridgeInstall.error;
209
+ }
204
210
 
205
211
  return response;
206
212
  }
207
213
 
208
214
  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
- };
215
+ // Signal the play-server DM's StopPlayMonitor via plugin:SetSetting (a
216
+ // cross-DM persistent store). The monitor polls at 1Hz, sees the flag,
217
+ // calls StudioTestService:EndTest, then resets the flag. We wait up to
218
+ // 2.5s for the reset to confirm a play DM actually consumed the request,
219
+ // which avoids returning success when nothing is running.
220
+ if (!StopPlayMonitor.requestStop()) {
221
+ return { error: "Plugin not ready. Try again in a moment." };
222
+ }
223
+ if (StopPlayMonitor.waitForConsumption()) {
224
+ return { success: true, message: "Playtest stopped." };
225
+ }
226
+ // Clean up the pending flag so a future playtest's monitor doesn't fire
227
+ // EndTest on its own startup against a stale signal.
228
+ StopPlayMonitor.clearPending();
229
+ return { error: "No active playtest to stop." };
222
230
  }
223
231
 
224
232
  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
  }