@chrrxs/robloxstudio-mcp-inspector 2.16.0 → 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,7 @@ 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")
13
14
  local _EvalBridges = TS.import(script, script, "modules", "EvalBridges")
14
15
  local cleanupLegacyEditBridges = _EvalBridges.cleanupLegacyEditBridges
15
16
  local ensureRuntimeBridgeInstalled = _EvalBridges.ensureRuntimeBridgeInstalled
@@ -28,6 +29,7 @@ RuntimeLogBuffer.install()
28
29
  -- edit DM (write the flag) and the play-server DM (read+act on the flag) can
29
30
  -- access plugin:SetSetting/GetSetting.
30
31
  StopPlayMonitor.init(plugin)
32
+ ServerUrlSettings.init(plugin)
31
33
  UI.init(plugin)
32
34
  local elements = UI.getElements()
33
35
  local ICON_DISCONNECTED = "rbxassetid://75876056391496"
@@ -76,7 +78,7 @@ task.delay(2, function()
76
78
  else
77
79
  local result = ensureRuntimeBridgeInstalled()
78
80
  if not result.installed then
79
- warn(`[MCPPlugin] Runtime eval bridge install failed: {result.error}`)
81
+ warn(`[robloxstudio-mcp] Runtime eval bridge install failed: {result.error}`)
80
82
  end
81
83
  end
82
84
  if role == "edit" or role == "server" then
@@ -84,10 +86,29 @@ task.delay(2, function()
84
86
  local idx = State.getActiveTabIndex()
85
87
  local conn = State.getConnection(idx)
86
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
87
107
  -- Defensive default: in invisible play-DM UIs, the input field
88
108
  -- may not be populated by the time we activate.
89
109
  if conn.serverUrl == nil or conn.serverUrl == "" then
90
- conn.serverUrl = ClientBroker.MCP_URL
110
+ conn.serverUrl = ClientBroker.DEFAULT_MCP_URL
111
+ elements.urlInput.Text = conn.serverUrl
91
112
  end
92
113
  Communication.activatePlugin(idx)
93
114
  end
@@ -96,8 +117,8 @@ task.delay(2, function()
96
117
  if role == "server" then
97
118
  ClientBroker.setupServerBroker()
98
119
  -- The play-server DM is the only one where StudioTestService:EndTest is
99
- -- legal, so the stop-play monitor lives here. Reads MCP_STOP_PLAY_SIGNAL
100
- -- 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.
101
122
  StopPlayMonitor.startMonitor()
102
123
  elseif role == "client" then
103
124
  ClientBroker.setupClientBroker()
@@ -128,6 +149,7 @@ local InputHandlers = TS.import(script, script.Parent, "handlers", "InputHandler
128
149
  local EvalRuntimeHandlers = TS.import(script, script.Parent, "handlers", "EvalRuntimeHandlers")
129
150
  local LuauExec = TS.import(script, script.Parent, "LuauExec")
130
151
  local State = TS.import(script, script.Parent, "State")
152
+ local HttpDiagnostics = TS.import(script, script.Parent, "HttpDiagnostics")
131
153
  local StudioTestService = game:GetService("StudioTestService")
132
154
  -- Mirror of Communication.computeInstanceId() — duplicated here because the
133
155
  -- client broker runs in the play-server DM where it can't easily import from
@@ -180,7 +202,8 @@ end
180
202
  -- intercept /api/stop-playtest and call StudioTestService:EndTest. That hack
181
203
  -- is gone: stop now uses StopPlayMonitor with plugin:SetSetting cross-DM
182
204
  -- signaling, which works regardless of MCP server state.)
183
- local MCP_URL = "http://localhost:58741"
205
+ local DEFAULT_MCP_URL = "http://localhost:58741"
206
+ local mcpUrl = DEFAULT_MCP_URL
184
207
  local BROKER_NAME = "__MCPClientBroker"
185
208
  local BROKER_OWNER_ATTRIBUTE = "__MCPBrokerOwner"
186
209
  -- Endpoints the server-peer broker is allowed to forward to the client peer.
@@ -241,7 +264,7 @@ end
241
264
  function postJson(endpoint, body)
242
265
  return pcall(function()
243
266
  return HttpService:RequestAsync({
244
- Url = `{MCP_URL}{endpoint}`,
267
+ Url = `{mcpUrl}{endpoint}`,
245
268
  Method = "POST",
246
269
  Headers = {
247
270
  ["Content-Type"] = "application/json",
@@ -250,6 +273,17 @@ function postJson(endpoint, body)
250
273
  })
251
274
  end)
252
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
253
287
  local function handleExecuteLuau(data)
254
288
  local code = data and (data.code)
255
289
  if type(code) == "string" == false or code == "" then
@@ -396,6 +430,7 @@ local function setupClientBroker()
396
430
  end
397
431
  end
398
432
  local proxyByPlayer = {}
433
+ local proxyRegisterFailuresByPlayer = {}
399
434
  local serverBrokerStarted = false
400
435
  local function pollProxy(proxyId, player, rf)
401
436
  while true do
@@ -409,7 +444,7 @@ local function pollProxy(proxyId, player, rf)
409
444
  end
410
445
  local ok, res = pcall(function()
411
446
  return HttpService:RequestAsync({
412
- Url = `{MCP_URL}/poll?pluginSessionId={proxyId}`,
447
+ Url = `{mcpUrl}/poll?pluginSessionId={proxyId}`,
413
448
  Method = "GET",
414
449
  Headers = {
415
450
  ["Content-Type"] = "application/json",
@@ -488,7 +523,9 @@ local function registerProxy(player, rf)
488
523
  pluginVariant = State.PLUGIN_VARIANT,
489
524
  })
490
525
  if not ok or not res or not res.Success then
491
- 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)}`)
492
529
  return nil
493
530
  end
494
531
  local body = HttpService:JSONDecode(res.Body)
@@ -503,11 +540,17 @@ local function registerProxy(player, rf)
503
540
  role = assigned,
504
541
  }
505
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
506
549
  task.spawn(pollProxy, proxyId, player, rf)
507
550
  end
508
551
  -- (Removed: startEditProxyLoop. The play-server DM no longer registers an
509
552
  -- "edit-proxy" peer with the MCP server. stop_playtest now uses a cross-DM
510
- -- plugin:SetSetting flag consumed by StopPlayMonitor in the play-server DM,
553
+ -- plugin:SetSetting request consumed by StopPlayMonitor in the play-server DM,
511
554
  -- which doesn't depend on MCP server state or peer registration at all.)
512
555
  local function setupServerBroker()
513
556
  if serverBrokerStarted then
@@ -537,6 +580,8 @@ local function setupServerBroker()
537
580
  if entry then
538
581
  local _p_1 = p
539
582
  proxyByPlayer[_p_1] = nil
583
+ local _p_2 = p
584
+ proxyRegisterFailuresByPlayer[_p_2] = nil
540
585
  postJson("/disconnect", {
541
586
  pluginSessionId = entry.pluginSessionId,
542
587
  })
@@ -552,7 +597,10 @@ local function setupServerBroker()
552
597
  end)
553
598
  end
554
599
  return {
555
- MCP_URL = MCP_URL,
600
+ MCP_URL = DEFAULT_MCP_URL,
601
+ DEFAULT_MCP_URL = DEFAULT_MCP_URL,
602
+ getServerUrl = getServerUrl,
603
+ setServerUrl = setServerUrl,
556
604
  forkRole = forkRole,
557
605
  setupClientBroker = setupClientBroker,
558
606
  setupServerBroker = setupServerBroker,
@@ -588,6 +636,8 @@ local SerializationHandlers = TS.import(script, script.Parent, "handlers", "Seri
588
636
  local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandlers")
589
637
  local SceneAnalysisHandlers = TS.import(script, script.Parent, "handlers", "SceneAnalysisHandlers")
590
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")
591
641
  -- Per-plugin-load random GUID. Used as the /poll URL param so the server
592
642
  -- can tell our polls apart from any other plugin's polls. Not user-facing —
593
643
  -- MCP tools and the LLM operate on instanceId (the place identifier).
@@ -617,6 +667,7 @@ local assignedRole
617
667
  local duplicateInstanceRole = false
618
668
  local hasVersionMismatch = false
619
669
  local lastVersionMismatchWarningKey
670
+ local readyFailureLogKeys = {}
620
671
  -- Cache the published place name from MarketplaceService:GetProductInfo so
621
672
  -- /ready can carry a friendly identifier (e.g. "Natural Disasters") distinct
622
673
  -- from game.Name (the DataModel name, often "Place1" in edit). We only fetch
@@ -824,31 +875,49 @@ function sendReady(conn)
824
875
  }),
825
876
  })
826
877
  end)
878
+ local readyUrl = `{conn.serverUrl}/ready`
879
+ local readyRole = detectRole()
880
+ local readyLogKey = `{conn.serverUrl}|{instanceId}|{readyRole}`
827
881
  if not readyOk then
882
+ readyFailureLogKeys[readyLogKey] = true
883
+ warn(`[robloxstudio-mcp] /ready failed for {instanceId}/{readyRole}: {HttpDiagnostics.formatRequestFailure(readyUrl, readyOk, readyResult)}`)
828
884
  return nil
829
885
  end
830
- -- 409 = duplicate_instance_role. Surface in UI and stop polling.
831
- if readyResult.StatusCode == 409 then
832
- duplicateInstanceRole = true
833
- conn.isActive = false
834
- local ui = UI.getElements()
835
- if State.getActiveTabIndex() == 0 then
836
- ui.statusLabel.Text = "Duplicate instance"
837
- ui.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
838
- ui.detailStatusLabel.Text = "Another Studio is already connected as this place + role"
839
- ui.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
840
- end
841
- 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}`)
842
904
  return nil
843
905
  end
844
- if readyResult.Success then
845
- local parseOk, readyData = pcall(function()
846
- return HttpService:JSONDecode(readyResult.Body)
847
- end)
848
- local _value = parseOk and readyData.assignedRole
849
- if _value ~= "" and _value then
850
- assignedRole = readyData.assignedRole
851
- 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}`)
852
921
  end
853
922
  end)
854
923
  end
@@ -894,7 +963,7 @@ local function pollForRequests(connIndex)
894
963
  local warningKey = `{State.CURRENT_VERSION}:{serverVersion}`
895
964
  if lastVersionMismatchWarningKey ~= warningKey then
896
965
  lastVersionMismatchWarningKey = warningKey
897
- 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.`)
898
967
  end
899
968
  UI.showBanner("version-mismatch", `Plugin v{State.CURRENT_VERSION} / MCP v{serverVersion} mismatch`)
900
969
  elseif hasVersionMismatch then
@@ -1062,6 +1131,7 @@ local function activatePlugin(connIndex)
1062
1131
  UI.updateTabLabel(idx)
1063
1132
  UI.updateUIState()
1064
1133
  end
1134
+ ServerUrlSettings.rememberServerUrl(conn.serverUrl)
1065
1135
  UI.updateTabDot(idx)
1066
1136
  if not conn.heartbeatConnection then
1067
1137
  conn.heartbeatConnection = RunService.Heartbeat:Connect(function()
@@ -1292,9 +1362,9 @@ local function computeBridgeStamp()
1292
1362
  for i = 1, #combined do
1293
1363
  h = (h * 33 + (string.byte(combined, i))) % 2147483647
1294
1364
  end
1295
- -- "2.16.0" is replaced with the package version at package time
1365
+ -- "2.16.1" is replaced with the package version at package time
1296
1366
  -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1297
- return `{tostring(h)}-2.16.0`
1367
+ return `{tostring(h)}-2.16.1`
1298
1368
  end
1299
1369
  local BRIDGE_STAMP = computeBridgeStamp()
1300
1370
  local function setSource(scriptInst, source)
@@ -6674,7 +6744,7 @@ local function startPlaytest(requestData)
6674
6744
  return injectStopListener()
6675
6745
  end)
6676
6746
  if not injected then
6677
- warn(`[MCP] Failed to inject stop listener: {injErr}`)
6747
+ warn(`[robloxstudio-mcp] Failed to inject stop listener: {injErr}`)
6678
6748
  end
6679
6749
  task.spawn(function()
6680
6750
  local ok, result = pcall(function()
@@ -6702,17 +6772,17 @@ local function startPlaytest(requestData)
6702
6772
  return response
6703
6773
  end
6704
6774
  local function stopPlaytest(_requestData)
6705
- -- Signal the play-server DM's StopPlayMonitor via plugin:SetSetting (a
6706
- -- cross-DM persistent store). The monitor polls at 1Hz, sees the flag,
6707
- -- calls StudioTestService:EndTest, then resets the flag. We wait up to
6708
- -- 2.5s for the reset to confirm a play DM actually consumed the request,
6709
- -- which avoids returning success when nothing is running.
6710
- 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
6711
6780
  return {
6712
6781
  error = "Plugin not ready. Try again in a moment.",
6713
6782
  }
6714
6783
  end
6715
- if not StopPlayMonitor.waitForConsumption() then
6784
+ local consumption = StopPlayMonitor.waitForConsumption(stopRequest.requestId)
6785
+ if not consumption.ok then
6716
6786
  -- Two distinct failure modes collapse here, distinguished by whether
6717
6787
  -- THIS edit DM has a playtest tracked:
6718
6788
  --
@@ -6724,19 +6794,28 @@ local function stopPlaytest(_requestData)
6724
6794
  -- from the caller's perspective — playtest may actually have ended).
6725
6795
  -- Tell the caller it's a timing issue and they can retry.
6726
6796
  --
6727
- -- 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
6728
6798
  -- doesn't fire EndTest on startup against a stale signal.
6729
- StopPlayMonitor.clearPending()
6799
+ StopPlayMonitor.clearPending(stopRequest.requestId)
6730
6800
  if testRunning then
6731
6801
  return {
6732
- 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,
6733
6810
  }
6734
6811
  end
6735
6812
  return {
6736
6813
  error = "No active playtest to stop.",
6814
+ detail = consumption.error,
6737
6815
  }
6738
6816
  end
6739
- -- Flag was consumed (EndTest called). ExecutePlayModeAsync in our
6817
+ StopPlayMonitor.clearPending(stopRequest.requestId)
6818
+ -- Request was consumed (EndTest called). ExecutePlayModeAsync in our
6740
6819
  -- startPlaytest task.spawn is still unwinding though — testRunning stays
6741
6820
  -- true until that yield completes and the post-block runs. Wait so
6742
6821
  -- back-to-back stop -> start sequences don't race against the prior
@@ -7040,6 +7119,87 @@ return {
7040
7119
  </Item>
7041
7120
  </Item>
7042
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">
7043
7203
  <Properties>
7044
7204
  <string name="Name">LuauExec</string>
7045
7205
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7473,7 +7633,7 @@ return {
7473
7633
  ]]></string>
7474
7634
  </Properties>
7475
7635
  </Item>
7476
- <Item class="ModuleScript" referent="22">
7636
+ <Item class="ModuleScript" referent="23">
7477
7637
  <Properties>
7478
7638
  <string name="Name">Recording</string>
7479
7639
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7503,7 +7663,7 @@ return {
7503
7663
  ]]></string>
7504
7664
  </Properties>
7505
7665
  </Item>
7506
- <Item class="ModuleScript" referent="23">
7666
+ <Item class="ModuleScript" referent="24">
7507
7667
  <Properties>
7508
7668
  <string name="Name">RenderMonitor</string>
7509
7669
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7571,7 +7731,7 @@ return {
7571
7731
  ]]></string>
