@chrrxs/robloxstudio-mcp 2.15.2 → 2.16.1

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.
@@ -10,6 +10,10 @@ local State = TS.import(script, script, "modules", "State")
10
10
  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
+ local ServerUrlSettings = TS.import(script, script, "modules", "ServerUrlSettings")
14
+ local _EvalBridges = TS.import(script, script, "modules", "EvalBridges")
15
+ local cleanupLegacyEditBridges = _EvalBridges.cleanupLegacyEditBridges
16
+ local ensureRuntimeBridgeInstalled = _EvalBridges.ensureRuntimeBridgeInstalled
13
17
  local RuntimeLogBuffer = TS.import(script, script, "modules", "RuntimeLogBuffer")
14
18
  local StopPlayMonitor = TS.import(script, script, "modules", "StopPlayMonitor")
15
19
  local RenderMonitor = TS.import(script, script, "modules", "RenderMonitor")
@@ -25,18 +29,30 @@ RuntimeLogBuffer.install()
25
29
  -- edit DM (write the flag) and the play-server DM (read+act on the flag) can
26
30
  -- access plugin:SetSetting/GetSetting.
27
31
  StopPlayMonitor.init(plugin)
32
+ ServerUrlSettings.init(plugin)
28
33
  UI.init(plugin)
29
34
  local elements = UI.getElements()
30
35
  local ICON_DISCONNECTED = "rbxassetid://75876056391496"
31
36
  local ICON_CONNECTING = "rbxassetid://71302583919560"
32
37
  local ICON_CONNECTED = "rbxassetid://130958234173611"
