@chrrxs/robloxstudio-mcp 2.16.0 → 2.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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://125921838360800"
@@ -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).
@@ -612,20 +662,24 @@ local function computeInstanceId()
612
662
  end)
613
663
  return `anon:{fresh}`
614
664
  end
615
- local instanceId = computeInstanceId()
616
665
  local assignedRole
617
666
  local duplicateInstanceRole = false
618
667
  local hasVersionMismatch = false
619
668
  local lastVersionMismatchWarningKey
669
+ local lastReadyInstanceId
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
623
674
  -- once per plugin load; the published name doesn't change mid-session.
624
675
  local cachedPlaceName
676
+ local cachedPlaceNamePlaceId
625
677
  local function resolvePlaceName()
626
- if cachedPlaceName ~= nil then
678
+ if cachedPlaceName ~= nil and cachedPlaceNamePlaceId == game.PlaceId then
627
679
  return cachedPlaceName
628
680
  end
681
+ cachedPlaceName = nil
682
+ cachedPlaceNamePlaceId = game.PlaceId
629
683
  if game.PlaceId == 0 then
630
684
  cachedPlaceName = game.Name
631
685
  return cachedPlaceName
@@ -769,28 +823,36 @@ end
769
823
  -- Without this, every poll during the brief window where the server has just
770
824
  -- restarted but hasn't seen our re-ready yet would fire a duplicate /ready.
771
825
  local lastReadyPostAt = 0
772
- -- game.Name is sometimes "Place1" at plugin-load time and only settles to
773
- -- the real DataModel name (e.g. "Game" once playtest spawns the play DM)
774
- -- after Studio finishes wiring things up. Re-fire /ready when it changes so
775
- -- get_connected_instances doesn't show a stale dataModelName forever. Set
776
- -- up once per plugin load — the connection passed in is whichever was
777
- -- active when activatePlugin was first called.
826
+ -- game.Name and game.PlaceId can both settle after plugin load. PlaceId also
827
+ -- changes when an unpublished file is published while MCP is already active.
828
+ -- Re-fire /ready so the bridge can migrate anon:<uuid> to place:<PlaceId>.
778
829
  local nameChangeConn
830
+ local placeIdChangeConn
779
831
  local sendReady
780
- local function ensureNameChangeWatcher(conn)
781
- if nameChangeConn then
782
- return nil
832
+ local function ensureIdentityWatcher(conn)
833
+ if not nameChangeConn then
834
+ local okSig, signal = pcall(function()
835
+ return game:GetPropertyChangedSignal("Name")
836
+ end)
837
+ if okSig and signal then
838
+ nameChangeConn = signal:Connect(function()
839
+ -- sendReady has its own 2s throttle, so rapid burst changes coalesce.
840
+ sendReady(conn)
841
+ end)
842
+ end
783
843
  end
784
- local okSig, signal = pcall(function()
785
- return game:GetPropertyChangedSignal("Name")
786
- end)
787
- if not okSig or not signal then
788
- return nil
844
+ if not placeIdChangeConn then
845
+ local okSig, signal = pcall(function()
846
+ return game:GetPropertyChangedSignal("PlaceId")
847
+ end)
848
+ if okSig and signal then
849
+ placeIdChangeConn = signal:Connect(function()
850
+ cachedPlaceName = nil
851
+ cachedPlaceNamePlaceId = nil
852
+ sendReady(conn)
853
+ end)
854
+ end
789
855
  end
790
- nameChangeConn = signal:Connect(function()
791
- -- sendReady has its own 2s throttle, so rapid burst changes coalesce.
792
- sendReady(conn)
793
- end)
794
856
  end
795
857
  function sendReady(conn)
796
858
  if duplicateInstanceRole then
@@ -801,6 +863,7 @@ function sendReady(conn)
801
863
  return nil
802
864
  end
803
865
  lastReadyPostAt = now
866
+ local instanceId = computeInstanceId()
804
867
  task.spawn(function()
805
868
  local readyOk, readyResult = pcall(function()
806
869
  return HttpService:RequestAsync({
@@ -824,32 +887,59 @@ function sendReady(conn)
824
887
  }),
825
888
  })
826
889
  end)
890
+ local readyUrl = `{conn.serverUrl}/ready`
891
+ local readyRole = detectRole()
892
+ local readyLogKey = `{conn.serverUrl}|{instanceId}|{readyRole}`
827
893
  if not readyOk then
894
+ readyFailureLogKeys[readyLogKey] = true
895
+ warn(`[robloxstudio-mcp] /ready failed for {instanceId}/{readyRole}: {HttpDiagnostics.formatRequestFailure(readyUrl, readyOk, readyResult)}`)
828
896
  return nil