7572
7732
  </Properties>
7573
7733
  </Item>
7574
- <Item class="ModuleScript" referent="24">
7734
+ <Item class="ModuleScript" referent="25">
7575
7735
  <Properties>
7576
7736
  <string name="Name">RuntimeLogBuffer</string>
7577
7737
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7752,11 +7912,71 @@ return {
7752
7912
  ]]></string>
7753
7913
  </Properties>
7754
7914
  </Item>
7755
- <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">
7756
7976
  <Properties>
7757
7977
  <string name="Name">State</string>
7758
7978
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
7759
- local CURRENT_VERSION = "2.16.0"
7979
+ local CURRENT_VERSION = "2.16.1"
7760
7980
  local PLUGIN_VARIANT = "main"
7761
7981
  local MAX_CONNECTIONS = 5
7762
7982
  local BASE_PORT = 58741
@@ -7850,7 +8070,7 @@ return {
7850
8070
  ]]></string>
7851
8071
  </Properties>
7852
8072
  </Item>
7853
- <Item class="ModuleScript" referent="26">
8073
+ <Item class="ModuleScript" referent="28">
7854
8074
  <Properties>
7855
8075
  <string name="Name">StopPlayMonitor</string>
7856
8076
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7862,16 +8082,16 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
7862
8082
  -- `plugin:SetSetting` / `plugin:GetSetting` is a per-plugin persistent store
