@chrrxs/robloxstudio-mcp 3.0.1 → 3.0.3

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.
@@ -12,7 +12,7 @@ local Communication = TS.import(script, script, "modules", "Communication")
12
12
  local ClientBroker = TS.import(script, script, "modules", "ClientBroker")
13
13
  local ServerUrlSettings = TS.import(script, script, "modules", "ServerUrlSettings")
14
14
  local _EvalBridges = TS.import(script, script, "modules", "EvalBridges")
15
- local cleanupLegacyEditBridges = _EvalBridges.cleanupLegacyEditBridges
15
+ 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")
@@ -93,7 +93,7 @@ task.delay(TOOLBAR_REGISTRATION_DELAY_SECONDS, registerToolbarButton)
93
93
  task.delay(2, function()
94
94
  local role = ClientBroker.forkRole()
95
95
  if role == "edit" then
96
- cleanupLegacyEditBridges()
96
+ cleanupEditBridgeArtifacts()
97
97
  else
98
98
  local result = ensureRuntimeBridgeInstalled()
99
99
  if not result.installed then
@@ -265,7 +265,6 @@ local HttpService = _services.HttpService
265
265
  local Players = _services.Players
266
266
  local ReplicatedStorage = _services.ReplicatedStorage
267
267
  local RunService = _services.RunService
268
- local ServerStorage = _services.ServerStorage
269
268
  local RuntimeLogBuffer = TS.import(script, script.Parent, "RuntimeLogBuffer")
270
269
  local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandlers")
271
270
  local SceneAnalysisHandlers = TS.import(script, script.Parent, "handlers", "SceneAnalysisHandlers")
@@ -277,60 +276,15 @@ local BreakpointHandlers = TS.import(script, script.Parent, "handlers", "Breakpo
277
276
  local ScriptProfilerHandlers = TS.import(script, script.Parent, "handlers", "ScriptProfilerHandlers")
278
277
  local MicroProfilerHandlers = TS.import(script, script.Parent, "handlers", "MicroProfilerHandlers")
279
278
  local LuauExec = TS.import(script, script.Parent, "LuauExec")
280
- local State = TS.import(script, script.Parent, "State")
281
279
  local HttpDiagnostics = TS.import(script, script.Parent, "HttpDiagnostics")
280
+ local PluginSession = TS.import(script, script.Parent, "PluginSession")
282
281
  local StudioTestService = game:GetService("StudioTestService")
283
- -- Mirror of Communication.computeInstanceId() — duplicated here because the
284
- -- client broker runs in the play-server DM where it can't easily import from
285
- -- the edit-side module, and the place identifier must match what the edit-DM
286
- -- plugin reports. Both use the same algorithm against the shared DataModel.
287
- local function computeInstanceId()
288
- if game.PlaceId ~= 0 then
289
- return `place:{tostring(game.PlaceId)}`
290
- end
291
- local existing = ServerStorage:GetAttribute("__MCPPlaceId")
292
- if type(existing) == "string" and existing ~= "" then
293
- return `anon:{existing}`
294
- end
295
- local fresh = HttpService:GenerateGUID(false)
296
- pcall(function()
297
- return ServerStorage:SetAttribute("__MCPPlaceId", fresh)
298
- end)
299
- return `anon:{fresh}`
300
- end
301
- local cachedPlaceName
302
- local function resolvePlaceName()
303
- if cachedPlaceName ~= nil then
304
- return cachedPlaceName
305
- end
306
- if game.PlaceId == 0 then
307
- cachedPlaceName = game.Name
308
- return cachedPlaceName
309
- end
310
- local MarketplaceService = game:GetService("MarketplaceService")
311
- local ok, info = pcall(function()
312
- return MarketplaceService:GetProductInfo(game.PlaceId)
313
- end)
314
- if ok and info ~= nil then
315
- local name = info.Name
316
- if type(name) == "string" and name ~= "" then
317
- cachedPlaceName = name
318
- return cachedPlaceName
319
- end
320
- end
321
- return game.Name
322
- end
323
282
  -- The client peer cannot reach the MCP HTTP server - Roblox forbids
324
283
  -- HttpService:RequestAsync from the client DM even under PluginSecurity, and
325
284
  -- HttpEnabled reads as false there regardless of identity. So the server peer
326
- -- brokers execute_luau requests to the client via a RemoteFunction it places
327
- -- in ReplicatedStorage; each player gets a proxy "client" registration on the
328
- -- MCP side, polled and dispatched by the server peer.
329
- --
330
- -- (Previously the server peer also registered an "edit-proxy" role to
331
- -- intercept /api/stop-playtest and call StudioTestService:EndTest. That hack
332
- -- is gone: stop now uses StopPlayMonitor with plugin:SetSetting cross-DM
333
- -- signaling, which works regardless of MCP server state.)
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.
334
288
  local DEFAULT_MCP_URL = "http://localhost:58741"
335
289
  local mcpUrl = DEFAULT_MCP_URL
336
290
  local BROKER_NAME = "__MCPClientBroker"
@@ -354,37 +308,6 @@ local CLIENT_BROKER_ALLOWED_ENDPOINTS = {
354
308
  ["/api/simulate-keyboard-input"] = true,
355
309
  ["/api/focus-viewport"] = true,
356
310
  }
357
- -- Throttle re-ready calls per proxyId so a brief window of unknownInstance
358
- -- polls doesn't cause a re-register stampede.
359
- local lastReadyByProxy = {}
360
- local postJson
361
- local function reRegisterProxy(proxyId, role)
362
- local now = tick()
363
- local _proxyId = proxyId
364
- local _condition = lastReadyByProxy[_proxyId]
365
- if _condition == nil then
366
- _condition = 0
367
- end
368
- local last = _condition
369
- if now - last < 2 then
370
- return nil
371
- end
372
- local _proxyId_1 = proxyId
373
- lastReadyByProxy[_proxyId_1] = now
374
- pcall(function()
375
- return postJson("/ready", {
376
- pluginSessionId = proxyId,
377
- instanceId = computeInstanceId(),
378
- role = role,
379
- placeId = game.PlaceId,
380
- placeName = resolvePlaceName(),
381
- dataModelName = game.Name,
382
- isRunning = RunService:IsRunning(),
383
- pluginVersion = State.CURRENT_VERSION,
384
- pluginVariant = State.PLUGIN_VARIANT,
385
- })
386
- end)
387
- end
388
311
  local function forkRole()
389
312
  if not RunService:IsRunning() then
390
313
  return "edit"
@@ -394,7 +317,7 @@ local function forkRole()
394
317
  end
395
318
  return "client"
396
319
  end
397
- function postJson(endpoint, body)
320
+ local function postJson(endpoint, body)
398
321
  return pcall(function()
399
322
  return HttpService:RequestAsync({
400
323
  Url = `{mcpUrl}{endpoint}`,
@@ -414,9 +337,6 @@ local function setServerUrl(serverUrl)
414
337
  mcpUrl = serverUrl
415
338
  end
416
339
  end
417
- local function getServerUrl()
418
- return mcpUrl
419
- end
420
340
  local function handleExecuteLuau(data)
421
341
  local code = data and (data.code)
422
342
  if type(code) == "string" == false or code == "" then
@@ -522,12 +442,16 @@ local function setupClientBroker()
522
442
  return nil
523
443
  end
524
444
  rf.OnClientInvoke = function(payload)
525
- -- Two payload shapes in the wild:
526
- -- - {endpoint, data} from v2.10+ server-peer broker (this is the new
527
- -- discriminated form that lets us dispatch on endpoint)
528
- -- - {code} from pre-v2.10 server-peer broker (raw execute-luau payload)
529
- -- The shapes coexist gracefully because we fall back to execute-luau
530
- -- when endpoint is missing.
445
+ local _condition = not payload
446
+ if not _condition then
447
+ local _endpoint = payload.endpoint
448
+ _condition = not (type(_endpoint) == "string")
449
+ end
450
+ if _condition then
451
+ return {
452
+ error = "Client broker request is missing its endpoint.",
453
+ }
454
+ end
531
455
  if payload and payload.endpoint == "/api/get-runtime-logs" then
532
456
  return handleGetRuntimeLogs(payload.data)
533
457
  end
@@ -570,13 +494,19 @@ local function setupClientBroker()
570
494
  if payload and payload.endpoint == "/api/eval-runtime" then
571
495
  return EvalRuntimeHandlers.evalRuntime(payload.data or {})
572
496
  end
573
- -- Legacy: raw execute-luau payload at the top level.
574
- return handleExecuteLuau(payload)
497
+ return {
498
+ error = `Unsupported client broker endpoint: {payload.endpoint}`,
499
+ }
575
500
  end
576
501
  end
502
+ local INITIAL_PROXY_RETRY_DELAY_SECONDS = 0.5
503
+ local MAX_PROXY_RETRY_DELAY_SECONDS = 5
577
504
  local proxyByPlayer = {}
505
+ local proxyBySessionId = {}
578
506
  local proxyRegisterFailuresByPlayer = {}
507
+ local pendingProxyDisconnects = {}
579
508
  local serverBrokerStarted = false
509
+ local queueProxyDisconnect
580
510
  local function unregisterProxy(player, entry)
581
511
  local _condition = entry
582
512
  if _condition == nil then
@@ -587,96 +517,154 @@ local function unregisterProxy(player, entry)
587
517
  if not proxy then
588
518
  return nil
589
519
  end
520
+ proxy.generation += 1
590
521
  local _player = player
591
522
  proxyByPlayer[_player] = nil
523
+ local _pluginSessionId = proxy.pluginSessionId
524
+ proxyBySessionId[_pluginSessionId] = nil
592
525
  local _player_1 = player
593
526
  proxyRegisterFailuresByPlayer[_player_1] = nil
594
- postJson("/disconnect", {
595
- pluginSessionId = proxy.pluginSessionId,
596
- })
527
+ queueProxyDisconnect(proxy.pluginSessionId)
597
528
  end
598
529
  local function disconnectAllProxies()
599
530
  for player, entry in proxyByPlayer do
600
531
  unregisterProxy(player, entry)
601
532
  end
602
533
  table.clear(proxyByPlayer)
534
+ table.clear(proxyBySessionId)
603
535
  table.clear(proxyRegisterFailuresByPlayer)
604
536
  end
605
- local function pollProxy(proxyId, player, rf)
606
- while true do
607
- local _condition = player.Parent ~= nil
537
+ local function proxyRetryDelay(attempt)
538
+ return math.min(INITIAL_PROXY_RETRY_DELAY_SECONDS * math.pow(2, math.max(attempt - 1, 0)), MAX_PROXY_RETRY_DELAY_SECONDS)
539
+ end
540
+ local function deliverProxyDisconnect(pluginSessionId, attempt)
541
+ local _pluginSessionId = pluginSessionId
542
+ if not (pendingProxyDisconnects[_pluginSessionId] ~= nil) then
543
+ return nil
544
+ end
545
+ local ok, response = postJson("/disconnect", {
546
+ pluginSessionId = pluginSessionId,
547
+ })
548
+ if ok and response and response.Success then
549
+ local _pluginSessionId_1 = pluginSessionId
550
+ pendingProxyDisconnects[_pluginSessionId_1] = nil
551
+ return nil
552
+ end
553
+ task.delay(proxyRetryDelay(attempt + 1), function()
554
+ deliverProxyDisconnect(pluginSessionId, attempt + 1)
555
+ end)
556
+ end
557
+ function queueProxyDisconnect(pluginSessionId)
558
+ local _pluginSessionId = pluginSessionId
559
+ if pendingProxyDisconnects[_pluginSessionId] ~= nil then
560
+ return nil
561
+ end
562
+ local _pluginSessionId_1 = pluginSessionId
563
+ pendingProxyDisconnects[_pluginSessionId_1] = true
564
+ task.spawn(deliverProxyDisconnect, pluginSessionId, 0)
565
+ end
566
+ local function parseAssignedRole(body)
567
+ local decodeOk, decoded = pcall(function()
568
+ return HttpService:JSONDecode(body)
569
+ end)
570
+ if not decodeOk or not (type(decoded) == "table") then
571
+ return nil
572
+ end
573
+ local ready = decoded
574
+ if ready.success ~= true then
575
+ return nil
576
+ end
577
+ local _assignedRole = ready.assignedRole
578
+ local _condition = type(_assignedRole) == "string"
579
+ if _condition then
580
+ _condition = ready.assignedRole ~= ""
581
+ end
582
+ return if _condition then ready.assignedRole else nil
583
+ end
584
+ local registerProxyEntry
585
+ local function scheduleProxyRetry(entry)
586
+ entry.retryAttempt += 1
587
+ local expectedGeneration = entry.generation
588
+ task.delay(proxyRetryDelay(entry.retryAttempt), function()
589
+ local _player = entry.player
590
+ local _condition = proxyByPlayer[_player] ~= entry
591
+ if not _condition then
592
+ _condition = entry.generation ~= expectedGeneration or entry.registered or entry.registering or entry.player.Parent == nil or not RunService:IsRunning()
593
+ end
608
594
  if _condition then
609
- local _player = player
610
- _condition = proxyByPlayer[_player] ~= nil
595
+ return nil
611
596
  end
597
+ registerProxyEntry(entry)
598
+ end)
599
+ end
600
+ local function failProxyRegistration(entry, detail)
601
+ entry.registered = false
602
+ local _player = entry.player
603
+ if not (proxyRegisterFailuresByPlayer[_player] ~= nil) then
604
+ local _player_1 = entry.player
605
+ proxyRegisterFailuresByPlayer[_player_1] = true
606
+ warn(`[robloxstudio-mcp] proxy register failed for {entry.player.Name}: {detail}`)
607
+ end
608
+ scheduleProxyRetry(entry)
609
+ end
610
+ function registerProxyEntry(entry)
611
+ local _condition = entry.registering
612
+ if not _condition then
613
+ local _player = entry.player
614
+ _condition = proxyByPlayer[_player] ~= entry
612
615
  if not _condition then
613
- break
616
+ _condition = entry.player.Parent == nil or not RunService:IsRunning()
614
617
  end
615
- if not RunService:IsRunning() then
616
- unregisterProxy(player)
617
- break
618
+ end
619
+ if _condition then
620
+ return nil
621
+ end
622
+ entry.registering = true
623
+ local expectedGeneration = entry.generation
624
+ local requestedRole = if entry.role == "client" then "client" else entry.role
625
+ local readyPayload = PluginSession.createReadyPayload(entry.pluginSessionId, requestedRole)
626
+ local _player = entry.player
627
+ 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)
618
633
  end
619
- local ok, res = pcall(function()
620
- return HttpService:RequestAsync({
621
- Url = `{mcpUrl}/poll?pluginSessionId={proxyId}`,
622
- Method = "GET",
623
- Headers = {
624
- ["Content-Type"] = "application/json",
625
- },
626
- })
627
- end)
628
- if ok and res and (res.Success or res.StatusCode == 503) then
629
- local okJson, body = pcall(function()
630
- return HttpService:JSONDecode(res.Body)
631
- end)
632
- if okJson and body then
633
- -- Server lost our proxy registration (process restart, etc.) -
634
- -- re-register so the next poll cycle starts routing again.
635
- if body.knownInstance == false then
636
- reRegisterProxy(proxyId, "client")
637
- end
638
- if body.request and body.requestId ~= nil then
639
- local request = body.request
640
- local response
641
- local _endpoint = request.endpoint
642
- if CLIENT_BROKER_ALLOWED_ENDPOINTS[_endpoint] ~= nil then
643
- -- Forward as a discriminated envelope so the client-side
644
- -- OnClientInvoke knows which endpoint it's serving.
645
- local envelope = {
646
- endpoint = request.endpoint,
647
- data = request.data,
648
- }
649
- local okInvoke, invokeRes = pcall(function()
650
- return rf:InvokeClient(player, envelope)
651
- end)
652
- if okInvoke then
653
- response = if invokeRes ~= nil then invokeRes else {
654
- success = false,
655
- error = "nil response",
656
- }
657
- else
658
- response = {
659
- success = false,
660
- error = `InvokeClient failed: {tostring(invokeRes)}`,
661
- }
662
- end
663
- else
664
- local allowed = {}
665
- for ep in CLIENT_BROKER_ALLOWED_ENDPOINTS do
666
- table.insert(allowed, ep)
667
- end
668
- response = {
669
- error = `Client-proxy does not forward {tostring(request.endpoint)}. ` .. `Allowed: {table.concat(allowed, ", ")}.`,
670
- }
671
- end
672
- postJson("/response", {
673
- requestId = body.requestId,
674
- response = response,
675
- })
676
- end
677
- 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
+ if ok and res and res.Success then
640
+ queueProxyDisconnect(entry.pluginSessionId)
678
641
  end
679
- task.wait(0.5)
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
+ entry.registering = false
651
+ if not ok or not res or not res.Success then
652
+ failProxyRegistration(entry, formatPostJsonFailure("/ready", ok, res))
653
+ return nil
654
+ end
655
+ local assignedRole = parseAssignedRole(res.Body)
656
+ if assignedRole == nil then
657
+ failProxyRegistration(entry, "invalid /ready response: expected success=true and a non-empty assignedRole")
658
+ return nil
659
+ end
660
+ entry.role = assignedRole
661
+ entry.registered = true
662
+ 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
667
+ print(`[robloxstudio-mcp] proxy registered for {entry.player.Name} as {assignedRole} via {mcpUrl}`)
680
668
  end
681
669
  end
682
670
  local function registerProxy(player, rf)
@@ -684,48 +672,89 @@ local function registerProxy(player, rf)
684
672
  if proxyByPlayer[_player] ~= nil then
685
673
  return nil
686
674
  end
687
- local proxyId = HttpService:GenerateGUID(false)
688
- local ok, res = postJson("/ready", {
689
- pluginSessionId = proxyId,
690
- instanceId = computeInstanceId(),
675
+ local entry = {
676
+ player = player,
677
+ remote = rf,
678
+ pluginSessionId = HttpService:GenerateGUID(false),
691
679
  role = "client",
692
- placeId = game.PlaceId,
693
- placeName = resolvePlaceName(),
694
- dataModelName = game.Name,
695
- isRunning = RunService:IsRunning(),
696
- pluginVersion = State.CURRENT_VERSION,
697
- pluginVariant = State.PLUGIN_VARIANT,
698
- })
699
- if not ok or not res or not res.Success then
700
- local _player_1 = player
701
- proxyRegisterFailuresByPlayer[_player_1] = true
702
- warn(`[robloxstudio-mcp] proxy register failed for {player.Name}: {formatPostJsonFailure("/ready", ok, res)}`)
703
- return nil
680
+ registered = false,
681
+ registering = false,
682
+ retryAttempt = 0,
683
+ generation = 0,
684
+ }
685
+ local _player_1 = player
686
+ proxyByPlayer[_player_1] = entry
687
+ local _pluginSessionId = entry.pluginSessionId
688
+ proxyBySessionId[_pluginSessionId] = entry
689
+ task.spawn(registerProxyEntry, entry)
690
+ end
691
+ local function refreshAllLogicalRegistrations()
692
+ for _, entry in proxyByPlayer do
693
+ entry.generation += 1
694
+ entry.registered = false
695
+ entry.registering = false
696
+ entry.retryAttempt = 0
697
+ task.spawn(registerProxyEntry, entry)
698
+ end
699
+ end
700
+ local function dispatchClientRequest(logicalSessionId, target, endpoint, data)
701
+ local _logicalSessionId = logicalSessionId
702
+ local entry = proxyBySessionId[_logicalSessionId]
703
+ local _condition = not entry
704
+ if not _condition then
705
+ local _player = entry.player
706
+ _condition = proxyByPlayer[_player] ~= entry
704
707
  end
705
- local body = HttpService:JSONDecode(res.Body)
706
- local _condition = body.assignedRole
707
- if _condition == nil then
708
- _condition = "client"
708
+ if _condition then
709
+ return {
710
+ error = `Client proxy {target} ({logicalSessionId}) is not registered.`,
711
+ }
709
712
  end
710
- local assigned = _condition
711
- local _player_1 = player
712
- local _arg1 = {
713
- pluginSessionId = proxyId,
714
- role = assigned,
713
+ if entry.role == "client" then
714
+ local assignedClientRole = string.match(target, "^client%-%d+$")
715
+ if assignedClientRole ~= nil then
716
+ entry.role = target
717
+ end
718
+ end
719
+ if entry.role ~= target then
720
+ return {
721
+ error = `Client proxy {logicalSessionId} is registered as {entry.role}, not {target}.`,
722
+ }
723
+ end
724
+ if entry.player.Parent == nil or not RunService:IsRunning() then
725
+ unregisterProxy(entry.player, entry)
726
+ return {
727
+ error = `Client proxy {target} is no longer available.`,
728
+ }
729
+ end
730
+ local _endpoint = endpoint
731
+ if not (CLIENT_BROKER_ALLOWED_ENDPOINTS[_endpoint] ~= nil) then
732
+ local allowed = {}
733
+ for allowedEndpoint in CLIENT_BROKER_ALLOWED_ENDPOINTS do
734
+ table.insert(allowed, allowedEndpoint)
735
+ end
736
+ return {
737
+ error = `Client-proxy does not forward {endpoint}. Allowed: {table.concat(allowed, ", ")}.`,
738
+ }
739
+ end
740
+ local envelope = {
741
+ endpoint = endpoint,
742
+ data = data,
715
743
  }
716
- proxyByPlayer[_player_1] = _arg1
717
- local _player_2 = player
718
- if proxyRegisterFailuresByPlayer[_player_2] ~= nil then
719
- local _player_3 = player
720
- proxyRegisterFailuresByPlayer[_player_3] = nil
721
- print(`[robloxstudio-mcp] proxy registered for {player.Name} as {assigned} via {mcpUrl}`)
744
+ local invokeOk, invokeResult = pcall(function()
745
+ return entry.remote:InvokeClient(entry.player, envelope)
746
+ end)
747
+ if not invokeOk then
748
+ return {
749
+ success = false,
750
+ error = `InvokeClient failed: {tostring(invokeResult)}`,
751
+ }
722
752
  end
723
- task.spawn(pollProxy, proxyId, player, rf)
753
+ return if invokeResult ~= nil then invokeResult else {
754
+ success = false,
755
+ error = "nil response",
756
+ }
724
757
  end
725
- -- (Removed: startEditProxyLoop. The play-server DM no longer registers an
726
- -- "edit-proxy" peer with the MCP server. stop_playtest now uses a cross-DM
727
- -- plugin:SetSetting request consumed by StopPlayMonitor in the play-server DM,
728
- -- which doesn't depend on MCP server state or peer registration at all.)
729
758
  local function setupServerBroker()
730
759
  if serverBrokerStarted then
731
760
  return nil
@@ -756,11 +785,11 @@ local function setupServerBroker()
756
785
  end)
757
786
  end
758
787
  return {
759
- MCP_URL = DEFAULT_MCP_URL,
760
788
  DEFAULT_MCP_URL = DEFAULT_MCP_URL,
761
- getServerUrl = getServerUrl,
762
789
  setServerUrl = setServerUrl,
763
790
  disconnectAllProxies = disconnectAllProxies,
791
+ refreshAllLogicalRegistrations = refreshAllLogicalRegistrations,
792
+ dispatchClientRequest = dispatchClientRequest,
764
793
  forkRole = forkRole,
765
794
  setupClientBroker = setupClientBroker,
766
795
  setupServerBroker = setupServerBroker,
@@ -776,11 +805,10 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
776
805
  local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
777
806
  local HttpService = _services.HttpService
778
807
  local RunService = _services.RunService
779
- local ServerStorage = _services.ServerStorage
780
808
  local State = TS.import(script, script.Parent, "State")
781
809
  local Utils = TS.import(script, script.Parent, "Utils")
782
810
  local UI = TS.import(script, script.Parent, "UI")
783
- local cleanupLegacyEditBridges = TS.import(script, script.Parent, "EvalBridges").cleanupLegacyEditBridges
811
+ local cleanupEditBridgeArtifacts = TS.import(script, script.Parent, "EvalBridges").cleanupEditBridgeArtifacts
784
812
  local QueryHandlers = TS.import(script, script.Parent, "handlers", "QueryHandlers")
785
813
  local PropertyHandlers = TS.import(script, script.Parent, "handlers", "PropertyHandlers")
786
814
  local ScriptHandlers = TS.import(script, script.Parent, "handlers", "ScriptHandlers")
@@ -800,78 +828,11 @@ local GenerateModelHandlers = TS.import(script, script.Parent, "handlers", "Gene
800
828
  local EvalRuntimeHandlers = TS.import(script, script.Parent, "handlers", "EvalRuntimeHandlers")
801
829
  local ClientBroker = TS.import(script, script.Parent, "ClientBroker")
802
830
  local ServerUrlSettings = TS.import(script, script.Parent, "ServerUrlSettings")
803
- local HttpDiagnostics = TS.import(script, script.Parent, "HttpDiagnostics")
804
- -- Per-plugin-load random GUID. Used as the /poll URL param so the server
805
- -- can tell our polls apart from any other plugin's polls. Not user-facing —
806
- -- MCP tools and the LLM operate on instanceId (the place identifier).
807
- local pluginSessionId = HttpService:GenerateGUID(false)
808
- -- Place-level identifier shared by every plugin running in DataModels of
809
- -- the same place file (edit DM + playtest server DM + playtest clients).
810
- -- Format: "place:<PlaceId>" when published, "anon:<UUID>" for unpublished
811
- -- places where the UUID lives on ServerStorage's __MCPPlaceId attribute
812
- -- and travels with the .rbxl.
813
- local MCP_PLACE_ID_ATTRIBUTE = "__MCPPlaceId"
814
- local function computeInstanceId()
815
- if game.PlaceId ~= 0 then
816
- return `place:{tostring(game.PlaceId)}`
817
- end
818
- local existing = ServerStorage:GetAttribute(MCP_PLACE_ID_ATTRIBUTE)
819
- if type(existing) == "string" and existing ~= "" then
820
- return `anon:{existing}`
821
- end
822
- local fresh = HttpService:GenerateGUID(false)
823
- pcall(function()
824
- return ServerStorage:SetAttribute(MCP_PLACE_ID_ATTRIBUTE, fresh)
825
- end)
826
- return `anon:{fresh}`
827
- end
831
+ local PluginSession = TS.import(script, script.Parent, "PluginSession")
832
+ local StudioEventStream = TS.import(script, script.Parent, "StudioEventStream")
828
833
  local assignedRole
829
- local hasVersionMismatch = false
830
- local lastVersionMismatchWarningKey
831
834
  local lastReadyInstanceId
832
- local readyFailureLogKeys = {}
833
- local retryingDuplicateReady = false
834
- -- Cache the published place name from MarketplaceService:GetProductInfo so
835
- -- /ready can carry a friendly identifier (e.g. "Natural Disasters") distinct
836
- -- from game.Name (the DataModel name, often "Place1" in edit). We only fetch
837
- -- once per plugin load; the published name doesn't change mid-session.
838
- local cachedPlaceName
839
- local cachedPlaceNamePlaceId
840
- local function resolvePlaceName()
841
- if cachedPlaceName ~= nil and cachedPlaceNamePlaceId == game.PlaceId then
842
- return cachedPlaceName
843
- end
844
- cachedPlaceName = nil
845
- cachedPlaceNamePlaceId = game.PlaceId
846
- if game.PlaceId == 0 then
847
- cachedPlaceName = game.Name
848
- return cachedPlaceName
849
- end
850
- local MarketplaceService = game:GetService("MarketplaceService")
851
- local ok, info = pcall(function()
852
- return MarketplaceService:GetProductInfo(game.PlaceId)
853
- end)
854
- if ok and info ~= nil then
855
- local name = info.Name
856
- if type(name) == "string" and name ~= "" then
857
- cachedPlaceName = name
858
- return cachedPlaceName
859
- end
860
- end
861
- -- Don't cache failures — could be transient (offline, rate-limited).
862
- -- Next /ready will retry. Return game.Name as fallback.
863
- return game.Name
864
- end
865
- local function detectRole()
866
- if not RunService:IsRunning() then
867
- return "edit"
868
- end
869
- if RunService:IsServer() then
870
- return "server"
871
- end
872
- return "client"
873
- end
874
- local initialRole = detectRole()
835
+ local initialRole = PluginSession.getRole()
875
836
  local routeMap = {
876
837
  ["/api/file-tree"] = QueryHandlers.getFileTree,
877
838
  ["/api/search-files"] = QueryHandlers.searchFiles,
@@ -931,35 +892,6 @@ local function processRequest(request)
931
892
  }
932
893
  end
933
894
  end
934
- local function sendResponse(conn, requestId, responseData)
935
- local responseUrl = `{conn.serverUrl}/response`
936
- local encodeOk, encoded = pcall(function()
937
- return HttpService:JSONEncode({
938
- requestId = requestId,
939
- response = responseData,
940
- })
941
- end)
942
- local body = if encodeOk then encoded else HttpService:JSONEncode({
943
- requestId = requestId,
944
- error = `Plugin response serialization failed: {tostring(encoded)}`,
945
- })
946
- if not encodeOk then
947
- warn(`[robloxstudio-mcp] Failed to serialize response {requestId}: {tostring(encoded)}`)
948
- end
949
- local requestOk, requestResult = pcall(function()
950
- return HttpService:RequestAsync({
951
- Url = responseUrl,
952
- Method = "POST",
953
- Headers = {
954
- ["Content-Type"] = "application/json",
955
- },
956
- Body = body,
957
- })
958
- end)
959
- if not requestOk or not requestResult.Success then
960
- warn(`[robloxstudio-mcp] Failed to deliver response {requestId}: {HttpDiagnostics.formatRequestFailure(responseUrl, requestOk, requestResult)}`)
961
- end
962
- end
963
895
  local function getConnectionStatus()
964
896
  local conn = State.getActiveConnection()
965
897
  if not conn.isActive then
@@ -973,323 +905,141 @@ local function getConnectionStatus()
973
905
  end
974
906
  return "connecting"
975
907
  end
976
- -- Throttle for re-issuing /ready after the server reports knownInstance=false.
977
- -- Without this, every poll during the brief window where the server has just
978
- -- restarted but hasn't seen our re-ready yet would fire a duplicate /ready.
979
- local lastReadyPostAt = 0
980
- -- game.Name and game.PlaceId can both settle after plugin load. PlaceId also
981
- -- changes when an unpublished file is published while MCP is already active.
982
- -- Re-fire /ready so the bridge can migrate anon:<uuid> to place:<PlaceId>.
983
- local nameChangeConn
984
- local placeIdChangeConn
985
- local sendReady
986
- local function ensureIdentityWatcher(conn)
987
- if not nameChangeConn then
988
- local okSig, signal = pcall(function()
989
- return game:GetPropertyChangedSignal("Name")
990
- end)
991
- if okSig and signal then
992
- nameChangeConn = signal:Connect(function()
993
- -- sendReady has its own 2s throttle, so rapid burst changes coalesce.
994
- sendReady(conn)
995
- end)
996
- end
908
+ local function dispatchStreamRequest(request)
909
+ if request.logicalSessionId ~= PluginSession.id then
910
+ return ClientBroker.dispatchClientRequest(request.logicalSessionId, request.target, request.endpoint, request.data)
997
911
  end
998
- if not placeIdChangeConn then
999
- local okSig, signal = pcall(function()
1000
- return game:GetPropertyChangedSignal("PlaceId")
1001
- end)
1002
- if okSig and signal then
1003
- placeIdChangeConn = signal:Connect(function()
1004
- cachedPlaceName = nil
1005
- cachedPlaceNamePlaceId = nil
1006
- sendReady(conn)
1007
- end)
1008
- end
912
+ local _condition = assignedRole
913
+ if _condition == nil then
914
+ _condition = PluginSession.getRole()
1009
915
  end
916
+ local localRole = _condition
917
+ if request.target ~= localRole then
918
+ return {
919
+ error = `Physical plugin session is registered as {localRole}, not {request.target}.`,
920
+ }
921
+ end
922
+ return processRequest({
923
+ endpoint = request.endpoint,
924
+ data = request.data,
925
+ })
1010
926
  end
1011
- function sendReady(conn)
1012
- local now = tick()
1013
- -- Normal identity refreshes stay conservatively throttled. Once a stale
1014
- -- predecessor causes 409, retry once per second so takeover follows the
1015
- -- server's short inactivity lease without another two seconds of jitter.
1016
- local readyInterval = if retryingDuplicateReady then 1 else 2
1017
- if now - lastReadyPostAt < readyInterval then
927
+ local function handleReady(response)
928
+ local conn = State.getActiveConnection()
929
+ if not conn.isActive then
1018
930
  return nil
1019
931
  end
1020
- lastReadyPostAt = now
1021
- local instanceId = computeInstanceId()
1022
- task.spawn(function()
1023
- local readyOk, readyResult = pcall(function()
1024
- return HttpService:RequestAsync({
1025
- Url = `{conn.serverUrl}/ready`,
1026
- Method = "POST",
1027
- Headers = {
1028
- ["Content-Type"] = "application/json",
1029
- },
1030
- Body = HttpService:JSONEncode({
1031
- pluginSessionId = pluginSessionId,
1032
- instanceId = instanceId,
1033
- role = detectRole(),
1034
- placeId = game.PlaceId,
1035
- placeName = resolvePlaceName(),
1036
- dataModelName = game.Name,
1037
- isRunning = RunService:IsRunning(),
1038
- pluginVersion = State.CURRENT_VERSION,
1039
- pluginVariant = State.PLUGIN_VARIANT,
1040
- pluginReady = true,
1041
- timestamp = tick(),
1042
- }),
1043
- })
1044
- end)
1045
- local readyUrl = `{conn.serverUrl}/ready`
1046
- local readyRole = detectRole()
1047
- local readyLogKey = `{conn.serverUrl}|{instanceId}|{readyRole}`
1048
- if not readyOk then
1049
- local shouldLog = not (readyFailureLogKeys[readyLogKey] ~= nil)
1050
- readyFailureLogKeys[readyLogKey] = true
1051
- if shouldLog then
1052
- warn(`[robloxstudio-mcp] /ready failed for {instanceId}/{readyRole}: {HttpDiagnostics.formatRequestFailure(readyUrl, readyOk, readyResult)}`)
1053
- end
1054
- return nil
1055
- end
1056
- if not readyResult.Success then
1057
- local reason = HttpDiagnostics.formatRequestFailure(readyUrl, true, readyResult)
1058
- local shouldLog = not (readyFailureLogKeys[readyLogKey] ~= nil)
1059
- readyFailureLogKeys[readyLogKey] = true
1060
- -- A predecessor can remain registered briefly when Studio exits before
1061
- -- its asynchronous /disconnect completes. Keep polling and retrying
1062
- -- /ready: the server will take us over once the predecessor has stopped
1063
- -- polling, while a genuinely active duplicate continues to hold routing.
1064
- if readyResult.StatusCode == 409 then
1065
- retryingDuplicateReady = true
1066
- local ui = UI.getElements()
1067
- ui.statusLabel.Text = "Waiting for previous instance"
1068
- ui.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
1069
- ui.detailStatusLabel.Text = reason
1070
- ui.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
1071
- if shouldLog then
1072
- warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{readyRole}: {reason}`)
1073
- end
1074
- return nil
1075
- end
1076
- if shouldLog then
1077
- warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{readyRole}: {reason}`)
1078
- end
1079
- return nil
1080
- end
1081
- retryingDuplicateReady = false
1082
- local parseOk, readyData = pcall(function()
1083
- return HttpService:JSONDecode(readyResult.Body)
1084
- end)
1085
- local _value = parseOk and readyData.assignedRole
1086
- if _value ~= "" and _value then
1087
- assignedRole = readyData.assignedRole
1088
- end
1089
- local _condition = parseOk
1090
- if _condition then
1091
- local _instanceId = readyData.instanceId
1092
- _condition = type(_instanceId) == "string"
1093
- if _condition then
1094
- _condition = readyData.instanceId ~= ""
1095
- end
1096
- end
1097
- lastReadyInstanceId = if _condition then readyData.instanceId else instanceId
1098
- ServerUrlSettings.rememberServerUrl(conn.serverUrl)
1099
- local _condition_1 = assignedRole
1100
- if _condition_1 == nil then
1101
- _condition_1 = detectRole()
1102
- end
1103
- local connectedRole = _condition_1
1104
- if readyFailureLogKeys[readyLogKey] ~= nil then
1105
- readyFailureLogKeys[readyLogKey] = nil
1106
- print(`[robloxstudio-mcp] /ready connected for {instanceId}/{connectedRole} via {conn.serverUrl}`)
1107
- end
1108
- end)
932
+ assignedRole = response.assignedRole
933
+ lastReadyInstanceId = response.instanceId
934
+ ServerUrlSettings.rememberServerUrl(conn.serverUrl)
935
+ ClientBroker.refreshAllLogicalRegistrations()
1109
936
  end
1110
- local function pollForRequests()
937
+ local function handleStatus(status)
1111
938
  local conn = State.getActiveConnection()
1112
939
  if not conn.isActive then
1113
940
  return nil
1114
941
  end
1115
- if conn.isPolling then
1116
- return nil
942
+ conn.lastHttpOk = true
943
+ conn.lastMcpOk = status.mcpConnected
944
+ conn.consecutiveFailures = 0
945
+ conn.currentRetryDelay = 0.5
946
+ if status.mcpConnected then
947
+ conn.mcpWaitStartTime = nil
948
+ elseif conn.mcpWaitStartTime == nil then
949
+ conn.mcpWaitStartTime = tick()
1117
950
  end
1118
- conn.isPolling = true
1119
- local success, result = pcall(function()
1120
- return HttpService:RequestAsync({
1121
- Url = `{conn.serverUrl}/poll?pluginSessionId={pluginSessionId}`,
1122
- Method = "GET",
1123
- Headers = {
1124
- ["Content-Type"] = "application/json",
1125
- },
1126
- })
1127
- end)
1128
- conn.isPolling = false
1129
- local ui = UI.getElements()
951
+ UI.updateUIState()
1130
952
  UI.updateToolbarIcon()
1131
- if success and (result.Success or result.StatusCode == 503) then
953
+ end
954
+ local function handleHeartbeat(_timestamp)
955
+ if not State.getActiveConnection().isActive then
956
+ return nil
957
+ end
958
+ UI.updateUIState()
959
+ end
960
+ local function handleTransportUpdate(update)
961
+ local conn = State.getActiveConnection()
962
+ if not conn.isActive then
963
+ return nil
964
+ end
965
+ if update.state == "open" then
966
+ conn.lastHttpOk = true
967
+ conn.lastMcpOk = false
1132
968
  conn.consecutiveFailures = 0
1133
969
  conn.currentRetryDelay = 0.5
1134
- conn.lastSuccessfulConnection = tick()
1135
- local data = HttpService:JSONDecode(result.Body)
1136
- local mcpConnected = data.mcpConnected == true
1137
- conn.lastHttpOk = true
1138
- conn.lastMcpOk = mcpConnected
1139
- local _condition = data.serverVersion
1140
- if _condition == nil then
1141
- _condition = "unknown"
1142
- end
1143
- local serverVersion = _condition
1144
- if data.versionMismatch == true then
1145
- hasVersionMismatch = true
1146
- local warningKey = `{State.CURRENT_VERSION}:{serverVersion}`
1147
- if lastVersionMismatchWarningKey ~= warningKey then
1148
- lastVersionMismatchWarningKey = warningKey
1149
- warn(`[robloxstudio-mcp] Version mismatch: Studio plugin v{State.CURRENT_VERSION} / MCP v{serverVersion}. Run npx -y @chrrxs/robloxstudio-mcp@latest --auto-install-plugin and restart Studio.`)
1150
- end
1151
- UI.showBanner("version-mismatch", `Plugin v{State.CURRENT_VERSION} / MCP v{serverVersion} mismatch`)
1152
- elseif hasVersionMismatch then
1153
- hasVersionMismatch = false
1154
- UI.hideBanner("version-mismatch")
1155
- end
1156
- -- Server tells us when its in-memory instances map doesn't have us
1157
- -- (e.g. after an MCP process restart). Re-issue /ready immediately so
1158
- -- target=server/client-N start routing again. The throttle inside
1159
- -- sendReady() prevents duplicate registrations while the server
1160
- -- catches up.
1161
- if data.knownInstance == false then
1162
- sendReady(conn)
1163
- end
1164
- local el = ui
1165
- el.step1Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
1166
- el.step1Label.Text = "HTTP server (OK)"
1167
- local _condition_1 = mcpConnected
1168
- if _condition_1 then
1169
- local _value = (string.find(el.statusLabel.Text, "Connected"))
1170
- _condition_1 = not (_value ~= 0 and _value == _value and _value)
970
+ conn.mcpWaitStartTime = tick()
971
+ else
972
+ conn.lastHttpOk = false
973
+ conn.lastMcpOk = false
974
+ conn.consecutiveFailures = update.attempt
975
+ if update.retryDelay > 0 then
976
+ conn.currentRetryDelay = update.retryDelay
1171
977
  end
1172
- if _condition_1 then
1173
- el.statusLabel.Text = "Connected"
1174
- el.statusLabel.TextColor3 = Color3.fromRGB(34, 197, 94)
1175
- el.statusIndicator.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
1176
- el.statusPulse.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
1177
- el.statusText.Text = "ONLINE"
1178
- el.detailStatusLabel.Text = "HTTP: OK MCP: OK"
1179
- el.detailStatusLabel.TextColor3 = Color3.fromRGB(34, 197, 94)
1180
- el.step2Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
1181
- el.step2Label.Text = "MCP bridge (OK)"
1182
- el.step3Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
1183
- el.step3Label.Text = "Commands (OK)"
1184
- conn.mcpWaitStartTime = nil
1185
- el.troubleshootLabel.Visible = false
1186
- UI.stopPulseAnimation()
1187
- elseif not mcpConnected then
1188
- el.statusLabel.Text = "Waiting for MCP server"
1189
- el.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
1190
- el.statusIndicator.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
1191
- el.statusPulse.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
1192
- el.statusText.Text = "WAITING"
1193
- el.detailStatusLabel.Text = "HTTP: OK MCP: ..."
1194
- el.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
1195
- el.step2Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
1196
- el.step2Label.Text = "MCP bridge (waiting...)"
1197
- el.step3Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
1198
- el.step3Label.Text = "Commands (waiting...)"
1199
- if conn.mcpWaitStartTime == nil then
1200
- conn.mcpWaitStartTime = tick()
1201
- end
1202
- local _exp = tick()
1203
- local _condition_2 = conn.mcpWaitStartTime
1204
- if _condition_2 == nil then
1205
- _condition_2 = tick()
1206
- end
1207
- local elapsed = _exp - _condition_2
1208
- el.troubleshootLabel.Visible = elapsed > 8
1209
- UI.startPulseAnimation()
978
+ conn.mcpWaitStartTime = nil
979
+ end
980
+ UI.updateUIState()
981
+ UI.updateToolbarIcon()
982
+ if update.state == "waiting-duplicate" then
983
+ local ui = UI.getElements()
984
+ ui.statusLabel.Text = "Waiting for previous instance"
985
+ ui.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
986
+ local _condition = update.detail
987
+ if _condition == nil then
988
+ _condition = "The previous plugin instance is still active."
1210
989
  end
1211
- if data.request and mcpConnected then
1212
- task.spawn(function()
1213
- local ok, response = pcall(function()
1214
- return processRequest(data.request)
1215
- end)
1216
- if ok then
1217
- sendResponse(conn, data.requestId, response)
1218
- else
1219
- sendResponse(conn, data.requestId, {
1220
- error = tostring(response),
1221
- })
1222
- end
990
+ ui.detailStatusLabel.Text = _condition
991
+ ui.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
992
+ end
993
+ end
994
+ local nameChangeConn
995
+ local placeIdChangeConn
996
+ local function ensureIdentityWatchers()
997
+ if not nameChangeConn then
998
+ local signalOk, signal = pcall(function()
999
+ return game:GetPropertyChangedSignal("Name")
1000
+ end)
1001
+ if signalOk and signal then
1002
+ nameChangeConn = signal:Connect(function()
1003
+ return StudioEventStream.refresh()
1223
1004
  end)
1224
1005
  end
1225
- elseif conn.isActive then
1226
- conn.consecutiveFailures += 1
1227
- if conn.consecutiveFailures > 1 then
1228
- conn.currentRetryDelay = math.min(conn.currentRetryDelay * conn.retryBackoffMultiplier, conn.maxRetryDelay)
1229
- end
1230
- local el = ui
1231
- if conn.consecutiveFailures >= conn.maxFailuresBeforeError then
1232
- el.statusLabel.Text = "Server unavailable"
1233
- el.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
1234
- el.statusIndicator.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
1235
- el.statusPulse.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
1236
- el.statusText.Text = "ERROR"
1237
- el.detailStatusLabel.Text = "HTTP: X MCP: X"
1238
- el.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
1239
- el.step1Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
1240
- el.step1Label.Text = "HTTP server (error)"
1241
- el.step2Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
1242
- el.step2Label.Text = "MCP bridge (error)"
1243
- el.step3Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
1244
- el.step3Label.Text = "Commands (error)"
1245
- conn.mcpWaitStartTime = nil
1246
- el.troubleshootLabel.Visible = false
1247
- UI.stopPulseAnimation()
1248
- elseif conn.consecutiveFailures > 5 then
1249
- local waitTime = math.ceil(conn.currentRetryDelay)
1250
- el.statusLabel.Text = `Retrying ({waitTime}s)`
1251
- el.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
1252
- el.statusIndicator.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
1253
- el.statusPulse.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
1254
- el.statusText.Text = "RETRY"
1255
- el.detailStatusLabel.Text = "HTTP: ... MCP: ..."
1256
- el.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
1257
- el.step1Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
1258
- el.step1Label.Text = "HTTP server (retrying...)"
1259
- el.step2Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
1260
- el.step2Label.Text = "MCP bridge (retrying...)"
1261
- el.step3Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
1262
- el.step3Label.Text = "Commands (retrying...)"
1263
- conn.mcpWaitStartTime = nil
1264
- el.troubleshootLabel.Visible = false
1265
- UI.startPulseAnimation()
1266
- elseif conn.consecutiveFailures > 1 then
1267
- el.statusLabel.Text = `Connecting (attempt {conn.consecutiveFailures})`
1268
- el.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
1269
- el.statusIndicator.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
1270
- el.statusPulse.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
1271
- el.statusText.Text = "CONNECTING"
1272
- el.detailStatusLabel.Text = "HTTP: ... MCP: ..."
1273
- el.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
1274
- el.step1Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
1275
- el.step1Label.Text = "HTTP server (connecting...)"
1276
- el.step2Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
1277
- el.step2Label.Text = "MCP bridge (connecting...)"
1278
- el.step3Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
1279
- el.step3Label.Text = "Commands (connecting...)"
1280
- conn.mcpWaitStartTime = nil
1281
- el.troubleshootLabel.Visible = false
1282
- UI.startPulseAnimation()
1006
+ end
1007
+ if not placeIdChangeConn then
1008
+ local signalOk, signal = pcall(function()
1009
+ return game:GetPropertyChangedSignal("PlaceId")
1010
+ end)
1011
+ if signalOk and signal then
1012
+ placeIdChangeConn = signal:Connect(function()
1013
+ PluginSession.invalidatePlaceName()
1014
+ lastReadyInstanceId = PluginSession.getInstanceId()
1015
+ StudioEventStream.refresh()
1016
+ end)
1283
1017
  end
1284
1018
  end
1285
1019
  end
1020
+ local function disconnectIdentityWatchers()
1021
+ if nameChangeConn then
1022
+ nameChangeConn:Disconnect()
1023
+ nameChangeConn = nil
1024
+ end
1025
+ if placeIdChangeConn then
1026
+ placeIdChangeConn:Disconnect()
1027
+ placeIdChangeConn = nil
1028
+ end
1029
+ end
1286
1030
  local deactivatePlugin
1287
1031
  local function activatePlugin()
1288
1032
  local conn = State.getActiveConnection()
1033
+ if conn.isActive then
1034
+ return nil
1035
+ end
1289
1036
  local ui = UI.getElements()
1290
1037
  conn.isActive = true
1291
1038
  conn.consecutiveFailures = 0
1292
1039
  conn.currentRetryDelay = 0.5
1040
+ conn.lastHttpOk = false
1041
+ conn.lastMcpOk = false
1042
+ conn.mcpWaitStartTime = nil
1293
1043
  local normalizedUrl = ServerUrlSettings.normalizeServerUrl(ui.urlInput.Text)
1294
1044
  conn.serverUrl = if normalizedUrl ~= "" then normalizedUrl else conn.serverUrl
1295
1045
  if conn.serverUrl == "" then
@@ -1300,63 +1050,60 @@ local function activatePlugin()
1300
1050
  if port ~= nil then
1301
1051
  conn.port = port
1302
1052
  end
1053
+ ClientBroker.setServerUrl(conn.serverUrl)
1054
+ lastReadyInstanceId = PluginSession.getInstanceId()
1303
1055
  UI.updateUIState()
1056
+ StudioEventStream.start({
1057
+ serverUrl = conn.serverUrl,
1058
+ dispatchRequest = dispatchStreamRequest,
1059
+ onStatus = handleStatus,
1060
+ onHeartbeat = handleHeartbeat,
1061
+ onReady = handleReady,
1062
+ onTransportUpdate = handleTransportUpdate,
1063
+ })
1304
1064
  if not conn.heartbeatConnection then
1305
1065
  conn.heartbeatConnection = RunService.Heartbeat:Connect(function()
1306
- local now = tick()
1307
1066
  if initialRole == "server" and not RunService:IsRunning() then
1308
1067
  ClientBroker.disconnectAllProxies()
1309
1068
  deactivatePlugin()
1310
1069
  return nil
1311
1070
  end
1312
- local currentInstanceId = computeInstanceId()
1071
+ local currentInstanceId = PluginSession.getInstanceId()
1313
1072
  if lastReadyInstanceId ~= nil and currentInstanceId ~= lastReadyInstanceId then
1314
- cachedPlaceName = nil
1315
- cachedPlaceNamePlaceId = nil
1316
- sendReady(conn)
1317
- end
1318
- local currentInterval = if conn.consecutiveFailures > 5 then conn.currentRetryDelay else conn.pollInterval
1319
- if now - conn.lastPoll > currentInterval then
1320
- conn.lastPoll = now
1321
- pollForRequests()
1073
+ lastReadyInstanceId = currentInstanceId
1074
+ PluginSession.invalidatePlaceName()
1075
+ StudioEventStream.refresh()
1322
1076
  end
1323
1077
  end)
1324
1078
  end
1325
- -- Initial /ready; pollForRequests will also re-fire ready if the server
1326
- -- later reports knownInstance=false (process restart, etc).
1327
- sendReady(conn)
1328
- -- Remove legacy edit-mode eval bridge scripts from older plugin builds.
1329
- -- Current bridges are created only in running play DataModels.
1330
1079
  if not RunService:IsRunning() then
1331
- task.spawn(cleanupLegacyEditBridges)
1080
+ task.spawn(cleanupEditBridgeArtifacts)
1332
1081
  end
1333
- -- Watch identity fields so stale name or anon instance ids are refreshed.
1334
- ensureIdentityWatcher(conn)
1082
+ ensureIdentityWatchers()
1335
1083
  end
1336
1084
  function deactivatePlugin()
1337
1085
  local conn = State.getActiveConnection()
1086
+ if not conn.isActive then
1087
+ return nil
1088
+ end
1338
1089
  conn.isActive = false
1090
+ conn.lastHttpOk = false
1339
1091
  conn.lastMcpOk = false
1340
- UI.updateUIState()
1341
- pcall(function()
1342
- HttpService:RequestAsync({
1343
- Url = `{conn.serverUrl}/disconnect`,
1344
- Method = "POST",
1345
- Headers = {
1346
- ["Content-Type"] = "application/json",
1347
- },
1348
- Body = HttpService:JSONEncode({
1349
- pluginSessionId = pluginSessionId,
1350
- timestamp = tick(),
1351
- }),
1352
- })
1353
- end)
1092
+ conn.mcpWaitStartTime = nil
1093
+ StudioEventStream.stop()
1094
+ disconnectIdentityWatchers()
1095
+ if initialRole == "server" then
1096
+ ClientBroker.disconnectAllProxies()
1097
+ end
1354
1098
  if conn.heartbeatConnection then
1355
1099
  conn.heartbeatConnection:Disconnect()
1356
1100
  conn.heartbeatConnection = nil
1357
1101
  end
1102
+ lastReadyInstanceId = nil
1103
+ assignedRole = nil
1358
1104
  conn.consecutiveFailures = 0
1359
1105
  conn.currentRetryDelay = 0.5
1106
+ UI.updateUIState()
1360
1107
  end
1361
1108
  local function deactivateAll()
1362
1109
  local conn = State.getActiveConnection()
@@ -1390,9 +1137,7 @@ local function checkForUpdates()
1390
1137
  if _condition ~= "" and _condition then
1391
1138
  local latestVersion = data.version
1392
1139
  if Utils.compareVersions(State.CURRENT_VERSION, latestVersion) < 0 then
1393
- if not hasVersionMismatch then
1394
- UI.showBanner("update", `v{latestVersion} available - github.com/chrrxs/robloxstudio-mcp`)
1395
- end
1140
+ UI.showBanner("update", `v{latestVersion} available - github.com/chrrxs/robloxstudio-mcp`)
1396
1141
  end
1397
1142
  end
1398
1143
  end
@@ -1528,9 +1273,9 @@ local function computeBridgeStamp()
1528
1273
  for i = 1, #combined do
1529
1274
  h = (h * 33 + (string.byte(combined, i))) % 2147483647
1530
1275
  end
1531
- -- "3.0.1" is replaced with the package version at package time
1276
+ -- "3.0.3" is replaced with the package version at package time
1532
1277
  -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1533
- return `{tostring(h)}-3.0.1`
1278
+ return `{tostring(h)}-3.0.3`
1534
1279
  end
1535
1280
  local BRIDGE_STAMP = computeBridgeStamp()
1536
1281
  local function setSource(scriptInst, source)
@@ -1546,7 +1291,7 @@ local function setSource(scriptInst, source)
1546
1291
  scriptInst.Source = source
1547
1292
  end
1548
1293
  end
1549
- local function findLegacyEditBridges()
1294
+ local function findEditBridgeArtifacts()
1550
1295
  local sps = getStarterPlayerScripts()
1551
1296
  return {
1552
1297
  server = ServerScriptService:FindFirstChild(SERVER_SCRIPT_NAME),
@@ -1561,11 +1306,11 @@ local function destroyIfPresent(parent, name)
1561
1306
  end)
1562
1307
  end
1563
1308
  end
1564
- local function cleanupLegacyEditBridges()
1309
+ local function cleanupEditBridgeArtifacts()
1565
1310
  if RunService:IsRunning() then
1566
1311
  return nil
1567
1312
  end
1568
- local _binding = findLegacyEditBridges()
1313
+ local _binding = findEditBridgeArtifacts()
1569
1314
  local server = _binding.server
1570
1315
  local client = _binding.client
1571
1316
  if server then
@@ -1676,7 +1421,7 @@ local function ensureRuntimeBridgeInstalled()
1676
1421
  return installClientRuntimeBridge()
1677
1422
  end
1678
1423
  return {
1679
- cleanupLegacyEditBridges = cleanupLegacyEditBridges,
1424
+ cleanupEditBridgeArtifacts = cleanupEditBridgeArtifacts,
1680
1425
  ensureRuntimeBridgeInstalled = ensureRuntimeBridgeInstalled,
1681
1426
  BRIDGE_NAMES = BRIDGE_NAMES,
1682
1427
  }
@@ -2655,6 +2400,8 @@ local RenderMonitor = TS.import(script, script.Parent.Parent, "RenderMonitor")
2655
2400
  local CaptureService = game:GetService("CaptureService")
2656
2401
  local AssetService = game:GetService("AssetService")
2657
2402
  local MAX_TILE_SIZE = 1024
2403
+ local MAX_RAW_PIXEL_BYTES = 36 * 1024 * 1024
2404
+ local MAX_CREATED_IMAGE_DIM = 2048
2658
2405
  local BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
2659
2406
  local PAD_BYTE = (string.byte("="))
2660
2407
  local B64 = {}
@@ -2806,14 +2553,39 @@ local function readContentToBase64(contentId)
2806
2553
  error = `Failed to create EditableImage from screenshot. Enable EditableImage API: Game Settings > Security > 'Allow Mesh / Image APIs'. ({tostring(editableResult)})`,
2807
2554
  }
2808
2555
  end
2809
- local editableImage = editableResult
2810
- local imgSize = editableImage.Size
2811
- local w = math.floor(imgSize.X)
2812
- local h = math.floor(imgSize.Y)
2556
+ local sourceImage = editableResult
2557
+ local imgSize = sourceImage.Size
2558
+ local nativeW = math.floor(imgSize.X)
2559
+ local nativeH = math.floor(imgSize.Y)
2560
+ local w = nativeW
2561
+ local h = nativeH
2562
+ if nativeW * nativeH * 4 > MAX_RAW_PIXEL_BYTES then
2563
+ local scale = math.min(math.sqrt(MAX_RAW_PIXEL_BYTES / (nativeW * nativeH * 4)), MAX_CREATED_IMAGE_DIM / math.max(nativeW, nativeH))
2564
+ w = math.max(1, math.floor(nativeW * scale))
2565
+ h = math.max(1, math.floor(nativeH * scale))
2566
+ local scaleOk, scaledResult = pcall(function()
2567
+ local target = AssetService:CreateEditableImage({
2568
+ Size = Vector2.new(w, h),
2569
+ })
2570
+ target:DrawImageTransformed(Vector2.new(0, 0), Vector2.new(w / nativeW, h / nativeH), 0, sourceImage, {
2571
+ CombineType = Enum.ImageCombineType.AlphaBlend,
2572
+ SamplingMode = Enum.ResamplerMode.Default,
2573
+ PivotPoint = Vector2.new(0, 0),
2574
+ })
2575
+ return target
2576
+ end)
2577
+ sourceImage:Destroy()
2578
+ if not scaleOk then
2579
+ return {
2580
+ error = `Screenshot is {nativeW}x{nativeH} (too large to transfer raw) and downscaling failed: {tostring(scaledResult)}`,
2581
+ }
2582
+ end
2583
+ sourceImage = scaledResult
2584
+ end
2813
2585
  local readOk, pixelBuffer = pcall(function()
2814
- return readPixelsTiled(editableImage, w, h)
2586
+ return readPixelsTiled(sourceImage, w, h)
2815
2587
  end)
2816
- editableImage:Destroy()
2588
+ sourceImage:Destroy()
2817
2589
  if not readOk then
2818
2590
  return {
2819
2591
  error = `Failed to read pixel data: {tostring(pixelBuffer)}`,
@@ -2825,6 +2597,8 @@ local function readContentToBase64(contentId)
2825
2597
  width = w,
2826
2598
  height = h,
2827
2599
  data = base64Data,
2600
+ nativeWidth = nativeW,
2601
+ nativeHeight = nativeH,
2828
2602
  }
2829
2603
  end
2830
2604
  -- Edit-mode single shot: capture and read back in the same (edit) context.
@@ -3600,11 +3374,14 @@ local function getAttributes(requestData)
3600
3374
  }
3601
3375
  count += 1
3602
3376
  end
3603
- return {
3377
+ local response = {
3604
3378
  instancePath = instancePath,
3605
- attributes = serializedAttributes,
3606
3379
  count = count,
3607
3380
  }
3381
+ if count > 0 then
3382
+ response.attributes = serializedAttributes
3383
+ end
3384
+ return response
3608
3385
  end)
3609
3386
  if success then
3610
3387
  return result
@@ -6152,7 +5929,6 @@ local function getProjectStructure(requestData)
6152
5929
  name = instance.Name,
6153
5930
  className = instance.ClassName,
6154
5931
  path = getInstancePath(instance),
6155
- children = {},
6156
5932
  }
6157
5933
  if instance:IsA("LuaSourceContainer") then
6158
5934
  node.hasSource = true
@@ -6192,7 +5968,7 @@ local function getProjectStructure(requestData)
6192
5968
  -- ▲ ReadonlyArray.filter ▲
6193
5969
  children = _newValue
6194
5970
  end
6195
- local nodeChildren = node.children
5971
+ local nodeChildren = {}
6196
5972
  local childCount = #children
6197
5973
  if childCount > 20 and depth < maxDepth then
6198
5974
  local classGroups = {}
@@ -6267,6 +6043,9 @@ local function getProjectStructure(requestData)
6267
6043
  table.insert(nodeChildren, _arg0)
6268
6044
  end
6269
6045
  end
6046
+ if #nodeChildren > 0 then
6047
+ node.children = nodeChildren
6048
+ end
6270
6049
  return node
6271
6050
  end
6272
6051
  local result = getStructure(startInstance, 0)
@@ -6819,16 +6598,6 @@ local finishRecording = _binding_1.finishRecording
6819
6598
  local SOURCE_TRUNCATE_CHAR_BUDGET = 25000
6820
6599
  local SOURCE_TRUNCATE_LINE_BUDGET = 400
6821
6600
  local SOURCE_TRUNCATE_TO_LINES = 300
6822
- local function normalizeEscapes(s)
6823
- local result = s
6824
- result = (string.gsub(result, "\\\\", "\x01"))
6825
- result = (string.gsub(result, "\\n", "\n"))
6826
- result = (string.gsub(result, "\\t", "\t"))
6827
- result = (string.gsub(result, "\\r", "\r"))
6828
- result = (string.gsub(result, '\\"', '"'))
6829
- result = (string.gsub(result, "\x01", "\\"))
6830
- return result
6831
- end
6832
6601
  local function getTopServiceName(instance)
6833
6602
  local topServiceInst = instance
6834
6603
  while topServiceInst.Parent and topServiceInst.Parent ~= game do
@@ -6972,7 +6741,8 @@ local function setScriptSource(requestData)
6972
6741
  error = `Instance is not a script-like object: {instance.ClassName}`,
6973
6742
  }
6974
6743
  end
6975
- local sourceToSet = normalizeEscapes(newSource)
6744
+ -- Communication has already JSON-decoded the transport payload; source text is exact at this boundary.
6745
+ local sourceToSet = newSource
6976
6746
  local recordingId = beginRecording(`Set script source: {instance.Name}`)
6977
6747
  local readSuccess, readResult = pcall(function()
6978
6748
  return #readScriptSource(instance)
@@ -6996,40 +6766,9 @@ local function setScriptSource(requestData)
6996
6766
  message = `Script source updated successfully ({if applyResult.method == "UpdateSourceAsync" then "editor-safe" else "direct assignment"})`,
6997
6767
  }
6998
6768
  end
6999
- local replaceSuccess, replaceResult = pcall(function()
7000
- local parent = instance.Parent
7001
- local name = instance.Name
7002
- local className = instance.ClassName
7003
- local wasBaseScript = instance:IsA("BaseScript")
7004
- local enabled = if wasBaseScript then instance.Enabled else nil
7005
- local newScript = Instance.new(className)
7006
- newScript.Name = name
7007
- -- @rbxts/types does not expose PluginSecurity Source writes.
7008
- local writableNewScript = newScript
7009
- writableNewScript.Source = sourceToSet
7010
- if readScriptSource(newScript) ~= sourceToSet then
7011
- error("Replacement script source did not match the requested source")
7012
- end
7013
- if wasBaseScript and enabled ~= nil then
7014
- local newBaseScript = newScript
7015
- newBaseScript.Enabled = enabled
7016
- end
7017
- newScript.Parent = parent
7018
- instance:Destroy()
7019
- return {
7020
- success = true,
7021
- instancePath = getInstancePath(newScript),
7022
- method = "replace",
7023
- message = "Script replaced successfully with new source",
7024
- }
7025
- end)
7026
- if replaceSuccess then
7027
- finishRecording(recordingId, true)
7028
- return replaceResult
7029
- end
7030
6769
  finishRecording(recordingId, false)
7031
6770
  return {
7032
- error = `Failed to set script source. {applyResult.error} Replace method failed: {replaceResult}`,
6771
+ error = `Failed to set script source: {applyResult.error}`,
7033
6772
  }
7034
6773
  end
7035
6774
  local function editScriptLines(requestData)
@@ -7042,8 +6781,6 @@ local function editScriptLines(requestData)
7042
6781
  error = "Instance path, old_string, and new_string are required",
7043
6782
  }
7044
6783
  end
7045
- oldString = normalizeEscapes(oldString)
7046
- newString = normalizeEscapes(newString)
7047
6784
  local instance = getInstanceByPath(instancePath)
7048
6785
  if not instance then
7049
6786
  return {
@@ -7140,7 +6877,6 @@ local function insertScriptLines(requestData)
7140
6877
  error = "Instance path and newContent are required",
7141
6878
  }
7142
6879
  end
7143
- newContent = normalizeEscapes(newContent)
7144
6880
  local instance = getInstanceByPath(instancePath)
7145
6881
  if not instance then
7146
6882
  return {
@@ -9058,6 +8794,98 @@ return {
9058
8794
  </Properties>
9059
8795
  </Item>
9060
8796
  <Item class="ModuleScript" referent="26">
8797
+ <Properties>
8798
+ <string name="Name">PluginSession</string>
8799
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
8800
+ local TS = require(script.Parent.Parent.include.RuntimeLib)
8801
+ local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
8802
+ local HttpService = _services.HttpService
8803
+ local RunService = _services.RunService
8804
+ local ServerStorage = _services.ServerStorage
8805
+ local State = TS.import(script, script.Parent, "State")
8806
+ local MCP_PLACE_ID_ATTRIBUTE = "__MCPPlaceId"
8807
+ local id = HttpService:GenerateGUID(false)
8808
+ local cachedPlaceName
8809
+ local cachedPlaceNamePlaceId
8810
+ local function getInstanceId()
8811
+ if game.PlaceId ~= 0 then
8812
+ return `place:{tostring(game.PlaceId)}`
8813
+ end
8814
+ local existing = ServerStorage:GetAttribute(MCP_PLACE_ID_ATTRIBUTE)
8815
+ if type(existing) == "string" and existing ~= "" then
8816
+ return `anon:{existing}`
8817
+ end
8818
+ local fresh = HttpService:GenerateGUID(false)
8819
+ pcall(function()
8820
+ return ServerStorage:SetAttribute(MCP_PLACE_ID_ATTRIBUTE, fresh)
8821
+ end)
8822
+ return `anon:{fresh}`
8823
+ end
8824
+ local function getRole()
8825
+ if not RunService:IsRunning() then
8826
+ return "edit"
8827
+ end
8828
+ if RunService:IsServer() then
8829
+ return "server"
8830
+ end
8831
+ return "client"
8832
+ end
8833
+ local function invalidatePlaceName()
8834
+ cachedPlaceName = nil
8835
+ cachedPlaceNamePlaceId = nil
8836
+ end
8837
+ local function getPlaceName()
8838
+ if cachedPlaceName ~= nil and cachedPlaceNamePlaceId == game.PlaceId then
8839
+ return cachedPlaceName
8840
+ end
8841
+ invalidatePlaceName()
8842
+ cachedPlaceNamePlaceId = game.PlaceId
8843
+ if game.PlaceId == 0 then
8844
+ cachedPlaceName = game.Name
8845
+ return cachedPlaceName
8846
+ end
8847
+ local MarketplaceService = game:GetService("MarketplaceService")
8848
+ local ok, info = pcall(function()
8849
+ return MarketplaceService:GetProductInfo(game.PlaceId)
8850
+ end)
8851
+ if ok and info ~= nil then
8852
+ -- GetProductInfo's generated type is broader than the place metadata returned here.
8853
+ local placeInfo = info
8854
+ local name = placeInfo.Name
8855
+ if type(name) == "string" and name ~= "" then
8856
+ cachedPlaceName = name
8857
+ return cachedPlaceName
8858
+ end
8859
+ end
8860
+ return game.Name
8861
+ end
8862
+ local function createReadyPayload(pluginSessionId, role)
8863
+ return {
8864
+ pluginSessionId = pluginSessionId,
8865
+ physicalSessionId = id,
8866
+ instanceId = getInstanceId(),
8867
+ role = role,
8868
+ placeId = game.PlaceId,
8869
+ placeName = getPlaceName(),
8870
+ dataModelName = game.Name,
8871
+ isRunning = RunService:IsRunning(),
8872
+ pluginVersion = State.CURRENT_VERSION,
8873
+ pluginVariant = State.PLUGIN_VARIANT,
8874
+ timestamp = tick(),
8875
+ }
8876
+ end
8877
+ return {
8878
+ id = id,
8879
+ getInstanceId = getInstanceId,
8880
+ getRole = getRole,
8881
+ getPlaceName = getPlaceName,
8882
+ invalidatePlaceName = invalidatePlaceName,
8883
+ createReadyPayload = createReadyPayload,
8884
+ }
8885
+ ]]></string>
8886
+ </Properties>
8887
+ </Item>
8888
+ <Item class="ModuleScript" referent="27">
9061
8889
  <Properties>
9062
8890
  <string name="Name">Recording</string>
9063
8891
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9087,7 +8915,7 @@ return {
9087
8915
  ]]></string>
9088
8916
  </Properties>
9089
8917
  </Item>
9090
- <Item class="ModuleScript" referent="27">
8918
+ <Item class="ModuleScript" referent="28">
9091
8919
  <Properties>
9092
8920
  <string name="Name">RenderMonitor</string>
9093
8921
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9155,7 +8983,7 @@ return {
9155
8983
  ]]></string>
