@chrrxs/robloxstudio-mcp 3.0.4 → 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.
@@ -16,6 +16,7 @@ local cleanupEditBridgeArtifacts = _EvalBridges.cleanupEditBridgeArtifacts
16
16
  local ensureRuntimeBridgeInstalled = _EvalBridges.ensureRuntimeBridgeInstalled
17
17
  local RuntimeLogBuffer = TS.import(script, script, "modules", "RuntimeLogBuffer")
18
18
  local StopPlayMonitor = TS.import(script, script, "modules", "StopPlayMonitor")
19
+ local StudioEventStream = TS.import(script, script, "modules", "StudioEventStream")
19
20
  local BreakpointHandlers = TS.import(script, script, "modules", "handlers", "BreakpointHandlers")
20
21
  local RenderMonitor = TS.import(script, script, "modules", "RenderMonitor")
21
22
  -- Track render-loop liveness so input/screenshot tools can report "window
@@ -32,8 +33,9 @@ RuntimeLogBuffer.install()
32
33
  StopPlayMonitor.init(plugin)
33
34
  BreakpointHandlers.init(plugin)
34
35
  ServerUrlSettings.init(plugin)
36
+ local startupRole = ClientBroker.forkRole()
35
37
  local function applyRememberedServerUrl()
36
- if ClientBroker.forkRole() == "client" then
38
+ if startupRole == "client" then
37
39
  return nil
38
40
  end
39
41
  local rememberedServerUrl = ServerUrlSettings.readServerUrl()
@@ -49,6 +51,15 @@ local function applyRememberedServerUrl()
49
51
  ClientBroker.setServerUrl(rememberedServerUrl)
50
52
  end
51
53
  applyRememberedServerUrl()
54
+ -- Play DataModels do not reliably fire Plugin.Unloading before Studio tears
55
+ -- down their VM. Close the persistent WebStreamClient while BindToClose still
56
+ -- gives this server peer a live execution context; otherwise each play cycle
57
+ -- can retain one native event stream until Studio itself is restarted. The
58
+ -- transport releases that native stream before yielding to unregister the
59
+ -- logical server peer.
60
+ if startupRole == "server" then
61
+ game:BindToClose(StudioEventStream.suspendForShutdown)
62
+ end
52
63
  UI.init(plugin)
53
64
  local elements = UI.getElements()
54
65
  local ICON_DISCONNECTED = "rbxassetid://75876056391496"
@@ -86,12 +97,12 @@ end)
86
97
  UI.updateUIState()
87
98
  Communication.checkForUpdates()
88
99
  task.delay(TOOLBAR_REGISTRATION_DELAY_SECONDS, registerToolbarButton)
89
- -- Auto-activate per peer. The boshyxd plugin only registers with MCP when the
90
- -- user clicks Connect in its UI, but that UI is invisible in play DMs - so
91
- -- play peers' plugin instances load without ever registering. Run after a
92
- -- short delay so the UI/State have a chance to initialize first.
93
- task.delay(2, function()
94
- local role = ClientBroker.forkRole()
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
+ local role = startupRole
95
106
  if role == "edit" then
96
107
  cleanupEditBridgeArtifacts()
97
108
  else
@@ -101,7 +112,7 @@ task.delay(2, function()
101
112
  end
102
113
  end
103
114
  if role == "edit" or role == "server" then
104
- pcall(function()
115
+ local activationOk, activationError = pcall(function()
105
116
  local conn = State.getActiveConnection()
106
117
  if not conn.isActive then
107
118
  if role == "server" then
@@ -127,17 +138,28 @@ task.delay(2, function()
127
138
  Communication.activatePlugin()
128
139
  end
129
140
  end)
141
+ if not activationOk then
142
+ warn(`[robloxstudio-mcp] Automatic {role} Peer activation failed: {activationError}`)
143
+ end
130
144
  end
131
145
  if role == "server" then
132
146
  ClientBroker.setupServerBroker()
133
147
  -- The play-server DM is the only one where StudioTestService:EndTest is
134
148
  -- legal, so the stop-play monitor lives here. It consumes tokenized
135
149
  -- stop requests from plugin settings and acknowledges EndTest results.
136
- StopPlayMonitor.startMonitor()
150
+ StopPlayMonitor.startMonitor({
151
+ beforeEndTest = StudioEventStream.suspendForShutdown,
152
+ afterEndTestFailure = StudioEventStream.resumeAfterShutdownFailure,
153
+ })
137
154
  elseif role == "client" then
138
155
  ClientBroker.setupClientBroker()
139
156
  end
140
- end)
157
+ end
158
+ if startupRole == "edit" then
159
+ task.delay(2, autoActivatePeer)
160
+ else
161
+ autoActivatePeer()
162
+ end
141
163
  ]]></string>
142
164
  </Properties>
143
165
  <Item class="Folder" referent="1">
@@ -278,17 +300,18 @@ local MicroProfilerHandlers = TS.import(script, script.Parent, "handlers", "Micr
278
300
  local LuauExec = TS.import(script, script.Parent, "LuauExec")
279
301
  local HttpDiagnostics = TS.import(script, script.Parent, "HttpDiagnostics")
280
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")
281
305
  local StudioTestService = game:GetService("StudioTestService")
282
- -- The client peer cannot reach the MCP HTTP server - Roblox forbids
283
- -- HttpService:RequestAsync from the client DM even under PluginSecurity, and
284
- -- HttpEnabled reads as false there regardless of identity. So the server peer
285
- -- brokers client-targeted requests through a RemoteFunction it places
286
- -- in ReplicatedStorage; each player gets a logical proxy registration on the
287
- -- 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.
288
310
  local DEFAULT_MCP_URL = "http://localhost:58741"
289
311
  local mcpUrl = DEFAULT_MCP_URL
290
312
  local BROKER_NAME = "__MCPClientBroker"
291
313
  local BROKER_OWNER_ATTRIBUTE = "__MCPBrokerOwner"
314
+ local CLIENT_IDENTITY_KIND = "identity"
292
315
  -- Endpoints the server-peer broker is allowed to forward to the client peer.
293
316
  -- Each requires the client peer's plugin VM (because the buffer / require
294
317
  -- cache / etc. lives there) so the server peer alone can't satisfy them.
@@ -309,13 +332,7 @@ local CLIENT_BROKER_ALLOWED_ENDPOINTS = {
309
332
  ["/api/focus-viewport"] = true,
310
333
  }
311
334
  local function forkRole()
312
- if not RunService:IsRunning() then
313
- return "edit"
314
- end
315
- if RunService:IsServer() then
316
- return "server"
317
- end
318
- return "client"
335
+ return PeerRole.detect()
319
336
  end
320
337
  local function postJson(endpoint, body)
321
338
  return pcall(function()
@@ -356,13 +373,11 @@ local function handleGetRuntimeLogs(data)
356
373
  local since = d.since
357
374
  local tail = d.tail
358
375
  local filter = d.filter
359
- -- "client" is the generic capture tag; MCP-side aggregation overrides it
360
- -- with the specific role (e.g. "client-1") for capturedBy.
361
376
  return RuntimeLogBuffer.query({
362
377
  since = since,
363
378
  tail = tail,
364
379
  filter = filter,
365
- }, "client")
380
+ })
366
381
  end
367
382
  local function handleMultiplayerTestState()
368
383
  local argsOk, args = pcall(function()
@@ -435,10 +450,44 @@ local function handleMultiplayerTestLeaveClient()
435
450
  localPlayer = localPlayer,
436
451
  }
437
452
  end
438
- local function setupClientBroker()
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
439
483
  local rf = ReplicatedStorage:WaitForChild(BROKER_NAME, 10)
440
484
  if not rf or not rf:IsA("RemoteFunction") then
441
- warn(`[robloxstudio-mcp] client: {BROKER_NAME} not found`)
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
442
491
  return nil
443
492
  end
444
493
  rf.OnClientInvoke = function(payload)
@@ -498,11 +547,12 @@ local function setupClientBroker()
498
547
  error = `Unsupported client broker endpoint: {payload.endpoint}`,
499
548
  }
500
549
  end
550
+ task.spawn(sendClientIdentity, rf, 0)
501
551
  end
502
552
  local INITIAL_PROXY_RETRY_DELAY_SECONDS = 0.5
503
553
  local MAX_PROXY_RETRY_DELAY_SECONDS = 5
504
554
  local proxyByPlayer = {}
505
- local proxyBySessionId = {}
555
+ local proxyByPeerId = {}
506
556
  local proxyRegisterFailuresByPlayer = {}
507
557
  local pendingProxyDisconnects = {}
508
558
  local serverBrokerStarted = false
@@ -520,50 +570,50 @@ local function unregisterProxy(player, entry)
520
570
  proxy.generation += 1