829
897
  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.`)
898
+ if not readyResult.Success then
899
+ local reason = HttpDiagnostics.formatRequestFailure(readyUrl, true, readyResult)
900
+ readyFailureLogKeys[readyLogKey] = true
901
+ -- 409 = duplicate_instance_role. Surface in UI and stop polling.
902
+ if readyResult.StatusCode == 409 then
903
+ duplicateInstanceRole = true
904
+ conn.isActive = false
905
+ local ui = UI.getElements()
906
+ if State.getActiveTabIndex() == 0 then
907
+ ui.statusLabel.Text = "Duplicate instance"
908
+ ui.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
909
+ ui.detailStatusLabel.Text = reason
910
+ ui.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
911
+ end
912
+ warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{readyRole}: {reason}`)
913
+ return nil
914
+ end
915
+ warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{readyRole}: {reason}`)
842
916
  return nil
843
917
  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
918
+ local parseOk, readyData = pcall(function()
919
+ return HttpService:JSONDecode(readyResult.Body)
920
+ end)
921
+ local _value = parseOk and readyData.assignedRole
922
+ if _value ~= "" and _value then
923
+ assignedRole = readyData.assignedRole
924
+ end
925
+ local _condition = parseOk
926
+ if _condition then
927
+ local _instanceId = readyData.instanceId
928
+ _condition = type(_instanceId) == "string"
929
+ if _condition then
930
+ _condition = readyData.instanceId ~= ""
851
931
  end
852
932
  end
933
+ lastReadyInstanceId = if _condition then readyData.instanceId else instanceId
934
+ local _condition_1 = assignedRole
935
+ if _condition_1 == nil then
936
+ _condition_1 = detectRole()
937
+ end
938
+ local connectedRole = _condition_1
939
+ if readyFailureLogKeys[readyLogKey] ~= nil then
940
+ readyFailureLogKeys[readyLogKey] = nil
941
+ print(`[robloxstudio-mcp] /ready connected for {instanceId}/{connectedRole} via {conn.serverUrl}`)
942
+ end
853
943
  end)
854
944
  end
855
945
  local function pollForRequests(connIndex)
@@ -894,7 +984,7 @@ local function pollForRequests(connIndex)
894
984
  local warningKey = `{State.CURRENT_VERSION}:{serverVersion}`
895
985
  if lastVersionMismatchWarningKey ~= warningKey then
896
986
  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.`)
987
+ 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
988
  end
899
989
  UI.showBanner("version-mismatch", `Plugin v{State.CURRENT_VERSION} / MCP v{serverVersion} mismatch`)
900
990
  elseif hasVersionMismatch then
@@ -1062,10 +1152,17 @@ local function activatePlugin(connIndex)
1062
1152
  UI.updateTabLabel(idx)
1063
1153
  UI.updateUIState()
1064
1154
  end
1155
+ ServerUrlSettings.rememberServerUrl(conn.serverUrl)
1065
1156
  UI.updateTabDot(idx)
1066
1157
  if not conn.heartbeatConnection then
1067
1158
  conn.heartbeatConnection = RunService.Heartbeat:Connect(function()
1068
1159
  local now = tick()
1160
+ local currentInstanceId = computeInstanceId()
1161
+ if lastReadyInstanceId ~= nil and currentInstanceId ~= lastReadyInstanceId then
1162
+ cachedPlaceName = nil
1163
+ cachedPlaceNamePlaceId = nil
1164
+ sendReady(conn)
1165
+ end
1069
1166
  local currentInterval = if conn.consecutiveFailures > 5 then conn.currentRetryDelay else conn.pollInterval
1070
1167
  if now - conn.lastPoll > currentInterval then
1071
1168
  conn.lastPoll = now
@@ -1081,9 +1178,8 @@ local function activatePlugin(connIndex)
1081
1178
  if not RunService:IsRunning() then
1082
1179
  task.spawn(cleanupLegacyEditBridges)
1083
1180
  end
1084
- -- Watch for game.Name updates so a stale "Place1" captured at first
1085
- -- /ready gets refreshed once Studio settles on the real DM name.
1086
- ensureNameChangeWatcher(conn)
1181
+ -- Watch identity fields so stale name or anon instance ids are refreshed.
1182
+ ensureIdentityWatcher(conn)
1087
1183
  end
1088
1184
  local function deactivatePlugin(connIndex)
1089
1185
  local _condition = connIndex
@@ -1292,9 +1388,9 @@ local function computeBridgeStamp()
1292
1388
  for i = 1, #combined do
1293
1389
  h = (h * 33 + (string.byte(combined, i))) % 2147483647
1294
1390
  end
1295
- -- "2.16.0" is replaced with the package version at package time
1391
+ -- "2.16.2" is replaced with the package version at package time
1296
1392
  -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1297
- return `{tostring(h)}-2.16.0`
1393
+ return `{tostring(h)}-2.16.2`
1298
1394
  end
1299
1395
  local BRIDGE_STAMP = computeBridgeStamp()
1300
1396
  local function setSource(scriptInst, source)
@@ -6674,7 +6770,7 @@ local function startPlaytest(requestData)
6674
6770
  return injectStopListener()
6675
6771
  end)
