@chrrxs/robloxstudio-mcp 3.0.5 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 peer. The boshyxd plugin only registers with MCP when the
101
- -- user clicks Connect in its UI, but that UI is invisible in play DMs - so
102
- -- play peers' plugin instances load without ever registering. Run after a
103
- -- short delay so the UI/State have a chance to initialize first.
104
- task.delay(2, function()
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
- -- The client peer cannot reach the MCP HTTP server - Roblox forbids
297
- -- HttpService:RequestAsync from the client DM even under PluginSecurity, and
298
- -- HttpEnabled reads as false there regardless of identity. So the server peer
299
- -- brokers client-targeted requests through a RemoteFunction it places
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
- if not RunService:IsRunning() then
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
- }, "client")
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 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
453
483
  local rf = ReplicatedStorage:WaitForChild(BROKER_NAME, 10)
454
484
  if not rf or not rf:IsA("RemoteFunction") then
455
- 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
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 proxyBySessionId = {}
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 _pluginSessionId = proxy.pluginSessionId
538
- proxyBySessionId[_pluginSessionId] = nil
573
+ local _peerId = proxy.peerId
574
+ proxyByPeerId[_peerId] = nil
539
575
  local _player_1 = player
540
576
  proxyRegisterFailuresByPlayer[_player_1] = nil
541
- queueProxyDisconnect(proxy.pluginSessionId)
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(proxyBySessionId)
584
+ table.clear(proxyByPeerId)
549
585
  table.clear(proxyRegisterFailuresByPlayer)
550
586
  end
551
- local function proxyRetryDelay(attempt)
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(pluginSessionId, attempt)
555
- local _pluginSessionId = pluginSessionId
556
- if not (pendingProxyDisconnects[_pluginSessionId] ~= nil) then
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
- pluginSessionId = pluginSessionId,
596
+ peerId = peerId,
561
597
  })
562
598
  if ok and response and response.Success then
563
- local _pluginSessionId_1 = pluginSessionId
564
- pendingProxyDisconnects[_pluginSessionId_1] = nil
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(pluginSessionId, attempt + 1)
604
+ deliverProxyDisconnect(peerId, attempt + 1)
569
605
  end)
570
606
  end
571
- function queueProxyDisconnect(pluginSessionId)
572
- local _pluginSessionId = pluginSessionId
573
- if pendingProxyDisconnects[_pluginSessionId] ~= nil then
607
+ function queueProxyDisconnect(peerId)
608
+ local _peerId = peerId
609
+ if pendingProxyDisconnects[_peerId] ~= nil then
574
610
  return nil
575
611
  end
576
- local _pluginSessionId_1 = pluginSessionId
577
- pendingProxyDisconnects[_pluginSessionId_1] = true
578
- task.spawn(deliverProxyDisconnect, pluginSessionId, 0)
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.pluginSessionId, requestedRole)
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.pluginSessionId)
680
+ queueProxyDisconnect(entry.peerId)
655
681
  end
656
682
  return nil
657
683
  end
658
684
  if entry.generation ~= expectedGeneration then
659
- if not entry.registering then
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: expected success=true and a non-empty assignedRole")
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 _player_2 = entry.player
678
- if proxyRegisterFailuresByPlayer[_player_2] ~= nil then
679
- local _player_3 = entry.player
680
- 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
681
708
  print(`[robloxstudio-mcp] proxy registered for {entry.player.Name} as {assignedRole} via {mcpUrl}`)
682
709
  end
683
710
  end