9156
8984
  </Properties>
9157
8985
  </Item>
9158
- <Item class="ModuleScript" referent="28">
8986
+ <Item class="ModuleScript" referent="29">
9159
8987
  <Properties>
9160
8988
  <string name="Name">RuntimeLogBuffer</string>
9161
8989
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9410,7 +9238,7 @@ return {
9410
9238
  ]]></string>
9411
9239
  </Properties>
9412
9240
  </Item>
9413
- <Item class="ModuleScript" referent="29">
9241
+ <Item class="ModuleScript" referent="30">
9414
9242
  <Properties>
9415
9243
  <string name="Name">ServerUrlSettings</string>
9416
9244
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9418,7 +9246,6 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
9418
9246
  local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
9419
9247
  local HttpService = _services.HttpService
9420
9248
  local ServerStorage = _services.ServerStorage
9421
- local LEGACY_SETTING_KEY_PREFIX = "MCP_SERVER_URL_"
9422
9249
  local SETTING_KEY_PREFIX = "MCP_LAST_SUCCESSFUL_SERVER_URL_"
9423
9250
  local GLOBAL_SETTING_KEY = "MCP_LAST_SUCCESSFUL_SERVER_URL_GLOBAL_V1"
9424
9251
  local pluginRef
@@ -9488,9 +9315,6 @@ end
9488
9315
  local function settingKey(instanceId)