6676
6772
  if not injected then
6677
- warn(`[MCP] Failed to inject stop listener: {injErr}`)
6773
+ warn(`[robloxstudio-mcp] Failed to inject stop listener: {injErr}`)
6678
6774
  end
6679
6775
  task.spawn(function()
6680
6776
  local ok, result = pcall(function()
@@ -6702,17 +6798,17 @@ local function startPlaytest(requestData)
6702
6798
  return response
6703
6799
  end
6704
6800
  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
6801
+ -- Signal the play-server DM's StopPlayMonitor via plugin:SetSetting.
6802
+ -- The monitor acknowledges with the matching request id only after its
6803
+ -- StudioTestService:EndTest call returns from pcall.
6804
+ local stopRequest = StopPlayMonitor.requestStop()
6805
+ if not stopRequest.ok or stopRequest.requestId == nil then
6711
6806
  return {
6712
6807
  error = "Plugin not ready. Try again in a moment.",
6713
6808
  }
6714
6809
  end
6715
- if not StopPlayMonitor.waitForConsumption() then
6810
+ local consumption = StopPlayMonitor.waitForConsumption(stopRequest.requestId)
6811
+ if not consumption.ok then
6716
6812
  -- Two distinct failure modes collapse here, distinguished by whether
6717
6813
  -- THIS edit DM has a playtest tracked:
6718
6814
  --
@@ -6724,19 +6820,28 @@ local function stopPlaytest(_requestData)
6724
6820
  -- from the caller's perspective — playtest may actually have ended).
6725
6821
  -- Tell the caller it's a timing issue and they can retry.
6726
6822
  --
6727
- -- Either way clean up the pending flag so a future playtest's monitor
6823
+ -- Either way clean up the pending request so a future playtest's monitor
6728
6824
  -- doesn't fire EndTest on startup against a stale signal.
6729
- StopPlayMonitor.clearPending()
6825
+ StopPlayMonitor.clearPending(stopRequest.requestId)
6730
6826
  if testRunning then
6731
6827
  return {
6732
- error = "Playtest stop signal sent but consumption confirmation timed out. " .. "The playtest may have ended anyway; check get_connected_instances.",
6828
+ error = "Playtest stop signal failed or was not acknowledged. " .. "The playtest may have ended anyway; check get_connected_instances.",
6829
+ detail = consumption.error,
6830
+ }
6831
+ end
6832
+ if consumption.consumed then
6833
+ return {
6834
+ error = "Playtest stop request reached the play server, but EndTest failed.",
6835
+ detail = consumption.error,
6733
6836
  }
6734
6837
  end
6735
6838
  return {
6736
6839
  error = "No active playtest to stop.",
6840
+ detail = consumption.error,
6737
6841
  }
6738
6842
  end
6739
- -- Flag was consumed (EndTest called). ExecutePlayModeAsync in our
6843
+ StopPlayMonitor.clearPending(stopRequest.requestId)
6844
+ -- Request was consumed (EndTest called). ExecutePlayModeAsync in our
6740
6845
  -- startPlaytest task.spawn is still unwinding though — testRunning stays
6741
6846
  -- true until that yield completes and the post-block runs. Wait so
6742
6847
  -- back-to-back stop -> start sequences don't race against the prior
@@ -7040,6 +7145,87 @@ return {
7040
7145
  </Item>
7041
7146
  </Item>
7042
7147
  <Item class="ModuleScript" referent="21">
7148
+ <Properties>
7149
+ <string name="Name">HttpDiagnostics</string>
7150
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
7151
+ local TS = require(script.Parent.Parent.include.RuntimeLib)
7152
+ local HttpService = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services").HttpService
7153
+ local function encodeForLog(value)
7154
+ local ok, encoded = pcall(function()
7155
+ return HttpService:JSONEncode(value)
7156
+ end)
7157
+ return if ok then encoded else tostring(value)
7158
+ end
7159
+ local function formatBody(body)
7160
+ if body == "" then
7161
+ return ""
7162
+ end
7163
+ local ok, decoded = pcall(function()
7164
+ return HttpService:JSONDecode(body)
7165
+ end)
7166
+ if ok and type(decoded) == "table" then
7167
+ local data = decoded
7168
+ local parts = {}
7169
+ local _error = data.error
7170
+ local _condition = type(_error) == "string"
7171
+ if _condition then
7172
+ _condition = data.error ~= ""
7173
+ end
7174
+ if _condition then
7175
+ local _arg0 = `error={data.error}`
7176
+ table.insert(parts, _arg0)
7177
+ end
7178
+ local _message = data.message
7179
+ local _condition_1 = type(_message) == "string"
7180
+ if _condition_1 then
7181
+ _condition_1 = data.message ~= ""
7182
+ end
7183
+ if _condition_1 then
7184
+ local _arg0 = `message={data.message}`
7185
+ table.insert(parts, _arg0)
7186
+ end
7187
+ if data.missingFields ~= nil then
7188
+ local _arg0 = `missingFields={encodeForLog(data.missingFields)}`
7189
+ table.insert(parts, _arg0)
7190
+ end
7191
+ if data.request ~= nil then
7192
+ local _arg0 = `request={encodeForLog(data.request)}`
7193
+ table.insert(parts, _arg0)
7194
+ end
7195
+ if data.existing ~= nil then
7196
+ local _arg0 = `existing={encodeForLog(data.existing)}`
7197
+ table.insert(parts, _arg0)
7198
+ end
7199
+ if data.details ~= nil then
7200
+ local _arg0 = `details={encodeForLog(data.details)}`
7201
+ table.insert(parts, _arg0)
7202
+ end
7203
+ if #parts > 0 then
7204
+ return table.concat(parts, " ")
7205
+ end
7206
+ end
7207
+ return `body={body}`
7208
+ end
7209
+ local function formatRequestFailure(url, ok, res)
7210
+ if not ok then
7211
+ return `RequestAsync threw for {url}: {tostring(res)}`
7212
+ end
7213
+ if res == nil then
7214
+ return `RequestAsync returned no response for {url}`
7215
+ end
7216
+ local response = res
7217
+ local statusMessage = if response.StatusMessage ~= "" then ` {response.StatusMessage}` else ""
7218
+ local body = formatBody(response.Body)
7219
+ local suffix = if body ~= "" then `: {body}` else ""
7220
+ return `HTTP {response.StatusCode}{statusMessage} from {url}{suffix}`
7221
+ end
7222
+ return {
7223
+ formatRequestFailure = formatRequestFailure,
7224
+ }
7225
+ ]]></string>
7226
+ </Properties>
7227
+ </Item>
7228
+ <Item class="ModuleScript" referent="22">
7043
7229
  <Properties>
7044
7230
  <string name="Name">LuauExec</string>
7045
7231
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7473,7 +7659,7 @@ return {
7473
7659
  ]]></string>