684
- local function registerProxy(player, rf)
685
- local _player = player
686
- 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
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
- pluginSessionId = HttpService:GenerateGUID(false),
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 _pluginSessionId = entry.pluginSessionId
702
- proxyBySessionId[_pluginSessionId] = entry
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 refreshAllLogicalRegistrations()
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(logicalSessionId, target, endpoint, data)
715
- local _logicalSessionId = logicalSessionId
716
- local entry = proxyBySessionId[_logicalSessionId]
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} ({logicalSessionId}) is not registered.`,
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 {logicalSessionId} is registered as {entry.role}, not {target}.`,
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 rf = ReplicatedStorage:FindFirstChild(BROKER_NAME)
777
- 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
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:SetAttribute(BROKER_OWNER_ATTRIBUTE, HttpService:GenerateGUID(false))
786
- serverBrokerStarted = true
787
- local broker = rf
788
- Players.PlayerAdded:Connect(function(p)
789
- return registerProxy(p, broker)
790
- end)
791
- for _, p in Players:GetPlayers() do
792
- 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
+ }
898
+ end
899
+ if rf.Parent == nil then
900
+ rf.Parent = ReplicatedStorage
793
901
  end
794
- Players.PlayerRemoving:Connect(function(p)
795
- unregisterProxy(p)
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
- refreshAllLogicalRegistrations = refreshAllLogicalRegistrations,
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 lastReadyInstanceId
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.logicalSessionId ~= PluginSession.id then
924
- 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)
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 = `Physical plugin session is registered as {localRole}, not {request.target}.`,
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
- lastReadyInstanceId = response.instanceId
1056
+ lastReadyPlaceKey = PluginSession.getPlaceKey()
948
1057
  ServerUrlSettings.rememberServerUrl(conn.serverUrl)
949
- ClientBroker.refreshAllLogicalRegistrations()
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
- lastReadyInstanceId = PluginSession.getInstanceId()
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
- lastReadyInstanceId = PluginSession.getInstanceId()
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 currentInstanceId = PluginSession.getInstanceId()
1086
- if lastReadyInstanceId ~= nil and currentInstanceId ~= lastReadyInstanceId then
1087
- lastReadyInstanceId = currentInstanceId
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 not RunService:IsRunning() then
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
- lastReadyInstanceId = nil
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.5" is replaced with the package version at package time
1475
+ -- "3.1.1" 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.5`
1477
+ return `{tostring(h)}-3.1.1`
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 not RunService:IsRunning() then
1611
+ if PeerRole.detect() == "edit" then
1502
1612
  return {
1503
1613
  installed = false,
1504
- error = "Eval bridges are installed only in running play DataModels",
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 computeInstanceId()
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
- if not RunService:IsRunning() then
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 ___mcp_instance_id = requestData.__mcp_instance_id
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}{instanceId}:{role}`,
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
- }, capturedBy)
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 RunService = TS.import(script, script.Parent.Parent.Parent, "node_modules", "@rbxts", "services").RunService
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 = not RunService:IsRunning()
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
- if not RunService:IsRunning() then
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 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()
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 getRole()
8697
- if not RunService:IsRunning() then
8698
- return "edit"
8699
- end
8700
- if RunService:IsServer() then
8701
- 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
8702
8858
  end