7863
8083
  -- shared across every DataModel the plugin runs in (edit DMs, play-server
7864
8084
  -- DMs, play-client DMs). For each connected place we use a dedicated key
7865
- -- "MCP_STOP_PLAY_<instanceId>" as a single-bit mailbox:
8085
+ -- "MCP_STOP_PLAY_<instanceId>" as a tiny request/result mailbox:
7866
8086
  --
7867
- -- * 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
7868
8088
  -- (computed from its placeId / ServerStorage anon UUID).
7869
8089
  -- * Each play-server DM's monitor loop polls the key matching its own
7870
- -- instanceId at 0.1Hz; on `true` it clears the key and calls
7871
- -- StudioTestService:EndTest. Play-server DMs for other places never
7872
- -- touch this key.
7873
- -- * The edit DM waits up to ~8s for its key to be cleared, confirming a
7874
- -- 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.
7875
8095
  --
7876
8096
  -- Earlier versions used a single shared boolean flag, which let any
7877
8097
  -- play-server DM in the same Studio process consume any place's stop
@@ -7879,20 +8099,24 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
7879
8099
  -- below is the fix.
7880
8100
  local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
7881
8101
  local HttpService = _services.HttpService
8102
+ local RunService = _services.RunService
7882
8103
  local ServerStorage = _services.ServerStorage