521
571
  local _player = player
522
572
  proxyByPlayer[_player] = nil
523
- local _pluginSessionId = proxy.pluginSessionId
524
- proxyBySessionId[_pluginSessionId] = nil
573
+ local _peerId = proxy.peerId
574
+ proxyByPeerId[_peerId] = nil
525
575
  local _player_1 = player
526
576
  proxyRegisterFailuresByPlayer[_player_1] = nil
527
- queueProxyDisconnect(proxy.pluginSessionId)
577
+ queueProxyDisconnect(proxy.peerId)
528
578
  end
529
579
  local function disconnectAllProxies()
530
580
  for player, entry in proxyByPlayer do
531
581
  unregisterProxy(player, entry)
532
582
  end
533
583
  table.clear(proxyByPlayer)
534
- table.clear(proxyBySessionId)
584
+ table.clear(proxyByPeerId)
535
585
  table.clear(proxyRegisterFailuresByPlayer)
536
586
  end
537
- local function proxyRetryDelay(attempt)
587
+ function proxyRetryDelay(attempt)
538
588
  return math.min(INITIAL_PROXY_RETRY_DELAY_SECONDS * math.pow(2, math.max(attempt - 1, 0)), MAX_PROXY_RETRY_DELAY_SECONDS)
539
589
  end
540
- local function deliverProxyDisconnect(pluginSessionId, attempt)
541
- local _pluginSessionId = pluginSessionId
542
- if not (pendingProxyDisconnects[_pluginSessionId] ~= nil) then
590
+ local function deliverProxyDisconnect(peerId, attempt)
591
+ local _peerId = peerId
592
+ if not (pendingProxyDisconnects[_peerId] ~= nil) then
543
593
  return nil
544
594
  end
545
595
  local ok, response = postJson("/disconnect", {
546
- pluginSessionId = pluginSessionId,
596
+ peerId = peerId,
547
597
  })
548
598
  if ok and response and response.Success then
549
- local _pluginSessionId_1 = pluginSessionId
550
- pendingProxyDisconnects[_pluginSessionId_1] = nil
599
+ local _peerId_1 = peerId
600
+ pendingProxyDisconnects[_peerId_1] = nil
551
601
  return nil
552
602
  end
553
603
  task.delay(proxyRetryDelay(attempt + 1), function()
554
- deliverProxyDisconnect(pluginSessionId, attempt + 1)
604
+ deliverProxyDisconnect(peerId, attempt + 1)
555
605
  end)
556
606
  end
557
- function queueProxyDisconnect(pluginSessionId)
558
- local _pluginSessionId = pluginSessionId
559
- if pendingProxyDisconnects[_pluginSessionId] ~= nil then
607
+ function queueProxyDisconnect(peerId)
608
+ local _peerId = peerId
609
+ if pendingProxyDisconnects[_peerId] ~= nil then
560
610
  return nil
561
611
  end
562
- local _pluginSessionId_1 = pluginSessionId
563
- pendingProxyDisconnects[_pluginSessionId_1] = true
564
- task.spawn(deliverProxyDisconnect, pluginSessionId, 0)
612
+ local _peerId_1 = peerId
613
+ pendingProxyDisconnects[_peerId_1] = true
614
+ task.spawn(deliverProxyDisconnect, peerId, 0)
565
615
  end
566
- local function parseAssignedRole(body)
616
+ local function parseAssignedRole(body, entry)
567
617
  local decodeOk, decoded = pcall(function()
568
618
  return HttpService:JSONDecode(body)
569
619
  end)
@@ -571,7 +621,7 @@ local function parseAssignedRole(body)
571
621
  return nil
572
622
  end
573
623
  local ready = decoded
574
- 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
575
625
  return nil
576
626
  end
577
627
  local _assignedRole = ready.assignedRole
@@ -622,27 +672,18 @@ function registerProxyEntry(entry)
622
672
  entry.registering = true
623
673
  local expectedGeneration = entry.generation
624
674
  local requestedRole = if entry.role == "client" then "client" else entry.role
625
- local readyPayload = PluginSession.createReadyPayload(entry.pluginSessionId, requestedRole)
675
+ local readyPayload = PluginSession.createReadyPayload(entry.peerId, requestedRole, entry.instanceId, entry.multiplayerGroupId)
676
+ local ok, res = postJson("/ready", readyPayload)
626
677
  local _player = entry.player
627
678
  if proxyByPlayer[_player] ~= entry then
628
- return nil
629
- end
630
- if entry.generation ~= expectedGeneration then
631
- if not entry.registering then
632
- task.spawn(registerProxyEntry, entry)
633
- end
634
- return nil
635
- end
636
- local ok, res = postJson("/ready", readyPayload)
637
- local _player_1 = entry.player
638
- if proxyByPlayer[_player_1] ~= entry then
639
679
  if ok and res and res.Success then
640
- queueProxyDisconnect(entry.pluginSessionId)
680
+ queueProxyDisconnect(entry.peerId)
641
681
  end
642
682
  return nil
643
683
  end
644
684
  if entry.generation ~= expectedGeneration then
645
- if not entry.registering then
685
+ entry.registering = false
686
+ if not entry.registered then
646
687
  task.spawn(registerProxyEntry, entry)
647
688
  end
648
689
  return nil
@@ -652,30 +693,91 @@ function registerProxyEntry(entry)
652
693
  failProxyRegistration(entry, formatPostJsonFailure("/ready", ok, res))
653
694
  return nil
654
695
  end
655
- local assignedRole = parseAssignedRole(res.Body)
696
+ local assignedRole = parseAssignedRole(res.Body, entry)
656
697
  if assignedRole == nil then
657
- failProxyRegistration(entry, "invalid /ready response: expected success=true and a non-empty assignedRole")
698
+ failProxyRegistration(entry, "invalid /ready response for client Peer topology")
658
699
  return nil
659
700
  end
660
701
  entry.role = assignedRole
661
702
  entry.registered = true
662
703
  entry.retryAttempt = 0
663
- local _player_2 = entry.player
664
- if proxyRegisterFailuresByPlayer[_player_2] ~= nil then
665
- local _player_3 = entry.player
666
- proxyRegisterFailuresByPlayer[_player_3] = nil
704
+ local _player_1 = entry.player
705
+ if proxyRegisterFailuresByPlayer[_player_1] ~= nil then
706
+ local _player_2 = entry.player
707
+ proxyRegisterFailuresByPlayer[_player_2] = nil
667
708
  print(`[robloxstudio-mcp] proxy registered for {entry.player.Name} as {assignedRole} via {mcpUrl}`)
668
709
  end
669
710
  end
670
- local function registerProxy(player, rf)
671
- local _player = player
672
- if proxyByPlayer[_player] ~= nil then
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
673
726
  return nil
674
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
675
775
  local entry = {
676
776
  player = player,
677
777
  remote = rf,
678
- pluginSessionId = HttpService:GenerateGUID(false),
778
+ peerId = identity.peerId,
779
+ instanceId = instanceId,
780
+ multiplayerGroupId = multiplayerGroupId,
679
781
  role = "client",
680
782
  registered = false,
681
783
  registering = false,
@@ -684,11 +786,12 @@ local function registerProxy(player, rf)
684
786
  }
685
787
  local _player_1 = player
686
788
  proxyByPlayer[_player_1] = entry
687
- local _pluginSessionId = entry.pluginSessionId
688
- proxyBySessionId[_pluginSessionId] = entry
789
+ local _peerId_1 = entry.peerId
790
+ proxyByPeerId[_peerId_1] = entry
689
791
  task.spawn(registerProxyEntry, entry)
792
+ return true
690
793
  end
691
- local function refreshAllLogicalRegistrations()
794
+ local function refreshAllProxyRegistrations()
692
795
  for _, entry in proxyByPlayer do
693
796
  entry.generation += 1
694
797
  entry.registered = false
@@ -697,9 +800,9 @@ local function refreshAllLogicalRegistrations()
697
800
  task.spawn(registerProxyEntry, entry)
698
801
  end
699
802
  end
700
- local function dispatchClientRequest(logicalSessionId, target, endpoint, data)
701
- local _logicalSessionId = logicalSessionId
702
- local entry = proxyBySessionId[_logicalSessionId]
803
+ local function dispatchClientRequest(peerId, target, endpoint, data)
804
+ local _peerId = peerId
805
+ local entry = proxyByPeerId[_peerId]
703
806
  local _condition = not entry
704
807
  if not _condition then
705
808
  local _player = entry.player
@@ -707,7 +810,7 @@ local function dispatchClientRequest(logicalSessionId, target, endpoint, data)
707
810
  end
708
811
  if _condition then