8703
- 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()
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(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
8735
8903
  return {
8736
- pluginSessionId = pluginSessionId,
8737
- physicalSessionId = id,
8738
- instanceId = getInstanceId(),
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
- id = id,
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="28">
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="29">
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="30">
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
- -- Per-capture in-memory ring buffer for LogService.MessageOut events.
8864
- -- Powers the get_runtime_logs MCP tool. Replaces the out-of-tree LogBuffer
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
- -- Capture caveat: returned entries reflect which plugin buffer CAPTURED the
8874
- -- entry, NOT which peer's script originated the print. LogService reflects
8875
- -- prints across peers in ordinary Studio Play (a server print can appear in
8876
- -- server and client LogService:GetLogHistory()). The MCP-side aggregator
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 = not RunService:IsRunning()
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 detectPeer()
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="31">
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="32">
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 computeInstanceIds(options)
9488
- local ids = {}
9643
+ local function computePlaceKeys(options)
9644
+ local placeKeys = {}
9489
9645
  if game.PlaceId ~= 0 then
9490
- addUnique(ids, `place:{tostring(game.PlaceId)}`)
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(ids, `anon:{existing}`)
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(ids, `anon:{fresh}`)
9665
+ addUnique(placeKeys, `anon:{fresh}`)
9510
9666
  end
9511
9667
  end
9512
- return ids
9668
+ return placeKeys
9513
9669
  end
9514
- local function settingKey(instanceId)
9515
- return SETTING_KEY_PREFIX .. instanceId
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 _, instanceId in computeInstanceIds({
9700
+ for _, placeKey in computePlaceKeys({
9545
9701
  createAnonymous = true,
9546
9702
  }) do
9547
- writeSettingString(settingKey(instanceId), normalized)
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 identity. Client play DMs have
9555
- -- their own ServerStorage; creating an id there makes a misleading anon id
9556
- -- that never matches the edit/server bridge identity.
9557
- for _, instanceId in computeInstanceIds() do
9558
- 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))
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="33">
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.5"
9739
+ local CURRENT_VERSION = "3.1.1"
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="34">
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-DM stop_playtest signaling via plugin:SetSetting, scoped by
9619
- -- per-instance setting key so the same Studio process can host playtests
9620
- -- for multiple places without one place's stop_playtest yanking another's.
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 ServerStorage = _services.ServerStorage
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
- -- Mirror of PluginSession's place identity rules. Duplicated here because
9668
- -- StopPlayMonitor runs in both edit and play-server DMs, and both must
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
- for _, myKey in settingKeys() do
9837
- local payload = decodePayload(readSetting(myKey))
9838
- if payload then
9839
- handleStopRequest(myKey, payload)
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 = false
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
- for _, myKey in settingKeys() do
9878
- local payload = decodePayload(readSetting(myKey))
9879
- if payload and payload.kind == "result" and payload.id == requestId then
9880
- return {
9881
- ok = payload.ok == true,
9882
- consumed = true,
9883
- error = payload.error,
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
- for _, myKey in settingKeys() do
9900
- if requestId ~= nil then
9901
- local payload = decodePayload(readSetting(myKey))
9902
- if payload and payload.id ~= requestId then
9903
- continue
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="35">
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
@@ -9930,6 +10013,8 @@ local INITIAL_RESPONSE_RETRY_DELAY_SECONDS = 0.5
9930
10013
  local MAX_RESPONSE_RETRY_DELAY_SECONDS = 5
9931
10014
  local MAX_TERMINAL_RESPONSES = 256
9932
10015
  local STREAM_SILENCE_TIMEOUT_SECONDS = 20
10016
+ -- The bridge accepts request bodies up to 50 MiB; leave room for its envelope.
10017
+ local MAX_STREAM_FRAME_BYTES = 64 * 1024 * 1024
9933
10018
  local options
9934
10019
  local active = false
9935
10020
  local shutdownSuspended = false
@@ -9943,15 +10028,13 @@ local pendingResponses = {}
9943
10028
  local terminalResponseIds = {}
9944
10029
  local terminalResponseOrder = {}
9945
10030
  local readyFailureLogKeys = {}
9946
- local function decodeMessage(message)
9947
- -- Studio versions in the supported channel have surfaced either the SSE
9948
- -- data payload or the complete single-line `data:` frame. The bridge emits
9949
- -- one JSON data line per event, so normalize both forms before decoding.
9950
- local payload = message
9951
- local normalized = (string.gsub((string.gsub(message, "\r\n", "\n")), "\r", "\n"))
9952
- if string.sub(normalized, 1, 5) == "data:" then
9953
- payload = (string.gsub((string.gsub(string.sub(normalized, 6), "^%s+", "")), "%s+$", ""))
9954
- end
10031
+ local streamLineParts = {}
10032
+ local streamDataLines = {}
10033
+ local streamFrameBytes = 0
10034
+ local streamLineHasContent = false
10035
+ local streamDiscardingFrame = false
10036
+ local streamSkipLf = false
10037
+ local function decodeMessage(payload)
9955
10038
  local decodeOk, decoded = pcall(function()
9956
10039
  return HttpService:JSONDecode(payload)
9957
10040
  end)
@@ -9970,8 +10053,8 @@ local function decodeMessage(message)
9970
10053
  }
9971
10054
  end
9972
10055
  if envelope.kind == "status" then
9973
- local _knownInstance = envelope.knownInstance
9974
- local _condition = not (type(_knownInstance) == "boolean")
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
- knownInstance = envelope.knownInstance,
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 _logicalSessionId = envelope.logicalSessionId
10018
- _condition = not (type(_logicalSessionId) == "string")
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
- logicalSessionId = envelope.logicalSessionId,
10129
+ peerId = envelope.peerId,
10047
10130
  target = envelope.target,
10048
10131
  endpoint = envelope.endpoint,
10049
10132
  data = data,
@@ -10052,9 +10135,123 @@ local function decodeMessage(message)
10052
10135
  end
10053
10136
  return nil
10054
10137
  end
10138
+ local function resetStreamFrame()
10139
+ streamLineParts = {}
10140
+ streamDataLines = {}
10141
+ streamFrameBytes = 0
10142
+ streamLineHasContent = false
10143
+ streamDiscardingFrame = false
10144
+ end
10145
+ local function finishStreamLine(events)
10146
+ if not streamLineHasContent then
10147
+ if not streamDiscardingFrame and #streamDataLines > 0 then
10148
+ local event = decodeMessage(table.concat(streamDataLines, "\n"))
10149
+ if event ~= nil then
10150
+ table.insert(events, event)
10151
+ end
10152
+ end
10153
+ resetStreamFrame()
10154
+ return nil
10155
+ end
10156
+ if not streamDiscardingFrame then
10157
+ local line = table.concat(streamLineParts, "")
10158
+ if string.sub(line, 1, 5) == "data:" then
10159
+ local start = if string.sub(line, 6, 6) == " " then 7 else 6
10160
+ local _streamDataLines = streamDataLines
10161
+ local _arg0 = string.sub(line, start)
10162
+ table.insert(_streamDataLines, _arg0)
10163
+ elseif line == "data" then
10164
+ table.insert(streamDataLines, "")
10165
+ end
10166
+ end
10167
+ streamLineParts = {}
10168
+ streamLineHasContent = false
10169
+ end
10170
+ -- MessageReceived may coalesce frames (notably server/client fanout) or split
10171
+ -- them across callbacks. Callback boundaries are not SSE event boundaries.
10172
+ local function decodeMessages(message)
10173
+ local events = {}
10174
+ local messageBytes = #message
10175
+ if messageBytes == 0 then
10176
+ return events
10177
+ end
10178
+ -- Some Studio versions deliver the decoded SSE data or a complete data line
10179
+ -- without delimiters. Only recognize those forms with no unfinished frame;
10180
+ -- once framing starts, callback boundaries never terminate partial data.
10181
+ if streamFrameBytes == 0 and not streamDiscardingFrame and messageBytes <= MAX_STREAM_FRAME_BYTES then
10182
+ local first = string.sub(message, 1, 1)
10183
+ if first == "{" or first == " " or first == "\t" or first == "\r" or first == "\n" then
10184
+ local event = decodeMessage(message)
10185
+ if event ~= nil then
10186
+ streamSkipLf = false
10187
+ table.insert(events, event)
10188
+ return events
10189
+ end
10190
+ if first == "{" then
10191
+ return events
10192
+ end
10193
+ end
10194
+ if string.sub(message, 1, 5) == "data:" and (string.find(message, "[\r\n]")) == nil then
10195
+ local event = decodeMessage(string.sub(message, 6))
10196
+ if event ~= nil then
10197
+ streamSkipLf = false
10198
+ table.insert(events, event)
10199
+ return events
10200
+ end
10201
+ end
10202
+ end
10203
+ local offset = 1
10204
+ while offset <= messageBytes do
10205
+ if streamSkipLf then
10206
+ streamSkipLf = false
10207
+ local _message = message
10208
+ local _offset = offset
10209
+ local _offset_1 = offset
10210
+ if string.sub(_message, _offset, _offset_1) == "\n" then
10211
+ offset += 1
10212
+ continue
10213
+ end
10214
+ end
10215
+ local _message = message
10216
+ local _offset = offset
10217
+ local newline = string.find(_message, "[\r\n]", _offset)
10218
+ local lineEnd = if newline == nil then messageBytes + 1 else newline
10219
+ local partBytes = lineEnd - offset
10220
+ if partBytes > 0 then
10221
+ streamLineHasContent = true
10222
+ end
10223
+ if not streamDiscardingFrame then
10224
+ streamFrameBytes += partBytes + (if newline == nil then 0 else 1)
10225
+ if streamFrameBytes > MAX_STREAM_FRAME_BYTES then
10226
+ -- Keep scanning delimiters but retain no oversized frame bytes.
10227
+ streamDiscardingFrame = true
10228
+ streamLineParts = {}
10229
+ streamDataLines = {}
10230
+ elseif partBytes > 0 then
10231
+ -- Join only on a line boundary, not every callback (large scripts
10232
+ -- can arrive in many fragments).
10233
+ local _streamLineParts = streamLineParts
10234
+ local _message_1 = message
10235
+ local _offset_1 = offset
10236
+ local _arg1 = lineEnd - 1
10237
+ local _arg0 = string.sub(_message_1, _offset_1, _arg1)
10238
+ table.insert(_streamLineParts, _arg0)
10239
+ end
10240
+ end
10241
+ if newline == nil then
10242
+ break
10243
+ end
10244
+ finishStreamLine(events)
10245
+ streamSkipLf = string.sub(message, newline, newline) == "\r"
10246
+ offset = newline + 1
10247
+ end
10248
+ return events
10249
+ end
10055
10250
  local function closeCurrentStream()
10056
10251
  local current = streamClient
10057
10252
  streamClient = nil
10253
+ resetStreamFrame()
10254
+ streamSkipLf = false
10058
10255
  for _, connection in streamConnections do
10059
10256
  connection:Disconnect()
10060
10257
  end
@@ -10074,8 +10271,7 @@ local function disconnectSession(currentOptions)
10074
10271
  ["Content-Type"] = "application/json",
10075
10272
  },
10076
10273
  Body = HttpService:JSONEncode({
10077
- pluginSessionId = PluginSession.id,
10078
- timestamp = tick(),
10274
+ peerId = PluginSession.peerId,
10079
10275
  }),
10080
10276
  })