7883
8104
  local StudioTestService = game:GetService("StudioTestService")
7884
8105
  local SETTING_KEY_PREFIX = "MCP_STOP_PLAY_"
7885
- -- Monitor checks the key at this cadence. 0.1s keeps worst-case detection
7886
- -- lag tight so the consumption-confirmation window doesn't have to absorb
7887
- -- polling jitter on top of EndTest's teardown time.
7888
- 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
7889
8110
  -- Total time we wait for the matching play-server DM to consume the
7890
8111
  -- signal. Must cover: monitor detection (<= POLL_INTERVAL_SEC) +
7891
8112
  -- StudioTestService:EndTest teardown (several seconds on heavier places).
7892
- -- 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.
7893
8115
  local WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 8.0
7894
8116
  local WAIT_POLL_SEC = 0.1
8117
+ local REQUEST_TTL_SEC = 12.0
7895
8118
  local pluginRef
8119
+ local endTestIssued = false
7896
8120
  local function init(p)
7897
8121
  pluginRef = p
7898
8122
  end
@@ -7918,32 +8142,125 @@ end
7918
8142
  local function settingKey(instanceId)
7919
8143
  return SETTING_KEY_PREFIX .. instanceId
7920
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
7921
8246
  local function startMonitor()
7922
8247
  if not pluginRef then