7474
7660
  </Properties>
7475
7661
  </Item>
7476
- <Item class="ModuleScript" referent="22">
7662
+ <Item class="ModuleScript" referent="23">
7477
7663
  <Properties>
7478
7664
  <string name="Name">Recording</string>
7479
7665
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7503,7 +7689,7 @@ return {
7503
7689
  ]]></string>
7504
7690
  </Properties>
7505
7691
  </Item>
7506
- <Item class="ModuleScript" referent="23">
7692
+ <Item class="ModuleScript" referent="24">
7507
7693
  <Properties>
7508
7694
  <string name="Name">RenderMonitor</string>
7509
7695
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7571,7 +7757,7 @@ return {
7571
7757
  ]]></string>
7572
7758
  </Properties>
7573
7759
  </Item>
7574
- <Item class="ModuleScript" referent="24">
7760
+ <Item class="ModuleScript" referent="25">
7575
7761
  <Properties>
7576
7762
  <string name="Name">RuntimeLogBuffer</string>
7577
7763
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7752,11 +7938,87 @@ return {
7752
7938
  ]]></string>
7753
7939
  </Properties>
7754
7940
  </Item>
7755
- <Item class="ModuleScript" referent="25">
7941
+ <Item class="ModuleScript" referent="26">
7942
+ <Properties>
7943
+ <string name="Name">ServerUrlSettings</string>
7944
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
7945
+ local TS = require(script.Parent.Parent.include.RuntimeLib)
7946
+ local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
7947
+ local HttpService = _services.HttpService
7948
+ local ServerStorage = _services.ServerStorage
7949
+ local SETTING_KEY_PREFIX = "MCP_SERVER_URL_"
7950
+ local pluginRef
7951
+ local function init(p)
7952
+ pluginRef = p
7953
+ end
7954
+ local function addUnique(values, value)
7955
+ local _values = values
7956
+ local _value = value
7957
+ if not (table.find(_values, _value) ~= nil) then
7958
+ local _values_1 = values
7959
+ local _value_1 = value
7960
+ table.insert(_values_1, _value_1)
7961
+ end
7962
+ end
7963
+ local function computeInstanceIds()
7964
+ local ids = {}
7965
+ if game.PlaceId ~= 0 then
7966
+ addUnique(ids, `place:{tostring(game.PlaceId)}`)
7967
+ end
7968
+ local existing = ServerStorage:GetAttribute("__MCPPlaceId")
7969
+ if type(existing) == "string" and existing ~= "" then
7970
+ addUnique(ids, `anon:{existing}`)
7971
+ elseif game.PlaceId == 0 then
7972
+ local fresh = HttpService:GenerateGUID(false)
7973
+ pcall(function()
7974
+ return ServerStorage:SetAttribute("__MCPPlaceId", fresh)
7975
+ end)
7976
+ addUnique(ids, `anon:{fresh}`)
7977
+ end
7978
+ return ids
7979
+ end
7980
+ local function settingKey(instanceId)
7981
+ return SETTING_KEY_PREFIX .. instanceId
7982
+ end
7983
+ local function rememberServerUrl(serverUrl)
7984
+ if not pluginRef or serverUrl == "" then
7985
+ return nil
7986
+ end
7987
+ for _, instanceId in computeInstanceIds() do
7988
+ local key = settingKey(instanceId)
7989
+ pcall(function()
7990
+ return pluginRef:SetSetting(key, serverUrl)
7991
+ end)
7992
+ end
7993
+ end
7994
+ local function readServerUrl()
7995
+ if not pluginRef then
7996
+ return nil
7997
+ end
7998
+ for _, instanceId in computeInstanceIds() do
7999
+ local key = settingKey(instanceId)
8000
+ local ok, value = pcall(function()
8001
+ return pluginRef:GetSetting(key)
8002
+ end)
8003
+ if ok and type(value) == "string" and value ~= "" then
8004
+ return value
8005
+ end
8006
+ end
8007
+ return nil
8008
+ end
8009
+ return {
8010
+ init = init,
8011
+ rememberServerUrl = rememberServerUrl,
8012
+ readServerUrl = readServerUrl,
8013
+ }
8014
+ ]]></string>
8015
+ </Properties>
8016
+ </Item>
8017
+ <Item class="ModuleScript" referent="27">
7756
8018
  <Properties>