709
812
  return {
710
- error = `Client proxy {target} ({logicalSessionId}) is not registered.`,
813
+ error = `Client proxy {target} ({peerId}) is not registered.`,
711
814
  }
712
815
  end
713
816
  if entry.role == "client" then
@@ -718,7 +821,7 @@ local function dispatchClientRequest(logicalSessionId, target, endpoint, data)
718
821
  end
719
822
  if entry.role ~= target then
720
823
  return {
721
- error = `Client proxy {logicalSessionId} is registered as {entry.role}, not {target}.`,
824
+ error = `Client proxy {peerId} is registered as {entry.role}, not {target}.`,
722
825
  }
723
826
  end
724
827
  if entry.player.Parent == nil or not RunService:IsRunning() then
@@ -759,26 +862,46 @@ local function setupServerBroker()
759
862
  if serverBrokerStarted then
760
863
  return nil
761
864
  end
762
- local rf = ReplicatedStorage:FindFirstChild(BROKER_NAME)
763
- if not rf then
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
764
874
  rf = Instance.new("RemoteFunction")
765
- rf.Name = BROKER_NAME
766
- rf.Parent = ReplicatedStorage
767
875
  end
768
876
  if rf:GetAttribute(BROKER_OWNER_ATTRIBUTE) ~= nil then
769
877
  return nil
770
878
  end
771
- rf:SetAttribute(BROKER_OWNER_ATTRIBUTE, HttpService:GenerateGUID(false))
772
- serverBrokerStarted = true
773
- local broker = rf
774
- Players.PlayerAdded:Connect(function(p)
775
- return registerProxy(p, broker)
776
- end)
777
- for _, p in Players:GetPlayers() do
778
- task.spawn(registerProxy, p, broker)
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
+ }
779
898
  end