10081
10277
  end)
@@ -10396,15 +10592,23 @@ local function parseReadyResponse(body)
10396
10592
  if not _condition then
10397
10593
  _condition = value.assignedRole == ""
10398
10594
  if not _condition then
10399
- local _instanceId = value.instanceId
10400
- _condition = not (type(_instanceId) == "string")
10595
+ local _peerId = value.peerId
10596
+ _condition = not (type(_peerId) == "string")
10401
10597
  if not _condition then
10402
- _condition = value.instanceId == ""
10598
+ _condition = value.peerId == ""
10403
10599
  if not _condition then
10404
- local _serverVersion = value.serverVersion
10405
- _condition = not (type(_serverVersion) == "string")
10600
+ local _instanceId = value.instanceId
10601
+ _condition = not (type(_instanceId) == "string")
10406
10602
  if not _condition then
10407
- _condition = value.serverVersion == ""
10603
+ _condition = value.instanceId == ""
10604
+ if not _condition then
10605
+ local _condition_1 = value.multiplayerGroupId ~= nil
10606
+ if _condition_1 then
10607
+ local _multiplayerGroupId = value.multiplayerGroupId
10608
+ _condition_1 = not (type(_multiplayerGroupId) == "string")
10609
+ end
10610
+ _condition = _condition_1
10611
+ end
10408
10612
  end