7757
8019
  <string name="Name">State</string>
7758
8020
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
7759
- local CURRENT_VERSION = "2.16.0"
8021
+ local CURRENT_VERSION = "2.16.2"
7760
8022
  local PLUGIN_VARIANT = "inspector"
7761
8023
  local MAX_CONNECTIONS = 5
7762
8024
  local BASE_PORT = 58741
@@ -7850,7 +8112,7 @@ return {
7850
8112
  ]]></string>
7851
8113
  </Properties>
7852
8114
  </Item>
7853
- <Item class="ModuleScript" referent="26">
8115
+ <Item class="ModuleScript" referent="28">
7854
8116
  <Properties>
7855
8117
  <string name="Name">StopPlayMonitor</string>
7856
8118
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7858,20 +8120,23 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
7858
8120
  -- Cross-DM stop_playtest signaling via plugin:SetSetting, scoped by
7859
8121
  -- per-instance setting key so the same Studio process can host playtests
7860
8122
  -- for multiple places without one place's stop_playtest yanking another's.
8123
+ -- During publish-after-connect, both "anon:<uuid>" and "place:<PlaceId>"
8124
+ -- can refer to the same Studio place, so stop requests are mirrored across
8125
+ -- both keys while the monitor waits for a matching result on either key.
7861
8126
  --
7862
8127
  -- `plugin:SetSetting` / `plugin:GetSetting` is a per-plugin persistent store
7863
8128
  -- shared across every DataModel the plugin runs in (edit DMs, play-server
7864
8129
  -- DMs, play-client DMs). For each connected place we use a dedicated key
7865
- -- "MCP_STOP_PLAY_<instanceId>" as a single-bit mailbox:
8130
+ -- "MCP_STOP_PLAY_<instanceId>" as a tiny request/result mailbox:
7866
8131
  --
7867
- -- * The edit DM's stopPlaytest handler writes `true` into its own key
8132
+ -- * The edit DM's handler writes a tokenized stop request into its own key
7868
8133
  -- (computed from its placeId / ServerStorage anon UUID).
7869
8134
  -- * 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.
8135
+ -- instanceId at 1Hz. On a fresh token, it calls StudioTestService:EndTest
8136
+ -- and writes a matching result token. Play-server DMs for other places
8137
+ -- never touch this key.
8138
+ -- * The edit DM waits up to ~8s for its result token, confirming a matching
8139
+ -- play-server actually consumed the request.
7875
8140
  --