780
- Players.PlayerRemoving:Connect(function(p)
781
- unregisterProxy(p)
899
+ if rf.Parent == nil then
900
+ rf.Parent = ReplicatedStorage
901
+ end
902
+ serverBrokerStarted = true
903
+ Players.PlayerRemoving:Connect(function(player)
904
+ unregisterProxy(player)
782
905
  end)
783
906
  game:BindToClose(function()
784
907
  disconnectAllProxies()
@@ -788,7 +911,7 @@ return {
788
911
  DEFAULT_MCP_URL = DEFAULT_MCP_URL,
789
912
  setServerUrl = setServerUrl,
790
913
  disconnectAllProxies = disconnectAllProxies,
791
- refreshAllLogicalRegistrations = refreshAllLogicalRegistrations,
914
+ refreshAllProxyRegistrations = refreshAllProxyRegistrations,
792
915
  dispatchClientRequest = dispatchClientRequest,
793
916
  forkRole = forkRole,
794
917
  setupClientBroker = setupClientBroker,
@@ -831,7 +954,7 @@ local ServerUrlSettings = TS.import(script, script.Parent, "ServerUrlSettings")
831
954
  local PluginSession = TS.import(script, script.Parent, "PluginSession")
832
955
  local StudioEventStream = TS.import(script, script.Parent, "StudioEventStream")
833
956
  local assignedRole
834
- local lastReadyInstanceId
957
+ local lastReadyPlaceKey
835
958
  local initialRole = PluginSession.getRole()
836
959
  local routeMap = {
837
960
  ["/api/file-tree"] = QueryHandlers.getFileTree,
@@ -906,8 +1029,8 @@ local function getConnectionStatus()
906
1029
  return "connecting"
907
1030
  end
908
1031
  local function dispatchStreamRequest(request, context)
909
- if request.logicalSessionId ~= PluginSession.id then
910
- return ClientBroker.dispatchClientRequest(request.logicalSessionId, request.target, request.endpoint, request.data)
1032
+ if request.peerId ~= PluginSession.peerId then
1033
+ return ClientBroker.dispatchClientRequest(request.peerId, request.target, request.endpoint, request.data)
911
1034
  end
912
1035
  local _condition = assignedRole
913
1036
  if _condition == nil then
@@ -916,7 +1039,7 @@ local function dispatchStreamRequest(request, context)
916
1039
  local localRole = _condition
917
1040
  if request.target ~= localRole then
918
1041
  return {
919
- error = `Physical plugin session is registered as {localRole}, not {request.target}.`,
1042
+ error = `Transport peer is registered as {localRole}, not {request.target}.`,
920
1043
  }
921
1044
  end
922
1045
  return processRequest({
@@ -930,9 +1053,9 @@ local function handleReady(response)
930
1053
  return nil
931
1054
  end
932
1055
  assignedRole = response.assignedRole
933
- lastReadyInstanceId = response.instanceId
1056
+ lastReadyPlaceKey = PluginSession.getPlaceKey()
934
1057
  ServerUrlSettings.rememberServerUrl(conn.serverUrl)
935
- ClientBroker.refreshAllLogicalRegistrations()
1058
+ ClientBroker.refreshAllProxyRegistrations()
936
1059
  end
937
1060
  local function handleStatus(status)
938
1061
  local conn = State.getActiveConnection()
@@ -1011,7 +1134,7 @@ local function ensureIdentityWatchers()
1011
1134
  if signalOk and signal then
1012
1135
  placeIdChangeConn = signal:Connect(function()
1013
1136
  PluginSession.invalidatePlaceName()
1014
- lastReadyInstanceId = PluginSession.getInstanceId()
1137
+ lastReadyPlaceKey = PluginSession.getPlaceKey()
1015
1138
  StudioEventStream.refresh()
1016
1139
  end)
1017
1140
  end
@@ -1051,7 +1174,7 @@ local function activatePlugin()
1051
1174
  conn.port = port
1052
1175
  end
1053
1176
  ClientBroker.setServerUrl(conn.serverUrl)
1054
- lastReadyInstanceId = PluginSession.getInstanceId()
1177
+ lastReadyPlaceKey = PluginSession.getPlaceKey()
1055
1178
  UI.updateUIState()
1056
1179
  StudioEventStream.start({
1057
1180
  serverUrl = conn.serverUrl,
@@ -1068,15 +1191,15 @@ local function activatePlugin()
1068
1191
  deactivatePlugin()
1069
1192
  return nil
1070
1193
  end
1071
- local currentInstanceId = PluginSession.getInstanceId()
1072
- if lastReadyInstanceId ~= nil and currentInstanceId ~= lastReadyInstanceId then
1073
- lastReadyInstanceId = currentInstanceId
1194
+ local currentPlaceKey = PluginSession.getPlaceKey()
1195
+ if lastReadyPlaceKey ~= nil and currentPlaceKey ~= lastReadyPlaceKey then
1196
+ lastReadyPlaceKey = currentPlaceKey
1074
1197
  PluginSession.invalidatePlaceName()
1075
1198
  StudioEventStream.refresh()
1076
1199
  end
1077
1200
  end)
1078
1201
  end
1079
- if not RunService:IsRunning() then
1202
+ if initialRole == "edit" then
1080
1203
  task.spawn(cleanupEditBridgeArtifacts)
1081
1204
  end
1082
1205
  ensureIdentityWatchers()
@@ -1099,7 +1222,7 @@ function deactivatePlugin()
1099
1222
  conn.heartbeatConnection:Disconnect()
1100
1223
  conn.heartbeatConnection = nil
1101
1224
  end
1102
- lastReadyInstanceId = nil
1225
+ lastReadyPlaceKey = nil
1103
1226
  assignedRole = nil
1104
1227
  conn.consecutiveFailures = 0
1105
1228
  conn.currentRetryDelay = 0.5
@@ -1267,6 +1390,7 @@ local ReplicatedStorage = _services.ReplicatedStorage
1267
1390
  local RunService = _services.RunService
1268
1391
  local ServerScriptService = _services.ServerScriptService
1269
1392
  local StarterPlayer = _services.StarterPlayer
1393
+ local PeerRole = TS.import(script, script.Parent, "PeerRole")
1270
1394
  local ScriptEditorService = game:GetService("ScriptEditorService")
1271
1395
  local function getStarterPlayerScripts()
1272
1396
  return StarterPlayer:FindFirstChild("StarterPlayerScripts")
@@ -1348,9 +1472,9 @@ local function computeBridgeStamp()
1348
1472
  for i = 1, #combined do
1349
1473
  h = (h * 33 + (string.byte(combined, i))) % 2147483647
1350
1474
  end
1351
- -- "3.0.4" is replaced with the package version at package time
1475
+ -- "3.1.0" is replaced with the package version at package time
1352
1476
  -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1353
- return `{tostring(h)}-3.0.4`
1477
+ return `{tostring(h)}-3.1.0`
1354
1478
  end
1355
1479
  local BRIDGE_STAMP = computeBridgeStamp()
1356
1480
  local function setSource(scriptInst, source)
@@ -1484,10 +1608,10 @@ local function installClientRuntimeBridge()
1484
1608
  }
1485
1609
  end
1486
1610
  local function ensureRuntimeBridgeInstalled()
1487
- if not RunService:IsRunning() then
1611
+ if PeerRole.detect() == "edit" then
1488
1612
  return {
1489
1613
  installed = false,
1490
- error = "Eval bridges are installed only in running play DataModels",
1614
+ error = "Eval bridges are installed only in play DataModels",
1491
1615
  }
1492
1616
  end
1493
1617
  if RunService:IsServer() then
@@ -1954,10 +2078,10 @@ return {
1954
2078
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
1955
2079
  local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
1956
2080
  local Utils = TS.import(script, script.Parent.Parent, "Utils")
2081
+ local PeerRole = TS.import(script, script.Parent.Parent, "PeerRole")
1957
2082
  local _binding = Utils
1958
2083
  local getInstanceByPath = _binding.getInstanceByPath
1959
2084
  local HttpService = game:GetService("HttpService")
1960
- local RunService = game:GetService("RunService")
1961
2085
  local ServerStorage = game:GetService("ServerStorage")
1962
2086
  local LOG_PREFIX = "Breakpoint"
1963
2087
  local REGISTRY_KEY_PREFIX = "MCP_BREAKPOINTS_V1_"
@@ -1972,7 +2096,7 @@ end
1972
2096
  local function breakpointKey(scriptPath, line)
1973
2097
  return `{scriptPath}:{line}`
1974
2098
  end
1975
- local function computeInstanceId()
2099
+ local function computePlaceKey()
1976
2100
  if game.PlaceId ~= 0 then
1977
2101
  return `place:{tostring(game.PlaceId)}`
1978
2102
  end
@@ -1987,13 +2111,7 @@ local function computeInstanceId()
1987
2111
  return `anon:{fresh}`
1988
2112
  end
1989
2113
  local function detectRole()
1990
- if not RunService:IsRunning() then
1991
- return "edit"
1992
- end
1993
- if RunService:IsServer() then
1994
- return "server"
1995
- end
1996
- return "client"
2114
+ return PeerRole.detect()
1997
2115
  end
1998
2116
  local function requestedRole(requestData)
1999
2117
  local ___mcp_target_role = requestData.__mcp_target_role
@@ -2004,15 +2122,10 @@ local function requestedRole(requestData)
2004
2122
  return if _condition then requestData.__mcp_target_role else detectRole()
2005
2123
  end
2006
2124
  local function registryScope(requestData)
2007
- local ___mcp_instance_id = requestData.__mcp_instance_id
2008
- local _condition = type(___mcp_instance_id) == "string"
2009
- if _condition then
2010
- _condition = requestData.__mcp_instance_id ~= ""
2011
- end
2012
- local instanceId = if _condition then requestData.__mcp_instance_id else computeInstanceId()
2125
+ local placeKey = computePlaceKey()
2013
2126
  local role = requestedRole(requestData)
2014
2127
  return {
2015
- key = `{REGISTRY_KEY_PREFIX}{instanceId}:{role}`,
2128
+ key = `{REGISTRY_KEY_PREFIX}{placeKey}:{role}`,
2016
2129
  }
2017
2130
  end
2018
2131
  local function readSetting(key)
@@ -3279,15 +3392,11 @@ local function getRuntimeLogs(requestData)
3279
3392
  local since = requestData.since
3280
3393
  local tail = requestData.tail
3281
3394
  local filter = requestData.filter
3282
- -- This is the buffer that captured the LogService event, not necessarily
3283
- -- the script-origin peer. Ordinary playtests share/reflect logs across
3284
- -- edit/server/client LogService buffers.
3285
- local capturedBy = RuntimeLogBuffer.detectPeer()
3286
3395
  return RuntimeLogBuffer.query({
3287
3396
  since = since,
3288
3397
  tail = tail,
3289
3398
  filter = filter,
3290
- }, capturedBy)
3399
+ })
3291
3400
  end
3292
3401
  return {
3293
3402
  getRuntimeLogs = getRuntimeLogs,
@@ -7576,7 +7685,7 @@ return {
7576
7685
  <string name="Name">SerializationHandlers</string>
7577
7686
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
7578
7687
  local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
7579
- local RunService = TS.import(script, script.Parent.Parent.Parent, "node_modules", "@rbxts", "services").RunService
7688
+ local PeerRole = TS.import(script, script.Parent.Parent, "PeerRole")
7580
7689
  local Utils = TS.import(script, script.Parent.Parent, "Utils")
7581
7690
  local Recording = TS.import(script, script.Parent.Parent, "Recording")
7582
7691
  -- SerializationService:SerializeInstancesAsync / DeserializeInstancesAsync were
@@ -7696,7 +7805,7 @@ local function importRbxm(requestData)
7696
7805
  -- All-or-nothing parenting. Track every instance we've attached and roll back
7697
7806
  -- (unparent + Destroy) if any later one fails - partial imports leave the DM
7698
7807
  -- in a worse state than failing cleanly.
7699
- local isEdit = not RunService:IsRunning()
7808
+ local isEdit = PeerRole.detect() == "edit"
7700
7809
  local recordingId = if isEdit then beginRecording(`Import rbxm`) else nil
7701
7810
  local attached = {}
7702
7811
  local failureMessage
@@ -7767,19 +7876,15 @@ local HttpService = _services.HttpService
7767
7876
  local Players = _services.Players
7768
7877
  local RunService = _services.RunService
7769
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")
7770
7881
  local StudioTestService = game:GetService("StudioTestService")
7771
7882
  local testRunning = false
7772
7883
  local multiplayerState = {
7773
7884
  phase = "idle",
7774
7885
  }
7775
7886
  local function detectPeerRole()
7776
- if not RunService:IsRunning() then
7777
- return "edit"
7778
- end
7779
- if RunService:IsServer() then
7780
- return "server"
7781
- end
7782
- return "client"
7887
+ return PeerRole.detect()
7783
7888
  end
7784
7889
  local function getPlayersSnapshot()
7785
7890
  local _exp = Players:GetPlayers()
@@ -7854,6 +7959,7 @@ local function startPlaytest(requestData)
7854
7959
  }
7855
7960
  end
7856
7961
  testRunning = true
7962
+ local topologyMarkerToken = PluginSession.prepareSharedTopology()
7857
7963
  task.spawn(function()
7858
7964
  local ok, result = pcall(function()
7859
7965
  if mode == "play" then
@@ -7861,6 +7967,7 @@ local function startPlaytest(requestData)
7861
7967
  end
7862
7968
  return StudioTestService:ExecuteRunModeAsync({})
7863
7969
  end)
7970
+ PluginSession.clearTopologyMarker(topologyMarkerToken)
7864
7971
  if not ok then
7865
7972
  warn(`[robloxstudio-mcp] Playtest ended with error: {result}`)
7866
7973
  end
@@ -7966,11 +8073,13 @@ local function multiplayerTestStart(requestData)
7966
8073
  testArgs = testArgs,
7967
8074
  startedAt = tick(),
7968
8075
  }
8076
+ local topologyMarkerToken = PluginSession.prepareMultiplayerTopology(testId)
7969
8077
  task.spawn(function()
7970
8078
  multiplayerState.phase = "running"
7971
8079
  local ok, result = pcall(function()
7972
8080
  return StudioTestService:ExecuteMultiplayerTestAsync(numPlayers, testArgs)
7973
8081
  end)
8082
+ PluginSession.clearTopologyMarker(topologyMarkerToken)
7974
8083
  multiplayerState.completedAt = tick()
7975
8084
  multiplayerState.ok = ok
7976
8085
  if ok then
@@ -8652,20 +8761,69 @@ return {
8652
8761
  </Properties>
8653
8762
  </Item>
8654
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">
8655
8785
  <Properties>
8656
8786
  <string name="Name">PluginSession</string>
8657
8787
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
8658
8788
  local TS = require(script.Parent.Parent.include.RuntimeLib)
8659
8789
  local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
8660
8790
  local HttpService = _services.HttpService
8791
+ local ReplicatedStorage = _services.ReplicatedStorage
8661
8792
  local RunService = _services.RunService
8662
8793
  local ServerStorage = _services.ServerStorage
8663
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")
8664
8797
  local MCP_PLACE_ID_ATTRIBUTE = "__MCPPlaceId"
8665
- local id = HttpService:GenerateGUID(false)
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()
8666
8804
  local cachedPlaceName
8667
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
8668
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()
8669
8827
  if game.PlaceId ~= 0 then
8670
8828
  return `place:{tostring(game.PlaceId)}`
8671
8829
  end
@@ -8679,14 +8837,32 @@ local function getInstanceId()
8679
8837
  end)
8680
8838
  return `anon:{fresh}`
8681
8839
  end
8682
- local function getRole()
8683
- if not RunService:IsRunning() then
8684
- return "edit"
8685
- end
8686
- if RunService:IsServer() then
8687
- return "server"
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
8688
8858
  end
8689
- return "client"
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()
8690
8866
  end
8691
8867
  local function invalidatePlaceName()
8692
8868
  cachedPlaceName = nil
@@ -8717,14 +8893,22 @@ local function getPlaceName()
8717
8893
  end
8718
8894
  return game.Name
8719
8895
  end
8720
- local function createReadyPayload(pluginSessionId, role)
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
8721
8903
  return {
8722
- pluginSessionId = pluginSessionId,
8723
- physicalSessionId = id,
8724
- instanceId = getInstanceId(),
8904
+ peerId = readyPeerId,
8905
+ transportPeerId = peerId,
8906
+ instanceId = instanceId,
8907
+ multiplayerGroupId = multiplayerGroupId,
8725
8908
  role = role,
8726
8909
  placeId = game.PlaceId,
8727
8910
  placeName = getPlaceName(),
8911
+ placeKey = getPlaceKey(),
8728
8912
  dataModelName = game.Name,
8729
8913
  isRunning = RunService:IsRunning(),
8730
8914
  pluginVersion = State.CURRENT_VERSION,
@@ -8733,17 +8917,22 @@ local function createReadyPayload(pluginSessionId, role)
8733
8917
  }
8734
8918
  end
8735
8919
  return {
8736
- id = id,
8920
+ peerId = peerId,
8737
8921
  getInstanceId = getInstanceId,
8922
+ getMultiplayerGroupId = getMultiplayerGroupId,
8923
+ getPlaceKey = getPlaceKey,
8738
8924
  getRole = getRole,
8739
8925
  getPlaceName = getPlaceName,
8740
8926
  invalidatePlaceName = invalidatePlaceName,
8927
+ prepareSharedTopology = prepareSharedTopology,
8928
+ prepareMultiplayerTopology = prepareMultiplayerTopology,
8929
+ clearTopologyMarker = clearTopologyMarker,
8741
8930
  createReadyPayload = createReadyPayload,
8742
8931
  }
8743
8932
  ]]></string>
8744
8933
  </Properties>
8745
8934
  </Item>
8746
- <Item class="ModuleScript" referent="28">
8935
+ <Item class="ModuleScript" referent="29">
8747
8936
  <Properties>
8748
8937
  <string name="Name">Recording</string>
8749
8938
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -8773,7 +8962,7 @@ return {
8773
8962
  ]]></string>
8774
8963
  </Properties>
8775
8964
  </Item>
8776
- <Item class="ModuleScript" referent="29">
8965
+ <Item class="ModuleScript" referent="30">
8777
8966
  <Properties>
8778
8967
  <string name="Name">RenderMonitor</string>
8779
8968
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -8841,30 +9030,22 @@ return {
8841
9030
  ]]></string>
8842
9031
  </Properties>
8843
9032
  </Item>
8844
- <Item class="ModuleScript" referent="30">
9033
+ <Item class="ModuleScript" referent="31">
8845
9034
  <Properties>
8846
9035
  <string name="Name">RuntimeLogBuffer</string>
8847
9036
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
8848
9037
  local TS = require(script.Parent.Parent.include.RuntimeLib)
8849
- -- Per-capture in-memory ring buffer for LogService.MessageOut events.
8850
- -- Powers the get_runtime_logs MCP tool. Replaces the out-of-tree LogBuffer
8851
- -- primitives + StringValue approach from chrrxs/roblox-mcp-primitives.
8852
- --
8853
- -- Each peer's plugin attaches a MessageOut listener at plugin load (edit DM,
8854
- -- play-server DM, play-client DM all run their own copy of this module).
8855
- -- Captured entries live in plugin module-state; nothing is parented to the
8856
- -- DataModel. The buffer is bounded by a message-byte budget; oldest entries
8857
- -- 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.
8858
9040
  --
8859
- -- Capture caveat: returned entries reflect which plugin buffer CAPTURED the
8860
- -- entry, NOT which peer's script originated the print. LogService reflects
8861
- -- prints across peers in ordinary Studio Play (a server print can appear in
8862
- -- server and client LogService:GetLogHistory()). The MCP-side aggregator
8863
- -- exposes that as capturedBy, and only promotes it to origin peer in
8864
- -- 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.
8865
9045
  local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
8866
9046
  local LogService = _services.LogService
8867
9047
  local RunService = _services.RunService
9048
+ local PeerRole = TS.import(script, script.Parent, "PeerRole")
8868
9049
  local MAX_BYTES = 64 * 1024
8869
9050
  local HARD_ENTRY_CAP = 50_000
8870
9051
  local entries = {}
@@ -8954,7 +9135,7 @@ local function seedRuntimeHistory()
8954
9135
  if not ok then
8955
9136
  return nil
8956
9137
  end
8957
- local isEdit = not RunService:IsRunning()
9138
+ local isEdit = PeerRole.detect() == "edit"
8958
9139
  -- GetLogHistory timestamps and DateTime.now() share Unix time, while
8959
9140
  -- os.clock() is elapsed time for this Studio process. Their difference is
8960
9141
  -- therefore the process launch boundary. Edit-mode history is filtered to
@@ -8990,16 +9171,7 @@ local function install()
8990
9171
  pushEntry(msg, t, nil, context)
8991
9172
  end)
8992
9173
  end
8993
- local function detectPeer()
8994
- if not RunService:IsRunning() then
8995
- return "edit"
8996
- end
8997
- if RunService:IsServer() then
8998
- return "server"
8999
- end
9000
- return "client"
9001
- end
9002
- local function query(opts, capturedBy)
9174
+ local function query(opts)
9003
9175
  local _result
9004
9176
  if opts.since ~= nil then
9005
9177
  -- ▼ ReadonlyArray.filter ▼
@@ -9070,7 +9242,6 @@ local function query(opts, capturedBy)
9070
9242
  end
9071
9243
  local last = if #entries > 0 then entries[#entries] else nil
9072
9244
  local _object = {
9073
- capturedBy = capturedBy,
9074
9245
  entries = result,
9075
9246
  totalDropped = totalDropped,
9076
9247
  }
@@ -9090,13 +9261,12 @@ local function query(opts, capturedBy)
9090
9261
  end
9091
9262
  return {
9092
9263
  install = install,
9093
- detectPeer = detectPeer,
9094
9264
  query = query,
9095
9265
  }
9096
9266
  ]]></string>