7923
- warn("[MCP] StopPlayMonitor.startMonitor called before init; skipping")
8248
+ warn("[robloxstudio-mcp] StopPlayMonitor.startMonitor called before init; skipping")
7924
8249
  return nil
7925
8250
  end
7926
8251
  local myKey = settingKey(computeInstanceId())
7927
- -- Clear any stale value left from a prior session. If a real stop
7928
- -- request is in-flight when this runs, the requesting edit DM will
7929
- -- write again within its consumption-confirmation window.
7930
- pcall(function()
7931
- return pluginRef:SetSetting(myKey, false)
7932
- end)
7933
8252
  task.spawn(function()
7934
8253
  while true do
7935
- local okGet, val = pcall(function()
7936
- return pluginRef:GetSetting(myKey)
7937
- end)
7938
- if okGet and val == true then
7939
- -- Consume the flag first so requestStop's
7940
- -- waitForConsumption returns success, then end the test.
7941
- pcall(function()
7942
- return pluginRef:SetSetting(myKey, false)
7943
- end)
7944
- pcall(function()
7945
- return StudioTestService:EndTest("stopped_by_mcp")
7946
- 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
7947
8264
  end
7948
8265
  task.wait(POLL_INTERVAL_SEC)
7949
8266
  end
@@ -7951,39 +8268,61 @@ local function startMonitor()
7951
8268
  end
7952
8269
  local function requestStop()
7953
8270
  if not pluginRef then
7954
- return false
8271
+ return {
8272
+ ok = false,
8273
+ }
7955
8274
  end
7956
8275
  local myKey = settingKey(computeInstanceId())
7957
- local ok = pcall(function()
7958
- return pluginRef:SetSetting(myKey, true)
7959
- end)
7960
- 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
+ }
7961
8286
  end
7962
- local function waitForConsumption()
8287
+ local function waitForConsumption(requestId)
7963
8288
  if not pluginRef then
7964
- return false
8289
+ return {
8290
+ ok = false,
8291
+ consumed = false,
8292
+ error = "Plugin reference is not initialized.",
8293
+ }
7965
8294
  end
7966
8295
  local myKey = settingKey(computeInstanceId())
7967
8296
  local start = tick()
7968
8297
  while tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC do
7969
- local okGet, val = pcall(function()
7970
- return pluginRef:GetSetting(myKey)
7971
- end)
7972
- if okGet and val ~= true then
7973
- 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
+ }
7974
8305
  end