9489
9316
  return SETTING_KEY_PREFIX .. instanceId
9490
9317
  end
9491
- local function legacySettingKey(instanceId)
9492
- return LEGACY_SETTING_KEY_PREFIX .. instanceId
9493
- end
9494
9318
  local function readSettingString(key)
9495
9319
  if not pluginRef then
9496
9320
  return nil
@@ -9522,7 +9346,6 @@ local function rememberServerUrl(serverUrl)
9522
9346
  createAnonymous = true,
9523
9347
  }) do
9524
9348
  writeSettingString(settingKey(instanceId), normalized)
9525
- writeSettingString(legacySettingKey(instanceId), normalized)
9526
9349
  end
9527
9350
  end
9528
9351
  local function readServerUrl()
@@ -9542,12 +9365,6 @@ local function readServerUrl()
9542
9365
  if globalRemembered ~= nil then
9543
9366
  return globalRemembered
9544
9367
  end
9545
- for _, instanceId in computeInstanceIds() do
9546
- local legacyRemembered = readSettingString(legacySettingKey(instanceId))
9547
- if legacyRemembered ~= nil then
9548
- return legacyRemembered
9549
- end
9550
- end
9551
9368
  return nil
9552
9369
  end
9553
9370
  return {
@@ -9560,11 +9377,11 @@ return {
9560
9377
  ]]></string>