9097
9267
  </Properties>
9098
9268
  </Item>
9099
- <Item class="ModuleScript" referent="31">
9269
+ <Item class="ModuleScript" referent="32">
9100
9270
  <Properties>
9101
9271
  <string name="Name">ScriptSearch</string>
9102
9272
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9423,7 +9593,7 @@ return {
9423
9593
  ]]></string>
9424
9594
  </Properties>
9425
9595
  </Item>
9426
- <Item class="ModuleScript" referent="32">
9596
+ <Item class="ModuleScript" referent="33">
9427
9597
  <Properties>
9428
9598
  <string name="Name">ServerUrlSettings</string>
9429
9599
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9470,14 +9640,14 @@ local function addUnique(values, value)
9470
9640
  table.insert(_values_1, _value_1)
9471
9641
  end
9472
9642
  end
9473
- local function computeInstanceIds(options)
9474
- local ids = {}
9643
+ local function computePlaceKeys(options)
9644
+ local placeKeys = {}
9475
9645
  if game.PlaceId ~= 0 then
9476
- addUnique(ids, `place:{tostring(game.PlaceId)}`)
9646
+ addUnique(placeKeys, `place:{tostring(game.PlaceId)}`)
9477
9647
  end
9478
9648
  local existing = ServerStorage:GetAttribute("__MCPPlaceId")
9479
9649
  if type(existing) == "string" and existing ~= "" then
9480
- addUnique(ids, `anon:{existing}`)
9650
+ addUnique(placeKeys, `anon:{existing}`)
9481
9651
  else
9482
9652
  local _condition = game.PlaceId == 0
9483
9653
  if _condition then
@@ -9492,13 +9662,13 @@ local function computeInstanceIds(options)
9492
9662
  pcall(function()
9493
9663
  return ServerStorage:SetAttribute("__MCPPlaceId", fresh)
9494
9664
  end)
9495
- addUnique(ids, `anon:{fresh}`)
9665
+ addUnique(placeKeys, `anon:{fresh}`)
9496
9666
  end
9497
9667
  end
9498
- return ids
9668
+ return placeKeys
9499
9669
  end