7876
8141
  -- Earlier versions used a single shared boolean flag, which let any
7877
8142
  -- play-server DM in the same Studio process consume any place's stop
@@ -7879,20 +8144,24 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
7879
8144
  -- below is the fix.
7880
8145
  local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
7881
8146
  local HttpService = _services.HttpService
8147
+ local RunService = _services.RunService
7882
8148
  local ServerStorage = _services.ServerStorage
7883
8149
  local StudioTestService = game:GetService("StudioTestService")
7884
8150
  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
8151
+ -- Keep this conservative. plugin:GetSetting is backed by Studio's plugin
8152
+ -- settings store, and this monitor runs during every play session, including
8153
+ -- manually-started Play. The official reference implementation polls at 1s.
8154
+ local POLL_INTERVAL_SEC = 1
7889
8155
  -- Total time we wait for the matching play-server DM to consume the
7890
8156
  -- signal. Must cover: monitor detection (<= POLL_INTERVAL_SEC) +
7891
8157
  -- StudioTestService:EndTest teardown (several seconds on heavier places).
7892
- -- 8s is comfortable; the tighter poll above keeps real cases well under.
8158
+ -- 8s is intentionally shorter than the MCP request timeout but long enough
8159
+ -- for the 1s monitor cadence plus ordinary Studio teardown latency.
7893
8160
  local WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 8.0
7894
8161
  local WAIT_POLL_SEC = 0.1
8162
+ local REQUEST_TTL_SEC = 12.0
7895
8163
  local pluginRef
8164
+ local endTestIssued = false
7896
8165
  local function init(p)
7897
8166
  pluginRef = p
7898
8167
  end
@@ -7901,49 +8170,168 @@ end
7901
8170
  -- agree on the place identifier (published places: placeId; unpublished:
7902
8171
  -- UUID on ServerStorage's __MCPPlaceId attribute, travels with the .rbxl
7903
8172
  -- into the play DM).
7904
- local function computeInstanceId()
8173
+ local function addUnique(values, value)
8174
+ local _values = values
8175
+ local _value = value
8176
+ if not (table.find(_values, _value) ~= nil) then
8177
+ local _values_1 = values
8178
+ local _value_1 = value
8179
+ table.insert(_values_1, _value_1)
8180
+ end
8181
+ end
8182
+ local function computeInstanceIds()
8183
+ local ids = {}
7905
8184
  if game.PlaceId ~= 0 then
7906
- return `place:{tostring(game.PlaceId)}`
8185
+ addUnique(ids, `place:{tostring(game.PlaceId)}`)
7907
8186
  end
7908
8187
  local existing = ServerStorage:GetAttribute("__MCPPlaceId")
7909
8188
  if type(existing) == "string" and existing ~= "" then
7910
- return `anon:{existing}`
8189
+ addUnique(ids, `anon:{existing}`)
8190
+ elseif game.PlaceId == 0 then
8191
+ local fresh = HttpService:GenerateGUID(false)
8192
+ pcall(function()
8193
+ return ServerStorage:SetAttribute("__MCPPlaceId", fresh)
8194
+ end)
8195
+ addUnique(ids, `anon:{fresh}`)
7911
8196
  end
7912
- local fresh = HttpService:GenerateGUID(false)
7913
- pcall(function()
7914
- return ServerStorage:SetAttribute("__MCPPlaceId", fresh)
7915
- end)
7916
- return `anon:{fresh}`
8197
+ return ids
7917
8198
  end
7918
8199
  local function settingKey(instanceId)
7919
8200
  return SETTING_KEY_PREFIX .. instanceId
7920
8201
  end
7921
- local function startMonitor()
8202
+ local function settingKeys()
8203
+ local _exp = computeInstanceIds()
8204
+ -- ▼ ReadonlyArray.map ▼
8205
+ local _newValue = table.create(#_exp)
8206
+ local _callback = function(instanceId)
8207
+ return settingKey(instanceId)
8208
+ end
8209
+ for _k, _v in _exp do
8210
+ _newValue[_k] = _callback(_v, _k - 1, _exp)
8211
+ end
8212
+ -- ▲ ReadonlyArray.map ▲
8213
+ return _newValue
8214
+ end
8215
+ local function readSetting(key)
7922
8216
  if not pluginRef then
7923
- warn("[MCP] StopPlayMonitor.startMonitor called before init; skipping")
7924
8217
  return nil
7925
8218
  end
7926
- 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)
8219
+ local ok, value = pcall(function()
8220
+ return pluginRef:GetSetting(key)
8221
+ end)
8222
+ return if ok then value else nil
8223
+ end
8224
+ local function writeSetting(key, value)
8225
+ if not pluginRef then
8226
+ return false
8227
+ end
8228
+ local ok = pcall(function()
8229
+ return pluginRef:SetSetting(key, value)
7932
8230
  end)
