@chrrxs/robloxstudio-mcp 3.0.5 → 3.1.0
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 +1313 -875
- package/package.json +2 -2
- package/studio-plugin/MCPPlugin.rbxmx +479 -336
|
@@ -97,11 +97,11 @@ end)
|
|
|
97
97
|
UI.updateUIState()
|
|
98
98
|
Communication.checkForUpdates()
|
|
99
99
|
task.delay(TOOLBAR_REGISTRATION_DELAY_SECONDS, registerToolbarButton)
|
|
100
|
-
-- Auto-activate per
|
|
101
|
-
--
|
|
102
|
-
--
|
|
103
|
-
--
|
|
104
|
-
|
|
100
|
+
-- Auto-activate per Peer. Runtime plugin VMs can load before their first
|
|
101
|
+
-- Heartbeat; task.delay() would then wait behind the very multiplayer startup
|
|
102
|
+
-- that needs this Peer to register. Start runtime initialization immediately,
|
|
103
|
+
-- while retaining the short UI settling delay for the edit Peer.
|
|
104
|
+
local function autoActivatePeer()
|
|
105
105
|
local role = startupRole
|
|
106
106
|
if role == "edit" then
|
|
107
107
|
cleanupEditBridgeArtifacts()
|
|
@@ -112,7 +112,7 @@ task.delay(2, function()
|
|
|
112
112
|
end
|
|
113
113
|
end
|
|
114
114
|
if role == "edit" or role == "server" then
|
|
115
|
-
pcall(function()
|
|
115
|
+
local activationOk, activationError = pcall(function()
|
|
116
116
|
local conn = State.getActiveConnection()
|
|
117
117
|
if not conn.isActive then
|
|
118
118
|
if role == "server" then
|
|
@@ -138,6 +138,9 @@ task.delay(2, function()
|
|
|
138
138
|
Communication.activatePlugin()
|
|
139
139
|
end
|
|
140
140
|
end)
|
|
141
|
+
if not activationOk then
|
|
142
|
+
warn(`[robloxstudio-mcp] Automatic {role} Peer activation failed: {activationError}`)
|
|
143
|
+
end
|
|
141
144
|
end
|
|
142
145
|
if role == "server" then
|
|
143
146
|
ClientBroker.setupServerBroker()
|
|
@@ -151,7 +154,12 @@ task.delay(2, function()
|
|
|
151
154
|
elseif role == "client" then
|
|
152
155
|
ClientBroker.setupClientBroker()
|
|
153
156
|
end
|
|
154
|
-
end
|
|
157
|
+
end
|
|
158
|
+
if startupRole == "edit" then
|
|
159
|
+
task.delay(2, autoActivatePeer)
|
|
160
|
+
else
|
|
161
|
+
autoActivatePeer()
|
|
162
|
+
end
|
|
155
163
|
]]></string>
|
|
156
164
|
</Properties>
|
|
157
165
|
<Item class="Folder" referent="1">
|
|
@@ -292,17 +300,18 @@ local MicroProfilerHandlers = TS.import(script, script.Parent, "handlers", "Micr
|
|
|
292
300
|
local LuauExec = TS.import(script, script.Parent, "LuauExec")
|
|
293
301
|
local HttpDiagnostics = TS.import(script, script.Parent, "HttpDiagnostics")
|
|
294
302
|
local PluginSession = TS.import(script, script.Parent, "PluginSession")
|
|
303
|
+
local TopologyId = TS.import(script, script.Parent, "TopologyId")
|
|
304
|
+
local PeerRole = TS.import(script, script.Parent, "PeerRole")
|
|
295
305
|
local StudioTestService = game:GetService("StudioTestService")
|
|
296
|
-
--
|
|
297
|
-
--
|
|
298
|
-
--
|
|
299
|
-
--
|
|
300
|
-
-- in ReplicatedStorage; each player gets a logical proxy registration on the
|
|
301
|
-
-- MCP side, multiplexed over the play-server peer's physical event stream.
|
|
306
|
+
-- Client Peers cannot reach the MCP HTTP server, so the server transport
|
|
307
|
+
-- forwards requests over this RemoteFunction. The client supplies only its
|
|
308
|
+
-- VM Peer identity; the server broker assigns trusted process/group topology
|
|
309
|
+
-- from its own playtest context before publishing that Peer to MCP.
|
|
302
310
|
local DEFAULT_MCP_URL = "http://localhost:58741"
|
|
303
311
|
local mcpUrl = DEFAULT_MCP_URL
|
|
304
312
|
local BROKER_NAME = "__MCPClientBroker"
|
|
305
313
|
local BROKER_OWNER_ATTRIBUTE = "__MCPBrokerOwner"
|
|
314
|
+
local CLIENT_IDENTITY_KIND = "identity"
|
|
306
315
|
-- Endpoints the server-peer broker is allowed to forward to the client peer.
|
|
307
316
|
-- Each requires the client peer's plugin VM (because the buffer / require
|
|
308
317
|
-- cache / etc. lives there) so the server peer alone can't satisfy them.
|
|
@@ -323,13 +332,7 @@ local CLIENT_BROKER_ALLOWED_ENDPOINTS = {
|
|
|
323
332
|
["/api/focus-viewport"] = true,
|
|
324
333
|
}
|
|
325
334
|
local function forkRole()
|
|
326
|
-
|
|
327
|
-
return "edit"
|
|
328
|
-
end
|
|
329
|
-
if RunService:IsServer() then
|
|
330
|
-
return "server"
|
|
331
|
-
end
|
|
332
|
-
return "client"
|
|
335
|
+
return PeerRole.detect()
|
|
333
336
|
end
|
|
334
337
|
local function postJson(endpoint, body)
|
|
335
338
|
return pcall(function()
|
|
@@ -370,13 +373,11 @@ local function handleGetRuntimeLogs(data)
|
|
|
370
373
|
local since = d.since
|
|
371
374
|
local tail = d.tail
|
|
372
375
|
local filter = d.filter
|
|
373
|
-
-- "client" is the generic capture tag; MCP-side aggregation overrides it
|
|
374
|
-
-- with the specific role (e.g. "client-1") for capturedBy.
|
|
375
376
|
return RuntimeLogBuffer.query({
|
|
376
377
|
since = since,
|
|
377
378
|
tail = tail,
|
|
378
379
|
filter = filter,
|
|
379
|
-
}
|
|
380
|
+
})
|
|
380
381
|
end
|
|
381
382
|
local function handleMultiplayerTestState()
|
|
382
383
|
local argsOk, args = pcall(function()
|
|
@@ -449,10 +450,44 @@ local function handleMultiplayerTestLeaveClient()
|
|
|
449
450
|
localPlayer = localPlayer,
|
|
450
451
|
}
|
|
451
452
|
end
|
|
452
|
-
local
|
|
453
|
+
local proxyRetryDelay
|
|
454
|
+
local function sendClientIdentity(rf, attempt)
|
|
455
|
+
if PeerRole.detect() ~= "client" or rf.Parent == nil then
|
|
456
|
+
return nil
|
|
457
|
+
end
|
|
458
|
+
local identity = {
|
|
459
|
+
kind = "identity",
|
|
460
|
+
peerId = PluginSession.peerId,
|
|
461
|
+
}
|
|
462
|
+
local ok, response = pcall(function()
|
|
463
|
+
return rf:InvokeServer(identity)
|
|
464
|
+
end)
|
|
465
|
+
if ok and type(response) == "table" then
|
|
466
|
+
local acknowledgement = response
|
|
467
|
+
if acknowledgement.success == true then
|
|
468
|
+
return nil
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
if attempt == 0 then
|
|
472
|
+
warn(`[robloxstudio-mcp] client identity handshake failed; retrying`)
|
|
473
|
+
end
|
|
474
|
+
task.delay(proxyRetryDelay(attempt + 1), sendClientIdentity, rf, attempt + 1)
|
|
475
|
+
end
|
|
476
|
+
local function setupClientBroker(attempt)
|
|
477
|
+
if attempt == nil then
|
|
478
|
+
attempt = 0
|
|
479
|
+
end
|
|
480
|
+
if PeerRole.detect() ~= "client" then
|
|
481
|
+
return nil
|
|
482
|
+
end
|
|
453
483
|
local rf = ReplicatedStorage:WaitForChild(BROKER_NAME, 10)
|
|
454
484
|
if not rf or not rf:IsA("RemoteFunction") then
|
|
455
|
-
|
|
485
|
+
if attempt == 0 then
|
|
486
|
+
warn(`[robloxstudio-mcp] client: {BROKER_NAME} not found; retrying`)
|
|
487
|
+
end
|
|
488
|
+
if RunService:IsRunning() then
|
|
489
|
+
task.delay(proxyRetryDelay(attempt + 1), setupClientBroker, attempt + 1)
|
|
490
|
+
end
|
|
456
491
|
return nil
|
|
457
492
|
end
|
|
458
493
|
rf.OnClientInvoke = function(payload)
|
|
@@ -512,11 +547,12 @@ local function setupClientBroker()
|
|
|
512
547
|
error = `Unsupported client broker endpoint: {payload.endpoint}`,
|
|
513
548
|
}
|
|
514
549
|
end
|
|
550
|
+
task.spawn(sendClientIdentity, rf, 0)
|
|
515
551
|
end
|
|
516
552
|
local INITIAL_PROXY_RETRY_DELAY_SECONDS = 0.5
|
|
517
553
|
local MAX_PROXY_RETRY_DELAY_SECONDS = 5
|
|
518
554
|
local proxyByPlayer = {}
|
|
519
|
-
local
|
|
555
|
+
local proxyByPeerId = {}
|
|
520
556
|
local proxyRegisterFailuresByPlayer = {}
|
|
521
557
|
local pendingProxyDisconnects = {}
|
|
522
558
|
local serverBrokerStarted = false
|
|
@@ -534,50 +570,50 @@ local function unregisterProxy(player, entry)
|
|
|
534
570
|
proxy.generation += 1
|
|
535
571
|
local _player = player
|
|
536
572
|
proxyByPlayer[_player] = nil
|
|
537
|
-
local
|
|
538
|
-
|
|
573
|
+
local _peerId = proxy.peerId
|
|
574
|
+
proxyByPeerId[_peerId] = nil
|
|
539
575
|
local _player_1 = player
|
|
540
576
|
proxyRegisterFailuresByPlayer[_player_1] = nil
|
|
541
|
-
queueProxyDisconnect(proxy.
|
|
577
|
+
queueProxyDisconnect(proxy.peerId)
|
|
542
578
|
end
|
|
543
579
|
local function disconnectAllProxies()
|
|
544
580
|
for player, entry in proxyByPlayer do
|
|
545
581
|
unregisterProxy(player, entry)
|
|
546
582
|
end
|
|
547
583
|
table.clear(proxyByPlayer)
|
|
548
|
-
table.clear(
|
|
584
|
+
table.clear(proxyByPeerId)
|
|
549
585
|
table.clear(proxyRegisterFailuresByPlayer)
|
|
550
586
|
end
|
|
551
|
-
|
|
587
|
+
function proxyRetryDelay(attempt)
|
|
552
588
|
return math.min(INITIAL_PROXY_RETRY_DELAY_SECONDS * math.pow(2, math.max(attempt - 1, 0)), MAX_PROXY_RETRY_DELAY_SECONDS)
|
|
553
589
|
end
|
|
554
|
-
local function deliverProxyDisconnect(
|
|
555
|
-
local
|
|
556
|
-
if not (pendingProxyDisconnects[
|
|
590
|
+
local function deliverProxyDisconnect(peerId, attempt)
|
|
591
|
+
local _peerId = peerId
|
|
592
|
+
if not (pendingProxyDisconnects[_peerId] ~= nil) then
|
|
557
593
|
return nil
|
|
558
594
|
end
|
|
559
595
|
local ok, response = postJson("/disconnect", {
|
|
560
|
-
|
|
596
|
+
peerId = peerId,
|
|
561
597
|
})
|
|
562
598
|
if ok and response and response.Success then
|
|
563
|
-
local
|
|
564
|
-
pendingProxyDisconnects[
|
|
599
|
+
local _peerId_1 = peerId
|
|
600
|
+
pendingProxyDisconnects[_peerId_1] = nil
|
|
565
601
|
return nil
|
|
566
602
|
end
|
|
567
603
|
task.delay(proxyRetryDelay(attempt + 1), function()
|
|
568
|
-
deliverProxyDisconnect(
|
|
604
|
+
deliverProxyDisconnect(peerId, attempt + 1)
|
|
569
605
|
end)
|
|
570
606
|
end
|
|
571
|
-
function queueProxyDisconnect(
|
|
572
|
-
local
|
|
573
|
-
if pendingProxyDisconnects[
|
|
607
|
+
function queueProxyDisconnect(peerId)
|
|
608
|
+
local _peerId = peerId
|
|
609
|
+
if pendingProxyDisconnects[_peerId] ~= nil then
|
|
574
610
|
return nil
|
|
575
611
|
end
|
|
576
|
-
local
|
|
577
|
-
pendingProxyDisconnects[
|
|
578
|
-
task.spawn(deliverProxyDisconnect,
|
|
612
|
+
local _peerId_1 = peerId
|
|
613
|
+
pendingProxyDisconnects[_peerId_1] = true
|
|
614
|
+
task.spawn(deliverProxyDisconnect, peerId, 0)
|
|
579
615
|
end
|
|
580
|
-
local function parseAssignedRole(body)
|
|
616
|
+
local function parseAssignedRole(body, entry)
|
|
581
617
|
local decodeOk, decoded = pcall(function()
|
|
582
618
|
return HttpService:JSONDecode(body)
|
|
583
619
|
end)
|
|
@@ -585,7 +621,7 @@ local function parseAssignedRole(body)
|
|
|
585
621
|
return nil
|
|
586
622
|
end
|
|
587
623
|
local ready = decoded
|
|
588
|
-
if ready.success ~= true then
|
|
624
|
+
if ready.success ~= true or ready.peerId ~= entry.peerId or ready.instanceId ~= entry.instanceId or ready.multiplayerGroupId ~= entry.multiplayerGroupId then
|
|
589
625
|
return nil
|
|
590
626
|
end
|
|
591
627
|
local _assignedRole = ready.assignedRole
|
|
@@ -636,27 +672,18 @@ function registerProxyEntry(entry)
|
|
|
636
672
|
entry.registering = true
|
|
637
673
|
local expectedGeneration = entry.generation
|
|
638
674
|
local requestedRole = if entry.role == "client" then "client" else entry.role
|
|
639
|
-
local readyPayload = PluginSession.createReadyPayload(entry.
|
|
675
|
+
local readyPayload = PluginSession.createReadyPayload(entry.peerId, requestedRole, entry.instanceId, entry.multiplayerGroupId)
|
|
676
|
+
local ok, res = postJson("/ready", readyPayload)
|
|
640
677
|
local _player = entry.player
|
|
641
678
|
if proxyByPlayer[_player] ~= entry then
|
|
642
|
-
return nil
|
|
643
|
-
end
|
|
644
|
-
if entry.generation ~= expectedGeneration then
|
|
645
|
-
if not entry.registering then
|
|
646
|
-
task.spawn(registerProxyEntry, entry)
|
|
647
|
-
end
|
|
648
|
-
return nil
|
|
649
|
-
end
|
|
650
|
-
local ok, res = postJson("/ready", readyPayload)
|
|
651
|
-
local _player_1 = entry.player
|
|
652
|
-
if proxyByPlayer[_player_1] ~= entry then
|
|
653
679
|
if ok and res and res.Success then
|
|
654
|
-
queueProxyDisconnect(entry.
|
|
680
|
+
queueProxyDisconnect(entry.peerId)
|
|
655
681
|
end
|
|
656
682
|
return nil
|
|
657
683
|
end
|
|
658
684
|
if entry.generation ~= expectedGeneration then
|
|
659
|
-
|
|
685
|
+
entry.registering = false
|
|
686
|
+
if not entry.registered then
|
|
660
687
|
task.spawn(registerProxyEntry, entry)
|
|
661
688
|
end
|
|
662
689
|
return nil
|
|
@@ -666,30 +693,91 @@ function registerProxyEntry(entry)
|
|
|
666
693
|
failProxyRegistration(entry, formatPostJsonFailure("/ready", ok, res))
|
|
667
694
|
return nil
|
|
668
695
|
end
|
|
669
|
-
local assignedRole = parseAssignedRole(res.Body)
|
|
696
|
+
local assignedRole = parseAssignedRole(res.Body, entry)
|
|
670
697
|
if assignedRole == nil then
|
|
671
|
-
failProxyRegistration(entry, "invalid /ready response
|
|
698
|
+
failProxyRegistration(entry, "invalid /ready response for client Peer topology")
|
|
672
699
|
return nil
|
|
673
700
|
end
|
|
674
701
|
entry.role = assignedRole
|
|
675
702
|
entry.registered = true
|
|
676
703
|
entry.retryAttempt = 0
|
|
677
|
-
local
|
|
678
|
-
if proxyRegisterFailuresByPlayer[
|
|
679
|
-
local
|
|
680
|
-
proxyRegisterFailuresByPlayer[
|
|
704
|
+
local _player_1 = entry.player
|
|
705
|
+
if proxyRegisterFailuresByPlayer[_player_1] ~= nil then
|
|
706
|
+
local _player_2 = entry.player
|
|
707
|
+
proxyRegisterFailuresByPlayer[_player_2] = nil
|
|
681
708
|
print(`[robloxstudio-mcp] proxy registered for {entry.player.Name} as {assignedRole} via {mcpUrl}`)
|
|
682
709
|
end
|
|
683
710
|
end
|
|
684
|
-
local function
|
|
685
|
-
local
|
|
686
|
-
if
|
|
711
|
+
local function parseClientIdentity(payload)
|
|
712
|
+
local _payload = payload
|
|
713
|
+
if not (type(_payload) == "table") then
|
|
714
|
+
return nil
|
|
715
|
+
end
|
|
716
|
+
local identity = payload
|
|
717
|
+
local _condition = identity.kind ~= CLIENT_IDENTITY_KIND
|
|
718
|
+
if not _condition then
|
|
719
|
+
local _peerId = identity.peerId
|
|
720
|
+
_condition = not (type(_peerId) == "string")
|
|
721
|
+
if not _condition then
|
|
722
|
+
_condition = identity.peerId == ""
|
|
723
|
+
end
|
|
724
|
+
end
|
|
725
|
+
if _condition then
|
|
687
726
|
return nil
|
|
688
727
|
end
|
|
728
|
+
return {
|
|
729
|
+
kind = "identity",
|
|
730
|
+
peerId = identity.peerId,
|
|
731
|
+
}
|
|
732
|
+
end
|
|
733
|
+
local function registerProxy(player, rf, identity)
|
|
734
|
+
local _peerId = identity.peerId
|
|
735
|
+
local peerOwner = proxyByPeerId[_peerId]
|
|
736
|
+
if peerOwner ~= nil and peerOwner.player ~= player then
|
|
737
|
+
return false
|
|
738
|
+
end
|
|
739
|
+
local _player = player
|
|
740
|
+
local current = proxyByPlayer[_player]
|
|
741
|
+
local multiplayerGroupId = PluginSession.getMultiplayerGroupId()
|
|
742
|
+
-- Managed multiplayer launches one client Player per Studio process. The
|
|
743
|
+
-- server creates that process identity instead of trusting replicated game
|
|
744
|
+
-- code to claim an Instance or Multiplayer Group. Solo clients use the
|
|
745
|
+
-- server's process Instance because their VMs share one Studio process.
|
|
746
|
+
local retainedMultiplayerInstanceId = if current ~= nil and current.multiplayerGroupId == multiplayerGroupId then current.instanceId else nil
|
|
747
|
+
local _result
|
|
748
|
+
if multiplayerGroupId ~= nil then
|
|
749
|
+
local _condition = retainedMultiplayerInstanceId
|
|
750
|
+
if _condition == nil then
|
|
751
|
+
_condition = TopologyId.createInstanceId()
|
|
752
|
+
end
|
|
753
|
+
_result = _condition
|
|
754
|
+
else
|
|
755
|
+
_result = PluginSession.getInstanceId()
|
|
756
|
+
end
|
|
757
|
+
local instanceId = _result
|
|
758
|
+
if current ~= nil then
|
|
759
|
+
if current.peerId ~= identity.peerId then
|
|
760
|
+
unregisterProxy(player, current)
|
|
761
|
+
else
|
|
762
|
+
if current.instanceId ~= instanceId or current.multiplayerGroupId ~= multiplayerGroupId then
|
|
763
|
+
current.generation += 1
|
|
764
|
+
current.instanceId = instanceId
|
|
765
|
+
current.multiplayerGroupId = multiplayerGroupId
|
|
766
|
+
current.role = "client"
|
|
767
|
+
current.registered = false
|
|
768
|
+
current.registering = false
|
|
769
|
+
current.retryAttempt = 0
|
|
770
|
+
task.spawn(registerProxyEntry, current)
|
|
771
|
+
end
|
|
772
|
+
return true
|
|
773
|
+
end
|
|
774
|
+
end
|
|
689
775
|
local entry = {
|
|
690
776
|
player = player,
|
|
691
777
|
remote = rf,
|
|
692
|
-
|
|
778
|
+
peerId = identity.peerId,
|
|
779
|
+
instanceId = instanceId,
|
|
780
|
+
multiplayerGroupId = multiplayerGroupId,
|
|
693
781
|
role = "client",
|
|
694
782
|
registered = false,
|
|
695
783
|
registering = false,
|
|
@@ -698,11 +786,12 @@ local function registerProxy(player, rf)
|
|
|
698
786
|
}
|
|
699
787
|
local _player_1 = player
|
|
700
788
|
proxyByPlayer[_player_1] = entry
|
|
701
|
-
local
|
|
702
|
-
|
|
789
|
+
local _peerId_1 = entry.peerId
|
|
790
|
+
proxyByPeerId[_peerId_1] = entry
|
|
703
791
|
task.spawn(registerProxyEntry, entry)
|
|
792
|
+
return true
|
|
704
793
|
end
|
|
705
|
-
local function
|
|
794
|
+
local function refreshAllProxyRegistrations()
|
|
706
795
|
for _, entry in proxyByPlayer do
|
|
707
796
|
entry.generation += 1
|
|
708
797
|
entry.registered = false
|
|
@@ -711,9 +800,9 @@ local function refreshAllLogicalRegistrations()
|
|
|
711
800
|
task.spawn(registerProxyEntry, entry)
|
|
712
801
|
end
|
|
713
802
|
end
|
|
714
|
-
local function dispatchClientRequest(
|
|
715
|
-
local
|
|
716
|
-
local entry =
|
|
803
|
+
local function dispatchClientRequest(peerId, target, endpoint, data)
|
|
804
|
+
local _peerId = peerId
|
|
805
|
+
local entry = proxyByPeerId[_peerId]
|
|
717
806
|
local _condition = not entry
|
|
718
807
|
if not _condition then
|
|
719
808
|
local _player = entry.player
|
|
@@ -721,7 +810,7 @@ local function dispatchClientRequest(logicalSessionId, target, endpoint, data)
|
|
|
721
810
|
end
|
|
722
811
|
if _condition then
|
|
723
812
|
return {
|
|
724
|
-
error = `Client proxy {target} ({
|
|
813
|
+
error = `Client proxy {target} ({peerId}) is not registered.`,
|
|
725
814
|
}
|
|
726
815
|
end
|
|
727
816
|
if entry.role == "client" then
|
|
@@ -732,7 +821,7 @@ local function dispatchClientRequest(logicalSessionId, target, endpoint, data)
|
|
|
732
821
|
end
|
|
733
822
|
if entry.role ~= target then
|
|
734
823
|
return {
|
|
735
|
-
error = `Client proxy {
|
|
824
|
+
error = `Client proxy {peerId} is registered as {entry.role}, not {target}.`,
|
|
736
825
|
}
|
|
737
826
|
end
|
|
738
827
|
if entry.player.Parent == nil or not RunService:IsRunning() then
|
|
@@ -773,26 +862,46 @@ local function setupServerBroker()
|
|
|
773
862
|
if serverBrokerStarted then
|
|
774
863
|
return nil
|
|
775
864
|
end
|
|
776
|
-
local
|
|
777
|
-
|
|
865
|
+
local existing = ReplicatedStorage:FindFirstChild(BROKER_NAME)
|
|
866
|
+
local rf
|
|
867
|
+
if existing ~= nil then
|
|
868
|
+
if not existing:IsA("RemoteFunction") then
|
|
869
|
+
warn(`[robloxstudio-mcp] server: {BROKER_NAME} exists but is not a RemoteFunction`)
|
|
870
|
+
return nil
|
|
871
|
+
end
|
|
872
|
+
rf = existing
|
|
873
|
+
else
|
|
778
874
|
rf = Instance.new("RemoteFunction")
|
|
779
|
-
rf.Name = BROKER_NAME
|
|
780
|
-
rf.Parent = ReplicatedStorage
|
|
781
875
|
end
|
|
782
876
|
if rf:GetAttribute(BROKER_OWNER_ATTRIBUTE) ~= nil then
|
|
783
877
|
return nil
|
|
784
878
|
end
|
|
785
|
-
rf
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
879
|
+
rf.Name = BROKER_NAME
|
|
880
|
+
rf:SetAttribute(BROKER_OWNER_ATTRIBUTE, PluginSession.peerId)
|
|
881
|
+
rf.OnServerInvoke = function(player, payload)
|
|
882
|
+
local identity = parseClientIdentity(payload)
|
|
883
|
+
if identity == nil then
|
|
884
|
+
return {
|
|
885
|
+
success = false,
|
|
886
|
+
error = "Invalid client Peer identity handshake.",
|
|
887
|
+
}
|
|
888
|
+
end
|
|
889
|
+
if not registerProxy(player, rf, identity) then
|
|
890
|
+
return {
|
|
891
|
+
success = false,
|
|
892
|
+
error = "Client Peer identity is already registered by another player.",
|
|
893
|
+
}
|
|
894
|
+
end
|
|
895
|
+
return {
|
|
896
|
+
success = true,
|
|
897
|
+
}
|
|
898
|
+
end
|
|
899
|
+
if rf.Parent == nil then
|
|
900
|
+
rf.Parent = ReplicatedStorage
|
|
793
901
|
end
|
|
794
|
-
|
|
795
|
-
|
|
902
|
+
serverBrokerStarted = true
|
|
903
|
+
Players.PlayerRemoving:Connect(function(player)
|
|
904
|
+
unregisterProxy(player)
|
|
796
905
|
end)
|
|
797
906
|
game:BindToClose(function()
|
|
798
907
|
disconnectAllProxies()
|
|
@@ -802,7 +911,7 @@ return {
|
|
|
802
911
|
DEFAULT_MCP_URL = DEFAULT_MCP_URL,
|
|
803
912
|
setServerUrl = setServerUrl,
|
|
804
913
|
disconnectAllProxies = disconnectAllProxies,
|
|
805
|
-
|
|
914
|
+
refreshAllProxyRegistrations = refreshAllProxyRegistrations,
|
|
806
915
|
dispatchClientRequest = dispatchClientRequest,
|
|
807
916
|
forkRole = forkRole,
|
|
808
917
|
setupClientBroker = setupClientBroker,
|
|
@@ -845,7 +954,7 @@ local ServerUrlSettings = TS.import(script, script.Parent, "ServerUrlSettings")
|
|
|
845
954
|
local PluginSession = TS.import(script, script.Parent, "PluginSession")
|
|
846
955
|
local StudioEventStream = TS.import(script, script.Parent, "StudioEventStream")
|
|
847
956
|
local assignedRole
|
|
848
|
-
local
|
|
957
|
+
local lastReadyPlaceKey
|
|
849
958
|
local initialRole = PluginSession.getRole()
|
|
850
959
|
local routeMap = {
|
|
851
960
|
["/api/file-tree"] = QueryHandlers.getFileTree,
|
|
@@ -920,8 +1029,8 @@ local function getConnectionStatus()
|
|
|
920
1029
|
return "connecting"
|
|
921
1030
|
end
|
|
922
1031
|
local function dispatchStreamRequest(request, context)
|
|
923
|
-
if request.
|
|
924
|
-
return ClientBroker.dispatchClientRequest(request.
|
|
1032
|
+
if request.peerId ~= PluginSession.peerId then
|
|
1033
|
+
return ClientBroker.dispatchClientRequest(request.peerId, request.target, request.endpoint, request.data)
|
|
925
1034
|
end
|
|
926
1035
|
local _condition = assignedRole
|
|
927
1036
|
if _condition == nil then
|
|
@@ -930,7 +1039,7 @@ local function dispatchStreamRequest(request, context)
|
|
|
930
1039
|
local localRole = _condition
|
|
931
1040
|
if request.target ~= localRole then
|
|
932
1041
|
return {
|
|
933
|
-
error = `
|
|
1042
|
+
error = `Transport peer is registered as {localRole}, not {request.target}.`,
|
|
934
1043
|
}
|
|
935
1044
|
end
|
|
936
1045
|
return processRequest({
|
|
@@ -944,9 +1053,9 @@ local function handleReady(response)
|
|
|
944
1053
|
return nil
|
|
945
1054
|
end
|
|
946
1055
|
assignedRole = response.assignedRole
|
|
947
|
-
|
|
1056
|
+
lastReadyPlaceKey = PluginSession.getPlaceKey()
|
|
948
1057
|
ServerUrlSettings.rememberServerUrl(conn.serverUrl)
|
|
949
|
-
ClientBroker.
|
|
1058
|
+
ClientBroker.refreshAllProxyRegistrations()
|
|
950
1059
|
end
|
|
951
1060
|
local function handleStatus(status)
|
|
952
1061
|
local conn = State.getActiveConnection()
|
|
@@ -1025,7 +1134,7 @@ local function ensureIdentityWatchers()
|
|
|
1025
1134
|
if signalOk and signal then
|
|
1026
1135
|
placeIdChangeConn = signal:Connect(function()
|
|
1027
1136
|
PluginSession.invalidatePlaceName()
|
|
1028
|
-
|
|
1137
|
+
lastReadyPlaceKey = PluginSession.getPlaceKey()
|
|
1029
1138
|
StudioEventStream.refresh()
|
|
1030
1139
|
end)
|
|
1031
1140
|
end
|
|
@@ -1065,7 +1174,7 @@ local function activatePlugin()
|
|
|
1065
1174
|
conn.port = port
|
|
1066
1175
|
end
|
|
1067
1176
|
ClientBroker.setServerUrl(conn.serverUrl)
|
|
1068
|
-
|
|
1177
|
+
lastReadyPlaceKey = PluginSession.getPlaceKey()
|
|
1069
1178
|
UI.updateUIState()
|
|
1070
1179
|
StudioEventStream.start({
|
|
1071
1180
|
serverUrl = conn.serverUrl,
|
|
@@ -1082,15 +1191,15 @@ local function activatePlugin()
|
|
|
1082
1191
|
deactivatePlugin()
|
|
1083
1192
|
return nil
|
|
1084
1193
|
end
|
|
1085
|
-
local
|
|
1086
|
-
if
|
|
1087
|
-
|
|
1194
|
+
local currentPlaceKey = PluginSession.getPlaceKey()
|
|
1195
|
+
if lastReadyPlaceKey ~= nil and currentPlaceKey ~= lastReadyPlaceKey then
|
|
1196
|
+
lastReadyPlaceKey = currentPlaceKey
|
|
1088
1197
|
PluginSession.invalidatePlaceName()
|
|
1089
1198
|
StudioEventStream.refresh()
|
|
1090
1199
|
end
|
|
1091
1200
|
end)
|
|
1092
1201
|
end
|
|
1093
|
-
if
|
|
1202
|
+
if initialRole == "edit" then
|
|
1094
1203
|
task.spawn(cleanupEditBridgeArtifacts)
|
|
1095
1204
|
end
|
|
1096
1205
|
ensureIdentityWatchers()
|
|
@@ -1113,7 +1222,7 @@ function deactivatePlugin()
|
|
|
1113
1222
|
conn.heartbeatConnection:Disconnect()
|
|
1114
1223
|
conn.heartbeatConnection = nil
|
|
1115
1224
|
end
|
|
1116
|
-
|
|
1225
|
+
lastReadyPlaceKey = nil
|
|
1117
1226
|
assignedRole = nil
|
|
1118
1227
|
conn.consecutiveFailures = 0
|
|
1119
1228
|
conn.currentRetryDelay = 0.5
|
|
@@ -1281,6 +1390,7 @@ local ReplicatedStorage = _services.ReplicatedStorage
|
|
|
1281
1390
|
local RunService = _services.RunService
|
|
1282
1391
|
local ServerScriptService = _services.ServerScriptService
|
|
1283
1392
|
local StarterPlayer = _services.StarterPlayer
|
|
1393
|
+
local PeerRole = TS.import(script, script.Parent, "PeerRole")
|
|
1284
1394
|
local ScriptEditorService = game:GetService("ScriptEditorService")
|
|
1285
1395
|
local function getStarterPlayerScripts()
|
|
1286
1396
|
return StarterPlayer:FindFirstChild("StarterPlayerScripts")
|
|
@@ -1362,9 +1472,9 @@ local function computeBridgeStamp()
|
|
|
1362
1472
|
for i = 1, #combined do
|
|
1363
1473
|
h = (h * 33 + (string.byte(combined, i))) % 2147483647
|
|
1364
1474
|
end
|
|
1365
|
-
-- "3.0
|
|
1475
|
+
-- "3.1.0" is replaced with the package version at package time
|
|
1366
1476
|
-- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
|
|
1367
|
-
return `{tostring(h)}-3.0
|
|
1477
|
+
return `{tostring(h)}-3.1.0`
|
|
1368
1478
|
end
|
|
1369
1479
|
local BRIDGE_STAMP = computeBridgeStamp()
|
|
1370
1480
|
local function setSource(scriptInst, source)
|
|
@@ -1498,10 +1608,10 @@ local function installClientRuntimeBridge()
|
|
|
1498
1608
|
}
|
|
1499
1609
|
end
|
|
1500
1610
|
local function ensureRuntimeBridgeInstalled()
|
|
1501
|
-
if
|
|
1611
|
+
if PeerRole.detect() == "edit" then
|
|
1502
1612
|
return {
|
|
1503
1613
|
installed = false,
|
|
1504
|
-
error = "Eval bridges are installed only in
|
|
1614
|
+
error = "Eval bridges are installed only in play DataModels",
|
|
1505
1615
|
}
|
|
1506
1616
|
end
|
|
1507
1617
|
if RunService:IsServer() then
|
|
@@ -1968,10 +2078,10 @@ return {
|
|
|
1968
2078
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
1969
2079
|
local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
|
|
1970
2080
|
local Utils = TS.import(script, script.Parent.Parent, "Utils")
|
|
2081
|
+
local PeerRole = TS.import(script, script.Parent.Parent, "PeerRole")
|
|
1971
2082
|
local _binding = Utils
|
|
1972
2083
|
local getInstanceByPath = _binding.getInstanceByPath
|
|
1973
2084
|
local HttpService = game:GetService("HttpService")
|
|
1974
|
-
local RunService = game:GetService("RunService")
|
|
1975
2085
|
local ServerStorage = game:GetService("ServerStorage")
|
|
1976
2086
|
local LOG_PREFIX = "Breakpoint"
|
|
1977
2087
|
local REGISTRY_KEY_PREFIX = "MCP_BREAKPOINTS_V1_"
|
|
@@ -1986,7 +2096,7 @@ end
|
|
|
1986
2096
|
local function breakpointKey(scriptPath, line)
|
|
1987
2097
|
return `{scriptPath}:{line}`
|
|
1988
2098
|
end
|
|
1989
|
-
local function
|
|
2099
|
+
local function computePlaceKey()
|
|
1990
2100
|
if game.PlaceId ~= 0 then
|
|
1991
2101
|
return `place:{tostring(game.PlaceId)}`
|
|
1992
2102
|
end
|
|
@@ -2001,13 +2111,7 @@ local function computeInstanceId()
|
|
|
2001
2111
|
return `anon:{fresh}`
|
|
2002
2112
|
end
|
|
2003
2113
|
local function detectRole()
|
|
2004
|
-
|
|
2005
|
-
return "edit"
|
|
2006
|
-
end
|
|
2007
|
-
if RunService:IsServer() then
|
|
2008
|
-
return "server"
|
|
2009
|
-
end
|
|
2010
|
-
return "client"
|
|
2114
|
+
return PeerRole.detect()
|
|
2011
2115
|
end
|
|
2012
2116
|
local function requestedRole(requestData)
|
|
2013
2117
|
local ___mcp_target_role = requestData.__mcp_target_role
|
|
@@ -2018,15 +2122,10 @@ local function requestedRole(requestData)
|
|
|
2018
2122
|
return if _condition then requestData.__mcp_target_role else detectRole()
|
|
2019
2123
|
end
|
|
2020
2124
|
local function registryScope(requestData)
|
|
2021
|
-
local
|
|
2022
|
-
local _condition = type(___mcp_instance_id) == "string"
|
|
2023
|
-
if _condition then
|
|
2024
|
-
_condition = requestData.__mcp_instance_id ~= ""
|
|
2025
|
-
end
|
|
2026
|
-
local instanceId = if _condition then requestData.__mcp_instance_id else computeInstanceId()
|
|
2125
|
+
local placeKey = computePlaceKey()
|
|
2027
2126
|
local role = requestedRole(requestData)
|
|
2028
2127
|
return {
|
|
2029
|
-
key = `{REGISTRY_KEY_PREFIX}{
|
|
2128
|
+
key = `{REGISTRY_KEY_PREFIX}{placeKey}:{role}`,
|
|
2030
2129
|
}
|
|
2031
2130
|
end
|
|
2032
2131
|
local function readSetting(key)
|
|
@@ -3293,15 +3392,11 @@ local function getRuntimeLogs(requestData)
|
|
|
3293
3392
|
local since = requestData.since
|
|
3294
3393
|
local tail = requestData.tail
|
|
3295
3394
|
local filter = requestData.filter
|
|
3296
|
-
-- This is the buffer that captured the LogService event, not necessarily
|
|
3297
|
-
-- the script-origin peer. Ordinary playtests share/reflect logs across
|
|
3298
|
-
-- edit/server/client LogService buffers.
|
|
3299
|
-
local capturedBy = RuntimeLogBuffer.detectPeer()
|
|
3300
3395
|
return RuntimeLogBuffer.query({
|
|
3301
3396
|
since = since,
|
|
3302
3397
|
tail = tail,
|
|
3303
3398
|
filter = filter,
|
|
3304
|
-
}
|
|
3399
|
+
})
|
|
3305
3400
|
end
|
|
3306
3401
|
return {
|
|
3307
3402
|
getRuntimeLogs = getRuntimeLogs,
|
|
@@ -7590,7 +7685,7 @@ return {
|
|
|
7590
7685
|
<string name="Name">SerializationHandlers</string>
|
|
7591
7686
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
7592
7687
|
local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
|
|
7593
|
-
local
|
|
7688
|
+
local PeerRole = TS.import(script, script.Parent.Parent, "PeerRole")
|
|
7594
7689
|
local Utils = TS.import(script, script.Parent.Parent, "Utils")
|
|
7595
7690
|
local Recording = TS.import(script, script.Parent.Parent, "Recording")
|
|
7596
7691
|
-- SerializationService:SerializeInstancesAsync / DeserializeInstancesAsync were
|
|
@@ -7710,7 +7805,7 @@ local function importRbxm(requestData)
|
|
|
7710
7805
|
-- All-or-nothing parenting. Track every instance we've attached and roll back
|
|
7711
7806
|
-- (unparent + Destroy) if any later one fails - partial imports leave the DM
|
|
7712
7807
|
-- in a worse state than failing cleanly.
|
|
7713
|
-
local isEdit =
|
|
7808
|
+
local isEdit = PeerRole.detect() == "edit"
|
|
7714
7809
|
local recordingId = if isEdit then beginRecording(`Import rbxm`) else nil
|
|
7715
7810
|
local attached = {}
|
|
7716
7811
|
local failureMessage
|
|
@@ -7781,19 +7876,15 @@ local HttpService = _services.HttpService
|
|
|
7781
7876
|
local Players = _services.Players
|
|
7782
7877
|
local RunService = _services.RunService
|
|
7783
7878
|
local StopPlayMonitor = TS.import(script, script.Parent.Parent, "StopPlayMonitor")
|
|
7879
|
+
local PluginSession = TS.import(script, script.Parent.Parent, "PluginSession")
|
|
7880
|
+
local PeerRole = TS.import(script, script.Parent.Parent, "PeerRole")
|
|
7784
7881
|
local StudioTestService = game:GetService("StudioTestService")
|
|
7785
7882
|
local testRunning = false
|
|
7786
7883
|
local multiplayerState = {
|
|
7787
7884
|
phase = "idle",
|
|
7788
7885
|
}
|
|
7789
7886
|
local function detectPeerRole()
|
|
7790
|
-
|
|
7791
|
-
return "edit"
|
|
7792
|
-
end
|
|
7793
|
-
if RunService:IsServer() then
|
|
7794
|
-
return "server"
|
|
7795
|
-
end
|
|
7796
|
-
return "client"
|
|
7887
|
+
return PeerRole.detect()
|
|
7797
7888
|
end
|
|
7798
7889
|
local function getPlayersSnapshot()
|
|
7799
7890
|
local _exp = Players:GetPlayers()
|
|
@@ -7868,6 +7959,7 @@ local function startPlaytest(requestData)
|
|
|
7868
7959
|
}
|
|
7869
7960
|
end
|
|
7870
7961
|
testRunning = true
|
|
7962
|
+
local topologyMarkerToken = PluginSession.prepareSharedTopology()
|
|
7871
7963
|
task.spawn(function()
|
|
7872
7964
|
local ok, result = pcall(function()
|
|
7873
7965
|
if mode == "play" then
|
|
@@ -7875,6 +7967,7 @@ local function startPlaytest(requestData)
|
|
|
7875
7967
|
end
|
|
7876
7968
|
return StudioTestService:ExecuteRunModeAsync({})
|
|
7877
7969
|
end)
|
|
7970
|
+
PluginSession.clearTopologyMarker(topologyMarkerToken)
|
|
7878
7971
|
if not ok then
|
|
7879
7972
|
warn(`[robloxstudio-mcp] Playtest ended with error: {result}`)
|
|
7880
7973
|
end
|
|
@@ -7980,11 +8073,13 @@ local function multiplayerTestStart(requestData)
|
|
|
7980
8073
|
testArgs = testArgs,
|
|
7981
8074
|
startedAt = tick(),
|
|
7982
8075
|
}
|
|
8076
|
+
local topologyMarkerToken = PluginSession.prepareMultiplayerTopology(testId)
|
|
7983
8077
|
task.spawn(function()
|
|
7984
8078
|
multiplayerState.phase = "running"
|
|
7985
8079
|
local ok, result = pcall(function()
|
|
7986
8080
|
return StudioTestService:ExecuteMultiplayerTestAsync(numPlayers, testArgs)
|
|
7987
8081
|
end)
|
|
8082
|
+
PluginSession.clearTopologyMarker(topologyMarkerToken)
|
|
7988
8083
|
multiplayerState.completedAt = tick()
|
|
7989
8084
|
multiplayerState.ok = ok
|
|
7990
8085
|
if ok then
|
|
@@ -8666,20 +8761,69 @@ return {
|
|
|
8666
8761
|
</Properties>
|
|
8667
8762
|
</Item>
|
|
8668
8763
|
<Item class="ModuleScript" referent="27">
|
|
8764
|
+
<Properties>
|
|
8765
|
+
<string name="Name">PeerRole</string>
|
|
8766
|
+
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
8767
|
+
local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
8768
|
+
local RunService = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services").RunService
|
|
8769
|
+
local function detect()
|
|
8770
|
+
if RunService:IsEdit() then
|
|
8771
|
+
return "edit"
|
|
8772
|
+
end
|
|
8773
|
+
if RunService:IsServer() then
|
|
8774
|
+
return "server"
|
|
8775
|
+
end
|
|
8776
|
+
return "client"
|
|
8777
|
+
end
|
|
8778
|
+
return {
|
|
8779
|
+
detect = detect,
|
|
8780
|
+
}
|
|
8781
|
+
]]></string>
|
|
8782
|
+
</Properties>
|
|
8783
|
+
</Item>
|
|
8784
|
+
<Item class="ModuleScript" referent="28">
|
|
8669
8785
|
<Properties>
|
|
8670
8786
|
<string name="Name">PluginSession</string>
|
|
8671
8787
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
8672
8788
|
local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
8673
8789
|
local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
|
|
8674
8790
|
local HttpService = _services.HttpService
|
|
8791
|
+
local ReplicatedStorage = _services.ReplicatedStorage
|
|
8675
8792
|
local RunService = _services.RunService
|
|
8676
8793
|
local ServerStorage = _services.ServerStorage
|
|
8677
8794
|
local State = TS.import(script, script.Parent, "State")
|
|
8795
|
+
local PeerRole = TS.import(script, script.Parent, "PeerRole")
|
|
8796
|
+
local TopologyId = TS.import(script, script.Parent, "TopologyId")
|
|
8678
8797
|
local MCP_PLACE_ID_ATTRIBUTE = "__MCPPlaceId"
|
|
8679
|
-
local
|
|
8798
|
+
local TOPOLOGY_MODE_ATTRIBUTE = "__MCPTopologyMode"
|
|
8799
|
+
local TOPOLOGY_INSTANCE_ID_ATTRIBUTE = "__MCPTopologyInstanceId"
|
|
8800
|
+
local TOPOLOGY_GROUP_ID_ATTRIBUTE = "__MCPTopologyGroupId"
|
|
8801
|
+
local TOPOLOGY_TOKEN_ATTRIBUTE = "__MCPTopologyToken"
|
|
8802
|
+
local peerId = TopologyId.createPeerId()
|
|
8803
|
+
local processInstanceId = TopologyId.currentProcessInstanceId()
|
|
8680
8804
|
local cachedPlaceName
|
|
8681
8805
|
local cachedPlaceNamePlaceId
|
|
8806
|
+
local function getMarkerMode()
|
|
8807
|
+
local mode = ReplicatedStorage:GetAttribute(TOPOLOGY_MODE_ATTRIBUTE)
|
|
8808
|
+
return if mode == "shared" or mode == "multiplayer" then mode else nil
|
|
8809
|
+
end
|
|
8682
8810
|
local function getInstanceId()
|
|
8811
|
+
if getMarkerMode() == "shared" then
|
|
8812
|
+
local sharedInstanceId = ReplicatedStorage:GetAttribute(TOPOLOGY_INSTANCE_ID_ATTRIBUTE)
|
|
8813
|
+
if type(sharedInstanceId) == "string" and sharedInstanceId ~= "" then
|
|
8814
|
+
return sharedInstanceId
|
|
8815
|
+
end
|
|
8816
|
+
end
|
|
8817
|
+
return processInstanceId
|
|
8818
|
+
end
|
|
8819
|
+
local function getMultiplayerGroupId()
|
|
8820
|
+
if getMarkerMode() ~= "multiplayer" then
|
|
8821
|
+
return nil
|
|
8822
|
+
end
|
|
8823
|
+
local groupId = ReplicatedStorage:GetAttribute(TOPOLOGY_GROUP_ID_ATTRIBUTE)
|
|
8824
|
+
return if type(groupId) == "string" and groupId ~= "" then groupId else nil
|
|
8825
|
+
end
|
|
8826
|
+
local function getPlaceKey()
|
|
8683
8827
|
if game.PlaceId ~= 0 then
|
|
8684
8828
|
return `place:{tostring(game.PlaceId)}`
|
|
8685
8829
|
end
|
|
@@ -8693,14 +8837,32 @@ local function getInstanceId()
|
|
|
8693
8837
|
end)
|
|
8694
8838
|
return `anon:{fresh}`
|
|
8695
8839
|
end
|
|
8696
|
-
local function
|
|
8697
|
-
|
|
8698
|
-
|
|
8699
|
-
|
|
8700
|
-
|
|
8701
|
-
|
|
8840
|
+
local function setTopologyMarker(mode, instanceId, groupId)
|
|
8841
|
+
local token = HttpService:GenerateGUID(false)
|
|
8842
|
+
ReplicatedStorage:SetAttribute(TOPOLOGY_MODE_ATTRIBUTE, nil)
|
|
8843
|
+
ReplicatedStorage:SetAttribute(TOPOLOGY_INSTANCE_ID_ATTRIBUTE, instanceId)
|
|
8844
|
+
ReplicatedStorage:SetAttribute(TOPOLOGY_GROUP_ID_ATTRIBUTE, groupId)
|
|
8845
|
+
ReplicatedStorage:SetAttribute(TOPOLOGY_TOKEN_ATTRIBUTE, token)
|
|
8846
|
+
ReplicatedStorage:SetAttribute(TOPOLOGY_MODE_ATTRIBUTE, mode)
|
|
8847
|
+
return token
|
|
8848
|
+
end
|
|
8849
|
+
local function prepareSharedTopology()
|
|
8850
|
+
return setTopologyMarker("shared", processInstanceId, nil)
|
|
8851
|
+
end
|
|
8852
|
+
local function prepareMultiplayerTopology(groupId)
|
|
8853
|
+
return setTopologyMarker("multiplayer", nil, groupId)
|
|
8854
|
+
end
|
|
8855
|
+
local function clearTopologyMarker(token)
|
|
8856
|
+
if ReplicatedStorage:GetAttribute(TOPOLOGY_TOKEN_ATTRIBUTE) ~= token then
|
|
8857
|
+
return nil
|
|
8702
8858
|
end
|
|
8703
|
-
|
|
8859
|
+
ReplicatedStorage:SetAttribute(TOPOLOGY_MODE_ATTRIBUTE, nil)
|
|
8860
|
+
ReplicatedStorage:SetAttribute(TOPOLOGY_INSTANCE_ID_ATTRIBUTE, nil)
|
|
8861
|
+
ReplicatedStorage:SetAttribute(TOPOLOGY_GROUP_ID_ATTRIBUTE, nil)
|
|
8862
|
+
ReplicatedStorage:SetAttribute(TOPOLOGY_TOKEN_ATTRIBUTE, nil)
|
|
8863
|
+
end
|
|
8864
|
+
local function getRole()
|
|
8865
|
+
return PeerRole.detect()
|
|
8704
8866
|
end
|
|
8705
8867
|
local function invalidatePlaceName()
|
|
8706
8868
|
cachedPlaceName = nil
|
|
@@ -8731,14 +8893,22 @@ local function getPlaceName()
|
|
|
8731
8893
|
end
|
|
8732
8894
|
return game.Name
|
|
8733
8895
|
end
|
|
8734
|
-
local function createReadyPayload(
|
|
8896
|
+
local function createReadyPayload(readyPeerId, role, instanceId, multiplayerGroupId)
|
|
8897
|
+
if instanceId == nil then
|
|
8898
|
+
instanceId = getInstanceId()
|
|
8899
|
+
end
|
|
8900
|
+
if multiplayerGroupId == nil then
|
|
8901
|
+
multiplayerGroupId = getMultiplayerGroupId()
|
|
8902
|
+
end
|
|
8735
8903
|
return {
|
|
8736
|
-
|
|
8737
|
-
|
|
8738
|
-
instanceId =
|
|
8904
|
+
peerId = readyPeerId,
|
|
8905
|
+
transportPeerId = peerId,
|
|
8906
|
+
instanceId = instanceId,
|
|
8907
|
+
multiplayerGroupId = multiplayerGroupId,
|
|
8739
8908
|
role = role,
|
|
8740
8909
|
placeId = game.PlaceId,
|
|
8741
8910
|
placeName = getPlaceName(),
|
|
8911
|
+
placeKey = getPlaceKey(),
|
|
8742
8912
|
dataModelName = game.Name,
|
|
8743
8913
|
isRunning = RunService:IsRunning(),
|
|
8744
8914
|
pluginVersion = State.CURRENT_VERSION,
|
|
@@ -8747,17 +8917,22 @@ local function createReadyPayload(pluginSessionId, role)
|
|
|
8747
8917
|
}
|
|
8748
8918
|
end
|
|
8749
8919
|
return {
|
|
8750
|
-
|
|
8920
|
+
peerId = peerId,
|
|
8751
8921
|
getInstanceId = getInstanceId,
|
|
8922
|
+
getMultiplayerGroupId = getMultiplayerGroupId,
|
|
8923
|
+
getPlaceKey = getPlaceKey,
|
|
8752
8924
|
getRole = getRole,
|
|
8753
8925
|
getPlaceName = getPlaceName,
|
|
8754
8926
|
invalidatePlaceName = invalidatePlaceName,
|
|
8927
|
+
prepareSharedTopology = prepareSharedTopology,
|
|
8928
|
+
prepareMultiplayerTopology = prepareMultiplayerTopology,
|
|
8929
|
+
clearTopologyMarker = clearTopologyMarker,
|
|
8755
8930
|
createReadyPayload = createReadyPayload,
|
|
8756
8931
|
}
|
|
8757
8932
|
]]></string>
|
|
8758
8933
|
</Properties>
|
|
8759
8934
|
</Item>
|
|
8760
|
-
<Item class="ModuleScript" referent="
|
|
8935
|
+
<Item class="ModuleScript" referent="29">
|
|
8761
8936
|
<Properties>
|
|
8762
8937
|
<string name="Name">Recording</string>
|
|
8763
8938
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -8787,7 +8962,7 @@ return {
|
|
|
8787
8962
|
]]></string>
|
|
8788
8963
|
</Properties>
|
|
8789
8964
|
</Item>
|
|
8790
|
-
<Item class="ModuleScript" referent="
|
|
8965
|
+
<Item class="ModuleScript" referent="30">
|
|
8791
8966
|
<Properties>
|
|
8792
8967
|
<string name="Name">RenderMonitor</string>
|
|
8793
8968
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -8855,30 +9030,22 @@ return {
|
|
|
8855
9030
|
]]></string>
|
|
8856
9031
|
</Properties>
|
|
8857
9032
|
</Item>
|
|
8858
|
-
<Item class="ModuleScript" referent="
|
|
9033
|
+
<Item class="ModuleScript" referent="31">
|
|
8859
9034
|
<Properties>
|
|
8860
9035
|
<string name="Name">RuntimeLogBuffer</string>
|
|
8861
9036
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
8862
9037
|
local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
8863
|
-
--
|
|
8864
|
-
-- Powers
|
|
8865
|
-
-- primitives + StringValue approach from chrrxs/roblox-mcp-primitives.
|
|
8866
|
-
--
|
|
8867
|
-
-- Each peer's plugin attaches a MessageOut listener at plugin load (edit DM,
|
|
8868
|
-
-- play-server DM, play-client DM all run their own copy of this module).
|
|
8869
|
-
-- Captured entries live in plugin module-state; nothing is parented to the
|
|
8870
|
-
-- DataModel. The buffer is bounded by a message-byte budget; oldest entries
|
|
8871
|
-
-- drop when over budget.
|
|
9038
|
+
-- Bounded capture for one Peer VM's LogService callbacks.
|
|
9039
|
+
-- Powers get_runtime_logs without parenting state to the DataModel.
|
|
8872
9040
|
--
|
|
8873
|
-
--
|
|
8874
|
-
--
|
|
8875
|
-
--
|
|
8876
|
-
--
|
|
8877
|
-
-- exposes that as capturedBy, and only promotes it to origin peer in
|
|
8878
|
-
-- StudioTestService multiplayer sessions where peer attribution is reliable.
|
|
9041
|
+
-- A Studio process can host several Peer VMs, and its LogService callbacks are
|
|
9042
|
+
-- delivered in those VM contexts. MCP reads every Peer buffer in an Instance
|
|
9043
|
+
-- and merges them into that process's log stream. Multiplayer Group Instances
|
|
9044
|
+
-- remain isolated and are returned independently.
|
|
8879
9045
|
local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
|
|
8880
9046
|
local LogService = _services.LogService
|
|
8881
9047
|
local RunService = _services.RunService
|
|
9048
|
+
local PeerRole = TS.import(script, script.Parent, "PeerRole")
|
|
8882
9049
|
local MAX_BYTES = 64 * 1024
|
|
8883
9050
|
local HARD_ENTRY_CAP = 50_000
|
|
8884
9051
|
local entries = {}
|
|
@@ -8968,7 +9135,7 @@ local function seedRuntimeHistory()
|
|
|
8968
9135
|
if not ok then
|
|
8969
9136
|
return nil
|
|
8970
9137
|
end
|
|
8971
|
-
local isEdit =
|
|
9138
|
+
local isEdit = PeerRole.detect() == "edit"
|
|
8972
9139
|
-- GetLogHistory timestamps and DateTime.now() share Unix time, while
|
|
8973
9140
|
-- os.clock() is elapsed time for this Studio process. Their difference is
|
|
8974
9141
|
-- therefore the process launch boundary. Edit-mode history is filtered to
|
|
@@ -9004,16 +9171,7 @@ local function install()
|
|
|
9004
9171
|
pushEntry(msg, t, nil, context)
|
|
9005
9172
|
end)
|
|
9006
9173
|
end
|
|
9007
|
-
local function
|
|
9008
|
-
if not RunService:IsRunning() then
|
|
9009
|
-
return "edit"
|
|
9010
|
-
end
|
|
9011
|
-
if RunService:IsServer() then
|
|
9012
|
-
return "server"
|
|
9013
|
-
end
|
|
9014
|
-
return "client"
|
|
9015
|
-
end
|
|
9016
|
-
local function query(opts, capturedBy)
|
|
9174
|
+
local function query(opts)
|
|
9017
9175
|
local _result
|
|
9018
9176
|
if opts.since ~= nil then
|
|
9019
9177
|
-- ▼ ReadonlyArray.filter ▼
|
|
@@ -9084,7 +9242,6 @@ local function query(opts, capturedBy)
|
|
|
9084
9242
|
end
|
|
9085
9243
|
local last = if #entries > 0 then entries[#entries] else nil
|
|
9086
9244
|
local _object = {
|
|
9087
|
-
capturedBy = capturedBy,
|
|
9088
9245
|
entries = result,
|
|
9089
9246
|
totalDropped = totalDropped,
|
|
9090
9247
|
}
|
|
@@ -9104,13 +9261,12 @@ local function query(opts, capturedBy)
|
|
|
9104
9261
|
end
|
|
9105
9262
|
return {
|
|
9106
9263
|
install = install,
|
|
9107
|
-
detectPeer = detectPeer,
|
|
9108
9264
|
query = query,
|
|
9109
9265
|
}
|
|
9110
9266
|
]]></string>
|
|
9111
9267
|
</Properties>
|
|
9112
9268
|
</Item>
|
|
9113
|
-
<Item class="ModuleScript" referent="
|
|
9269
|
+
<Item class="ModuleScript" referent="32">
|
|
9114
9270
|
<Properties>
|
|
9115
9271
|
<string name="Name">ScriptSearch</string>
|
|
9116
9272
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -9437,7 +9593,7 @@ return {
|
|
|
9437
9593
|
]]></string>
|
|
9438
9594
|
</Properties>
|
|
9439
9595
|
</Item>
|
|
9440
|
-
<Item class="ModuleScript" referent="
|
|
9596
|
+
<Item class="ModuleScript" referent="33">
|
|
9441
9597
|
<Properties>
|
|
9442
9598
|
<string name="Name">ServerUrlSettings</string>
|
|
9443
9599
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -9484,14 +9640,14 @@ local function addUnique(values, value)
|
|
|
9484
9640
|
table.insert(_values_1, _value_1)
|
|
9485
9641
|
end
|
|
9486
9642
|
end
|
|
9487
|
-
local function
|
|
9488
|
-
local
|
|
9643
|
+
local function computePlaceKeys(options)
|
|
9644
|
+
local placeKeys = {}
|
|
9489
9645
|
if game.PlaceId ~= 0 then
|
|
9490
|
-
addUnique(
|
|
9646
|
+
addUnique(placeKeys, `place:{tostring(game.PlaceId)}`)
|
|
9491
9647
|
end
|
|
9492
9648
|
local existing = ServerStorage:GetAttribute("__MCPPlaceId")
|
|
9493
9649
|
if type(existing) == "string" and existing ~= "" then
|
|
9494
|
-
addUnique(
|
|
9650
|
+
addUnique(placeKeys, `anon:{existing}`)
|
|
9495
9651
|
else
|
|
9496
9652
|
local _condition = game.PlaceId == 0
|
|
9497
9653
|
if _condition then
|
|
@@ -9506,13 +9662,13 @@ local function computeInstanceIds(options)
|
|
|
9506
9662
|
pcall(function()
|
|
9507
9663
|
return ServerStorage:SetAttribute("__MCPPlaceId", fresh)
|
|
9508
9664
|
end)
|
|
9509
|
-
addUnique(
|
|
9665
|
+
addUnique(placeKeys, `anon:{fresh}`)
|
|
9510
9666
|
end
|
|
9511
9667
|
end
|
|
9512
|
-
return
|
|
9668
|
+
return placeKeys
|
|
9513
9669
|
end
|
|
9514
|
-
local function settingKey(
|
|
9515
|
-
return SETTING_KEY_PREFIX ..
|
|
9670
|
+
local function settingKey(placeKey)
|
|
9671
|
+
return SETTING_KEY_PREFIX .. placeKey
|
|
9516
9672
|
end
|
|
9517
9673
|
local function readSettingString(key)
|
|
9518
9674
|
if not pluginRef then
|
|
@@ -9541,21 +9697,21 @@ local function rememberServerUrl(serverUrl)
|
|
|
9541
9697
|
return nil
|
|
9542
9698
|
end
|
|
9543
9699
|
writeSettingString(GLOBAL_SETTING_KEY, normalized)
|
|
9544
|
-
for _,
|
|
9700
|
+
for _, placeKey in computePlaceKeys({
|
|
9545
9701
|
createAnonymous = true,
|
|
9546
9702
|
}) do
|
|
9547
|
-
writeSettingString(settingKey(
|
|
9703
|
+
writeSettingString(settingKey(placeKey), normalized)
|
|
9548
9704
|
end
|
|
9549
9705
|
end
|
|
9550
9706
|
local function readServerUrl()
|
|
9551
9707
|
if not pluginRef then
|
|
9552
9708
|
return nil
|
|
9553
9709
|
end
|
|
9554
|
-
-- Reading settings should not mint a place
|
|
9555
|
-
-- their own ServerStorage; creating
|
|
9556
|
-
--
|
|
9557
|
-
for _,
|
|
9558
|
-
local remembered = readSettingString(settingKey(
|
|
9710
|
+
-- Reading settings should not mint a place key. Client play DataModels have
|
|
9711
|
+
-- their own ServerStorage; creating a key there would not match the
|
|
9712
|
+
-- edit/server place-scoped setting.
|
|
9713
|
+
for _, placeKey in computePlaceKeys() do
|
|
9714
|
+
local remembered = readSettingString(settingKey(placeKey))
|
|
9559
9715
|
if remembered ~= nil then
|
|
9560
9716
|
return remembered
|
|
9561
9717
|
end
|
|
@@ -9576,11 +9732,11 @@ return {
|
|
|
9576
9732
|
]]></string>
|
|
9577
9733
|
</Properties>
|
|
9578
9734
|
</Item>
|
|
9579
|
-
<Item class="ModuleScript" referent="
|
|
9735
|
+
<Item class="ModuleScript" referent="34">
|
|
9580
9736
|
<Properties>
|
|
9581
9737
|
<string name="Name">State</string>
|
|
9582
9738
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
9583
|
-
local CURRENT_VERSION = "3.0
|
|
9739
|
+
local CURRENT_VERSION = "3.1.0"
|
|
9584
9740
|
local PLUGIN_VARIANT = "main"
|
|
9585
9741
|
local BASE_PORT = 58741
|
|
9586
9742
|
local function createConnection(port)
|
|
@@ -9610,40 +9766,18 @@ return {
|
|
|
9610
9766
|
]]></string>
|
|
9611
9767
|
</Properties>
|
|
9612
9768
|
</Item>
|
|
9613
|
-
<Item class="ModuleScript" referent="
|
|
9769
|
+
<Item class="ModuleScript" referent="35">
|
|
9614
9770
|
<Properties>
|
|
9615
9771
|
<string name="Name">StopPlayMonitor</string>
|
|
9616
9772
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
9617
9773
|
local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
9618
|
-
-- Cross-
|
|
9619
|
-
--
|
|
9620
|
-
--
|
|
9621
|
-
-- During publish-after-connect, both "anon:<uuid>" and "place:<PlaceId>"
|
|
9622
|
-
-- can refer to the same Studio place, so stop requests are mirrored across
|
|
9623
|
-
-- both keys while the monitor waits for a matching result on either key.
|
|
9624
|
-
--
|
|
9625
|
-
-- `plugin:SetSetting` / `plugin:GetSetting` is a per-plugin persistent store
|
|
9626
|
-
-- shared across every DataModel the plugin runs in (edit DMs, play-server
|
|
9627
|
-
-- DMs, play-client DMs). For each connected place we use a dedicated key
|
|
9628
|
-
-- "MCP_STOP_PLAY_<instanceId>" as a tiny request/result mailbox:
|
|
9629
|
-
--
|
|
9630
|
-
-- * The edit DM's handler writes a tokenized stop request into its own key
|
|
9631
|
-
-- (computed from its placeId / ServerStorage anon UUID).
|
|
9632
|
-
-- * Each play-server DM's monitor loop polls the key matching its own
|
|
9633
|
-
-- instanceId at 1Hz. On a fresh token, it calls StudioTestService:EndTest
|
|
9634
|
-
-- and writes a matching result token. Play-server DMs for other places
|
|
9635
|
-
-- never touch this key.
|
|
9636
|
-
-- * The edit DM waits up to ~8s for its result token, confirming a matching
|
|
9637
|
-
-- play-server actually consumed the request.
|
|
9638
|
-
--
|
|
9639
|
-
-- Earlier versions used a single shared boolean flag, which let any
|
|
9640
|
-
-- play-server DM in the same Studio process consume any place's stop
|
|
9641
|
-
-- request — silently yanking teammates' playtests. The per-key scoping
|
|
9642
|
-
-- below is the fix.
|
|
9774
|
+
-- Cross-DataModel stop_playtest signaling via plugin settings. Solo edit and
|
|
9775
|
+
-- runtime peers share one process instanceId through PluginSession's topology
|
|
9776
|
+
-- marker, so only the intended Studio process can consume the request.
|
|
9643
9777
|
local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
|
|
9644
9778
|
local HttpService = _services.HttpService
|
|
9645
9779
|
local RunService = _services.RunService
|
|
9646
|
-
local
|
|
9780
|
+
local PluginSession = TS.import(script, script.Parent, "PluginSession")
|
|
9647
9781
|
local StudioTestService = game:GetService("StudioTestService")
|
|
9648
9782
|
local SETTING_KEY_PREFIX = "MCP_STOP_PLAY_"
|
|
9649
9783
|
-- Keep this conservative. plugin:GetSetting is backed by Studio's plugin
|
|
@@ -9664,52 +9798,8 @@ local transportLifecycle
|
|
|
9664
9798
|
local function init(p)
|
|
9665
9799
|
pluginRef = p
|
|
9666
9800
|
end
|
|
9667
|
-
|
|
9668
|
-
|
|
9669
|
-
-- agree on the place identifier (published places: placeId; unpublished:
|
|
9670
|
-
-- UUID on ServerStorage's __MCPPlaceId attribute, travels with the .rbxl
|
|
9671
|
-
-- into the play DM).
|
|
9672
|
-
local function addUnique(values, value)
|
|
9673
|
-
local _values = values
|
|
9674
|
-
local _value = value
|
|
9675
|
-
if not (table.find(_values, _value) ~= nil) then
|
|
9676
|
-
local _values_1 = values
|
|
9677
|
-
local _value_1 = value
|
|
9678
|
-
table.insert(_values_1, _value_1)
|
|
9679
|
-
end
|
|
9680
|
-
end
|
|
9681
|
-
local function computeInstanceIds()
|
|
9682
|
-
local ids = {}
|
|
9683
|
-
if game.PlaceId ~= 0 then
|
|
9684
|
-
addUnique(ids, `place:{tostring(game.PlaceId)}`)
|
|
9685
|
-
end
|
|
9686
|
-
local existing = ServerStorage:GetAttribute("__MCPPlaceId")
|
|
9687
|
-
if type(existing) == "string" and existing ~= "" then
|
|
9688
|
-
addUnique(ids, `anon:{existing}`)
|
|
9689
|
-
elseif game.PlaceId == 0 then
|
|
9690
|
-
local fresh = HttpService:GenerateGUID(false)
|
|
9691
|
-
pcall(function()
|
|
9692
|
-
return ServerStorage:SetAttribute("__MCPPlaceId", fresh)
|
|
9693
|
-
end)
|
|
9694
|
-
addUnique(ids, `anon:{fresh}`)
|
|
9695
|
-
end
|
|
9696
|
-
return ids
|
|
9697
|
-
end
|
|
9698
|
-
local function settingKey(instanceId)
|
|
9699
|
-
return SETTING_KEY_PREFIX .. instanceId
|
|
9700
|
-
end
|
|
9701
|
-
local function settingKeys()
|
|
9702
|
-
local _exp = computeInstanceIds()
|
|
9703
|
-
-- ▼ ReadonlyArray.map ▼
|
|
9704
|
-
local _newValue = table.create(#_exp)
|
|
9705
|
-
local _callback = function(instanceId)
|
|
9706
|
-
return settingKey(instanceId)
|
|
9707
|
-
end
|
|
9708
|
-
for _k, _v in _exp do
|
|
9709
|
-
_newValue[_k] = _callback(_v, _k - 1, _exp)
|
|
9710
|
-
end
|
|
9711
|
-
-- ▲ ReadonlyArray.map ▲
|
|
9712
|
-
return _newValue
|
|
9801
|
+
local function settingKey()
|
|
9802
|
+
return SETTING_KEY_PREFIX .. PluginSession.getInstanceId()
|
|
9713
9803
|
end
|
|
9714
9804
|
local function readSetting(key)
|
|
9715
9805
|
if not pluginRef then
|
|
@@ -9833,11 +9923,10 @@ local function startMonitor(lifecycle)
|
|
|
9833
9923
|
end
|
|
9834
9924
|
task.spawn(function()
|
|
9835
9925
|
while true do
|
|
9836
|
-
|
|
9837
|
-
|
|
9838
|
-
|
|
9839
|
-
|
|
9840
|
-
end
|
|
9926
|
+
local myKey = settingKey()
|
|
9927
|
+
local payload = decodePayload(readSetting(myKey))
|
|
9928
|
+
if payload then
|
|
9929
|
+
handleStopRequest(myKey, payload)
|
|
9841
9930
|
end
|
|
9842
9931
|
task.wait(POLL_INTERVAL_SEC)
|
|
9843
9932
|
end
|
|
@@ -9855,10 +9944,7 @@ local function requestStop()
|
|
|
9855
9944
|
id = requestId,
|
|
9856
9945
|
requestedAt = tick(),
|
|
9857
9946
|
}
|
|
9858
|
-
local ok =
|
|
9859
|
-
for _, myKey in settingKeys() do
|
|
9860
|
-
ok = writePayload(myKey, payload) or ok
|
|
9861
|
-
end
|
|
9947
|
+
local ok = writePayload(settingKey(), payload)
|
|
9862
9948
|
return {
|
|
9863
9949
|
ok = ok,
|
|
9864
9950
|
requestId = if ok then requestId else nil,
|
|
@@ -9874,15 +9960,13 @@ local function waitForConsumption(requestId)
|
|
|
9874
9960
|
end
|
|
9875
9961
|
local start = tick()
|
|
9876
9962
|
while tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC do
|
|
9877
|
-
|
|
9878
|
-
|
|
9879
|
-
|
|
9880
|
-
|
|
9881
|
-
|
|
9882
|
-
|
|
9883
|
-
|
|
9884
|
-
}
|
|
9885
|
-
end
|
|
9963
|
+
local payload = decodePayload(readSetting(settingKey()))
|
|
9964
|
+
if payload and payload.kind == "result" and payload.id == requestId then
|
|
9965
|
+
return {
|
|
9966
|
+
ok = payload.ok == true,
|
|
9967
|
+
consumed = true,
|
|
9968
|
+
error = payload.error,
|
|
9969
|
+
}
|
|
9886
9970
|
end
|
|
9887
9971
|
task.wait(WAIT_POLL_SEC)
|
|
9888
9972
|
end
|
|
@@ -9896,15 +9980,14 @@ local function clearPending(requestId)
|
|
|
9896
9980
|
if not pluginRef then
|
|
9897
9981
|
return nil
|
|
9898
9982
|
end
|
|
9899
|
-
|
|
9900
|
-
|
|
9901
|
-
|
|
9902
|
-
|
|
9903
|
-
|
|
9904
|
-
end
|
|
9983
|
+
local myKey = settingKey()
|
|
9984
|
+
if requestId ~= nil then
|
|
9985
|
+
local payload = decodePayload(readSetting(myKey))
|
|
9986
|
+
if payload and payload.id ~= requestId then
|
|
9987
|
+
return nil
|
|
9905
9988
|
end
|
|
9906
|
-
writeSetting(myKey, false)
|
|
9907
9989
|
end
|
|
9990
|
+
writeSetting(myKey, false)
|
|
9908
9991
|
end
|
|
9909
9992
|
return {
|
|
9910
9993
|
init = init,
|
|
@@ -9916,7 +9999,7 @@ return {
|
|
|
9916
9999
|
]]></string>
|
|
9917
10000
|
</Properties>
|
|
9918
10001
|
</Item>
|
|
9919
|
-
<Item class="ModuleScript" referent="
|
|
10002
|
+
<Item class="ModuleScript" referent="36">
|
|
9920
10003
|
<Properties>
|
|
9921
10004
|
<string name="Name">StudioEventStream</string>
|
|
9922
10005
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -9970,8 +10053,8 @@ local function decodeMessage(message)
|
|
|
9970
10053
|
}
|
|
9971
10054
|
end
|
|
9972
10055
|
if envelope.kind == "status" then
|
|
9973
|
-
local
|
|
9974
|
-
local _condition = not (type(
|
|
10056
|
+
local _knownPeer = envelope.knownPeer
|
|
10057
|
+
local _condition = not (type(_knownPeer) == "boolean")
|
|
9975
10058
|
if not _condition then
|
|
9976
10059
|
local _mcpConnected = envelope.mcpConnected
|
|
9977
10060
|
_condition = not (type(_mcpConnected) == "boolean")
|
|
@@ -9981,7 +10064,7 @@ local function decodeMessage(message)
|
|
|
9981
10064
|
end
|
|
9982
10065
|
local _object = {
|
|
9983
10066
|
kind = "status",
|
|
9984
|
-
|
|
10067
|
+
knownPeer = envelope.knownPeer,
|
|
9985
10068
|
mcpConnected = envelope.mcpConnected,
|
|
9986
10069
|
}
|
|
9987
10070
|
local _left = "serverVersion"
|
|
@@ -10014,8 +10097,8 @@ local function decodeMessage(message)
|
|
|
10014
10097
|
local _requestId = envelope.requestId
|
|
10015
10098
|
local _condition = not (type(_requestId) == "string")
|
|
10016
10099
|
if not _condition then
|
|
10017
|
-
local
|
|
10018
|
-
_condition = not (type(
|
|
10100
|
+
local _peerId = envelope.peerId
|
|
10101
|
+
_condition = not (type(_peerId) == "string")
|
|
10019
10102
|
if not _condition then
|
|
10020
10103
|
local _target = envelope.target
|
|
10021
10104
|
_condition = not (type(_target) == "string")
|
|
@@ -10043,7 +10126,7 @@ local function decodeMessage(message)
|
|
|
10043
10126
|
return {
|
|
10044
10127
|
kind = "request",
|
|
10045
10128
|
requestId = envelope.requestId,
|
|
10046
|
-
|
|
10129
|
+
peerId = envelope.peerId,
|
|
10047
10130
|
target = envelope.target,
|
|
10048
10131
|
endpoint = envelope.endpoint,
|
|
10049
10132
|
data = data,
|
|
@@ -10074,8 +10157,7 @@ local function disconnectSession(currentOptions)
|
|
|
10074
10157
|
["Content-Type"] = "application/json",
|
|
10075
10158
|
},
|
|
10076
10159
|
Body = HttpService:JSONEncode({
|
|
10077
|
-
|
|
10078
|
-
timestamp = tick(),
|
|
10160
|
+
peerId = PluginSession.peerId,
|
|
10079
10161
|
}),
|
|
10080
10162
|
})
|
|
10081
10163
|
end)
|
|
@@ -10396,15 +10478,23 @@ local function parseReadyResponse(body)
|
|
|
10396
10478
|
if not _condition then
|
|
10397
10479
|
_condition = value.assignedRole == ""
|
|
10398
10480
|
if not _condition then
|
|
10399
|
-
local
|
|
10400
|
-
_condition = not (type(
|
|
10481
|
+
local _peerId = value.peerId
|
|
10482
|
+
_condition = not (type(_peerId) == "string")
|
|
10401
10483
|
if not _condition then
|
|
10402
|
-
_condition = value.
|
|
10484
|
+
_condition = value.peerId == ""
|
|
10403
10485
|
if not _condition then
|
|
10404
|
-
local
|
|
10405
|
-
_condition = not (type(
|
|
10486
|
+
local _instanceId = value.instanceId
|
|
10487
|
+
_condition = not (type(_instanceId) == "string")
|
|
10406
10488
|
if not _condition then
|
|
10407
|
-
_condition = value.
|
|
10489
|
+
_condition = value.instanceId == ""
|
|
10490
|
+
if not _condition then
|
|
10491
|
+
local _condition_1 = value.multiplayerGroupId ~= nil
|
|
10492
|
+
if _condition_1 then
|
|
10493
|
+
local _multiplayerGroupId = value.multiplayerGroupId
|
|
10494
|
+
_condition_1 = not (type(_multiplayerGroupId) == "string")
|
|
10495
|
+
end
|
|
10496
|
+
_condition = _condition_1
|
|
10497
|
+
end
|
|
10408
10498
|
end
|
|
10409
10499
|
end
|
|
10410
10500
|
end
|
|
@@ -10417,8 +10507,9 @@ local function parseReadyResponse(body)
|
|
|
10417
10507
|
return {
|
|
10418
10508
|
success = true,
|
|
10419
10509
|
assignedRole = value.assignedRole,
|
|
10510
|
+
peerId = value.peerId,
|
|
10420
10511
|
instanceId = value.instanceId,
|
|
10421
|
-
|
|
10512
|
+
multiplayerGroupId = value.multiplayerGroupId,
|
|
10422
10513
|
}
|
|
10423
10514
|
end
|
|
10424
10515
|
local refresh
|
|
@@ -10434,10 +10525,10 @@ function connect(expectedGeneration)
|
|
|
10434
10525
|
})
|
|
10435
10526
|
task.spawn(function()
|
|
10436
10527
|
local instanceId = PluginSession.getInstanceId()
|
|
10528
|
+
local multiplayerGroupId = PluginSession.getMultiplayerGroupId()
|
|
10437
10529
|
local readyUrl = `{currentOptions.serverUrl}/ready`
|
|
10438
|
-
local
|
|
10439
|
-
local readyPayload = PluginSession.createReadyPayload(PluginSession.
|
|
10440
|
-
readyPayload.pluginReady = true
|
|
10530
|
+
local transportRole = PluginSession.getRole()
|
|
10531
|
+
local readyPayload = PluginSession.createReadyPayload(PluginSession.peerId, transportRole, instanceId, multiplayerGroupId)
|
|
10441
10532
|
if not active or generation ~= expectedGeneration or options ~= currentOptions then
|
|
10442
10533
|
return nil
|
|
10443
10534
|
end
|
|
@@ -10454,12 +10545,12 @@ function connect(expectedGeneration)
|
|
|
10454
10545
|
if not active or generation ~= expectedGeneration or options ~= currentOptions then
|
|
10455
10546
|
return nil
|
|
10456
10547
|
end
|
|
10457
|
-
local readyLogKey = `{currentOptions.serverUrl}|{
|
|
10548
|
+
local readyLogKey = `{currentOptions.serverUrl}|{PluginSession.peerId}`
|
|
10458
10549
|
if not readyOk then
|
|
10459
10550
|
local detail = HttpDiagnostics.formatRequestFailure(readyUrl, false, readyResult)
|
|
10460
10551
|
if not (readyFailureLogKeys[readyLogKey] ~= nil) then
|
|
10461
10552
|
readyFailureLogKeys[readyLogKey] = true
|
|
10462
|
-
warn(`[robloxstudio-mcp] /ready failed for {instanceId}/{
|
|
10553
|
+
warn(`[robloxstudio-mcp] /ready failed for {instanceId}/{transportRole}: {detail}`)
|
|
10463
10554
|
end
|
|
10464
10555
|
scheduleReconnect(expectedGeneration, detail)
|
|
10465
10556
|
return nil
|
|
@@ -10468,14 +10559,14 @@ function connect(expectedGeneration)
|
|
|
10468
10559
|
local detail = HttpDiagnostics.formatRequestFailure(readyUrl, true, readyResult)
|
|
10469
10560
|
if not (readyFailureLogKeys[readyLogKey] ~= nil) then
|
|
10470
10561
|
readyFailureLogKeys[readyLogKey] = true
|
|
10471
|
-
warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{
|
|
10562
|
+
warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{transportRole}: {detail}`)
|
|
10472
10563
|
end
|
|
10473
10564
|
scheduleReconnect(expectedGeneration, detail, readyResult.StatusCode == 409)
|
|
10474
10565
|
return nil
|
|
10475
10566
|
end
|
|
10476
10567
|
local readyData = parseReadyResponse(readyResult.Body)
|
|
10477
|
-
if readyData == nil then
|
|
10478
|
-
scheduleReconnect(expectedGeneration, "Invalid /ready response: expected the
|
|
10568
|
+
if readyData == nil or readyData.peerId ~= PluginSession.peerId or readyData.instanceId ~= instanceId or readyData.multiplayerGroupId ~= multiplayerGroupId then
|
|
10569
|
+
scheduleReconnect(expectedGeneration, "Invalid /ready response: expected the registered Peer topology")
|
|
10479
10570
|
return nil
|
|
10480
10571
|
end
|
|
10481
10572
|
if readyFailureLogKeys[readyLogKey] ~= nil then
|
|
@@ -10487,7 +10578,7 @@ function connect(expectedGeneration)
|
|
|
10487
10578
|
end)
|
|
10488
10579
|
local createOk, createdClient = pcall(function()
|
|
10489
10580
|
return HttpService:CreateWebStreamClient(Enum.WebStreamClientType.SSE, {
|
|
10490
|
-
Url = `{currentOptions.serverUrl}/events?
|
|
10581
|
+
Url = `{currentOptions.serverUrl}/events?peerId={PluginSession.peerId}`,
|
|
10491
10582
|
Method = "GET",
|
|
10492
10583
|
Headers = {
|
|
10493
10584
|
Accept = "text/event-stream",
|
|
@@ -10547,7 +10638,7 @@ function connect(expectedGeneration)
|
|
|
10547
10638
|
invokeCallback("event stream status", function()
|
|
10548
10639
|
return currentOptions.onStatus(event)
|
|
10549
10640
|
end)
|
|
10550
|
-
if not event.
|
|
10641
|
+
if not event.knownPeer then
|
|
10551
10642
|
refresh()
|
|
10552
10643
|
end
|
|
10553
10644
|
end), createdClient.Error:Connect(function(statusCode, message)
|
|
@@ -10644,7 +10735,59 @@ return {
|
|
|
10644
10735
|
]]></string>
|
|
10645
10736
|
</Properties>
|
|
10646
10737
|
</Item>
|
|
10647
|
-
<Item class="ModuleScript" referent="
|
|
10738
|
+
<Item class="ModuleScript" referent="37">
|
|
10739
|
+
<Properties>
|
|
10740
|
+
<string name="Name">TopologyId</string>
|
|
10741
|
+
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
10742
|
+
local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
10743
|
+
local HttpService = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services").HttpService
|
|
10744
|
+
local BASE36_DIGITS = "0123456789abcdefghijklmnopqrstuvwxyz"
|
|
10745
|
+
local TOKEN_LENGTH = 6
|
|
10746
|
+
local TOKEN_MODULUS = 2_176_782_336
|
|
10747
|
+
local function tokenFromNumber(value)
|
|
10748
|
+
local remaining = math.floor(math.abs(value)) % TOKEN_MODULUS
|
|
10749
|
+
local token = ""
|
|
10750
|
+
for index = 0, TOKEN_LENGTH - 1 do
|
|
10751
|
+
local digit = remaining % 36
|
|
10752
|
+
local _arg0 = digit + 1
|
|
10753
|
+
local _arg1 = digit + 1
|
|
10754
|
+
token = string.sub(BASE36_DIGITS, _arg0, _arg1) .. token
|
|
10755
|
+
remaining = math.floor(remaining / 36)
|
|
10756
|
+
end
|
|
10757
|
+
return `{string.sub(token, 1, 3)}-{string.sub(token, 4, 6)}`
|
|
10758
|
+
end
|
|
10759
|
+
local function formatId(kind, value)
|
|
10760
|
+
return `{kind}:{tokenFromNumber(value)}`
|
|
10761
|
+
end
|
|
10762
|
+
local function randomValue()
|
|
10763
|
+
local guidPrefix = string.sub(HttpService:GenerateGUID(false), 1, 8)
|
|
10764
|
+
local _condition = tonumber(guidPrefix, 16)
|
|
10765
|
+
if _condition == nil then
|
|
10766
|
+
_condition = 0
|
|
10767
|
+
end
|
|
10768
|
+
return _condition
|
|
10769
|
+
end
|
|
10770
|
+
local function createPeerId()
|
|
10771
|
+
return formatId("peer", randomValue())
|
|
10772
|
+
end
|
|
10773
|
+
local function createInstanceId()
|
|
10774
|
+
return formatId("instance", randomValue())
|
|
10775
|
+
end
|
|
10776
|
+
local function currentProcessInstanceId()
|
|
10777
|
+
-- Roblox exposes process uptime through os.clock(). Quantizing the recovered
|
|
10778
|
+
-- launch wall-clock to 10 ms gives every VM in one Studio process the same ID.
|
|
10779
|
+
local launchTick = math.round((DateTime.now().UnixTimestampMillis - os.clock() * 1000) / 10)
|
|
10780
|
+
return formatId("instance", launchTick)
|
|
10781
|
+
end
|
|
10782
|
+
return {
|
|
10783
|
+
createPeerId = createPeerId,
|
|
10784
|
+
createInstanceId = createInstanceId,
|
|
10785
|
+
currentProcessInstanceId = currentProcessInstanceId,
|
|
10786
|
+
}
|
|
10787
|
+
]]></string>
|
|
10788
|
+
</Properties>
|
|
10789
|
+
</Item>
|
|
10790
|
+
<Item class="ModuleScript" referent="38">
|
|
10648
10791
|
<Properties>
|
|
10649
10792
|
<string name="Name">UI</string>
|
|
10650
10793
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -11196,7 +11339,7 @@ return {
|
|
|
11196
11339
|
]]></string>
|
|
11197
11340
|
</Properties>
|
|
11198
11341
|
</Item>
|
|
11199
|
-
<Item class="ModuleScript" referent="
|
|
11342
|
+
<Item class="ModuleScript" referent="39">
|
|
11200
11343
|
<Properties>
|
|
11201
11344
|
<string name="Name">Utils</string>
|
|
11202
11345
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -12037,11 +12180,11 @@ return {
|
|
|
12037
12180
|
</Properties>
|
|
12038
12181
|
</Item>
|
|
12039
12182
|
</Item>
|
|
12040
|
-
<Item class="Folder" referent="
|
|
12183
|
+
<Item class="Folder" referent="44">
|
|
12041
12184
|
<Properties>
|
|
12042
12185
|
<string name="Name">include</string>
|
|
12043
12186
|
</Properties>
|
|
12044
|
-
<Item class="ModuleScript" referent="
|
|
12187
|
+
<Item class="ModuleScript" referent="40">
|
|
12045
12188
|
<Properties>
|
|
12046
12189
|
<string name="Name">LibMP</string>
|
|
12047
12190
|
<string name="Source"><![CDATA[-- =============================================================================
|
|
@@ -168425,7 +168568,7 @@ return LibMP
|
|
|
168425
168568
|
]]></string>
|
|
168426
168569
|
</Properties>
|
|
168427
168570
|
</Item>
|
|
168428
|
-
<Item class="ModuleScript" referent="
|
|
168571
|
+
<Item class="ModuleScript" referent="41">
|
|
168429
168572
|
<Properties>
|
|
168430
168573
|
<string name="Name">Promise</string>
|
|
168431
168574
|
<string name="Source"><![CDATA[--[[
|
|
@@ -170499,7 +170642,7 @@ return Promise
|
|
|
170499
170642
|
]]></string>
|
|
170500
170643
|
</Properties>
|
|
170501
170644
|
</Item>
|
|
170502
|
-
<Item class="ModuleScript" referent="
|
|
170645
|
+
<Item class="ModuleScript" referent="42">
|
|
170503
170646
|
<Properties>
|
|
170504
170647
|
<string name="Name">RuntimeLib</string>
|
|
170505
170648
|
<string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
|
|
@@ -170766,15 +170909,15 @@ return TS
|
|
|
170766
170909
|
</Properties>
|
|
170767
170910
|
</Item>
|
|
170768
170911
|
</Item>
|
|
170769
|
-
<Item class="Folder" referent="
|
|
170912
|
+
<Item class="Folder" referent="45">
|
|
170770
170913
|
<Properties>
|
|
170771
170914
|
<string name="Name">node_modules</string>
|
|
170772
170915
|
</Properties>
|
|
170773
|
-
<Item class="Folder" referent="
|
|
170916
|
+
<Item class="Folder" referent="46">
|
|
170774
170917
|
<Properties>
|
|
170775
170918
|
<string name="Name">@rbxts</string>
|
|
170776
170919
|
</Properties>
|
|
170777
|
-
<Item class="ModuleScript" referent="
|
|
170920
|
+
<Item class="ModuleScript" referent="43">
|
|
170778
170921
|
<Properties>
|
|
170779
170922
|
<string name="Name">services</string>
|
|
170780
170923
|
<string name="Source"><![CDATA[return setmetatable({}, {
|