9500
- local function settingKey(instanceId)
9501
- return SETTING_KEY_PREFIX .. instanceId
9670
+ local function settingKey(placeKey)
9671
+ return SETTING_KEY_PREFIX .. placeKey
9502
9672
  end
9503
9673
  local function readSettingString(key)
9504
9674
  if not pluginRef then
@@ -9527,21 +9697,21 @@ local function rememberServerUrl(serverUrl)
9527
9697
  return nil
9528
9698
  end
9529
9699
  writeSettingString(GLOBAL_SETTING_KEY, normalized)
9530
- for _, instanceId in computeInstanceIds({
9700
+ for _, placeKey in computePlaceKeys({
9531
9701
  createAnonymous = true,
9532
9702
  }) do
9533
- writeSettingString(settingKey(instanceId), normalized)
9703
+ writeSettingString(settingKey(placeKey), normalized)
9534
9704
  end
9535
9705
  end
9536
9706
  local function readServerUrl()
9537
9707
  if not pluginRef then
9538
9708
  return nil
9539
9709
  end
9540
- -- Reading settings should not mint a place identity. Client play DMs have
9541
- -- their own ServerStorage; creating an id there makes a misleading anon id
9542
- -- that never matches the edit/server bridge identity.
9543
- for _, instanceId in computeInstanceIds() do
9544
- local remembered = readSettingString(settingKey(instanceId))
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))
9545
9715
  if remembered ~= nil then
9546
9716
  return remembered
9547
9717
  end
@@ -9562,11 +9732,11 @@ return {
9562
9732
  ]]></string>
9563
9733
  </Properties>
9564
9734
  </Item>
9565
- <Item class="ModuleScript" referent="33">
9735
+ <Item class="ModuleScript" referent="34">
9566
9736
  <Properties>
9567
9737
  <string name="Name">State</string>
9568
9738
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
9569
- local CURRENT_VERSION = "3.0.4"
9739
+ local CURRENT_VERSION = "3.1.0"
9570
9740
  local PLUGIN_VARIANT = "main"
9571
9741
  local BASE_PORT = 58741
9572
9742
  local function createConnection(port)
@@ -9596,40 +9766,18 @@ return {
9596
9766
  ]]></string>
9597
9767
  </Properties>
9598
9768
  </Item>
9599
- <Item class="ModuleScript" referent="34">
9769
+ <Item class="ModuleScript" referent="35">
9600
9770
  <Properties>
9601
9771
  <string name="Name">StopPlayMonitor</string>
9602
9772
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
9603
9773
  local TS = require(script.Parent.Parent.include.RuntimeLib)
9604
- -- Cross-DM stop_playtest signaling via plugin:SetSetting, scoped by
9605
- -- per-instance setting key so the same Studio process can host playtests
9606
- -- for multiple places without one place's stop_playtest yanking another's.
9607
- -- During publish-after-connect, both "anon:<uuid>" and "place:<PlaceId>"
9608
- -- can refer to the same Studio place, so stop requests are mirrored across
9609
- -- both keys while the monitor waits for a matching result on either key.
9610
- --
9611
- -- `plugin:SetSetting` / `plugin:GetSetting` is a per-plugin persistent store
9612
- -- shared across every DataModel the plugin runs in (edit DMs, play-server
9613
- -- DMs, play-client DMs). For each connected place we use a dedicated key
9614
- -- "MCP_STOP_PLAY_<instanceId>" as a tiny request/result mailbox:
9615
- --
9616
- -- * The edit DM's handler writes a tokenized stop request into its own key
9617
- -- (computed from its placeId / ServerStorage anon UUID).
9618
- -- * Each play-server DM's monitor loop polls the key matching its own
9619
- -- instanceId at 1Hz. On a fresh token, it calls StudioTestService:EndTest
9620
- -- and writes a matching result token. Play-server DMs for other places
9621
- -- never touch this key.
9622
- -- * The edit DM waits up to ~8s for its result token, confirming a matching
9623
- -- play-server actually consumed the request.
9624
- --
9625
- -- Earlier versions used a single shared boolean flag, which let any
9626
- -- play-server DM in the same Studio process consume any place's stop
9627
- -- request — silently yanking teammates' playtests. The per-key scoping
9628
- -- 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.
9629
9777
  local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
9630
9778
  local HttpService = _services.HttpService
9631
9779
  local RunService = _services.RunService
9632
- local ServerStorage = _services.ServerStorage
9780
+ local PluginSession = TS.import(script, script.Parent, "PluginSession")
9633
9781
  local StudioTestService = game:GetService("StudioTestService")
9634
9782
  local SETTING_KEY_PREFIX = "MCP_STOP_PLAY_"
9635
9783
  -- Keep this conservative. plugin:GetSetting is backed by Studio's plugin
@@ -9646,55 +9794,12 @@ local WAIT_POLL_SEC = 0.1
9646
9794
  local REQUEST_TTL_SEC = 12.0
9647
9795
  local pluginRef
9648
9796
  local endTestIssued = false
9797
+ local transportLifecycle
9649
9798
  local function init(p)
9650
9799
  pluginRef = p
9651
9800
  end
9652
- -- Mirror of PluginSession's place identity rules. Duplicated here because
9653
- -- StopPlayMonitor runs in both edit and play-server DMs, and both must
9654
- -- agree on the place identifier (published places: placeId; unpublished:
9655
- -- UUID on ServerStorage's __MCPPlaceId attribute, travels with the .rbxl
9656
- -- into the play DM).
9657
- local function addUnique(values, value)
9658
- local _values = values
9659
- local _value = value
9660
- if not (table.find(_values, _value) ~= nil) then
9661
- local _values_1 = values
9662
- local _value_1 = value
9663
- table.insert(_values_1, _value_1)
9664
- end
9665
- end
9666
- local function computeInstanceIds()
9667
- local ids = {}
9668
- if game.PlaceId ~= 0 then
9669
- addUnique(ids, `place:{tostring(game.PlaceId)}`)
9670
- end
9671
- local existing = ServerStorage:GetAttribute("__MCPPlaceId")
9672
- if type(existing) == "string" and existing ~= "" then
9673
- addUnique(ids, `anon:{existing}`)
9674
- elseif game.PlaceId == 0 then
9675
- local fresh = HttpService:GenerateGUID(false)
9676
- pcall(function()
9677
- return ServerStorage:SetAttribute("__MCPPlaceId", fresh)
9678
- end)
9679
- addUnique(ids, `anon:{fresh}`)
9680
- end
9681
- return ids
9682
- end
9683
- local function settingKey(instanceId)
9684
- return SETTING_KEY_PREFIX .. instanceId
9685
- end
9686
- local function settingKeys()
9687
- local _exp = computeInstanceIds()
9688
- -- ▼ ReadonlyArray.map ▼
9689
- local _newValue = table.create(#_exp)
9690
- local _callback = function(instanceId)
9691
- return settingKey(instanceId)
9692
- end
9693
- for _k, _v in _exp do
9694
- _newValue[_k] = _callback(_v, _k - 1, _exp)
9695
- end
9696
- -- ▲ ReadonlyArray.map ▲
9697
- return _newValue
9801
+ local function settingKey()
9802
+ return SETTING_KEY_PREFIX .. PluginSession.getInstanceId()
9698
9803
  end
9699
9804
  local function readSetting(key)
9700
9805
  if not pluginRef then
@@ -9789,26 +9894,39 @@ local function handleStopRequest(key, request)
9789
9894
  return nil
9790
9895
  end
9791
9896
  endTestIssued = true
9897
+ local lifecycle = transportLifecycle
9898
+ if lifecycle ~= nil then
9899
+ local closeOk, closeError = pcall(lifecycle.beforeEndTest)
9900
+ if not closeOk then
9901
+ warn(`[robloxstudio-mcp] Failed to close the play-server transport before EndTest: {tostring(closeError)}`)
9902
+ end
9903
+ end
9792
9904
  local endOk, endErr = pcall(function()
9793
9905
  return StudioTestService:EndTest("stopped_by_mcp")
9794
9906
  end)
9795
9907
  writeResult(key, request, endOk, if endOk then nil else tostring(endErr))
9796
9908
  if not endOk then
9797
9909
  endTestIssued = false
9910
+ if lifecycle ~= nil then
9911
+ local restoreOk, restoreError = pcall(lifecycle.afterEndTestFailure)
9912
+ if not restoreOk then
9913
+ warn(`[robloxstudio-mcp] Failed to restore the play-server transport after EndTest failed: {tostring(restoreError)}`)
9914
+ end
9915
+ end
9798
9916
  end
9799
9917
  end
9800
- local function startMonitor()
9918
+ local function startMonitor(lifecycle)
9919
+ transportLifecycle = lifecycle
9801
9920
  if not pluginRef then
9802
9921
  warn("[robloxstudio-mcp] StopPlayMonitor.startMonitor called before init; skipping")
9803
9922
  return nil
9804
9923
  end
9805
9924
  task.spawn(function()
9806
9925
  while true do
9807
- for _, myKey in settingKeys() do
9808
- local payload = decodePayload(readSetting(myKey))
9809
- if payload then
9810
- handleStopRequest(myKey, payload)
9811
- end
9926
+ local myKey = settingKey()
9927
+ local payload = decodePayload(readSetting(myKey))
9928
+ if payload then
9929
+ handleStopRequest(myKey, payload)
9812
9930
  end
9813
9931
  task.wait(POLL_INTERVAL_SEC)
9814
9932
  end
@@ -9826,10 +9944,7 @@ local function requestStop()
9826
9944
  id = requestId,
9827
9945
  requestedAt = tick(),
9828
9946
  }
9829
- local ok = false
9830
- for _, myKey in settingKeys() do
9831
- ok = writePayload(myKey, payload) or ok
9832
- end
9947
+ local ok = writePayload(settingKey(), payload)
9833
9948
  return {
9834
9949
  ok = ok,
9835
9950
  requestId = if ok then requestId else nil,
@@ -9845,15 +9960,13 @@ local function waitForConsumption(requestId)
9845
9960
  end
9846
9961
  local start = tick()
9847
9962
  while tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC do
9848
- for _, myKey in settingKeys() do
9849
- local payload = decodePayload(readSetting(myKey))
9850
- if payload and payload.kind == "result" and payload.id == requestId then
9851
- return {
9852
- ok = payload.ok == true,
9853
- consumed = true,
9854
- error = payload.error,
9855
- }
9856
- 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
+ }
9857
9970
  end
9858
9971
  task.wait(WAIT_POLL_SEC)
9859
9972
  end
@@ -9867,15 +9980,14 @@ local function clearPending(requestId)
9867
9980
  if not pluginRef then
9868
9981
  return nil
9869
9982
  end
9870
- for _, myKey in settingKeys() do
9871
- if requestId ~= nil then
9872
- local payload = decodePayload(readSetting(myKey))
9873
- if payload and payload.id ~= requestId then
9874
- continue
9875
- 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
9876
9988
  end
9877
- writeSetting(myKey, false)
9878
9989
  end
9990
+ writeSetting(myKey, false)
9879
9991
  end
9880
9992
  return {
9881
9993
  init = init,
@@ -9887,7 +9999,7 @@ return {
9887
9999
  ]]></string>
9888
10000
  </Properties>
9889
10001
  </Item>
9890
- <Item class="ModuleScript" referent="35">
10002
+ <Item class="ModuleScript" referent="36">
9891
10003
  <Properties>
9892
10004
  <string name="Name">StudioEventStream</string>
9893
10005
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9903,6 +10015,7 @@ local MAX_TERMINAL_RESPONSES = 256
9903
10015
  local STREAM_SILENCE_TIMEOUT_SECONDS = 20
9904
10016
  local options
9905
10017
  local active = false
10018
+ local shutdownSuspended = false
9906
10019
  local generation = 0
9907
10020
  local reconnectAttempt = 0
9908
10021
  local streamClient
@@ -9940,8 +10053,8 @@ local function decodeMessage(message)
9940
10053
  }