8231
+ return ok
8232
+ end
8233
+ local function decodePayload(value)
8234
+ local decoded = value
8235
+ local _value = value
8236
+ if type(_value) == "string" then
8237
+ local ok, result = pcall(function()
8238
+ return HttpService:JSONDecode(value)
8239
+ end)
8240
+ if not ok then
8241
+ return nil
8242
+ end
8243
+ decoded = result
8244
+ end
8245
+ local _decoded = decoded
8246
+ if not (type(_decoded) == "table") then
8247
+ return nil
8248
+ end
8249
+ local payload = decoded
8250
+ local _kind = payload.kind
8251
+ local _condition = not (type(_kind) == "string")
8252
+ if not _condition then
8253
+ local _id = payload.id
8254
+ _condition = not (type(_id) == "string")
8255
+ end
8256
+ if _condition then
8257
+ return nil
8258
+ end
8259
+ return payload
8260
+ end
8261
+ local function writePayload(key, payload)
8262
+ local encodedOk, encoded = pcall(function()
8263
+ return HttpService:JSONEncode(payload)
8264
+ end)
8265
+ if not encodedOk or not (type(encoded) == "string") then
8266
+ return false
8267
+ end
8268
+ return writeSetting(key, encoded)
8269
+ end
8270
+ local function writeResult(key, request, ok, errText)
8271
+ writePayload(key, {
8272
+ kind = "result",
8273
+ id = request.id,
8274
+ requestedAt = request.requestedAt,
8275
+ consumedAt = tick(),
8276
+ ok = ok,
8277
+ error = errText,
8278
+ })
8279
+ end
8280
+ local function handleStopRequest(key, request)
8281
+ local _condition = request.kind ~= "request"
8282
+ if not _condition then
8283
+ local _id = request.id
8284
+ _condition = not (type(_id) == "string")
8285
+ end
8286
+ if _condition then
8287
+ return nil
8288
+ end
8289
+ local _requestedAt = request.requestedAt
8290
+ if not (type(_requestedAt) == "number") then
8291
+ writeSetting(key, false)
8292
+ return nil
8293
+ end
8294
+ local age = tick() - request.requestedAt
8295
+ if age < -5 or age > REQUEST_TTL_SEC then
8296
+ writeSetting(key, false)
8297
+ return nil
8298
+ end
8299
+ if endTestIssued then
8300
+ writeResult(key, request, true)
8301
+ return nil
8302
+ end
8303
+ if not RunService:IsRunning() or not RunService:IsServer() then
8304
+ writeResult(key, request, false, "StopPlayMonitor is not running in the server DataModel.")
8305
+ return nil
8306
+ end
8307
+ endTestIssued = true
8308
+ local endOk, endErr = pcall(function()
8309
+ return StudioTestService:EndTest("stopped_by_mcp")
8310
+ end)
8311
+ writeResult(key, request, endOk, if endOk then nil else tostring(endErr))
8312
+ if not endOk then
8313
+ endTestIssued = false
8314
+ end
8315
+ end
8316
+ local function startMonitor()
8317
+ if not pluginRef then
8318
+ warn("[robloxstudio-mcp] StopPlayMonitor.startMonitor called before init; skipping")
8319
+ return nil
8320
+ end
7933
8321
  task.spawn(function()
7934
8322
  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)
8323
+ for _, myKey in settingKeys() do
8324
+ local value = readSetting(myKey)
8325
+ if value == true then
8326
+ -- Legacy boolean requests are ambiguous and may be stale from
8327
+ -- a prior crashed session. New stop requests use token payloads.
8328
+ writeSetting(myKey, false)
8329
+ else
8330
+ local payload = decodePayload(value)
8331
+ if payload then
8332
+ handleStopRequest(myKey, payload)
8333
+ end
8334
+ end
7947
8335
  end
7948
8336
  task.wait(POLL_INTERVAL_SEC)
7949
8337
  end
@@ -7951,39 +8339,66 @@ local function startMonitor()
7951
8339
  end
7952
8340
  local function requestStop()
7953
8341
  if not pluginRef then
7954
- return false
8342
+ return {
8343
+ ok = false,
8344
+ }
7955
8345
  end
7956
- local myKey = settingKey(computeInstanceId())
7957
- local ok = pcall(function()
7958
- return pluginRef:SetSetting(myKey, true)
7959
- end)
7960
- return ok
8346
+ local requestId = HttpService:GenerateGUID(false)
8347
+ local payload = {
8348
+ kind = "request",
8349
+ id = requestId,
8350
+ requestedAt = tick(),
8351
+ }
8352
+ local ok = false
8353
+ for _, myKey in settingKeys() do
8354
+ ok = writePayload(myKey, payload) or ok
8355
+ end
8356
+ return {
8357
+ ok = ok,
8358
+ requestId = if ok then requestId else nil,
8359
+ }
7961
8360
  end