33
- local toolbar = plugin:CreateToolbar("MCP Integration")
34
- local button = toolbar:CreateButton("MCP Server", "Connect to MCP Server for AI Integration", ICON_DISCONNECTED)
35
- UI.setToolbarButton(button, {
36
- disconnected = ICON_DISCONNECTED,
37
- connecting = ICON_CONNECTING,
38
- connected = ICON_CONNECTED,
39
- })
38
+ local TOOLBAR_REGISTRATION_DELAY_SECONDS = 1
39
+ local toolbarButtonRegistered = false
40
+ local function registerToolbarButton()
41
+ if toolbarButtonRegistered then
42
+ return nil
43
+ end
44
+ toolbarButtonRegistered = true
45
+ local toolbar = plugin:CreateToolbar("MCP Integration")
46
+ local button = toolbar:CreateButton("MCP Server", "Connect to MCP Server for AI Integration", ICON_DISCONNECTED)
47
+ UI.setToolbarButton(button, {
48
+ disconnected = ICON_DISCONNECTED,
49
+ connecting = ICON_CONNECTING,
50
+ connected = ICON_CONNECTED,
51
+ })
52
+ button.Click:Connect(function()
53
+ elements.screenGui.Enabled = not elements.screenGui.Enabled
54
+ end)
55
+ end
40
56
  elements.connectButton.Activated:Connect(function()
41
57
  local conn = State.getActiveConnection()
42
58
  if conn and conn.isActive then
@@ -45,29 +61,54 @@ elements.connectButton.Activated:Connect(function()
45
61
  Communication.activatePlugin(State.getActiveTabIndex())
46
62
  end
47
63
  end)
48
- button.Click:Connect(function()
49
- elements.screenGui.Enabled = not elements.screenGui.Enabled
50
- end)
51
64
  plugin.Unloading:Connect(function()
52
65
  Communication.deactivateAll()
53
66
  end)
54
67
  UI.updateUIState()
55
68
  Communication.checkForUpdates()
69
+ task.delay(TOOLBAR_REGISTRATION_DELAY_SECONDS, registerToolbarButton)
56
70
  -- Auto-activate per peer. The boshyxd plugin only registers with MCP when the
57
71
  -- user clicks Connect in its UI, but that UI is invisible in play DMs - so
58
72
  -- play peers' plugin instances load without ever registering. Run after a
59
73
  -- short delay so the UI/State have a chance to initialize first.
60
74
  task.delay(2, function()
61
75
  local role = ClientBroker.forkRole()
76
+ if role == "edit" then
77
+ cleanupLegacyEditBridges()
78
+ else
79
+ local result = ensureRuntimeBridgeInstalled()
80
+ if not result.installed then
81
+ warn(`[robloxstudio-mcp] Runtime eval bridge install failed: {result.error}`)
82
+ end
83
+ end
62
84
  if role == "edit" or role == "server" then
63
85
  pcall(function()
64
86
  local idx = State.getActiveTabIndex()
65
87
  local conn = State.getConnection(idx)
66
88
  if conn and not conn.isActive then
89
+ if role == "server" then
90
+ local _condition = ServerUrlSettings.readServerUrl()
91
+ if _condition == nil then
92
+ _condition = ClientBroker.DEFAULT_MCP_URL
93
+ end
94
+ local inheritedServerUrl = _condition
95
+ conn.serverUrl = inheritedServerUrl
96
+ elements.urlInput.Text = inheritedServerUrl
97
+ local portStr = string.match(conn.serverUrl, ":(%d+)$")
98
+ if portStr ~= 0 and portStr == portStr and portStr ~= "" and portStr then
99
+ local _condition_1 = tonumber(portStr)
100
+ if _condition_1 == nil then
101
+ _condition_1 = conn.port
102
+ end
103
+ conn.port = _condition_1
104
+ end
105
+ ClientBroker.setServerUrl(inheritedServerUrl)
106
+ end
67
107
  -- Defensive default: in invisible play-DM UIs, the input field
68
108
  -- may not be populated by the time we activate.
69
109
  if conn.serverUrl == nil or conn.serverUrl == "" then
70
- conn.serverUrl = ClientBroker.MCP_URL
110
+ conn.serverUrl = ClientBroker.DEFAULT_MCP_URL
111
+ elements.urlInput.Text = conn.serverUrl
71
112
  end
72
113
  Communication.activatePlugin(idx)
73
114
  end
@@ -76,8 +117,8 @@ task.delay(2, function()
76
117
  if role == "server" then
77
118
  ClientBroker.setupServerBroker()
78
119
  -- The play-server DM is the only one where StudioTestService:EndTest is
79
- -- legal, so the stop-play monitor lives here. Reads MCP_STOP_PLAY_SIGNAL
80
- -- at 1Hz and calls EndTest when the edit DM sets it.
120
+ -- legal, so the stop-play monitor lives here. It consumes tokenized
121
+ -- stop requests from plugin settings and acknowledges EndTest results.
81
122
  StopPlayMonitor.startMonitor()
82
123
  elseif role == "client" then
83
124
  ClientBroker.setupClientBroker()
@@ -108,6 +149,7 @@ local InputHandlers = TS.import(script, script.Parent, "handlers", "InputHandler
108
149
  local EvalRuntimeHandlers = TS.import(script, script.Parent, "handlers", "EvalRuntimeHandlers")
109
150
  local LuauExec = TS.import(script, script.Parent, "LuauExec")
110
151
  local State = TS.import(script, script.Parent, "State")
152
+ local HttpDiagnostics = TS.import(script, script.Parent, "HttpDiagnostics")
111
153
  local StudioTestService = game:GetService("StudioTestService")
112
154
  -- Mirror of Communication.computeInstanceId() — duplicated here because the
113
155
  -- client broker runs in the play-server DM where it can't easily import from
@@ -160,7 +202,8 @@ end
160
202
  -- intercept /api/stop-playtest and call StudioTestService:EndTest. That hack
161
203
  -- is gone: stop now uses StopPlayMonitor with plugin:SetSetting cross-DM
162
204
  -- signaling, which works regardless of MCP server state.)
163
- local MCP_URL = "http://localhost:58741"
205
+ local DEFAULT_MCP_URL = "http://localhost:58741"
206
+ local mcpUrl = DEFAULT_MCP_URL
164
207
  local BROKER_NAME = "__MCPClientBroker"
165
208
  local BROKER_OWNER_ATTRIBUTE = "__MCPBrokerOwner"
166
209
  -- Endpoints the server-peer broker is allowed to forward to the client peer.
@@ -221,7 +264,7 @@ end
221
264
  function postJson(endpoint, body)
222
265
  return pcall(function()
223
266
  return HttpService:RequestAsync({
224
- Url = `{MCP_URL}{endpoint}`,
267
+ Url = `{mcpUrl}{endpoint}`,
225
268
  Method = "POST",
226
269
  Headers = {
227
270
  ["Content-Type"] = "application/json",
@@ -230,6 +273,17 @@ function postJson(endpoint, body)
230
273
  })
231
274
  end)
232
275
  end
276
+ local function formatPostJsonFailure(endpoint, ok, res)
277
+ return HttpDiagnostics.formatRequestFailure(`{mcpUrl}{endpoint}`, ok, res)
278
+ end
279
+ local function setServerUrl(serverUrl)
280
+ if serverUrl ~= nil and serverUrl ~= "" then
281
+ mcpUrl = serverUrl
282
+ end
283
+ end
284
+ local function getServerUrl()
285
+ return mcpUrl
286
+ end
233
287
  local function handleExecuteLuau(data)
234
288
  local code = data and (data.code)
235
289
  if type(code) == "string" == false or code == "" then
@@ -376,6 +430,7 @@ local function setupClientBroker()
376
430
  end
377
431
  end
378
432
  local proxyByPlayer = {}
433
+ local proxyRegisterFailuresByPlayer = {}
379
434
  local serverBrokerStarted = false
380
435
  local function pollProxy(proxyId, player, rf)
381
436
  while true do
@@ -389,7 +444,7 @@ local function pollProxy(proxyId, player, rf)
389
444
  end
390
445
  local ok, res = pcall(function()
391
446
  return HttpService:RequestAsync({
392
- Url = `{MCP_URL}/poll?pluginSessionId={proxyId}`,
447
+ Url = `{mcpUrl}/poll?pluginSessionId={proxyId}`,
393
448
  Method = "GET",
394
449
  Headers = {
395
450
  ["Content-Type"] = "application/json",
@@ -468,7 +523,9 @@ local function registerProxy(player, rf)
468
523
  pluginVariant = State.PLUGIN_VARIANT,
469
524
  })
470
525
  if not ok or not res or not res.Success then
471
- warn(`[robloxstudio-mcp] proxy register failed for {player.Name}`)
526
+ local _player_1 = player
527
+ proxyRegisterFailuresByPlayer[_player_1] = true
528
+ warn(`[robloxstudio-mcp] proxy register failed for {player.Name}: {formatPostJsonFailure("/ready", ok, res)}`)
472
529
  return nil
473
530
  end
474
531
  local body = HttpService:JSONDecode(res.Body)
@@ -483,11 +540,17 @@ local function registerProxy(player, rf)
483
540
  role = assigned,
484
541
  }
485
542
  proxyByPlayer[_player_1] = _arg1
543
+ local _player_2 = player
544
+ if proxyRegisterFailuresByPlayer[_player_2] ~= nil then
545
+ local _player_3 = player
546
+ proxyRegisterFailuresByPlayer[_player_3] = nil
547
+ print(`[robloxstudio-mcp] proxy registered for {player.Name} as {assigned} via {mcpUrl}`)
548
+ end
486
549
  task.spawn(pollProxy, proxyId, player, rf)
487
550
  end
488
551
  -- (Removed: startEditProxyLoop. The play-server DM no longer registers an
489
552
  -- "edit-proxy" peer with the MCP server. stop_playtest now uses a cross-DM
490
- -- plugin:SetSetting flag consumed by StopPlayMonitor in the play-server DM,
553
+ -- plugin:SetSetting request consumed by StopPlayMonitor in the play-server DM,
491
554
  -- which doesn't depend on MCP server state or peer registration at all.)
492
555
  local function setupServerBroker()
493
556
  if serverBrokerStarted then
@@ -517,6 +580,8 @@ local function setupServerBroker()
517
580
  if entry then
518
581
  local _p_1 = p
519
582
  proxyByPlayer[_p_1] = nil
583
+ local _p_2 = p
584
+ proxyRegisterFailuresByPlayer[_p_2] = nil
520
585
  postJson("/disconnect", {
521
586
  pluginSessionId = entry.pluginSessionId,
522
587
  })
@@ -532,7 +597,10 @@ local function setupServerBroker()
532
597
  end)
533
598
  end
534
599
  return {
535
- MCP_URL = MCP_URL,
600
+ MCP_URL = DEFAULT_MCP_URL,
601
+ DEFAULT_MCP_URL = DEFAULT_MCP_URL,
602
+ getServerUrl = getServerUrl,
603
+ setServerUrl = setServerUrl,
536
604
  forkRole = forkRole,
537
605
  setupClientBroker = setupClientBroker,
538
606
  setupServerBroker = setupServerBroker,
@@ -552,7 +620,7 @@ local ServerStorage = _services.ServerStorage
552
620
  local State = TS.import(script, script.Parent, "State")
553
621
  local Utils = TS.import(script, script.Parent, "Utils")
554
622
  local UI = TS.import(script, script.Parent, "UI")
555
- local ensureBridgesInstalled = TS.import(script, script.Parent, "EvalBridges").ensureBridgesInstalled
623
+ local cleanupLegacyEditBridges = TS.import(script, script.Parent, "EvalBridges").cleanupLegacyEditBridges
556
624
  local QueryHandlers = TS.import(script, script.Parent, "handlers", "QueryHandlers")
557
625
  local PropertyHandlers = TS.import(script, script.Parent, "handlers", "PropertyHandlers")
558
626
  local InstanceHandlers = TS.import(script, script.Parent, "handlers", "InstanceHandlers")
@@ -568,6 +636,8 @@ local SerializationHandlers = TS.import(script, script.Parent, "handlers", "Seri
568
636
  local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandlers")
569
637
  local SceneAnalysisHandlers = TS.import(script, script.Parent, "handlers", "SceneAnalysisHandlers")
570
638
  local EvalRuntimeHandlers = TS.import(script, script.Parent, "handlers", "EvalRuntimeHandlers")
639
+ local ServerUrlSettings = TS.import(script, script.Parent, "ServerUrlSettings")
640
+ local HttpDiagnostics = TS.import(script, script.Parent, "HttpDiagnostics")
571
641
  -- Per-plugin-load random GUID. Used as the /poll URL param so the server
572
642
  -- can tell our polls apart from any other plugin's polls. Not user-facing —
573
643
  -- MCP tools and the LLM operate on instanceId (the place identifier).
@@ -597,6 +667,7 @@ local assignedRole
597
667
  local duplicateInstanceRole = false
598
668
  local hasVersionMismatch = false
599
669
  local lastVersionMismatchWarningKey
670
+ local readyFailureLogKeys = {}
600
671
  -- Cache the published place name from MarketplaceService:GetProductInfo so
601
672
  -- /ready can carry a friendly identifier (e.g. "Natural Disasters") distinct
602
673
  -- from game.Name (the DataModel name, often "Place1" in edit). We only fetch
@@ -804,31 +875,49 @@ function sendReady(conn)
804
875
  }),
805
876
  })
806
877
  end)
878
+ local readyUrl = `{conn.serverUrl}/ready`
879
+ local readyRole = detectRole()
880
+ local readyLogKey = `{conn.serverUrl}|{instanceId}|{readyRole}`
807
881
  if not readyOk then
882
+ readyFailureLogKeys[readyLogKey] = true
883
+ warn(`[robloxstudio-mcp] /ready failed for {instanceId}/{readyRole}: {HttpDiagnostics.formatRequestFailure(readyUrl, readyOk, readyResult)}`)
808
884
  return nil
809
885
  end
810
- -- 409 = duplicate_instance_role. Surface in UI and stop polling.
811
- if readyResult.StatusCode == 409 then
812
- duplicateInstanceRole = true
813
- conn.isActive = false
814
- local ui = UI.getElements()
815
- if State.getActiveTabIndex() == 0 then
816
- ui.statusLabel.Text = "Duplicate instance"
817
- ui.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
818
- ui.detailStatusLabel.Text = "Another Studio is already connected as this place + role"
819
- ui.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
820
- end
821
- warn(`[MCPPlugin] Another Studio is already connected as ({instanceId}, {detectRole()}). Close the other Studio window or this one.`)
886
+ if not readyResult.Success then
887
+ local reason = HttpDiagnostics.formatRequestFailure(readyUrl, true, readyResult)
888
+ readyFailureLogKeys[readyLogKey] = true
889
+ -- 409 = duplicate_instance_role. Surface in UI and stop polling.
890
+ if readyResult.StatusCode == 409 then
891
+ duplicateInstanceRole = true
892
+ conn.isActive = false
893
+ local ui = UI.getElements()
894
+ if State.getActiveTabIndex() == 0 then
895
+ ui.statusLabel.Text = "Duplicate instance"
896
+ ui.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
897
+ ui.detailStatusLabel.Text = reason
898
+ ui.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
899
+ end
900
+ warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{readyRole}: {reason}`)
901
+ return nil
902
+ end
903
+ warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{readyRole}: {reason}`)
822
904
  return nil
823
905
  end
824
- if readyResult.Success then
825
- local parseOk, readyData = pcall(function()
826
- return HttpService:JSONDecode(readyResult.Body)
827
- end)
828
- local _value = parseOk and readyData.assignedRole
829
- if _value ~= "" and _value then
830
- assignedRole = readyData.assignedRole
831
- end
906
+ local parseOk, readyData = pcall(function()
907
+ return HttpService:JSONDecode(readyResult.Body)
908
+ end)
909
+ local _value = parseOk and readyData.assignedRole
910
+ if _value ~= "" and _value then
911
+ assignedRole = readyData.assignedRole
912
+ end
913
+ local _condition = assignedRole
914
+ if _condition == nil then
915
+ _condition = detectRole()
916
+ end
917
+ local connectedRole = _condition
918
+ if readyFailureLogKeys[readyLogKey] ~= nil then
919
+ readyFailureLogKeys[readyLogKey] = nil
920
+ print(`[robloxstudio-mcp] /ready connected for {instanceId}/{connectedRole} via {conn.serverUrl}`)
832
921
  end
833
922
  end)
834
923
  end
@@ -874,7 +963,7 @@ local function pollForRequests(connIndex)
874
963
  local warningKey = `{State.CURRENT_VERSION}:{serverVersion}`
875
964
  if lastVersionMismatchWarningKey ~= warningKey then
876
965
  lastVersionMismatchWarningKey = warningKey
877
- warn(`[MCPPlugin] Version mismatch: Studio plugin v{State.CURRENT_VERSION} / MCP v{serverVersion}. Run npx -y @chrrxs/robloxstudio-mcp@latest --auto-install-plugin and restart Studio.`)
966
+ warn(`[robloxstudio-mcp] Version mismatch: Studio plugin v{State.CURRENT_VERSION} / MCP v{serverVersion}. Run npx -y @chrrxs/robloxstudio-mcp@latest --auto-install-plugin and restart Studio.`)
878
967
  end
879
968
  UI.showBanner("version-mismatch", `Plugin v{State.CURRENT_VERSION} / MCP v{serverVersion} mismatch`)
880
969
  elseif hasVersionMismatch then
@@ -1042,6 +1131,7 @@ local function activatePlugin(connIndex)
1042
1131
  UI.updateTabLabel(idx)
1043
1132
  UI.updateUIState()
1044
1133
  end
1134
+ ServerUrlSettings.rememberServerUrl(conn.serverUrl)
1045
1135
  UI.updateTabDot(idx)
1046
1136
  if not conn.heartbeatConnection then
1047
1137
  conn.heartbeatConnection = RunService.Heartbeat:Connect(function()
@@ -1056,18 +1146,10 @@ local function activatePlugin(connIndex)
1056
1146
  -- Initial /ready; pollForRequests will also re-fire ready if the server
1057
1147
  -- later reports knownInstance=false (process restart, etc).
1058
1148
  sendReady(conn)
1059
- -- Keep the eval bridges present in the edit DM so that ANY playtest —
1060
- -- including one the dev starts manually via the Studio Play button —
1061
- -- clones them into the play DMs and eval_*_runtime works with no setup
1062
- -- roundtrip. Only the edit DM installs; play DMs already have the cloned
1063
- -- copies. Idempotent, so reconnects don't re-dirty the place.
1149
+ -- Remove legacy edit-mode eval bridge scripts from older plugin builds.
1150
+ -- Current bridges are created only in running play DataModels.
1064
1151
  if not RunService:IsRunning() then
1065
- task.spawn(function()
1066
- local result = ensureBridgesInstalled()
1067
- if not result.installed then
1068
- warn(`[MCPPlugin] Eval bridge install failed: {result.error}`)
1069
- end
1070
- end)
1152
+ task.spawn(cleanupLegacyEditBridges)
1071
1153
  end
1072
1154
  -- Watch for game.Name updates so a stale "Place1" captured at first
1073
1155
  -- /ready gets refreshed once Studio settles on the real DM name.
@@ -1188,29 +1270,15 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
1188
1270
  -- ServerScriptService.LoadStringEnabled, so eval_server_runtime works even
1189
1271
  -- when LoadStringEnabled=false (the default in fresh places).
1190
1272
  --
1191
- -- Lifecycle: the bridges live PERMANENTLY in the edit DM. Communication
1192
- -- installs them (ensureBridgesInstalled) when the plugin connects in edit,
1193
- -- and TestHandlers.startPlaytest force-refreshes them right before
1194
- -- ExecutePlayModeAsync. ExecutePlayModeAsync clones the DataModel into the
1195
- -- play DMs, so the scripts come along and run there. We keep them in the edit
1196
- -- DM after a playtest ends (rather than cleaning up) so that a playtest the
1197
- -- dev starts MANUALLY via the Studio Play button — not the MCP start_playtest
1198
- -- tool — also gets the bridges cloned in. This is intentionally a little
1199
- -- intrusive (two helper scripts visible in Explorer) in exchange for a
1200
- -- zero-roundtrip eval_*_runtime experience for devs working 1:1 with an agent.
1201
- --
1202
- -- Archivable handling: ExecutePlayModeAsync's deep-clone SKIPS instances
1203
- -- with Archivable=false (verified empirically in v2.9.0 testing - bridges
1204
- -- never reached the play DMs because we'd set them to false). We now keep
1205
- -- Archivable=true so the clone works, and rely on cleanupBridges() to
1206
- -- remove the scripts from the edit DM when the test ends. The only failure
1207
- -- mode is the user saving DURING an active playtest, which would persist
1208
- -- the bridges to the .rbxl - that's a no-op next session because
1209
- -- installBridges() always calls cleanupBridges() first to clear stale
1210
- -- instances. The RemoteFunction/BindableFunction that the bridge scripts
1211
- -- CREATE at runtime stay Archivable=false (they're runtime-only and should
1212
- -- never appear in a save).
1273
+ -- Lifecycle: bridge scripts are created only in running play DataModels.
1274
+ -- The server plugin peer creates the Script in runtime ServerScriptService;
1275
+ -- each client plugin peer creates its LocalScript in that client's
1276
+ -- PlayerScripts. Nothing is installed into the edit DataModel anymore.
1277
+ -- Runtime-created scripts disappear naturally when the playtest stops.
1213
1278
  local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
1279
+ local Players = _services.Players
1280
+ local ReplicatedStorage = _services.ReplicatedStorage
1281
+ local RunService = _services.RunService
1214
1282
  local ServerScriptService = _services.ServerScriptService
1215
1283
  local StarterPlayer = _services.StarterPlayer
1216
1284
  local ScriptEditorService = game:GetService("ScriptEditorService")
@@ -1283,12 +1351,10 @@ bf.OnInvoke = function(payload)\
1283
1351
  end\
1284
1352
  `
1285
1353
  -- Stamp written onto each installed bridge Script so we can tell whether the
1286
- -- bridge currently in the DM was produced by THIS plugin build. It's a djb2
1287
- -- hash of the actual bridge source plus the plugin version, so ANY change to
1288
- -- the source (or a version bump) yields a new stamp which makes
1289
- -- ensureBridgesInstalled() force a refresh on the next plugin load instead of
1290
- -- keeping a stale bridge that happens to still be present (e.g. one saved into
1291
- -- the .rbxl from an older build).
1354
+ -- runtime bridge currently in the play DM was produced by THIS plugin build.
1355
+ -- It's a djb2 hash of the actual bridge source plus the plugin version, so ANY
1356
+ -- change to the source (or a version bump) yields a new stamp and triggers a
1357
+ -- runtime refresh instead of keeping a stale bridge.
1292
1358
  local STAMP_ATTR = "__MCPBridgeStamp"
1293
1359
  local function computeBridgeStamp()
1294
1360
  local combined = `{SERVER_BRIDGE_SOURCE}|{CLIENT_BRIDGE_SOURCE}`
@@ -1296,9 +1362,9 @@ local function computeBridgeStamp()
1296
1362
  for i = 1, #combined do
1297
1363
  h = (h * 33 + (string.byte(combined, i))) % 2147483647
1298
1364
  end
1299
- -- "2.15.2" is replaced with the package version at package time
1365
+ -- "2.16.1" is replaced with the package version at package time
1300
1366
  -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1301
- return `{tostring(h)}-2.15.2`
1367
+ return `{tostring(h)}-2.16.1`
1302
1368
  end
1303
1369
  local BRIDGE_STAMP = computeBridgeStamp()
1304
1370
  local function setSource(scriptInst, source)
@@ -1314,15 +1380,26 @@ local function setSource(scriptInst, source)
1314
1380
  scriptInst.Source = source
1315
1381
  end
1316
1382
  end
1317
- local function findBridges()
1383
+ local function findLegacyEditBridges()
1318
1384
  local sps = getStarterPlayerScripts()
1319
1385
  return {
1320
1386
  server = ServerScriptService:FindFirstChild(SERVER_SCRIPT_NAME),
1321
1387
  client = if sps then sps:FindFirstChild(CLIENT_SCRIPT_NAME) else nil,
1322
1388
  }
1323
1389
  end
1324
- local function cleanupBridges()
1325
- local _binding = findBridges()
1390
+ local function destroyIfPresent(parent, name)
1391
+ local existing = parent:FindFirstChild(name)
1392
+ if existing then
1393
+ pcall(function()
1394
+ return existing:Destroy()
1395
+ end)
1396
+ end
1397
+ end
1398
+ local function cleanupLegacyEditBridges()
1399
+ if RunService:IsRunning() then
1400
+ return nil
1401
+ end
1402
+ local _binding = findLegacyEditBridges()
1326
1403
  local server = _binding.server
1327
1404
  local client = _binding.client
1328
1405
  if server then
@@ -1336,54 +1413,79 @@ local function cleanupBridges()
1336
1413
  end)
1337
1414
  end
1338
1415
  end
1339
- -- Idempotent variant: install only if the bridge scripts aren't already
1340
- -- present in the edit DM. Used to keep the bridges always available (so a
1341
- -- playtest the dev starts manually — not via the MCP start_playtest tool —
1342
- -- still clones them into the play DMs). Cheap no-op when already installed,
1343
- -- which avoids re-dirtying the place on every plugin reconnect.
1344
- local installBridges
1345
- local function ensureBridgesInstalled()
1346
- local _binding = findBridges()
1347
- local server = _binding.server
1348
- local client = _binding.client
1349
- if server and client then
1350
- -- Both present — but only skip the reinstall if they were produced by
1351
- -- THIS build. A mismatched/absent stamp means a stale bridge (older
1352
- -- plugin, or one persisted in the saved place), so force a refresh.
1353
- local sStamp = server:GetAttribute(STAMP_ATTR)
1354
- local cStamp = client:GetAttribute(STAMP_ATTR)
1355
- if sStamp == BRIDGE_STAMP and cStamp == BRIDGE_STAMP then
1356
- return {
1357
- installed = true,
1358
- }
1359
- end
1416
+ local function serverRuntimeBridgeReady()
1417
+ local scriptInst = ServerScriptService:FindFirstChild(SERVER_SCRIPT_NAME)
1418
+ local bindable = ServerScriptService:FindFirstChild(BRIDGE_NAMES.serverLocal)
1419
+ return scriptInst ~= nil and scriptInst:GetAttribute(STAMP_ATTR) == BRIDGE_STAMP and bindable ~= nil and bindable:IsA("BindableFunction")
1420
+ end
1421
+ local function getPlayerScripts()
1422
+ local localPlayer = Players.LocalPlayer
1423
+ if not localPlayer then
1424
+ return nil
1425
+ end
1426
+ local playerScripts = localPlayer:FindFirstChild("PlayerScripts")
1427
+ if not playerScripts then
1428
+ playerScripts = localPlayer:WaitForChild("PlayerScripts", 5)
1360
1429
  end
1361
- return installBridges()
1430
+ return playerScripts
1362
1431
  end
1363
- function installBridges()
1364
- -- Defensive: clear any stale bridges from a prior unclean exit before
1365
- -- inserting fresh. The injected script also self-cleans its
1366
- -- ReplicatedStorage/ServerScriptService children at startup, but the
1367
- -- containing Script/LocalScript objects themselves we must clear here.
1368
- cleanupBridges()
1432
+ local function clientRuntimeBridgeReady()
1433
+ local playerScripts = getPlayerScripts()
1434
+ if not playerScripts then
1435
+ return false
1436
+ end
1437
+ local scriptInst = playerScripts:FindFirstChild(CLIENT_SCRIPT_NAME)
1438
+ local bindable = ReplicatedStorage:FindFirstChild(BRIDGE_NAMES.clientLocal)
1439
+ return scriptInst ~= nil and scriptInst:GetAttribute(STAMP_ATTR) == BRIDGE_STAMP and bindable ~= nil and bindable:IsA("BindableFunction")
1440
+ end
1441
+ local function installServerRuntimeBridge()
1442
+ if serverRuntimeBridgeReady() then
1443
+ return {
1444
+ installed = true,
1445
+ }
1446
+ end
1369
1447
  local ok, err = pcall(function()
1448
+ destroyIfPresent(ServerScriptService, SERVER_SCRIPT_NAME)
1449
+ destroyIfPresent(ServerScriptService, BRIDGE_NAMES.serverLocal)
1370
1450
  local serverScript = Instance.new("Script")
1371
1451
  serverScript.Name = SERVER_SCRIPT_NAME
1372
- -- Archivable=true so ExecutePlayModeAsync's deep-clone includes the
1373
- -- script. cleanupBridges() removes it from the edit DM when the
1374
- -- playtest ends.
1452
+ serverScript.Archivable = false
1375
1453
  setSource(serverScript, SERVER_BRIDGE_SOURCE)
1376
1454
  serverScript:SetAttribute(STAMP_ATTR, BRIDGE_STAMP)
1377
1455
  serverScript.Parent = ServerScriptService
1378
- local sps = getStarterPlayerScripts()
1379
- if not sps then
1380
- error("StarterPlayer.StarterPlayerScripts not found - cannot install client eval bridge")
1381
- end
1456
+ end)
1457
+ if not ok then
1458
+ return {
1459
+ installed = false,
1460
+ error = tostring(err),
1461
+ }
1462
+ end
1463
+ return {
1464
+ installed = true,
1465
+ }
1466
+ end
1467
+ local function installClientRuntimeBridge()
1468
+ if clientRuntimeBridgeReady() then
1469
+ return {
1470
+ installed = true,
1471
+ }
1472
+ end
1473
+ local playerScripts = getPlayerScripts()
1474
+ if not playerScripts then
1475
+ return {
1476
+ installed = false,
1477
+ error = "Players.LocalPlayer.PlayerScripts not found - cannot install client eval bridge",
1478
+ }
1479
+ end
1480
+ local ok, err = pcall(function()
1481
+ destroyIfPresent(playerScripts, CLIENT_SCRIPT_NAME)
1482
+ destroyIfPresent(ReplicatedStorage, BRIDGE_NAMES.clientLocal)
1382
1483
  local clientScript = Instance.new("LocalScript")
1383
1484
  clientScript.Name = CLIENT_SCRIPT_NAME
1485
+ clientScript.Archivable = false
1384
1486
  setSource(clientScript, CLIENT_BRIDGE_SOURCE)
1385
1487
  clientScript:SetAttribute(STAMP_ATTR, BRIDGE_STAMP)
1386
- clientScript.Parent = sps
1488
+ clientScript.Parent = playerScripts
1387
1489
  end)
1388
1490
  if not ok then
1389
1491
  return {
@@ -1395,10 +1497,21 @@ function installBridges()
1395
1497
  installed = true,
1396
1498
  }
1397
1499
  end
1500
+ local function ensureRuntimeBridgeInstalled()
1501
+ if not RunService:IsRunning() then
1502
+ return {
1503
+ installed = false,
1504
+ error = "Eval bridges are installed only in running play DataModels",
1505
+ }
1506
+ end
1507
+ if RunService:IsServer() then
1508
+ return installServerRuntimeBridge()
1509
+ end
1510
+ return installClientRuntimeBridge()
1511
+ end
1398
1512
  return {
1399
- cleanupBridges = cleanupBridges,
1400
- ensureBridgesInstalled = ensureBridgesInstalled,
1401
- installBridges = installBridges,
1513
+ cleanupLegacyEditBridges = cleanupLegacyEditBridges,
1514
+ ensureRuntimeBridgeInstalled = ensureRuntimeBridgeInstalled,
1402
1515
  BRIDGE_NAMES = BRIDGE_NAMES,
1403
1516
  }
1404
1517
  ]]></string>
@@ -2484,9 +2597,27 @@ local LogService = _services.LogService
2484
2597
  local ReplicatedStorage = _services.ReplicatedStorage
2485
2598
  local RunService = _services.RunService
2486
2599
  local ServerScriptService = _services.ServerScriptService
2487
- local BRIDGE_NAMES = TS.import(script, script.Parent.Parent, "EvalBridges").BRIDGE_NAMES
2600
+ local _EvalBridges = TS.import(script, script.Parent.Parent, "EvalBridges")
2601
+ local BRIDGE_NAMES = _EvalBridges.BRIDGE_NAMES
2602
+ local ensureRuntimeBridgeInstalled = _EvalBridges.ensureRuntimeBridgeInstalled
2488
2603
  local LuauExec = TS.import(script, script.Parent.Parent, "LuauExec")
2489
2604
  local PAYLOAD_INSTANCE_NAME = "__MCPEvalPayload"
2605
+ local function findBridge(config)
2606
+ local bridge = config.service:FindFirstChild(config.bridgeName)
2607
+ return if bridge and bridge:IsA("BindableFunction") then bridge else nil
2608
+ end
2609
+ local function waitForBridge(config, timeoutSec)
2610
+ if timeoutSec == nil then
2611
+ timeoutSec = 2
2612
+ end
2613
+ local deadline = tick() + timeoutSec
2614
+ local bridge = findBridge(config)
2615
+ while not bridge and tick() < deadline do
2616
+ task.wait(0.05)
2617
+ bridge = findBridge(config)
2618
+ end
2619
+ return bridge
2620
+ end
2490
2621
  local function getBridgeConfig()
2491
2622
  if not RunService:IsRunning() then
2492
2623
  return {
@@ -2497,13 +2628,13 @@ local function getBridgeConfig()
2497
2628
  return {
2498
2629
  service = ServerScriptService,
2499
2630
  bridgeName = BRIDGE_NAMES.serverLocal,
2500
- missingError = "ServerEvalBridge not found. The bridge runs inside the play DM, so a playtest must be running. The bridge installs automatically (including for manually-started playtests); if a playtest is running and you still see this, reconnect the plugin in the edit window so the bridge reinstalls, then start the playtest again.",
2631
+ missingError = "ServerEvalBridge not found. The bridge runs inside the play DM, so a playtest must be running. The bridge installs automatically in the runtime server peer, including for manually-started playtests.",
2501
2632
  }
2502
2633
  end
2503
2634
  return {
2504
2635
  service = ReplicatedStorage,
2505
2636
  bridgeName = BRIDGE_NAMES.clientLocal,
2506
- missingError = "ClientEvalBridge not found. The bridge runs inside the play DM, so a playtest must be running. The bridge installs automatically (including for manually-started playtests); if a playtest is running and you still see this, reconnect the plugin in the edit window so the bridge reinstalls, then start the playtest again.",
2637
+ missingError = "ClientEvalBridge not found. The bridge runs inside the play DM, so a playtest must be running. The bridge installs automatically in the runtime client peer, including for manually-started playtests.",
2507
2638
  }
2508
2639
  end
2509
2640
  local function evalRuntime(requestData)
@@ -2520,11 +2651,21 @@ local function evalRuntime(requestData)
2520
2651
  error = config.error,
2521
2652
  }
2522
2653
  end
2523
- local bridge = config.service:FindFirstChild(config.bridgeName)
2524
- if not bridge or not bridge:IsA("BindableFunction") then
2654
+ local bridge = findBridge(config)
2655
+ if not bridge then
2656
+ local install = ensureRuntimeBridgeInstalled()
2657
+ if not install.installed then
2658
+ return {
2659
+ bridge = "missing",
2660
+ error = `{config.missingError} Runtime bridge install failed: {install.error}`,
2661
+ }
2662
+ end
2663
+ bridge = waitForBridge(config)
2664
+ end
2665
+ if not bridge then
2525
2666
  return {
2526
2667
  bridge = "missing",
2527
- error = config.missingError,
2668
+ error = `{config.missingError} Runtime bridge was installed but did not become ready.`,
2528
2669
  }
2529
2670
  end
2530
2671
  local m = Instance.new("ModuleScript")
@@ -6375,9 +6516,6 @@ local HttpService = _services.HttpService
6375
6516
  local LogService = _services.LogService
6376
6517
  local Players = _services.Players
6377
6518
  local RunService = _services.RunService
6378
- local _EvalBridges = TS.import(script, script.Parent.Parent, "EvalBridges")
6379
- local installBridges = _EvalBridges.installBridges
6380
- local ensureBridgesInstalled = _EvalBridges.ensureBridgesInstalled
6381
6519
  local StopPlayMonitor = TS.import(script, script.Parent.Parent, "StopPlayMonitor")
6382
6520
  local StudioTestService = game:GetService("StudioTestService")
6383
6521
  local ServerScriptService = game:GetService("ServerScriptService")
@@ -6564,9 +6702,8 @@ local function startPlaytest(requestData)
6564
6702
  logConnection = nil
6565
6703
  end
6566
6704
  cleanupStopListener()
6567
- -- Note: eval bridges are intentionally NOT cleaned up they live
6568
- -- permanently in the edit DM so manual playtests also get them. See
6569
- -- EvalBridges.ts lifecycle comment.
6705
+ -- Runtime eval bridges are created by the play server/client plugin
6706
+ -- peers and disappear with the play DataModels.
6570
6707
  end
6571
6708
  if testRunning then
6572
6709
  return {
@@ -6607,15 +6744,7 @@ local function startPlaytest(requestData)
6607
6744
  return injectStopListener()
6608
6745
  end)
6609
6746
  if not injected then
6610
- warn(`[MCP] Failed to inject stop listener: {injErr}`)
6611
- end
6612
- -- Force-refresh the game-VM eval bridges (ServerEvalBridge + ClientEvalBridge)
6613
- -- right before cloning so the play DMs get the current source. They also
6614
- -- live permanently in the edit DM (installed on connect) so manually-started
6615
- -- playtests get them too; here we just ensure they're fresh.
6616
- local bridgeInstall = installBridges()
6617
- if not bridgeInstall.installed then
6618
- warn(`[MCP] Eval bridge install failed: {bridgeInstall.error}`)
6747
+ warn(`[robloxstudio-mcp] Failed to inject stop listener: {injErr}`)
6619
6748
  end
6620
6749
  task.spawn(function()
6621
6750
  local ok, result = pcall(function()
@@ -6635,35 +6764,25 @@ local function startPlaytest(requestData)
6635
6764
  end
6636
6765
  testRunning = false
6637
6766
  cleanupStopListener()
6638
- -- Eval bridges persist in the edit DM (see EvalBridges.ts) — do not
6639
- -- clean up here, so the next manual playtest still gets them.
6640
- ensureBridgesInstalled()
6641
6767
  end)
6642
6768
  local response = {
6643
6769
  success = true,
6644
6770
  message = `Playtest started in {mode} mode.`,
6645
6771
  }
6646
- -- Only mention eval bridges when they failed — when they're fine, the
6647
- -- detail is noise. eval_server_runtime / eval_client_runtime will surface
6648
- -- their own clear errors if the caller tries to use them after a failed
6649
- -- install.
6650
- if not bridgeInstall.installed then
6651
- response.evalBridgesError = bridgeInstall.error
6652
- end
6653
6772
  return response
6654
6773
  end
6655
6774
  local function stopPlaytest(_requestData)
6656
- -- Signal the play-server DM's StopPlayMonitor via plugin:SetSetting (a
6657
- -- cross-DM persistent store). The monitor polls at 1Hz, sees the flag,
6658
- -- calls StudioTestService:EndTest, then resets the flag. We wait up to
6659
- -- 2.5s for the reset to confirm a play DM actually consumed the request,
6660
- -- which avoids returning success when nothing is running.
6661
- if not StopPlayMonitor.requestStop() then
6775
+ -- Signal the play-server DM's StopPlayMonitor via plugin:SetSetting.
6776
+ -- The monitor acknowledges with the matching request id only after its
6777
+ -- StudioTestService:EndTest call returns from pcall.
6778
+ local stopRequest = StopPlayMonitor.requestStop()
6779
+ if not stopRequest.ok or stopRequest.requestId == nil then
6662
6780
  return {
6663
6781
  error = "Plugin not ready. Try again in a moment.",
6664
6782
  }
6665
6783
  end
6666
- if not StopPlayMonitor.waitForConsumption() then
6784
+ local consumption = StopPlayMonitor.waitForConsumption(stopRequest.requestId)
6785
+ if not consumption.ok then
6667
6786
  -- Two distinct failure modes collapse here, distinguished by whether
6668
6787
  -- THIS edit DM has a playtest tracked:
6669
6788
  --
@@ -6675,19 +6794,28 @@ local function stopPlaytest(_requestData)
6675
6794
  -- from the caller's perspective — playtest may actually have ended).
6676
6795
  -- Tell the caller it's a timing issue and they can retry.
6677
6796
  --
6678
- -- Either way clean up the pending flag so a future playtest's monitor
6797
+ -- Either way clean up the pending request so a future playtest's monitor
6679
6798
  -- doesn't fire EndTest on startup against a stale signal.
6680
- StopPlayMonitor.clearPending()
6799
+ StopPlayMonitor.clearPending(stopRequest.requestId)
6681
6800
  if testRunning then
6682
6801
  return {
6683
- error = "Playtest stop signal sent but consumption confirmation timed out. " .. "The playtest may have ended anyway; check get_connected_instances.",
6802
+ error = "Playtest stop signal failed or was not acknowledged. " .. "The playtest may have ended anyway; check get_connected_instances.",
6803
+ detail = consumption.error,
6804
+ }
6805
+ end
6806
+ if consumption.consumed then
6807
+ return {
6808
+ error = "Playtest stop request reached the play server, but EndTest failed.",
6809
+ detail = consumption.error,
6684
6810
  }
6685
6811
  end
6686
6812
  return {
6687
6813
  error = "No active playtest to stop.",
6814
+ detail = consumption.error,
6688
6815
  }
6689
6816
  end
6690
- -- Flag was consumed (EndTest called). ExecutePlayModeAsync in our
6817
+ StopPlayMonitor.clearPending(stopRequest.requestId)
6818
+ -- Request was consumed (EndTest called). ExecutePlayModeAsync in our
6691
6819
  -- startPlaytest task.spawn is still unwinding though — testRunning stays
6692
6820
  -- true until that yield completes and the post-block runs. Wait so
6693
6821
  -- back-to-back stop -> start sequences don't race against the prior
@@ -6744,10 +6872,6 @@ local function multiplayerTestStart(requestData)
6744
6872
  end
6745
6873
  local testArgs = if requestData.testArgs ~= nil then requestData.testArgs else {}
6746
6874
  local testId = HttpService:GenerateGUID(false)
6747
- local bridgeInstall = installBridges()
6748
- if not bridgeInstall.installed then
6749
- warn(`[MCP] Eval bridge install failed: {bridgeInstall.error}`)
6750
- end
6751
6875
  multiplayerState = {
6752
6876
  phase = "starting",
6753
6877
  testId = testId,
@@ -6771,7 +6895,6 @@ local function multiplayerTestStart(requestData)
6771
6895
  multiplayerState.result = nil
6772
6896
  multiplayerState.error = tostring(result)
6773
6897
  end
6774
- ensureBridgesInstalled()
6775
6898
  end)
6776
6899
  local response = {
6777
6900
  success = true,
@@ -6781,9 +6904,6 @@ local function multiplayerTestStart(requestData)
6781
6904
  numPlayers = numPlayers,
6782
6905
  testArgs = testArgs,
6783
6906
  }
6784
- if not bridgeInstall.installed then
6785
- response.evalBridgesError = bridgeInstall.error
6786
- end
6787
6907
  return response
6788
6908
  end
6789
6909
  local function multiplayerTestState(_requestData)
@@ -6999,6 +7119,87 @@ return {
6999
7119
  </Item>
7000
7120
  </Item>
7001
7121
  <Item class="ModuleScript" referent="21">
7122
+ <Properties>
7123
+ <string name="Name">HttpDiagnostics</string>
7124
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
7125
+ local TS = require(script.Parent.Parent.include.RuntimeLib)
7126
+ local HttpService = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services").HttpService
7127
+ local function encodeForLog(value)
7128
+ local ok, encoded = pcall(function()
7129
+ return HttpService:JSONEncode(value)
7130
+ end)
7131
+ return if ok then encoded else tostring(value)
7132
+ end
7133
+ local function formatBody(body)
7134
+ if body == "" then
7135
+ return ""
7136
+ end
7137
+ local ok, decoded = pcall(function()
7138
+ return HttpService:JSONDecode(body)
7139
+ end)
7140
+ if ok and type(decoded) == "table" then
7141
+ local data = decoded
7142
+ local parts = {}
7143
+ local _error = data.error
7144
+ local _condition = type(_error) == "string"
7145
+ if _condition then
7146
+ _condition = data.error ~= ""
7147
+ end
7148
+ if _condition then
7149
+ local _arg0 = `error={data.error}`
7150
+ table.insert(parts, _arg0)
7151
+ end
7152
+ local _message = data.message
7153
+ local _condition_1 = type(_message) == "string"
7154
+ if _condition_1 then
7155
+ _condition_1 = data.message ~= ""
7156
+ end
7157
+ if _condition_1 then
7158
+ local _arg0 = `message={data.message}`
7159
+ table.insert(parts, _arg0)
7160
+ end
7161
+ if data.missingFields ~= nil then
7162
+ local _arg0 = `missingFields={encodeForLog(data.missingFields)}`
7163
+ table.insert(parts, _arg0)
7164
+ end
7165
+ if data.request ~= nil then
7166
+ local _arg0 = `request={encodeForLog(data.request)}`
7167
+ table.insert(parts, _arg0)
7168
+ end
7169
+ if data.existing ~= nil then
7170
+ local _arg0 = `existing={encodeForLog(data.existing)}`
7171
+ table.insert(parts, _arg0)
7172
+ end
7173
+ if data.details ~= nil then
7174
+ local _arg0 = `details={encodeForLog(data.details)}`
7175
+ table.insert(parts, _arg0)
7176
+ end
7177
+ if #parts > 0 then
7178
+ return table.concat(parts, " ")
7179
+ end
7180
+ end
7181
+ return `body={body}`
7182
+ end
7183
+ local function formatRequestFailure(url, ok, res)
7184
+ if not ok then
7185
+ return `RequestAsync threw for {url}: {tostring(res)}`
7186
+ end
7187
+ if res == nil then
7188
+ return `RequestAsync returned no response for {url}`
7189
+ end
7190
+ local response = res
7191
+ local statusMessage = if response.StatusMessage ~= "" then ` {response.StatusMessage}` else ""
7192
+ local body = formatBody(response.Body)
7193
+ local suffix = if body ~= "" then `: {body}` else ""
7194
+ return `HTTP {response.StatusCode}{statusMessage} from {url}{suffix}`
7195
+ end
7196
+ return {
7197
+ formatRequestFailure = formatRequestFailure,
7198
+ }
7199
+ ]]></string>
7200
+ </Properties>
7201
+ </Item>
7202
+ <Item class="ModuleScript" referent="22">
7002
7203
  <Properties>
7003
7204
  <string name="Name">LuauExec</string>
7004
7205
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7432,7 +7633,7 @@ return {
7432
7633
  ]]></string>
7433
7634
  </Properties>
7434
7635
  </Item>
7435
- <Item class="ModuleScript" referent="22">
7636
+ <Item class="ModuleScript" referent="23">
7436
7637
  <Properties>
7437
7638
  <string name="Name">Recording</string>
7438
7639
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7462,7 +7663,7 @@ return {
7462
7663
  ]]></string>
7463
7664
  </Properties>
7464
7665
  </Item>
7465
- <Item class="ModuleScript" referent="23">
7666
+ <Item class="ModuleScript" referent="24">
7466
7667
  <Properties>
7467
7668
  <string name="Name">RenderMonitor</string>
7468
7669
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7530,7 +7731,7 @@ return {
7530
7731
  ]]></string>
7531
7732
  </Properties>
7532
7733
  </Item>
7533
- <Item class="ModuleScript" referent="24">
7734
+ <Item class="ModuleScript" referent="25">
7534
7735
  <Properties>
7535
7736
  <string name="Name">RuntimeLogBuffer</string>
7536
7737
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7711,11 +7912,71 @@ return {
7711
7912
  ]]></string>
7712
7913
  </Properties>
7713
7914
  </Item>
7714
- <Item class="ModuleScript" referent="25">
7915
+ <Item class="ModuleScript" referent="26">
7916
+ <Properties>
7917
+ <string name="Name">ServerUrlSettings</string>
7918
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
7919
+ local TS = require(script.Parent.Parent.include.RuntimeLib)
7920
+ local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
7921
+ local HttpService = _services.HttpService
7922
+ local ServerStorage = _services.ServerStorage
7923
+ local SETTING_KEY_PREFIX = "MCP_SERVER_URL_"
7924
+ local pluginRef
7925
+ local function init(p)
7926
+ pluginRef = p
7927
+ end
7928
+ local function computeInstanceId()
7929
+ if game.PlaceId ~= 0 then
7930
+ return `place:{tostring(game.PlaceId)}`
7931
+ end
7932
+ local existing = ServerStorage:GetAttribute("__MCPPlaceId")
7933
+ if type(existing) == "string" and existing ~= "" then
7934
+ return `anon:{existing}`
7935
+ end
7936
+ local fresh = HttpService:GenerateGUID(false)
7937
+ pcall(function()
7938
+ return ServerStorage:SetAttribute("__MCPPlaceId", fresh)
7939
+ end)
7940
+ return `anon:{fresh}`
7941
+ end
7942
+ local function settingKey(instanceId)
7943
+ return SETTING_KEY_PREFIX .. instanceId
7944
+ end
7945
+ local function rememberServerUrl(serverUrl)
7946
+ if not pluginRef or serverUrl == "" then
7947
+ return nil
7948
+ end
7949
+ local key = settingKey(computeInstanceId())
7950
+ pcall(function()
7951
+ return pluginRef:SetSetting(key, serverUrl)
7952
+ end)
7953
+ end
7954
+ local function readServerUrl()
7955
+ if not pluginRef then
7956
+ return nil
7957
+ end
7958
+ local key = settingKey(computeInstanceId())
7959
+ local ok, value = pcall(function()
7960
+ return pluginRef:GetSetting(key)
7961
+ end)
7962
+ if ok and type(value) == "string" and value ~= "" then
7963
+ return value
7964
+ end
7965
+ return nil
7966
+ end
7967
+ return {
7968
+ init = init,
7969
+ rememberServerUrl = rememberServerUrl,
7970
+ readServerUrl = readServerUrl,
7971
+ }
7972
+ ]]></string>
7973
+ </Properties>
7974
+ </Item>
7975
+ <Item class="ModuleScript" referent="27">
7715
7976
  <Properties>
7716
7977
  <string name="Name">State</string>
7717
7978
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
7718
- local CURRENT_VERSION = "2.15.2"
7979
+ local CURRENT_VERSION = "2.16.1"
7719
7980
  local PLUGIN_VARIANT = "main"
7720
7981
  local MAX_CONNECTIONS = 5
7721
7982
  local BASE_PORT = 58741
@@ -7809,7 +8070,7 @@ return {
7809
8070
  ]]></string>
7810
8071
  </Properties>
7811
8072
  </Item>
7812
- <Item class="ModuleScript" referent="26">
8073
+ <Item class="ModuleScript" referent="28">
7813
8074
  <Properties>
7814
8075
  <string name="Name">StopPlayMonitor</string>
7815
8076
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7821,16 +8082,16 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
7821
8082
  -- `plugin:SetSetting` / `plugin:GetSetting` is a per-plugin persistent store
7822
8083
  -- shared across every DataModel the plugin runs in (edit DMs, play-server
7823
8084
  -- DMs, play-client DMs). For each connected place we use a dedicated key
7824
- -- "MCP_STOP_PLAY_<instanceId>" as a single-bit mailbox:
8085
+ -- "MCP_STOP_PLAY_<instanceId>" as a tiny request/result mailbox:
7825
8086
  --
7826
- -- * The edit DM's stopPlaytest handler writes `true` into its own key
8087
+ -- * The edit DM's handler writes a tokenized stop request into its own key
7827
8088
  -- (computed from its placeId / ServerStorage anon UUID).
7828
8089
  -- * Each play-server DM's monitor loop polls the key matching its own
7829
- -- instanceId at 0.1Hz; on `true` it clears the key and calls
7830
- -- StudioTestService:EndTest. Play-server DMs for other places never
7831
- -- touch this key.
7832
- -- * The edit DM waits up to ~8s for its key to be cleared, confirming a
7833
- -- matching play-server actually consumed the request.
8090
+ -- instanceId at 1Hz. On a fresh token, it calls StudioTestService:EndTest
8091
+ -- and writes a matching result token. Play-server DMs for other places
8092
+ -- never touch this key.
8093
+ -- * The edit DM waits up to ~8s for its result token, confirming a matching
8094
+ -- play-server actually consumed the request.
7834
8095
  --
7835
8096
  -- Earlier versions used a single shared boolean flag, which let any
7836
8097
  -- play-server DM in the same Studio process consume any place's stop
@@ -7838,20 +8099,24 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
7838
8099
  -- below is the fix.
7839
8100
  local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
7840
8101
  local HttpService = _services.HttpService
8102
+ local RunService = _services.RunService
7841
8103
  local ServerStorage = _services.ServerStorage
7842
8104
  local StudioTestService = game:GetService("StudioTestService")
7843
8105
  local SETTING_KEY_PREFIX = "MCP_STOP_PLAY_"
7844
- -- Monitor checks the key at this cadence. 0.1s keeps worst-case detection
7845
- -- lag tight so the consumption-confirmation window doesn't have to absorb
7846
- -- polling jitter on top of EndTest's teardown time.
7847
- local POLL_INTERVAL_SEC = 0.1
8106
+ -- Keep this conservative. plugin:GetSetting is backed by Studio's plugin
8107
+ -- settings store, and this monitor runs during every play session, including
8108
+ -- manually-started Play. The official reference implementation polls at 1s.
8109
+ local POLL_INTERVAL_SEC = 1
7848
8110
  -- Total time we wait for the matching play-server DM to consume the
7849
8111
  -- signal. Must cover: monitor detection (<= POLL_INTERVAL_SEC) +
7850
8112
  -- StudioTestService:EndTest teardown (several seconds on heavier places).
7851
- -- 8s is comfortable; the tighter poll above keeps real cases well under.
8113
+ -- 8s is intentionally shorter than the MCP request timeout but long enough
8114
+ -- for the 1s monitor cadence plus ordinary Studio teardown latency.
7852
8115
  local WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 8.0
7853
8116
  local WAIT_POLL_SEC = 0.1
8117
+ local REQUEST_TTL_SEC = 12.0
7854
8118
  local pluginRef
8119
+ local endTestIssued = false
7855
8120
  local function init(p)
7856
8121
  pluginRef = p
7857
8122
  end
@@ -7877,32 +8142,125 @@ end
7877
8142
  local function settingKey(instanceId)
7878
8143
  return SETTING_KEY_PREFIX .. instanceId
7879
8144
  end
8145
+ local function readSetting(key)
8146
+ if not pluginRef then
8147
+ return nil
8148
+ end
8149
+ local ok, value = pcall(function()
8150
+ return pluginRef:GetSetting(key)
8151
+ end)
8152
+ return if ok then value else nil
8153
+ end
8154
+ local function writeSetting(key, value)
8155
+ if not pluginRef then
8156
+ return false
8157
+ end
8158
+ local ok = pcall(function()
8159
+ return pluginRef:SetSetting(key, value)
8160
+ end)
8161
+ return ok
8162
+ end
8163
+ local function decodePayload(value)
8164
+ local decoded = value
8165
+ local _value = value
8166
+ if type(_value) == "string" then
8167
+ local ok, result = pcall(function()
8168
+ return HttpService:JSONDecode(value)
8169
+ end)
8170
+ if not ok then
8171
+ return nil
8172
+ end
8173
+ decoded = result
8174
+ end
8175
+ local _decoded = decoded
8176
+ if not (type(_decoded) == "table") then
8177
+ return nil
8178
+ end
8179
+ local payload = decoded
8180
+ local _kind = payload.kind
8181
+ local _condition = not (type(_kind) == "string")
8182
+ if not _condition then
8183
+ local _id = payload.id
8184
+ _condition = not (type(_id) == "string")
8185
+ end
8186
+ if _condition then
8187
+ return nil
8188
+ end
8189
+ return payload
8190
+ end
8191
+ local function writePayload(key, payload)
8192
+ local encodedOk, encoded = pcall(function()
8193
+ return HttpService:JSONEncode(payload)
8194
+ end)
8195
+ if not encodedOk or not (type(encoded) == "string") then
8196
+ return false
8197
+ end
8198
+ return writeSetting(key, encoded)
8199
+ end
8200
+ local function writeResult(key, request, ok, errText)
8201
+ writePayload(key, {
8202
+ kind = "result",
8203
+ id = request.id,
8204
+ requestedAt = request.requestedAt,
8205
+ consumedAt = tick(),
8206
+ ok = ok,
8207
+ error = errText,
8208
+ })
8209
+ end
8210
+ local function handleStopRequest(key, request)
8211
+ local _condition = request.kind ~= "request"
8212
+ if not _condition then
8213
+ local _id = request.id
8214
+ _condition = not (type(_id) == "string")
8215
+ end
8216
+ if _condition then
8217
+ return nil
8218
+ end
8219
+ local _requestedAt = request.requestedAt
8220
+ if not (type(_requestedAt) == "number") then
8221
+ writeSetting(key, false)
8222
+ return nil
8223
+ end
8224
+ local age = tick() - request.requestedAt
8225
+ if age < -5 or age > REQUEST_TTL_SEC then
8226
+ writeSetting(key, false)
8227
+ return nil
8228
+ end
8229
+ if endTestIssued then
8230
+ writeResult(key, request, true)
8231
+ return nil
8232
+ end
8233
+ if not RunService:IsRunning() or not RunService:IsServer() then
8234
+ writeResult(key, request, false, "StopPlayMonitor is not running in the server DataModel.")
8235
+ return nil
8236
+ end
8237
+ endTestIssued = true
8238
+ local endOk, endErr = pcall(function()
8239
+ return StudioTestService:EndTest("stopped_by_mcp")
8240
+ end)
8241
+ writeResult(key, request, endOk, if endOk then nil else tostring(endErr))
8242
+ if not endOk then
8243
+ endTestIssued = false
8244
+ end
8245
+ end
7880
8246
  local function startMonitor()
7881
8247
  if not pluginRef then
7882
- warn("[MCP] StopPlayMonitor.startMonitor called before init; skipping")
8248
+ warn("[robloxstudio-mcp] StopPlayMonitor.startMonitor called before init; skipping")
7883
8249
  return nil
7884
8250
  end
7885
8251
  local myKey = settingKey(computeInstanceId())
7886
- -- Clear any stale value left from a prior session. If a real stop
7887
- -- request is in-flight when this runs, the requesting edit DM will
7888
- -- write again within its consumption-confirmation window.
7889
- pcall(function()
7890
- return pluginRef:SetSetting(myKey, false)
7891
- end)
7892
8252
  task.spawn(function()
7893
8253
  while true do
7894
- local okGet, val = pcall(function()
7895
- return pluginRef:GetSetting(myKey)
7896
- end)
7897
- if okGet and val == true then
7898
- -- Consume the flag first so requestStop's
7899
- -- waitForConsumption returns success, then end the test.
7900
- pcall(function()
7901
- return pluginRef:SetSetting(myKey, false)
7902
- end)
7903
- pcall(function()
7904
- return StudioTestService:EndTest("stopped_by_mcp")
7905
- end)
8254
+ local value = readSetting(myKey)
8255
+ if value == true then
8256
+ -- Legacy boolean requests are ambiguous and may be stale from
8257
+ -- a prior crashed session. New stop requests use token payloads.
8258
+ writeSetting(myKey, false)
8259
+ else
8260
+ local payload = decodePayload(value)
8261
+ if payload then
8262
+ handleStopRequest(myKey, payload)
8263
+ end
7906
8264
  end
7907
8265
  task.wait(POLL_INTERVAL_SEC)
7908
8266
  end
@@ -7910,39 +8268,61 @@ local function startMonitor()
7910
8268
  end
7911
8269
  local function requestStop()
7912
8270
  if not pluginRef then
7913
- return false
8271
+ return {
8272
+ ok = false,
8273
+ }
7914
8274
  end
7915
8275
  local myKey = settingKey(computeInstanceId())
7916
- local ok = pcall(function()
7917
- return pluginRef:SetSetting(myKey, true)
7918
- end)
7919
- return ok
8276
+ local requestId = HttpService:GenerateGUID(false)
8277
+ local ok = writePayload(myKey, {
8278
+ kind = "request",
8279
+ id = requestId,
8280
+ requestedAt = tick(),
8281
+ })
8282
+ return {
8283
+ ok = ok,
8284
+ requestId = if ok then requestId else nil,
8285
+ }
7920
8286
  end
7921
- local function waitForConsumption()
8287
+ local function waitForConsumption(requestId)
7922
8288
  if not pluginRef then
7923
- return false
8289
+ return {
8290
+ ok = false,
8291
+ consumed = false,
8292
+ error = "Plugin reference is not initialized.",
8293
+ }
7924
8294
  end
7925
8295
  local myKey = settingKey(computeInstanceId())
7926
8296
  local start = tick()
7927
8297
  while tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC do
7928
- local okGet, val = pcall(function()
7929
- return pluginRef:GetSetting(myKey)
7930
- end)
7931
- if okGet and val ~= true then
7932
- return true
8298
+ local payload = decodePayload(readSetting(myKey))
8299
+ if payload and payload.kind == "result" and payload.id == requestId then
8300
+ return {
8301
+ ok = payload.ok == true,
8302
+ consumed = true,
8303
+ error = payload.error,
8304
+ }
7933
8305
  end
7934
8306
  task.wait(WAIT_POLL_SEC)
7935
8307
  end
7936
- return false
8308
+ return {
8309
+ ok = false,
8310
+ consumed = false,
8311
+ error = "Timed out waiting for the play-server DataModel to acknowledge stop_playtest.",
8312
+ }
7937
8313
  end
7938
- local function clearPending()
8314
+ local function clearPending(requestId)
7939
8315
  if not pluginRef then
7940
8316
  return nil
7941
8317
  end
7942
8318
  local myKey = settingKey(computeInstanceId())
7943
- pcall(function()
7944
- return pluginRef:SetSetting(myKey, false)
7945
- end)
8319
+ if requestId ~= nil then
8320
+ local payload = decodePayload(readSetting(myKey))
8321
+ if payload and payload.id ~= requestId then
8322
+ return nil
8323
+ end
8324
+ end
8325
+ writeSetting(myKey, false)
7946
8326
  end
7947
8327
  return {
7948
8328
  init = init,
@@ -7954,7 +8334,7 @@ return {
7954
8334
  ]]></string>
7955
8335
  </Properties>
7956
8336
  </Item>
7957
- <Item class="ModuleScript" referent="27">
8337
+ <Item class="ModuleScript" referent="29">
7958
8338
  <Properties>
7959
8339
  <string name="Name">UI</string>
7960
8340
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -8725,7 +9105,7 @@ return {
8725
9105
  ]]></string>
8726
9106
  </Properties>
8727
9107
  </Item>
8728
- <Item class="ModuleScript" referent="28">
9108
+ <Item class="ModuleScript" referent="30">
8729
9109
  <Properties>
8730
9110
  <string name="Name">Utils</string>
8731
9111
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9255,11 +9635,11 @@ return {
9255
9635
  </Properties>
9256
9636
  </Item>
9257
9637
  </Item>
9258
- <Item class="Folder" referent="32">
9638
+ <Item class="Folder" referent="34">
9259
9639
  <Properties>
9260
9640
  <string name="Name">include</string>
9261
9641
  </Properties>
9262
- <Item class="ModuleScript" referent="29">
9642
+ <Item class="ModuleScript" referent="31">
9263
9643
  <Properties>
9264
9644
  <string name="Name">Promise</string>
9265
9645
  <string name="Source"><![CDATA[--[[
@@ -11333,7 +11713,7 @@ return Promise
11333
11713
  ]]></string>
11334
11714
  </Properties>
11335
11715
  </Item>
11336
- <Item class="ModuleScript" referent="30">
11716
+ <Item class="ModuleScript" referent="32">
11337
11717
  <Properties>
11338
11718
  <string name="Name">RuntimeLib</string>
11339
11719
  <string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
@@ -11600,15 +11980,15 @@ return TS
11600
11980
  </Properties>
11601
11981
  </Item>
11602
11982
  </Item>
11603
- <Item class="Folder" referent="33">
11983
+ <Item class="Folder" referent="35">
11604
11984
  <Properties>
11605
11985
  <string name="Name">node_modules</string>
11606
11986
  </Properties>
11607
- <Item class="Folder" referent="34">
11987
+ <Item class="Folder" referent="36">
11608
11988
  <Properties>
11609
11989
  <string name="Name">@rbxts</string>
11610
11990
  </Properties>
11611
- <Item class="ModuleScript" referent="31">
11991
+ <Item class="ModuleScript" referent="33">
11612
11992
  <Properties>
11613
11993
  <string name="Name">services</string>
11614
11994
  <string name="Source"><![CDATA[return setmetatable({}, {