@chrrxs/robloxstudio-mcp-inspector 2.11.0 → 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 +11 -25
- package/package.json +1 -1
- package/studio-plugin/MCPInspectorPlugin.rbxmx +167 -88
- package/studio-plugin/MCPPlugin.rbxmx +167 -88
- package/studio-plugin/src/modules/ClientBroker.ts +8 -50
- package/studio-plugin/src/modules/StopPlayMonitor.ts +87 -0
- package/studio-plugin/src/modules/UI.ts +1 -1
- package/studio-plugin/src/modules/handlers/TestHandlers.ts +30 -22
- package/studio-plugin/src/server/index.server.ts +10 -0
package/dist/index.js
CHANGED
|
@@ -1928,23 +1928,9 @@ ${code}`
|
|
|
1928
1928
|
};
|
|
1929
1929
|
}
|
|
1930
1930
|
async stopPlaytest() {
|
|
1931
|
-
|
|
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) {
|
|
@@ -3203,7 +3189,7 @@ var init_server = __esm({
|
|
|
3203
3189
|
let promotionInterval;
|
|
3204
3190
|
try {
|
|
3205
3191
|
primaryApp = createHttpServer(this.tools, this.bridge, this.allowedToolNames, this.config);
|
|
3206
|
-
const result = await listenWithRetry(primaryApp, host, basePort,
|
|
3192
|
+
const result = await listenWithRetry(primaryApp, host, basePort, 1);
|
|
3207
3193
|
httpHandle = result.server;
|
|
3208
3194
|
boundPort = result.port;
|
|
3209
3195
|
console.error(`HTTP server listening on ${host}:${boundPort} for Studio plugin (primary mode)`);
|
|
@@ -3214,25 +3200,25 @@ var init_server = __esm({
|
|
|
3214
3200
|
const proxyBridge = new ProxyBridgeService(`http://localhost:${basePort}`);
|
|
3215
3201
|
this.bridge = proxyBridge;
|
|
3216
3202
|
this.tools = new RobloxStudioTools(this.bridge);
|
|
3217
|
-
console.error(`
|
|
3203
|
+
console.error(`Port ${basePort} in use - entering proxy mode (forwarding to localhost:${basePort})`);
|
|
3218
3204
|
const promotionIntervalMs = parseInt(process.env.ROBLOX_STUDIO_PROXY_PROMOTION_INTERVAL_MS || "5000");
|
|
3219
3205
|
promotionInterval = setInterval(async () => {
|
|
3206
|
+
const candidateBridge = new BridgeService();
|
|
3207
|
+
const candidateTools = new RobloxStudioTools(candidateBridge);
|
|
3208
|
+
const candidateApp = createHttpServer(candidateTools, candidateBridge, this.allowedToolNames, this.config);
|
|
3220
3209
|
try {
|
|
3221
|
-
|
|
3222
|
-
this.
|
|
3223
|
-
|
|
3224
|
-
const result = await listenWithRetry(primaryApp, host, basePort, 5);
|
|
3210
|
+
const result = await listenWithRetry(candidateApp, host, basePort, 1);
|
|
3211
|
+
this.bridge = candidateBridge;
|
|
3212
|
+
this.tools = candidateTools;
|
|
3225
3213
|
httpHandle = result.server;
|
|
3226
3214
|
boundPort = result.port;
|
|
3215
|
+
primaryApp = candidateApp;
|
|
3227
3216
|
bridgeMode = "primary";
|
|
3228
3217
|
primaryApp.setMCPServerActive(true);
|
|
3229
3218
|
console.error(`Promoted from proxy to primary on port ${boundPort}`);
|
|
3230
3219
|
if (promotionInterval)
|
|
3231
3220
|
clearInterval(promotionInterval);
|
|
3232
3221
|
} catch {
|
|
3233
|
-
this.bridge = new ProxyBridgeService(`http://localhost:${basePort}`);
|
|
3234
|
-
this.tools = new RobloxStudioTools(this.bridge);
|
|
3235
|
-
primaryApp = void 0;
|
|
3236
3222
|
}
|
|
3237
3223
|
}, promotionIntervalMs);
|
|
3238
3224
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chrrxs/robloxstudio-mcp-inspector",
|
|
3
|
-
"version": "2.11.
|
|
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
|
-
--
|
|
98
|
-
-- /api/stop-playtest
|
|
99
|
-
--
|
|
100
|
-
--
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
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 == "
|
|
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)
|
|
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
|
-
--
|
|
5889
|
-
--
|
|
5890
|
-
--
|
|
5891
|
-
-- the
|
|
5892
|
-
--
|
|
5893
|
-
|
|
5894
|
-
|
|
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
|
|
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.
|
|
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
|
|
@@ -6579,7 +6658,7 @@ local function init(pluginRef)
|
|
|
6579
6658
|
creditsLabel.Position = UDim2.new(0, 10, 0, 23)
|
|
6580
6659
|
creditsLabel.BackgroundTransparency = 1
|
|
6581
6660
|
creditsLabel.RichText = true
|
|
6582
|
-
creditsLabel.Text = '<font color="#999999">
|
|
6661
|
+
creditsLabel.Text = '<font color="#999999">github</font> <font color="#CCCCCC">Chrrxs/robloxstudio-mcp</font>'
|
|
6583
6662
|
creditsLabel.TextColor3 = C.muted
|
|
6584
6663
|
creditsLabel.TextSize = 8
|
|
6585
6664
|
creditsLabel.Font = Enum.Font.GothamMedium
|
|
@@ -7046,7 +7125,7 @@ return {
|
|
|
7046
7125
|
]]></string>
|
|
7047
7126
|
</Properties>
|
|
7048
7127
|
</Item>
|
|
7049
|
-
<Item class="ModuleScript" referent="
|
|
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="
|
|
7658
|
+
<Item class="Folder" referent="28">
|
|
7580
7659
|
<Properties>
|
|
7581
7660
|
<string name="Name">include</string>
|
|
7582
7661
|
</Properties>
|
|
7583
|
-
<Item class="ModuleScript" referent="
|
|
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="
|
|
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="
|
|
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="
|
|
10007
|
+
<Item class="Folder" referent="30">
|
|
9929
10008
|
<Properties>
|
|
9930
10009
|
<string name="Name">@rbxts</string>
|
|
9931
10010
|
</Properties>
|
|
9932
|
-
<Item class="ModuleScript" referent="
|
|
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
|
-
--
|
|
98
|
-
-- /api/stop-playtest
|
|
99
|
-
--
|
|
100
|
-
--
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
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 == "
|
|
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)
|
|
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
|
-
--
|
|
5889
|
-
--
|
|
5890
|
-
--
|
|
5891
|
-
-- the
|
|
5892
|
-
--
|
|
5893
|
-
|
|
5894
|
-
|
|
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
|
|
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.
|
|
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
|
|
@@ -6579,7 +6658,7 @@ local function init(pluginRef)
|
|
|
6579
6658
|
creditsLabel.Position = UDim2.new(0, 10, 0, 23)
|
|
6580
6659
|
creditsLabel.BackgroundTransparency = 1
|
|
6581
6660
|
creditsLabel.RichText = true
|
|
6582
|
-
creditsLabel.Text = '<font color="#999999">
|
|
6661
|
+
creditsLabel.Text = '<font color="#999999">github</font> <font color="#CCCCCC">Chrrxs/robloxstudio-mcp</font>'
|
|
6583
6662
|
creditsLabel.TextColor3 = C.muted
|
|
6584
6663
|
creditsLabel.TextSize = 8
|
|
6585
6664
|
creditsLabel.Font = Enum.Font.GothamMedium
|
|
@@ -7046,7 +7125,7 @@ return {
|
|
|
7046
7125
|
]]></string>
|
|
7047
7126
|
</Properties>
|
|
7048
7127
|
</Item>
|
|
7049
|
-
<Item class="ModuleScript" referent="
|
|
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="
|
|
7658
|
+
<Item class="Folder" referent="28">
|
|
7580
7659
|
<Properties>
|
|
7581
7660
|
<string name="Name">include</string>
|
|
7582
7661
|
</Properties>
|
|
7583
|
-
<Item class="ModuleScript" referent="
|
|
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="
|
|
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="
|
|
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="
|
|
10007
|
+
<Item class="Folder" referent="30">
|
|
9929
10008
|
<Properties>
|
|
9930
10009
|
<string name="Name">@rbxts</string>
|
|
9931
10010
|
</Properties>
|
|
9932
|
-
<Item class="ModuleScript" referent="
|
|
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
|
-
//
|
|
13
|
-
// /api/stop-playtest
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
+
};
|
|
@@ -301,7 +301,7 @@ function init(pluginRef: Plugin) {
|
|
|
301
301
|
creditsLabel.Position = new UDim2(0, 10, 0, 23);
|
|
302
302
|
creditsLabel.BackgroundTransparency = 1;
|
|
303
303
|
creditsLabel.RichText = true;
|
|
304
|
-
creditsLabel.Text = '<font color="#999999">
|
|
304
|
+
creditsLabel.Text = '<font color="#999999">github</font> <font color="#CCCCCC">Chrrxs/robloxstudio-mcp</font>';
|
|
305
305
|
creditsLabel.TextColor3 = C.muted;
|
|
306
306
|
creditsLabel.TextSize = 8;
|
|
307
307
|
creditsLabel.Font = Enum.Font.GothamMedium;
|
|
@@ -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
|
-
|
|
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 == "
|
|
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
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
// the
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
}
|