7962
- local function waitForConsumption()
8361
+ local function waitForConsumption(requestId)
7963
8362
  if not pluginRef then
7964
- return false
8363
+ return {
8364
+ ok = false,
8365
+ consumed = false,
8366
+ error = "Plugin reference is not initialized.",
8367
+ }
7965
8368
  end
7966
- local myKey = settingKey(computeInstanceId())
7967
8369
  local start = tick()
7968
8370
  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
8371
+ for _, myKey in settingKeys() do
8372
+ local payload = decodePayload(readSetting(myKey))
8373
+ if payload and payload.kind == "result" and payload.id == requestId then
8374
+ return {
8375
+ ok = payload.ok == true,
8376
+ consumed = true,
8377
+ error = payload.error,
8378
+ }
8379
+ end
7974
8380
  end
7975
8381
  task.wait(WAIT_POLL_SEC)
7976
8382
  end
7977
- return false
8383
+ return {
8384
+ ok = false,
8385
+ consumed = false,
8386
+ error = "Timed out waiting for the play-server DataModel to acknowledge stop_playtest.",
8387
+ }
7978
8388
  end
7979
- local function clearPending()
8389
+ local function clearPending(requestId)
7980
8390
  if not pluginRef then
7981
8391
  return nil
7982
8392
  end
7983
- local myKey = settingKey(computeInstanceId())
7984
- pcall(function()
7985
- return pluginRef:SetSetting(myKey, false)
7986
- end)
8393
+ for _, myKey in settingKeys() do
8394
+ if requestId ~= nil then
8395
+ local payload = decodePayload(readSetting(myKey))
8396
+ if payload and payload.id ~= requestId then
8397
+ continue
8398
+ end
8399
+ end
8400
+ writeSetting(myKey, false)
8401
+ end
7987
8402
  end
7988
8403
  return {
7989
8404
  init = init,
@@ -7995,7 +8410,7 @@ return {
7995
8410
  ]]></string>
7996
8411
  </Properties>
7997
8412
  </Item>
7998
- <Item class="ModuleScript" referent="27">
8413
+ <Item class="ModuleScript" referent="29">
7999
8414
  <Properties>
8000
8415
  <string name="Name">UI</string>
8001
8416
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -8766,7 +9181,7 @@ return {
8766
9181
  ]]></string>
8767
9182
  </Properties>
8768
9183
  </Item>
8769
- <Item class="ModuleScript" referent="28">
9184
+ <Item class="ModuleScript" referent="30">
8770
9185
  <Properties>
8771
9186
  <string name="Name">Utils</string>
8772
9187
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9296,11 +9711,11 @@ return {
9296
9711
  </Properties>
9297
9712
  </Item>
9298
9713
  </Item>
9299
- <Item class="Folder" referent="32">
9714
+ <Item class="Folder" referent="34">
9300
9715
  <Properties>
9301
9716
  <string name="Name">include</string>
9302
9717
  </Properties>
9303
- <Item class="ModuleScript" referent="29">
9718
+ <Item class="ModuleScript" referent="31">
9304
9719
  <Properties>
9305
9720
  <string name="Name">Promise</string>
9306
9721
  <string name="Source"><![CDATA[--[[
@@ -11374,7 +11789,7 @@ return Promise
11374
11789
  ]]></string>
11375
11790
  </Properties>
11376
11791
  </Item>
11377
- <Item class="ModuleScript" referent="30">
11792
+ <Item class="ModuleScript" referent="32">
11378
11793
  <Properties>
11379
11794
  <string name="Name">RuntimeLib</string>
11380
11795
  <string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
@@ -11641,15 +12056,15 @@ return TS
11641
12056
  </Properties>
11642
12057
  </Item>
11643
12058
  </Item>
11644
- <Item class="Folder" referent="33">
12059
+ <Item class="Folder" referent="35">
11645
12060
  <Properties>
11646
12061
  <string name="Name">node_modules</string>
11647
12062
  </Properties>
11648
- <Item class="Folder" referent="34">
12063
+ <Item class="Folder" referent="36">
11649
12064
  <Properties>
11650
12065
  <string name="Name">@rbxts</string>
11651
12066
  </Properties>
11652
- <Item class="ModuleScript" referent="31">
12067
+ <Item class="ModuleScript" referent="33">
11653
12068
  <Properties>
11654
12069
  <string name="Name">services</string>
11655
12070
  <string name="Source"><![CDATA[return setmetatable({}, {