9941
10054
  end
9942
10055
  if envelope.kind == "status" then
9943
- local _knownInstance = envelope.knownInstance
9944
- local _condition = not (type(_knownInstance) == "boolean")
10056
+ local _knownPeer = envelope.knownPeer
10057
+ local _condition = not (type(_knownPeer) == "boolean")
9945
10058
  if not _condition then
9946
10059
  local _mcpConnected = envelope.mcpConnected
9947
10060
  _condition = not (type(_mcpConnected) == "boolean")
@@ -9951,7 +10064,7 @@ local function decodeMessage(message)
9951
10064
  end
9952
10065
  local _object = {
9953
10066
  kind = "status",
9954
- knownInstance = envelope.knownInstance,
10067
+ knownPeer = envelope.knownPeer,
9955
10068
  mcpConnected = envelope.mcpConnected,
9956
10069
  }
9957
10070
  local _left = "serverVersion"
@@ -9984,8 +10097,8 @@ local function decodeMessage(message)
9984
10097
  local _requestId = envelope.requestId
9985
10098
  local _condition = not (type(_requestId) == "string")
9986
10099
  if not _condition then
9987
- local _logicalSessionId = envelope.logicalSessionId
9988
- _condition = not (type(_logicalSessionId) == "string")
10100
+ local _peerId = envelope.peerId
10101
+ _condition = not (type(_peerId) == "string")
9989
10102
  if not _condition then
9990
10103
  local _target = envelope.target
9991
10104
  _condition = not (type(_target) == "string")
@@ -10013,7 +10126,7 @@ local function decodeMessage(message)
10013
10126
  return {
10014
10127
  kind = "request",
10015
10128
  requestId = envelope.requestId,
10016
- logicalSessionId = envelope.logicalSessionId,
10129
+ peerId = envelope.peerId,
10017
10130
  target = envelope.target,
10018
10131
  endpoint = envelope.endpoint,
10019
10132
  data = data,
@@ -10035,6 +10148,20 @@ local function closeCurrentStream()
10035
10148
  end)
10036
10149
  end
10037
10150
  end
10151
+ local function disconnectSession(currentOptions)
10152
+ pcall(function()
10153
+ return HttpService:RequestAsync({
10154
+ Url = `{currentOptions.serverUrl}/disconnect`,
10155
+ Method = "POST",
10156
+ Headers = {
10157
+ ["Content-Type"] = "application/json",
10158
+ },
10159
+ Body = HttpService:JSONEncode({
10160
+ peerId = PluginSession.peerId,
10161
+ }),
10162
+ })
10163
+ end)
10164
+ end
10038
10165
  local function responseRetryDelay(attempt)
10039
10166
  return math.min(INITIAL_RESPONSE_RETRY_DELAY_SECONDS * math.pow(2, math.max(attempt - 1, 0)), MAX_RESPONSE_RETRY_DELAY_SECONDS)
10040
10167
  end
@@ -10351,15 +10478,23 @@ local function parseReadyResponse(body)
10351
10478
  if not _condition then
10352
10479
  _condition = value.assignedRole == ""
10353
10480
  if not _condition then
10354
- local _instanceId = value.instanceId
10355
- _condition = not (type(_instanceId) == "string")
10481
+ local _peerId = value.peerId
10482
+ _condition = not (type(_peerId) == "string")
10356
10483
  if not _condition then
10357
- _condition = value.instanceId == ""
10484
+ _condition = value.peerId == ""
10358
10485
  if not _condition then
10359
- local _serverVersion = value.serverVersion
10360
- _condition = not (type(_serverVersion) == "string")
10486
+ local _instanceId = value.instanceId
10487
+ _condition = not (type(_instanceId) == "string")
10361
10488
  if not _condition then
10362
- _condition = value.serverVersion == ""
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
10363
10498
  end
10364
10499
  end
10365
10500
  end
@@ -10372,8 +10507,9 @@ local function parseReadyResponse(body)
10372
10507
  return {
10373
10508
  success = true,
10374
10509
  assignedRole = value.assignedRole,
10510
+ peerId = value.peerId,
10375
10511
  instanceId = value.instanceId,
10376
- serverVersion = value.serverVersion,
10512
+ multiplayerGroupId = value.multiplayerGroupId,
10377
10513
  }
10378
10514
  end
10379
10515
  local refresh
@@ -10389,10 +10525,10 @@ function connect(expectedGeneration)
10389
10525
  })
