@chrrxs/robloxstudio-mcp-inspector 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.
- package/dist/index.js +210 -37
- package/package.json +1 -1
- package/studio-plugin/MCPInspectorPlugin.rbxmx +556 -141
- package/studio-plugin/MCPPlugin.rbxmx +556 -141
- package/studio-plugin/src/modules/ClientBroker.ts +32 -6
- package/studio-plugin/src/modules/Communication.ts +81 -41
- package/studio-plugin/src/modules/HttpDiagnostics.ts +50 -0
- package/studio-plugin/src/modules/ServerUrlSettings.ts +61 -0
- package/studio-plugin/src/modules/StopPlayMonitor.ts +184 -45
- package/studio-plugin/src/modules/handlers/TestHandlers.ts +18 -13
- package/studio-plugin/src/server/index.server.ts +15 -4
|
@@ -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(`[
|
|
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.
|
|
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.
|
|
100
|
-
--
|
|
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
|
|
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 = `{
|
|
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 = `{
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
773
|
-
--
|
|
774
|
-
--
|
|
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
|
|
781
|
-
if nameChangeConn then
|
|
782
|
-
|
|
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
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
ui
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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(`[
|
|
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
|
|
1085
|
-
|
|
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.
|
|
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.
|
|
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(`[
|
|
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
|
|
6706
|
-
--
|
|
6707
|
-
--
|
|
6708
|
-
|
|
6709
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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.
|
|
8021
|
+
local CURRENT_VERSION = "2.16.2"
|
|
7760
8022
|
local PLUGIN_VARIANT = "main"
|
|
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="
|
|
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
|
|
8130
|
+
-- "MCP_STOP_PLAY_<instanceId>" as a tiny request/result mailbox:
|
|
7866
8131
|
--
|
|
7867
|
-
-- * The edit DM's
|
|
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
|
|
7871
|
-
--
|
|
7872
|
-
-- touch this key.
|
|
7873
|
-
-- * The edit DM waits up to ~8s for its
|
|
7874
|
-
--
|
|
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
|
-
--
|
|
7886
|
-
--
|
|
7887
|
-
--
|
|
7888
|
-
local POLL_INTERVAL_SEC =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
7927
|
-
|
|
7928
|
-
|
|
7929
|
-
|
|
7930
|
-
|
|
7931
|
-
|
|
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
|
-
|
|
7936
|
-
|
|
7937
|
-
|
|
7938
|
-
|
|
7939
|
-
|
|
7940
|
-
|
|
7941
|
-
|
|
7942
|
-
|
|
7943
|
-
|
|
7944
|
-
|
|
7945
|
-
|
|
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
|
|
8342
|
+
return {
|
|
8343
|
+
ok = false,
|
|
8344
|
+
}
|
|
7955
8345
|
end
|
|
7956
|
-
local
|
|
7957
|
-
local
|
|
7958
|
-
|
|
7959
|
-
|
|
7960
|
-
|
|
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
|
|
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
|
-
|
|
7970
|
-
|
|
7971
|
-
|
|
7972
|
-
|
|
7973
|
-
|
|
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
|
|
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
|
-
|
|
7984
|
-
|
|
7985
|
-
|
|
7986
|
-
|
|
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="
|
|
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="
|
|
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="
|
|
9714
|
+
<Item class="Folder" referent="34">
|
|
9300
9715
|
<Properties>
|
|
9301
9716
|
<string name="Name">include</string>
|
|
9302
9717
|
</Properties>
|
|
9303
|
-
<Item class="ModuleScript" referent="
|
|
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="
|
|
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="
|
|
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="
|
|
12063
|
+
<Item class="Folder" referent="36">
|
|
11649
12064
|
<Properties>
|
|
11650
12065
|
<string name="Name">@rbxts</string>
|
|
11651
12066
|
</Properties>
|
|
11652
|
-
<Item class="ModuleScript" referent="
|
|
12067
|
+
<Item class="ModuleScript" referent="33">
|
|
11653
12068
|
<Properties>
|
|
11654
12069
|
<string name="Name">services</string>
|
|
11655
12070
|
<string name="Source"><![CDATA[return setmetatable({}, {
|