9561
9378
  </Properties>
9562
9379
  </Item>
9563
- <Item class="ModuleScript" referent="30">
9380
+ <Item class="ModuleScript" referent="31">
9564
9381
  <Properties>
9565
9382
  <string name="Name">State</string>
9566
9383
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
9567
- local CURRENT_VERSION = "3.0.1"
9384
+ local CURRENT_VERSION = "3.0.3"
9568
9385
  local PLUGIN_VARIANT = "main"
9569
9386
  local BASE_PORT = 58741
9570
9387
  local function createConnection(port)
@@ -9572,18 +9389,12 @@ local function createConnection(port)
9572
9389
  port = port,
9573
9390
  serverUrl = `http://localhost:{port}`,
9574
9391
  isActive = false,
9575
- pollInterval = 0.5,
9576
- lastPoll = 0,
9577
9392
  consecutiveFailures = 0,
9578
9393
  maxFailuresBeforeError = 50,
9579
- lastSuccessfulConnection = 0,
9580
9394
  currentRetryDelay = 0.5,
9581
- maxRetryDelay = 5,
9582
- retryBackoffMultiplier = 1.2,
9583
9395
  lastHttpOk = false,
9584
9396
  lastMcpOk = false,
9585
9397
  mcpWaitStartTime = nil,