10390
10526
  task.spawn(function()
10391
10527
  local instanceId = PluginSession.getInstanceId()
10528
+ local multiplayerGroupId = PluginSession.getMultiplayerGroupId()
10392
10529
  local readyUrl = `{currentOptions.serverUrl}/ready`
10393
- local physicalRole = PluginSession.getRole()
10394
- local readyPayload = PluginSession.createReadyPayload(PluginSession.id, physicalRole)
10395
- readyPayload.pluginReady = true
10530
+ local transportRole = PluginSession.getRole()
10531
+ local readyPayload = PluginSession.createReadyPayload(PluginSession.peerId, transportRole, instanceId, multiplayerGroupId)
10396
10532
  if not active or generation ~= expectedGeneration or options ~= currentOptions then
10397
10533
  return nil
10398
10534
  end
@@ -10409,12 +10545,12 @@ function connect(expectedGeneration)
10409
10545
  if not active or generation ~= expectedGeneration or options ~= currentOptions then
10410
10546
  return nil
10411
10547
  end
10412
- local readyLogKey = `{currentOptions.serverUrl}|{instanceId}|{physicalRole}`
10548
+ local readyLogKey = `{currentOptions.serverUrl}|{PluginSession.peerId}`
10413
10549
  if not readyOk then
10414
10550
  local detail = HttpDiagnostics.formatRequestFailure(readyUrl, false, readyResult)
10415
10551
  if not (readyFailureLogKeys[readyLogKey] ~= nil) then
10416
10552
  readyFailureLogKeys[readyLogKey] = true
10417
- warn(`[robloxstudio-mcp] /ready failed for {instanceId}/{physicalRole}: {detail}`)
10553
+ warn(`[robloxstudio-mcp] /ready failed for {instanceId}/{transportRole}: {detail}`)
10418
10554
  end
10419
10555
  scheduleReconnect(expectedGeneration, detail)
10420
10556
  return nil
@@ -10423,14 +10559,14 @@ function connect(expectedGeneration)
10423
10559
  local detail = HttpDiagnostics.formatRequestFailure(readyUrl, true, readyResult)
10424
10560
  if not (readyFailureLogKeys[readyLogKey] ~= nil) then
10425
10561
  readyFailureLogKeys[readyLogKey] = true
10426
- warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{physicalRole}: {detail}`)
10562
+ warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{transportRole}: {detail}`)
10427
10563
  end
10428
10564
  scheduleReconnect(expectedGeneration, detail, readyResult.StatusCode == 409)
10429
10565
  return nil
10430
10566
  end
10431
10567
  local readyData = parseReadyResponse(readyResult.Body)
10432
- if readyData == nil then
10433
- scheduleReconnect(expectedGeneration, "Invalid /ready response: expected the bundled server protocol")
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")
10434
10570
  return nil
10435
10571
  end
10436
10572
  if readyFailureLogKeys[readyLogKey] ~= nil then
@@ -10442,7 +10578,7 @@ function connect(expectedGeneration)
10442
10578
  end)
10443
10579
  local createOk, createdClient = pcall(function()
10444
10580
  return HttpService:CreateWebStreamClient(Enum.WebStreamClientType.SSE, {
10445
- Url = `{currentOptions.serverUrl}/events?pluginSessionId={PluginSession.id}`,
10581
+ Url = `{currentOptions.serverUrl}/events?peerId={PluginSession.peerId}`,
10446
10582
  Method = "GET",
10447
10583
  Headers = {
10448
10584
  Accept = "text/event-stream",
@@ -10502,7 +10638,7 @@ function connect(expectedGeneration)
10502
10638
  invokeCallback("event stream status", function()
10503
10639
  return currentOptions.onStatus(event)
10504
10640
  end)
10505
- if not event.knownInstance then
10641
+ if not event.knownPeer then
10506
10642
  refresh()
10507
10643
  end
10508
10644
  end), createdClient.Error:Connect(function(statusCode, message)
@@ -10523,11 +10659,12 @@ function connect(expectedGeneration)
10523
10659
  end
10524
10660
  local stop
10525
10661
  local function start(newOptions)
10526
- if active then
10662
+ if active or shutdownSuspended then
10527
10663
  stop()
10528
10664
  end
10529
10665
  options = newOptions
10530
10666
  active = true
10667
+ shutdownSuspended = false
10531
10668
  reconnectAttempt = 0
10532
10669
  generation += 1
10533
10670
  connect(generation)
@@ -10541,12 +10678,39 @@ function refresh()
10541
10678
  reconnectAttempt = 0
10542
10679
  connect(generation)
10543
10680
  end
10681
+ -- EndTest can tear down a play DataModel without giving Plugin.Unloading a
10682
+ -- usable execution window. Release the native WebStreamClient before the
10683
+ -- yielding unregister request, but retain local request state and connection
10684
+ -- options so a failed EndTest can register again and reconnect.
10685
+ local function suspendForShutdown()
10686
+ local currentOptions = options
10687
+ if not active or currentOptions == nil then
10688
+ return nil
10689
+ end
10690
+ active = false
10691
+ shutdownSuspended = true
10692
+ generation += 1
10693
+ reconnectAttempt = 0
10694
+ closeCurrentStream()
10695
+ disconnectSession(currentOptions)
10696
+ end
10697
+ local function resumeAfterShutdownFailure()
10698
+ if not shutdownSuspended or options == nil then
10699
+ return nil
10700
+ end
10701
+ shutdownSuspended = false
10702
+ active = true
10703
+ generation += 1
10704
+ reconnectAttempt = 0
10705
+ connect(generation)
10706
+ end
10544
10707
  function stop()
10545
- if not active then
10708
+ if not active and not shutdownSuspended then
10546
10709
  return nil
10547
10710
  end
10548
10711
  local currentOptions = options
10549
10712
  active = false
10713
+ shutdownSuspended = false
10550
10714
  generation += 1
10551
10715
  for _, inFlight in inFlightRequests do
10552
10716
  inFlight.cancelled = true
@@ -10558,30 +10722,72 @@ function stop()
10558
10722
  options = nil
10559
10723
  reconnectAttempt = 0
10560
10724
  if currentOptions ~= nil then
10561
- pcall(function()
10562
- return HttpService:RequestAsync({
10563
- Url = `{currentOptions.serverUrl}/disconnect`,
10564
- Method = "POST",
10565
- Headers = {
10566
- ["Content-Type"] = "application/json",
10567
- },
10568
- Body = HttpService:JSONEncode({
10569
- pluginSessionId = PluginSession.id,
10570
- timestamp = tick(),
10571
- }),
10572
- })
10573
- end)
10725
+ disconnectSession(currentOptions)
10574
10726
  end
10575
10727
  end
10576
10728
  return {
10577
10729
  start = start,
10578
10730
  refresh = refresh,
10731
+ suspendForShutdown = suspendForShutdown,
10732
+ resumeAfterShutdownFailure = resumeAfterShutdownFailure,
10579
10733
  stop = stop,
10580
10734
  }
10581
10735
  ]]></string>
10582
10736
  </Properties>
10583
10737
  </Item>
10584
- <Item class="ModuleScript" referent="36">
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">
10585
10791
  <Properties>
10586
10792
  <string name="Name">UI</string>
10587
10793
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -11133,7 +11339,7 @@ return {
11133
11339
  ]]></string>
11134
11340
  </Properties>
11135
11341
  </Item>
11136
- <Item class="ModuleScript" referent="37">
11342
+ <Item class="ModuleScript" referent="39">
11137
11343
  <Properties>
11138
11344
  <string name="Name">Utils</string>
11139
11345
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -11974,11 +12180,11 @@ return {
11974
12180
  </Properties>
11975
12181
  </Item>
11976
12182
  </Item>
11977
- <Item class="Folder" referent="42">
12183
+ <Item class="Folder" referent="44">
11978
12184
  <Properties>
11979
12185
  <string name="Name">include</string>
11980
12186
  </Properties>
11981
- <Item class="ModuleScript" referent="38">
12187
+ <Item class="ModuleScript" referent="40">
11982
12188
  <Properties>
11983
12189
  <string name="Name">LibMP</string>
11984
12190
  <string name="Source"><![CDATA[-- =============================================================================
@@ -168362,7 +168568,7 @@ return LibMP
168362
168568
  ]]></string>
168363
168569
  </Properties>
168364
168570
  </Item>
168365
- <Item class="ModuleScript" referent="39">
168571
+ <Item class="ModuleScript" referent="41">
168366
168572
  <Properties>
168367
168573
  <string name="Name">Promise</string>
168368
168574
  <string name="Source"><![CDATA[--[[
@@ -170436,7 +170642,7 @@ return Promise
170436
170642
  ]]></string>
170437
170643
  </Properties>
170438
170644
  </Item>
170439
- <Item class="ModuleScript" referent="40">
170645
+ <Item class="ModuleScript" referent="42">
170440
170646
  <Properties>
170441
170647
  <string name="Name">RuntimeLib</string>
170442
170648
  <string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
@@ -170703,15 +170909,15 @@ return TS
170703
170909
  </Properties>
170704
170910
  </Item>
170705
170911
  </Item>
170706
- <Item class="Folder" referent="43">
170912
+ <Item class="Folder" referent="45">
170707
170913
  <Properties>
170708
170914
  <string name="Name">node_modules</string>
170709
170915
  </Properties>
170710
- <Item class="Folder" referent="44">
170916
+ <Item class="Folder" referent="46">
170711
170917
  <Properties>
170712
170918
  <string name="Name">@rbxts</string>
170713
170919
  </Properties>
170714
- <Item class="ModuleScript" referent="41">
170920
+ <Item class="ModuleScript" referent="43">
170715
170921
  <Properties>
170716
170922
  <string name="Name">services</string>
170717
170923
  <string name="Source"><![CDATA[return setmetatable({}, {