10409
10613
  end
10410
10614
  end
@@ -10417,8 +10621,9 @@ local function parseReadyResponse(body)
10417
10621
  return {
10418
10622
  success = true,
10419
10623
  assignedRole = value.assignedRole,
10624
+ peerId = value.peerId,
10420
10625
  instanceId = value.instanceId,
10421
- serverVersion = value.serverVersion,
10626
+ multiplayerGroupId = value.multiplayerGroupId,
10422
10627
  }
10423
10628
  end
10424
10629
  local refresh
@@ -10434,10 +10639,10 @@ function connect(expectedGeneration)
10434
10639
  })
10435
10640
  task.spawn(function()
10436
10641
  local instanceId = PluginSession.getInstanceId()
10642
+ local multiplayerGroupId = PluginSession.getMultiplayerGroupId()
10437
10643
  local readyUrl = `{currentOptions.serverUrl}/ready`
10438
- local physicalRole = PluginSession.getRole()
10439
- local readyPayload = PluginSession.createReadyPayload(PluginSession.id, physicalRole)
10440
- readyPayload.pluginReady = true
10644
+ local transportRole = PluginSession.getRole()
10645
+ local readyPayload = PluginSession.createReadyPayload(PluginSession.peerId, transportRole, instanceId, multiplayerGroupId)
10441
10646
  if not active or generation ~= expectedGeneration or options ~= currentOptions then
10442
10647
  return nil
10443
10648
  end
@@ -10454,12 +10659,12 @@ function connect(expectedGeneration)
10454
10659
  if not active or generation ~= expectedGeneration or options ~= currentOptions then
10455
10660
  return nil
10456
10661
  end
10457
- local readyLogKey = `{currentOptions.serverUrl}|{instanceId}|{physicalRole}`
10662
+ local readyLogKey = `{currentOptions.serverUrl}|{PluginSession.peerId}`
10458
10663
  if not readyOk then
10459
10664
  local detail = HttpDiagnostics.formatRequestFailure(readyUrl, false, readyResult)
10460
10665
  if not (readyFailureLogKeys[readyLogKey] ~= nil) then
10461
10666
  readyFailureLogKeys[readyLogKey] = true
10462
- warn(`[robloxstudio-mcp] /ready failed for {instanceId}/{physicalRole}: {detail}`)
10667
+ warn(`[robloxstudio-mcp] /ready failed for {instanceId}/{transportRole}: {detail}`)
10463
10668
  end
10464
10669
  scheduleReconnect(expectedGeneration, detail)
10465
10670
  return nil
@@ -10468,14 +10673,14 @@ function connect(expectedGeneration)
10468
10673
  local detail = HttpDiagnostics.formatRequestFailure(readyUrl, true, readyResult)
10469
10674
  if not (readyFailureLogKeys[readyLogKey] ~= nil) then
10470
10675
  readyFailureLogKeys[readyLogKey] = true
10471
- warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{physicalRole}: {detail}`)
10676
+ warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{transportRole}: {detail}`)
10472
10677
  end