7975
8306
  task.wait(WAIT_POLL_SEC)
7976
8307
  end
7977
- 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
+ }
7978
8313
  end
7979
- local function clearPending()
8314
+ local function clearPending(requestId)
7980
8315
  if not pluginRef then
7981
8316
  return nil
7982
8317
  end
7983
8318
  local myKey = settingKey(computeInstanceId())
7984
- pcall(function()
7985
- return pluginRef:SetSetting(myKey, false)
7986
- 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)
7987
8326
  end
7988
8327
  return {
7989
8328
  init = init,
@@ -7995,7 +8334,7 @@ return {
7995
8334
  ]]></string>
7996
8335
  </Properties>
7997
8336
  </Item>
7998
- <Item class="ModuleScript" referent="27">
8337
+ <Item class="ModuleScript" referent="29">
7999
8338
  <Properties>
8000
8339
  <string name="Name">UI</string>
8001
8340
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -8766,7 +9105,7 @@ return {
8766
9105
  ]]></string>
8767
9106
  </Properties>
8768
9107
  </Item>
8769
- <Item class="ModuleScript" referent="28">
9108
+ <Item class="ModuleScript" referent="30">
8770
9109
  <Properties>
8771
9110
  <string name="Name">Utils</string>
8772
9111
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9296,11 +9635,11 @@ return {
9296
9635
  </Properties>
9297
9636
  </Item>
9298
9637
  </Item>
9299
- <Item class="Folder" referent="32">
9638
+ <Item class="Folder" referent="34">
9300
9639
  <Properties>
9301
9640
  <string name="Name">include</string>
9302
9641
  </Properties>
9303
- <Item class="ModuleScript" referent="29">
9642
+ <Item class="ModuleScript" referent="31">
9304
9643
  <Properties>
9305
9644
  <string name="Name">Promise</string>
9306
9645
  <string name="Source"><![CDATA[--[[
@@ -11374,7 +11713,7 @@ return Promise
11374
11713
  ]]></string>
11375
11714
  </Properties>
11376
11715
  </Item>
11377
- <Item class="ModuleScript" referent="30">
11716
+ <Item class="ModuleScript" referent="32">
11378
11717
  <Properties>
11379
11718
  <string name="Name">RuntimeLib</string>
11380
11719
  <string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
@@ -11641,15 +11980,15 @@ return TS
11641
11980
  </Properties>
11642
11981
  </Item>
11643
11982
  </Item>
11644
- <Item class="Folder" referent="33">
11983
+ <Item class="Folder" referent="35">
11645
11984
  <Properties>
11646
11985
  <string name="Name">node_modules</string>
11647
11986
  </Properties>
11648
- <Item class="Folder" referent="34">
11987
+ <Item class="Folder" referent="36">
11649
11988
  <Properties>
11650
11989
  <string name="Name">@rbxts</string>
11651
11990
  </Properties>
11652
- <Item class="ModuleScript" referent="31">
11991
+ <Item class="ModuleScript" referent="33">
11653
11992
  <Properties>
11654
11993
  <string name="Name">services</string>
11655
11994
  <string name="Source"><![CDATA[return setmetatable({}, {