9586
- isPolling = false,
9587
9398
  heartbeatConnection = nil,
9588
9399
  }
9589
9400
  end
@@ -9600,7 +9411,7 @@ return {
9600
9411
  ]]></string>
9601
9412
  </Properties>
9602
9413
  </Item>
9603
- <Item class="ModuleScript" referent="31">
9414
+ <Item class="ModuleScript" referent="32">
9604
9415
  <Properties>
9605
9416
  <string name="Name">StopPlayMonitor</string>
9606
9417
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9653,7 +9464,7 @@ local endTestIssued = false
9653
9464
  local function init(p)
9654
9465
  pluginRef = p
9655
9466
  end
9656
- -- Mirror of Communication.computeInstanceId(). Duplicated here because
9467
+ -- Mirror of PluginSession's place identity rules. Duplicated here because
9657
9468
  -- StopPlayMonitor runs in both edit and play-server DMs, and both must
9658
9469
  -- agree on the place identifier (published places: placeId; unpublished:
9659
9470
  -- UUID on ServerStorage's __MCPPlaceId attribute, travels with the .rbxl
@@ -9809,16 +9620,9 @@ local function startMonitor()
9809
9620
  task.spawn(function()
9810
9621
  while true do
9811
9622
  for _, myKey in settingKeys() do
9812
- local value = readSetting(myKey)
9813
- if value == true then
9814
- -- Legacy boolean requests are ambiguous and may be stale from
9815
- -- a prior crashed session. New stop requests use token payloads.
9816
- writeSetting(myKey, false)
9817
- else
9818
- local payload = decodePayload(value)
9819
- if payload then
9820
- handleStopRequest(myKey, payload)
9821
- end
9623
+ local payload = decodePayload(readSetting(myKey))
9624
+ if payload then
9625
+ handleStopRequest(myKey, payload)
9822
9626
  end
9823
9627
  end
9824
9628
  task.wait(POLL_INTERVAL_SEC)
@@ -9898,7 +9702,618 @@ return {
9898
9702
  ]]></string>
9899
9703
  </Properties>
9900
9704
  </Item>
9901
- <Item class="ModuleScript" referent="32">
9705
+ <Item class="ModuleScript" referent="33">
9706
+ <Properties>
9707
+ <string name="Name">StudioEventStream</string>
9708
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
9709
+ local TS = require(script.Parent.Parent.include.RuntimeLib)
9710
+ local HttpService = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services").HttpService
9711
+ local HttpDiagnostics = TS.import(script, script.Parent, "HttpDiagnostics")
9712
+ local PluginSession = TS.import(script, script.Parent, "PluginSession")
9713
+ local INITIAL_RECONNECT_DELAY_SECONDS = 0.5
9714
+ local MAX_RECONNECT_DELAY_SECONDS = 5
9715
+ local INITIAL_RESPONSE_RETRY_DELAY_SECONDS = 0.5
9716
+ local MAX_RESPONSE_RETRY_DELAY_SECONDS = 5
9717
+ local MAX_TERMINAL_RESPONSES = 256
9718
+ local STREAM_SILENCE_TIMEOUT_SECONDS = 20
9719
+ local options
9720
+ local active = false
9721
+ local generation = 0
9722
+ local reconnectAttempt = 0
9723
+ local streamClient
9724
+ local streamConnections = {}
9725
+ local lastValidEventAt = 0
9726
+ local inFlightRequestIds = {}
9727
+ local pendingResponses = {}
9728
+ local terminalResponseIds = {}
9729
+ local terminalResponseOrder = {}
9730
+ local readyFailureLogKeys = {}
9731
+ local function decodeMessage(message)
9732
+ -- Studio versions in the supported channel have surfaced either the SSE
9733
+ -- data payload or the complete single-line `data:` frame. The bridge emits
9734
+ -- one JSON data line per event, so normalize both forms before decoding.
9735
+ local payload = message
9736
+ local normalized = (string.gsub((string.gsub(message, "\r\n", "\n")), "\r", "\n"))
9737
+ if string.sub(normalized, 1, 5) == "data:" then
9738
+ payload = (string.gsub((string.gsub(string.sub(normalized, 6), "^%s+", "")), "%s+$", ""))
9739
+ end
9740
+ local decodeOk, decoded = pcall(function()
9741
+ return HttpService:JSONDecode(payload)
9742
+ end)
9743
+ if not decodeOk or not (type(decoded) == "table") then
9744
+ return nil
9745
+ end
9746
+ local envelope = decoded
9747
+ if envelope.kind == "heartbeat" then
9748
+ local _timestamp = envelope.timestamp
9749
+ if not (type(_timestamp) == "number") then
9750
+ return nil
9751
+ end
9752
+ return {
9753
+ kind = "heartbeat",
9754
+ timestamp = envelope.timestamp,
9755
+ }
9756
+ end
9757
+ if envelope.kind == "status" then
9758
+ local _knownInstance = envelope.knownInstance
9759
+ local _condition = not (type(_knownInstance) == "boolean")
9760
+ if not _condition then
9761
+ local _mcpConnected = envelope.mcpConnected
9762
+ _condition = not (type(_mcpConnected) == "boolean")
9763
+ end
9764
+ if _condition then
9765
+ return nil
9766
+ end
9767
+ local _object = {
9768
+ kind = "status",
9769
+ knownInstance = envelope.knownInstance,
9770
+ mcpConnected = envelope.mcpConnected,
9771
+ }
9772
+ local _left = "serverVersion"
9773
+ local _serverVersion = envelope.serverVersion
9774
+ _object[_left] = if type(_serverVersion) == "string" then envelope.serverVersion else nil
9775
+ local _left_1 = "pluginVersion"
9776
+ local _pluginVersion = envelope.pluginVersion
9777
+ _object[_left_1] = if type(_pluginVersion) == "string" then envelope.pluginVersion else nil
9778
+ local _left_2 = "pluginVariant"
9779
+ local _pluginVariant = envelope.pluginVariant
9780
+ _object[_left_2] = if type(_pluginVariant) == "string" then envelope.pluginVariant else nil
9781
+ return _object
9782
+ end
9783
+ if envelope.kind == "request" then
9784
+ local _requestId = envelope.requestId
9785
+ local _condition = not (type(_requestId) == "string")
9786
+ if not _condition then
9787
+ local _logicalSessionId = envelope.logicalSessionId
9788
+ _condition = not (type(_logicalSessionId) == "string")
9789
+ if not _condition then
9790
+ local _target = envelope.target
9791
+ _condition = not (type(_target) == "string")
9792
+ if not _condition then
9793
+ local _endpoint = envelope.endpoint
9794
+ _condition = not (type(_endpoint) == "string")
9795
+ end
9796
+ end
9797
+ end
9798
+ if _condition then
9799
+ return nil
9800
+ end
9801
+ local data
9802
+ local _data = envelope.data
9803
+ if type(_data) == "table" then
9804
+ data = envelope.data
9805
+ end
9806
+ return {
9807
+ kind = "request",
9808
+ requestId = envelope.requestId,
9809
+ logicalSessionId = envelope.logicalSessionId,
9810
+ target = envelope.target,
9811
+ endpoint = envelope.endpoint,
9812
+ data = data,
9813
+ }
9814
+ end
9815
+ return nil
9816
+ end
9817
+ local function closeCurrentStream()
9818
+ local current = streamClient
9819
+ streamClient = nil
9820
+ for _, connection in streamConnections do
9821
+ connection:Disconnect()
9822
+ end
9823
+ streamConnections = {}
9824
+ if current ~= nil then
9825
+ pcall(function()
9826
+ return current:Close()
9827
+ end)
9828
+ end
9829
+ end
9830
+ local function responseRetryDelay(attempt)
9831
+ return math.min(INITIAL_RESPONSE_RETRY_DELAY_SECONDS * math.pow(2, math.max(attempt - 1, 0)), MAX_RESPONSE_RETRY_DELAY_SECONDS)
9832
+ end
9833
+ local function parseResponseDisposition(success, body)
9834
+ local decodeOk, decoded = pcall(function()
9835
+ return HttpService:JSONDecode(body)
9836
+ end)
9837
+ if not decodeOk or not (type(decoded) == "table") then
9838
+ return nil
9839
+ end
9840
+ local acknowledgement = decoded
9841
+ local disposition = acknowledgement.disposition
9842
+ if disposition == "accepted" or disposition == "already_settled" or disposition == "unknown" then
9843
+ return disposition
9844
+ end
9845
+ if success and acknowledgement.success == true and disposition == nil then
9846
+ return "accepted"
9847
+ end
9848
+ return nil
9849
+ end
9850
+ local function rememberTerminalResponse(requestId)
9851
+ local _requestId = requestId
9852
+ if terminalResponseIds[_requestId] ~= nil then
9853
+ return nil
9854
+ end
9855
+ local _requestId_1 = requestId
9856
+ terminalResponseIds[_requestId_1] = true
9857
+ local _requestId_2 = requestId
9858
+ table.insert(terminalResponseOrder, _requestId_2)
9859
+ while #terminalResponseOrder > MAX_TERMINAL_RESPONSES do
9860
+ local oldest = table.remove(terminalResponseOrder, 1)
9861
+ if oldest ~= nil then
9862
+ terminalResponseIds[oldest] = nil
9863
+ end
9864
+ end
9865
+ end
9866
+ local function settleResponse(requestId, entry, disposition)
9867
+ local _requestId = requestId
9868
+ if pendingResponses[_requestId] ~= entry then
9869
+ return nil
9870
+ end
9871
+ local _requestId_1 = requestId
9872
+ pendingResponses[_requestId_1] = nil
9873
+ rememberTerminalResponse(requestId)
9874
+ if disposition == "unknown" then
9875
+ warn(`[robloxstudio-mcp] Server no longer recognizes response {requestId}; dropping stored result`)
9876
+ end
9877
+ end
9878
+ local function postPendingResponse(requestId, entry)
9879
+ local currentOptions = options
9880
+ local _condition = not active or currentOptions == nil
9881
+ if not _condition then
9882
+ local _requestId = requestId
9883
+ _condition = pendingResponses[_requestId] ~= entry
9884
+ if not _condition then
9885
+ _condition = entry.posting
9886
+ end
9887
+ end
9888
+ if _condition then
9889
+ return nil
9890
+ end
9891
+ entry.posting = true
9892
+ entry.retryToken += 1
9893
+ task.spawn(function()
9894
+ local _condition_1 = not active or options ~= currentOptions
9895
+ if not _condition_1 then
9896
+ local _requestId = requestId
9897
+ _condition_1 = pendingResponses[_requestId] ~= entry
9898
+ end
9899
+ if _condition_1 then
9900
+ entry.posting = false
9901
+ return nil
9902
+ end
9903
+ local responseUrl = `{currentOptions.serverUrl}/response`
9904
+ local requestOk, requestResult = pcall(function()
9905
+ return HttpService:RequestAsync({
9906
+ Url = responseUrl,
9907
+ Method = "POST",
9908
+ Headers = {
9909
+ ["Content-Type"] = "application/json",
9910
+ },
9911
+ Body = entry.body,
9912
+ })
9913
+ end)
9914
+ local _requestId = requestId
9915
+ if pendingResponses[_requestId] ~= entry then
9916
+ return nil
9917
+ end
9918
+ entry.posting = false
9919
+ local failure
9920
+ if not requestOk then
9921
+ failure = HttpDiagnostics.formatRequestFailure(responseUrl, false, requestResult)
9922
+ else
9923
+ local disposition = parseResponseDisposition(requestResult.Success, requestResult.Body)
9924
+ if disposition ~= nil then
9925
+ settleResponse(requestId, entry, disposition)
9926
+ return nil
9927
+ end
9928
+ failure = if requestResult.Success then "Invalid /response acknowledgement" else HttpDiagnostics.formatRequestFailure(responseUrl, true, requestResult)
9929
+ end
9930
+ warn(`[robloxstudio-mcp] Failed to deliver response {requestId}: {failure}`)
9931
+ entry.retryAttempt += 1
9932
+ local _condition_2 = not active
9933
+ if not _condition_2 then
9934
+ local _requestId_1 = requestId
9935
+ _condition_2 = pendingResponses[_requestId_1] ~= entry
9936
+ end
9937
+ if _condition_2 then
9938
+ return nil
9939
+ end
9940
+ entry.retryToken += 1
9941
+ local retryToken = entry.retryToken
9942
+ local delay = responseRetryDelay(entry.retryAttempt)
9943
+ task.delay(delay, function()
9944
+ local _condition_3 = not active
9945
+ if not _condition_3 then
9946
+ local _requestId_1 = requestId
9947
+ _condition_3 = pendingResponses[_requestId_1] ~= entry
9948
+ if not _condition_3 then
9949
+ _condition_3 = entry.retryToken ~= retryToken
9950
+ end
9951
+ end
9952
+ if _condition_3 then
9953
+ return nil
9954
+ end
9955
+ postPendingResponse(requestId, entry)
9956
+ end)
9957
+ end)
9958
+ end
9959
+ local function resumePendingResponses()
9960
+ for requestId, entry in pendingResponses do
9961
+ postPendingResponse(requestId, entry)
9962
+ end
9963
+ end
9964
+ local function encodeResponse(requestId, response)
9965
+ local encodeOk, encoded = pcall(function()
9966
+ return HttpService:JSONEncode({
9967
+ requestId = requestId,
9968
+ response = response,
9969
+ })
9970
+ end)
9971
+ if encodeOk then
9972
+ return encoded
9973
+ end
9974
+ warn(`[robloxstudio-mcp] Failed to serialize response {requestId}: {tostring(encoded)}`)
9975
+ return HttpService:JSONEncode({
9976
+ requestId = requestId,
9977
+ error = `Plugin response serialization failed: {tostring(encoded)}`,
9978
+ })
9979
+ end
9980
+ local function dispatchRequest(request)
9981
+ local _requestId = request.requestId
9982
+ local _condition = terminalResponseIds[_requestId] ~= nil
9983
+ if not _condition then
9984
+ local _requestId_1 = request.requestId
9985
+ _condition = pendingResponses[_requestId_1] ~= nil
9986
+ if not _condition then
9987
+ local _requestId_2 = request.requestId
9988
+ _condition = inFlightRequestIds[_requestId_2] ~= nil
9989
+ end
9990
+ end
9991
+ if _condition then
9992
+ return nil
9993
+ end
9994
+ local dispatchOptions = options
9995
+ if not active or dispatchOptions == nil then
9996
+ return nil
9997
+ end
9998
+ local _requestId_1 = request.requestId
9999
+ inFlightRequestIds[_requestId_1] = true
10000
+ task.spawn(function()
10001
+ local dispatchOk, response = pcall(function()
10002
+ return dispatchOptions.dispatchRequest(request)
10003
+ end)
10004
+ local responseData = if dispatchOk then response else {
10005
+ error = tostring(response),
10006
+ }
10007
+ local entry = {
10008
+ body = encodeResponse(request.requestId, responseData),
10009
+ retryAttempt = 0,
10010
+ posting = false,
10011
+ retryToken = 0,
10012
+ }
10013
+ local _requestId_2 = request.requestId
10014
+ pendingResponses[_requestId_2] = entry
10015
+ local _requestId_3 = request.requestId
10016
+ inFlightRequestIds[_requestId_3] = nil
10017
+ postPendingResponse(request.requestId, entry)
10018
+ end)
10019
+ end
10020
+ local function invokeCallback(name, callback)
10021
+ local callbackOk, callbackError = pcall(callback)
10022
+ if not callbackOk then
10023
+ warn(`[robloxstudio-mcp] {name} callback failed: {tostring(callbackError)}`)
10024
+ end
10025
+ end
10026
+ local function reportTransport(update)
10027
+ local currentOptions = options
10028
+ if active and currentOptions ~= nil then
10029
+ invokeCallback("event stream transport", function()
10030
+ return currentOptions.onTransportUpdate(update)
10031
+ end)
10032
+ end
10033
+ end
10034
+ local function reconnectDelay(attempt)
10035
+ return math.min(INITIAL_RECONNECT_DELAY_SECONDS * math.pow(2, math.max(attempt - 1, 0)), MAX_RECONNECT_DELAY_SECONDS)
10036
+ end
10037
+ local connect
10038
+ local function connectAfter(delaySeconds, expectedGeneration)
10039
+ task.delay(delaySeconds, function()
10040
+ if not active or generation ~= expectedGeneration then
10041
+ return nil
10042
+ end
10043
+ connect(expectedGeneration)
10044
+ end)
10045
+ end
10046
+ local function scheduleReconnect(expectedGeneration, detail, duplicate)
10047
+ if duplicate == nil then
10048
+ duplicate = false
10049
+ end
10050
+ if not active or generation ~= expectedGeneration then
10051
+ return nil
10052
+ end
10053
+ generation += 1
10054
+ closeCurrentStream()
10055
+ reconnectAttempt += 1
10056
+ local delay = if duplicate then 1 else reconnectDelay(reconnectAttempt)
10057
+ reportTransport({
10058
+ state = if duplicate then "waiting-duplicate" else "retrying",
10059
+ attempt = reconnectAttempt,
10060
+ retryDelay = delay,
10061
+ detail = detail,
10062
+ })
10063
+ connectAfter(delay, generation)
10064
+ end
10065
+ local function watchForSilence(expectedGeneration, expectedClient)
10066
+ local elapsed = tick() - lastValidEventAt
10067
+ local delay = math.max(STREAM_SILENCE_TIMEOUT_SECONDS - elapsed, 0.1)
10068
+ task.delay(delay, function()
10069
+ if not active or generation ~= expectedGeneration or streamClient ~= expectedClient then
10070
+ return nil
10071
+ end
10072
+ local silentFor = tick() - lastValidEventAt
10073
+ if silentFor >= STREAM_SILENCE_TIMEOUT_SECONDS then
10074
+ scheduleReconnect(expectedGeneration, `Event stream silent for {math.floor(silentFor)} seconds`)
10075
+ return nil
10076
+ end
10077
+ watchForSilence(expectedGeneration, expectedClient)
10078
+ end)
10079
+ end
10080
+ local function parseReadyResponse(body)
10081
+ local decodeOk, decoded = pcall(function()
10082
+ return HttpService:JSONDecode(body)
10083
+ end)
10084
+ if not decodeOk or not (type(decoded) == "table") then
10085
+ return nil
10086
+ end
10087
+ local value = decoded
10088
+ local _condition = value.success ~= true
10089
+ if not _condition then
10090
+ local _assignedRole = value.assignedRole
10091
+ _condition = not (type(_assignedRole) == "string")
10092
+ if not _condition then
10093
+ _condition = value.assignedRole == ""
10094
+ if not _condition then
10095
+ local _instanceId = value.instanceId
10096
+ _condition = not (type(_instanceId) == "string")
10097
+ if not _condition then
10098
+ _condition = value.instanceId == ""
10099
+ if not _condition then
10100
+ local _serverVersion = value.serverVersion
10101
+ _condition = not (type(_serverVersion) == "string")
10102
+ if not _condition then
10103
+ _condition = value.serverVersion == ""
10104
+ end
10105
+ end
10106
+ end
10107
+ end
10108
+ end
10109
+ end
10110
+ if _condition then
10111
+ return nil
10112
+ end
10113
+ return {
10114
+ success = true,
10115
+ assignedRole = value.assignedRole,
10116
+ instanceId = value.instanceId,
10117
+ serverVersion = value.serverVersion,
10118
+ }
10119
+ end
10120
+ local refresh
10121
+ function connect(expectedGeneration)
10122
+ local currentOptions = options
10123
+ if not active or generation ~= expectedGeneration or currentOptions == nil then
10124
+ return nil
10125
+ end
10126
+ reportTransport({
10127
+ state = "connecting",
10128
+ attempt = reconnectAttempt,
10129
+ retryDelay = 0,
10130
+ })
10131
+ task.spawn(function()
10132
+ local instanceId = PluginSession.getInstanceId()
10133
+ local readyUrl = `{currentOptions.serverUrl}/ready`
10134
+ local physicalRole = PluginSession.getRole()
10135
+ local readyPayload = PluginSession.createReadyPayload(PluginSession.id, physicalRole)
10136
+ readyPayload.pluginReady = true
10137
+ if not active or generation ~= expectedGeneration or options ~= currentOptions then
10138
+ return nil
10139
+ end
10140
+ local readyOk, readyResult = pcall(function()
10141
+ return HttpService:RequestAsync({
10142
+ Url = readyUrl,
10143
+ Method = "POST",
10144
+ Headers = {
10145
+ ["Content-Type"] = "application/json",
10146
+ },
10147
+ Body = HttpService:JSONEncode(readyPayload),
10148
+ })
10149
+ end)
10150
+ if not active or generation ~= expectedGeneration or options ~= currentOptions then
10151
+ return nil
10152
+ end
10153
+ local readyLogKey = `{currentOptions.serverUrl}|{instanceId}|{physicalRole}`
10154
+ if not readyOk then
10155
+ local detail = HttpDiagnostics.formatRequestFailure(readyUrl, false, readyResult)
10156
+ if not (readyFailureLogKeys[readyLogKey] ~= nil) then
10157
+ readyFailureLogKeys[readyLogKey] = true
10158
+ warn(`[robloxstudio-mcp] /ready failed for {instanceId}/{physicalRole}: {detail}`)
10159
+ end
10160
+ scheduleReconnect(expectedGeneration, detail)
10161
+ return nil
10162
+ end
10163
+ if not readyResult.Success then
10164
+ local detail = HttpDiagnostics.formatRequestFailure(readyUrl, true, readyResult)
10165
+ if not (readyFailureLogKeys[readyLogKey] ~= nil) then
10166
+ readyFailureLogKeys[readyLogKey] = true
10167
+ warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{physicalRole}: {detail}`)
10168
+ end
10169
+ scheduleReconnect(expectedGeneration, detail, readyResult.StatusCode == 409)
10170
+ return nil
10171
+ end
10172
+ local readyData = parseReadyResponse(readyResult.Body)
10173
+ if readyData == nil then
10174
+ scheduleReconnect(expectedGeneration, "Invalid /ready response: expected the bundled server protocol")
10175
+ return nil
10176
+ end
10177
+ if readyFailureLogKeys[readyLogKey] ~= nil then
10178
+ readyFailureLogKeys[readyLogKey] = nil
10179
+ print(`[robloxstudio-mcp] /ready connected for {instanceId}/{readyData.assignedRole} via {currentOptions.serverUrl}`)
10180
+ end
10181
+ invokeCallback("event stream ready", function()
10182
+ return currentOptions.onReady(readyData)
10183
+ end)
10184
+ local createOk, createdClient = pcall(function()
10185
+ return HttpService:CreateWebStreamClient(Enum.WebStreamClientType.SSE, {
10186
+ Url = `{currentOptions.serverUrl}/events?pluginSessionId={PluginSession.id}`,
10187
+ Method = "GET",
10188
+ Headers = {
10189
+ Accept = "text/event-stream",
10190
+ },
10191
+ })
10192
+ end)
10193
+ if not createOk then
10194
+ scheduleReconnect(expectedGeneration, `Failed to create event stream: {tostring(createdClient)}`)
10195
+ return nil
10196
+ end
10197
+ if not active or generation ~= expectedGeneration or options ~= currentOptions then
10198
+ pcall(function()
10199
+ return createdClient:Close()
10200
+ end)
10201
+ return nil
10202
+ end
10203
+ streamClient = createdClient
10204
+ streamConnections = { createdClient.Opened:Connect(function(statusCode, _headers)
10205
+ if not active or generation ~= expectedGeneration or streamClient ~= createdClient then
10206
+ return nil
10207
+ end
10208
+ lastValidEventAt = tick()
10209
+ if statusCode < 200 or statusCode >= 300 then
10210
+ scheduleReconnect(expectedGeneration, `Event stream opened with HTTP {statusCode}`)
10211
+ return nil
10212
+ end
10213
+ reconnectAttempt = 0
10214
+ reportTransport({
10215
+ state = "open",
10216
+ attempt = 0,
10217
+ retryDelay = 0,
10218
+ })
10219
+ resumePendingResponses()
10220
+ end), createdClient.MessageReceived:Connect(function(message)
10221
+ if not active or generation ~= expectedGeneration or streamClient ~= createdClient then
10222
+ return nil
10223
+ end
10224
+ local event = decodeMessage(message)
10225
+ if event == nil then
10226
+ return nil
10227
+ end
10228
+ lastValidEventAt = tick()
10229
+ if event.kind == "heartbeat" then
10230
+ invokeCallback("event stream heartbeat", function()
10231
+ return currentOptions.onHeartbeat(event.timestamp)
10232
+ end)
10233
+ return nil
10234
+ end
10235
+ if event.kind == "request" then
10236
+ dispatchRequest(event)
10237
+ return nil
10238
+ end
10239
+ invokeCallback("event stream status", function()
10240
+ return currentOptions.onStatus(event)
10241
+ end)
10242
+ if not event.knownInstance then
10243
+ refresh()
10244
+ end
10245
+ end), createdClient.Error:Connect(function(statusCode, message)
10246
+ if not active or generation ~= expectedGeneration or streamClient ~= createdClient then
10247
+ return nil
10248
+ end
10249
+ local detail = if statusCode == 404 then `Event stream session is not registered: {message}` else `Event stream error {statusCode}: {message}`
10250
+ scheduleReconnect(expectedGeneration, detail)
10251
+ end), createdClient.Closed:Connect(function()
10252
+ if not active or generation ~= expectedGeneration or streamClient ~= createdClient then
10253
+ return nil
10254
+ end
10255
+ scheduleReconnect(expectedGeneration, "Event stream closed")
10256
+ end) }
10257
+ lastValidEventAt = tick()
10258
+ watchForSilence(expectedGeneration, createdClient)
10259
+ end)
10260
+ end
10261
+ local stop
10262
+ local function start(newOptions)
10263
+ if active then
10264
+ stop()
10265
+ end
10266
+ options = newOptions
10267
+ active = true
10268
+ reconnectAttempt = 0
10269
+ generation += 1
10270
+ connect(generation)
10271
+ end
10272
+ function refresh()
10273
+ if not active or options == nil then
10274
+ return nil
10275
+ end
10276
+ generation += 1
10277
+ closeCurrentStream()
10278
+ reconnectAttempt = 0
10279
+ connect(generation)
10280
+ end
10281
+ function stop()
10282
+ if not active then
10283
+ return nil
10284
+ end
10285
+ local currentOptions = options
10286
+ active = false
10287
+ generation += 1
10288
+ closeCurrentStream()
10289
+ table.clear(readyFailureLogKeys)
10290
+ options = nil
10291
+ reconnectAttempt = 0
10292
+ if currentOptions ~= nil then
10293
+ pcall(function()
10294
+ return HttpService:RequestAsync({
10295
+ Url = `{currentOptions.serverUrl}/disconnect`,
10296
+ Method = "POST",
10297
+ Headers = {
10298
+ ["Content-Type"] = "application/json",
10299
+ },
10300
+ Body = HttpService:JSONEncode({
10301
+ pluginSessionId = PluginSession.id,
10302
+ timestamp = tick(),
10303
+ }),
10304
+ })
10305
+ end)
10306
+ end
10307
+ end
10308
+ return {
10309
+ start = start,
10310
+ refresh = refresh,
10311
+ stop = stop,
10312
+ }
10313
+ ]]></string>
10314
+ </Properties>
10315
+ </Item>
10316
+ <Item class="ModuleScript" referent="34">
9902
10317
  <Properties>
9903
10318
  <string name="Name">UI</string>
9904
10319
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -10450,7 +10865,7 @@ return {
10450
10865
  ]]></string>
10451
10866
  </Properties>
10452
10867
  </Item>
10453
- <Item class="ModuleScript" referent="33">
10868
+ <Item class="ModuleScript" referent="35">
10454
10869
  <Properties>
10455
10870
  <string name="Name">Utils</string>
10456
10871
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -11295,11 +11710,11 @@ return {
11295
11710
  </Properties>
11296
11711
  </Item>
11297
11712
  </Item>
11298
- <Item class="Folder" referent="38">
11713
+ <Item class="Folder" referent="40">
11299
11714
  <Properties>
11300
11715
  <string name="Name">include</string>
11301
11716
  </Properties>
11302
- <Item class="ModuleScript" referent="34">
11717
+ <Item class="ModuleScript" referent="36">
11303
11718
  <Properties>
11304
11719
  <string name="Name">LibMP</string>
11305
11720
  <string name="Source"><![CDATA[-- =============================================================================
@@ -167683,7 +168098,7 @@ return LibMP
167683
168098
  ]]></string>
167684
168099
  </Properties>
167685
168100
  </Item>
167686
- <Item class="ModuleScript" referent="35">
168101
+ <Item class="ModuleScript" referent="37">
167687
168102
  <Properties>
167688
168103
  <string name="Name">Promise</string>
167689
168104
  <string name="Source"><![CDATA[--[[
@@ -169757,7 +170172,7 @@ return Promise
169757
170172
  ]]></string>
169758
170173
  </Properties>
169759
170174
  </Item>
169760
- <Item class="ModuleScript" referent="36">
170175
+ <Item class="ModuleScript" referent="38">
169761
170176
  <Properties>
169762
170177
  <string name="Name">RuntimeLib</string>
169763
170178
  <string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
@@ -170024,15 +170439,15 @@ return TS
170024
170439
  </Properties>
170025
170440
  </Item>
170026
170441
  </Item>
170027
- <Item class="Folder" referent="39">
170442
+ <Item class="Folder" referent="41">
170028
170443
  <Properties>
170029
170444
  <string name="Name">node_modules</string>
170030
170445
  </Properties>
170031
- <Item class="Folder" referent="40">
170446
+ <Item class="Folder" referent="42">
170032
170447
  <Properties>
170033
170448
  <string name="Name">@rbxts</string>
170034
170449
  </Properties>
170035
- <Item class="ModuleScript" referent="37">
170450
+ <Item class="ModuleScript" referent="39">
170036
170451
  <Properties>
170037
170452
  <string name="Name">services</string>
170038
170453
  <string name="Source"><![CDATA[return setmetatable({}, {