10473
10678
  scheduleReconnect(expectedGeneration, detail, readyResult.StatusCode == 409)
10474
10679
  return nil
10475
10680
  end
10476
10681
  local readyData = parseReadyResponse(readyResult.Body)
10477
- if readyData == nil then
10478
- scheduleReconnect(expectedGeneration, "Invalid /ready response: expected the bundled server protocol")
10682
+ if readyData == nil or readyData.peerId ~= PluginSession.peerId or readyData.instanceId ~= instanceId or readyData.multiplayerGroupId ~= multiplayerGroupId then
10683
+ scheduleReconnect(expectedGeneration, "Invalid /ready response: expected the registered Peer topology")
10479
10684
  return nil
10480
10685
  end
10481
10686
  if readyFailureLogKeys[readyLogKey] ~= nil then
@@ -10487,7 +10692,7 @@ function connect(expectedGeneration)
10487
10692
  end)
10488
10693
  local createOk, createdClient = pcall(function()
10489
10694
  return HttpService:CreateWebStreamClient(Enum.WebStreamClientType.SSE, {
10490
- Url = `{currentOptions.serverUrl}/events?pluginSessionId={PluginSession.id}`,
10695
+ Url = `{currentOptions.serverUrl}/events?peerId={PluginSession.peerId}`,
10491
10696
  Method = "GET",
10492
10697
  Headers = {
10493
10698
  Accept = "text/event-stream",
@@ -10525,30 +10730,33 @@ function connect(expectedGeneration)
10525
10730
  if not active or generation ~= expectedGeneration or streamClient ~= createdClient then
10526
10731
  return nil
10527
10732
  end
10528
- local event = decodeMessage(message)
10529
- if event == nil then
10530
- return nil
10531
- end
10532
- lastValidEventAt = tick()
10533
- if event.kind == "heartbeat" then
10534
- invokeCallback("event stream heartbeat", function()
10535
- return currentOptions.onHeartbeat(event.timestamp)
10733
+ for _, event in decodeMessages(message) do
10734
+ -- Status callbacks can refresh/stop the stream. The remainder of
10735
+ -- that callback belongs to the old connection, not its successor.
10736
+ if not active or generation ~= expectedGeneration or streamClient ~= createdClient then
10737
+ return nil
10738
+ end
10739
+ lastValidEventAt = tick()
10740
+ if event.kind == "heartbeat" then
10741
+ invokeCallback("event stream heartbeat", function()
10742
+ return currentOptions.onHeartbeat(event.timestamp)
10743
+ end)
10744
+ continue
10745
+ end
10746
+ if event.kind == "cancel" then
10747
+ cancelRequest(event)
10748
+ continue
10749
+ end
10750
+ if event.kind == "request" then
10751
+ dispatchRequest(event)
10752
+ continue
10753
+ end
10754
+ invokeCallback("event stream status", function()
10755
+ return currentOptions.onStatus(event)
10536
10756
  end)
10537
- return nil
10538
- end
10539
- if event.kind == "cancel" then
10540
- cancelRequest(event)
10541
- return nil
10542
- end
10543
- if event.kind == "request" then
10544
- dispatchRequest(event)
10545
- return nil
10546
- end
10547
- invokeCallback("event stream status", function()
10548
- return currentOptions.onStatus(event)
10549
- end)
10550
- if not event.knownInstance then
10551
- refresh()
10757
+ if not event.knownPeer then
10758
+ refresh()
10759
+ end
10552
10760
  end
10553
10761
  end), createdClient.Error:Connect(function(statusCode, message)
10554
10762
  if not active or generation ~= expectedGeneration or streamClient ~= createdClient then
@@ -10644,7 +10852,59 @@ return {
10644
10852
  ]]></string>
10645
10853
  </Properties>
10646
10854
  </Item>
10647
- <Item class="ModuleScript" referent="36">
10855
+ <Item class="ModuleScript" referent="37">
10856
+ <Properties>
10857
+ <string name="Name">TopologyId</string>
10858
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
10859
+ local TS = require(script.Parent.Parent.include.RuntimeLib)
10860
+ local HttpService = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services").HttpService
10861
+ local BASE36_DIGITS = "0123456789abcdefghijklmnopqrstuvwxyz"
10862
+ local TOKEN_LENGTH = 6
10863
+ local TOKEN_MODULUS = 2_176_782_336
10864
+ local function tokenFromNumber(value)
10865
+ local remaining = math.floor(math.abs(value)) % TOKEN_MODULUS
10866
+ local token = ""
10867
+ for index = 0, TOKEN_LENGTH - 1 do
10868
+ local digit = remaining % 36
10869
+ local _arg0 = digit + 1
10870
+ local _arg1 = digit + 1
10871
+ token = string.sub(BASE36_DIGITS, _arg0, _arg1) .. token
10872
+ remaining = math.floor(remaining / 36)
10873
+ end
10874
+ return `{string.sub(token, 1, 3)}-{string.sub(token, 4, 6)}`
10875
+ end
10876
+ local function formatId(kind, value)
10877
+ return `{kind}:{tokenFromNumber(value)}`
10878
+ end
10879
+ local function randomValue()
10880
+ local guidPrefix = string.sub(HttpService:GenerateGUID(false), 1, 8)
10881
+ local _condition = tonumber(guidPrefix, 16)
10882
+ if _condition == nil then
10883
+ _condition = 0
10884
+ end
10885
+ return _condition
10886
+ end
10887
+ local function createPeerId()
10888
+ return formatId("peer", randomValue())
10889
+ end
10890
+ local function createInstanceId()
10891
+ return formatId("instance", randomValue())
10892
+ end
10893
+ local function currentProcessInstanceId()
10894
+ -- Roblox exposes process uptime through os.clock(). Quantizing the recovered
10895
+ -- launch wall-clock to 10 ms gives every VM in one Studio process the same ID.
10896
+ local launchTick = math.round((DateTime.now().UnixTimestampMillis - os.clock() * 1000) / 10)
10897
+ return formatId("instance", launchTick)
10898
+ end
10899
+ return {
10900
+ createPeerId = createPeerId,
10901
+ createInstanceId = createInstanceId,
10902
+ currentProcessInstanceId = currentProcessInstanceId,
10903
+ }
10904
+ ]]></string>
10905
+ </Properties>
10906
+ </Item>
10907
+ <Item class="ModuleScript" referent="38">
10648
10908
  <Properties>
10649
10909
  <string name="Name">UI</string>
10650
10910
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -11196,7 +11456,7 @@ return {
11196
11456
  ]]></string>
11197
11457
  </Properties>
11198
11458
  </Item>
11199
- <Item class="ModuleScript" referent="37">
11459
+ <Item class="ModuleScript" referent="39">
11200
11460
  <Properties>
11201
11461
  <string name="Name">Utils</string>
11202
11462
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -12037,11 +12297,11 @@ return {
12037
12297
  </Properties>
12038
12298
  </Item>
12039
12299
  </Item>
12040
- <Item class="Folder" referent="42">
12300
+ <Item class="Folder" referent="44">
12041
12301
  <Properties>
12042
12302
  <string name="Name">include</string>
12043
12303
  </Properties>
12044
- <Item class="ModuleScript" referent="38">
12304
+ <Item class="ModuleScript" referent="40">
12045
12305
  <Properties>
12046
12306
  <string name="Name">LibMP</string>
12047
12307
  <string name="Source"><![CDATA[-- =============================================================================
@@ -168425,7 +168685,7 @@ return LibMP
168425
168685
  ]]></string>
168426
168686
  </Properties>
168427
168687
  </Item>
168428
- <Item class="ModuleScript" referent="39">
168688
+ <Item class="ModuleScript" referent="41">
168429
168689
  <Properties>
168430
168690
  <string name="Name">Promise</string>
168431
168691
  <string name="Source"><![CDATA[--[[
@@ -170499,7 +170759,7 @@ return Promise
170499
170759
  ]]></string>
170500
170760
  </Properties>
170501
170761
  </Item>
170502
- <Item class="ModuleScript" referent="40">
170762
+ <Item class="ModuleScript" referent="42">
170503
170763
  <Properties>
170504
170764
  <string name="Name">RuntimeLib</string>
170505
170765
  <string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
@@ -170766,15 +171026,15 @@ return TS
170766
171026
  </Properties>
170767
171027
  </Item>
170768
171028
  </Item>
170769
- <Item class="Folder" referent="43">
171029
+ <Item class="Folder" referent="45">
170770
171030
  <Properties>
170771
171031
  <string name="Name">node_modules</string>
170772
171032
  </Properties>
170773
- <Item class="Folder" referent="44">
171033
+ <Item class="Folder" referent="46">
170774
171034
  <Properties>
170775
171035
  <string name="Name">@rbxts</string>
170776
171036
  </Properties>
170777
- <Item class="ModuleScript" referent="41">
171037
+ <Item class="ModuleScript" referent="43">
170778
171038
  <Properties>
170779
171039
  <string name="Name">services</string>
170780
171040
  <string name="Source"><![CDATA[return setmetatable({}, {