@chrrxs/robloxstudio-mcp 3.0.2 → 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,122 +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
608
- if _condition then
609
- local _player = player
610
- _condition = proxyByPlayer[_player] ~= nil
611
- end
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
612
591
  if not _condition then
613
- break
592
+ _condition = entry.generation ~= expectedGeneration or entry.registered or entry.registering or entry.player.Parent == nil or not RunService:IsRunning()
614
593
  end
615
- if not RunService:IsRunning() then
616
- unregisterProxy(player)
617
- break
594
+ if _condition then
595
+ return nil
618
596
  end
619
- local nextPollDelay = 0.5
620
- local ok, res = pcall(function()
621
- local requestOptions = {
622
- Url = `{mcpUrl}/poll?pluginSessionId={proxyId}&pollMode=long`,
623
- Method = "GET",
624
- Headers = {
625
- ["Content-Type"] = "application/json",
626
- },
627
- Timeout = State.POLL_REQUEST_TIMEOUT_SECONDS,
628
- }
629
- return HttpService:RequestAsync(requestOptions)
630
- end)
631
- -- RequestAsync may yield for the full long-poll window. Never apply a
632
- -- stale response to a player whose proxy was removed or replaced.
633
- local _player = player
634
- local currentProxy = proxyByPlayer[_player]
635
- local _condition_1 = player.Parent == nil
636
- if not _condition_1 then
637
- local _result = currentProxy
638
- if _result ~= nil then
639
- _result = _result.pluginSessionId
640
- end
641
- _condition_1 = _result ~= proxyId
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
615
+ if not _condition then
616
+ _condition = entry.player.Parent == nil or not RunService:IsRunning()
642
617
  end
643
- if _condition_1 then
644
- 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)
645
633
  end
646
- if ok and res and (res.Success or res.StatusCode == 503) then
647
- local okJson, body = pcall(function()
648
- return HttpService:JSONDecode(res.Body)
649
- end)
650
- if okJson and body then
651
- -- Server lost our proxy registration (process restart, etc.) -
652
- -- re-register so the next poll cycle starts routing again.
653
- if body.knownInstance == false then
654
- reRegisterProxy(proxyId, "client")
655
- end
656
- if body.request and body.requestId ~= nil then
657
- local request = body.request
658
- local response
659
- local _endpoint = request.endpoint
660
- if CLIENT_BROKER_ALLOWED_ENDPOINTS[_endpoint] ~= nil then
661
- -- Forward as a discriminated envelope so the client-side
662
- -- OnClientInvoke knows which endpoint it's serving.
663
- local envelope = {
664
- endpoint = request.endpoint,
665
- data = request.data,
666
- }
667
- local okInvoke, invokeRes = pcall(function()
668
- return rf:InvokeClient(player, envelope)
669
- end)
670
- if okInvoke then
671
- response = if invokeRes ~= nil then invokeRes else {
672
- success = false,
673
- error = "nil response",
674
- }
675
- else
676
- response = {
677
- success = false,
678
- error = `InvokeClient failed: {tostring(invokeRes)}`,
679
- }
680
- end
681
- else
682
- local allowed = {}
683
- for ep in CLIENT_BROKER_ALLOWED_ENDPOINTS do
684
- table.insert(allowed, ep)
685
- end
686
- response = {
687
- error = `Client-proxy does not forward {tostring(request.endpoint)}. ` .. `Allowed: {table.concat(allowed, ", ")}.`,
688
- }
689
- end
690
- postJson("/response", {
691
- requestId = body.requestId,
692
- response = response,
693
- })
694
- end
695
- if res.Success and body.pollMode == "long" and body.knownInstance ~= false and body.request == nil then
696
- nextPollDelay = 0
697
- end
698
- 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)
699
641
  end
700
- if nextPollDelay > 0 then
701
- task.wait(nextPollDelay)
702
- else
703
- -- Yield one scheduler turn before replacing a completed long poll.
704
- task.wait()
642
+ return nil
643
+ end
644
+ if entry.generation ~= expectedGeneration then
645
+ if not entry.registering then
646
+ task.spawn(registerProxyEntry, entry)
705
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}`)
706
668
  end
707
669
  end
708
670
  local function registerProxy(player, rf)
@@ -710,48 +672,89 @@ local function registerProxy(player, rf)
710
672
  if proxyByPlayer[_player] ~= nil then
711
673
  return nil
712
674
  end
713
- local proxyId = HttpService:GenerateGUID(false)
714
- local ok, res = postJson("/ready", {
715
- pluginSessionId = proxyId,
716
- instanceId = computeInstanceId(),
675
+ local entry = {
676
+ player = player,
677
+ remote = rf,
678
+ pluginSessionId = HttpService:GenerateGUID(false),
717
679
  role = "client",
718
- placeId = game.PlaceId,
719
- placeName = resolvePlaceName(),
720
- dataModelName = game.Name,
721
- isRunning = RunService:IsRunning(),
722
- pluginVersion = State.CURRENT_VERSION,
723
- pluginVariant = State.PLUGIN_VARIANT,
724
- })
725
- if not ok or not res or not res.Success then
726
- local _player_1 = player
727
- proxyRegisterFailuresByPlayer[_player_1] = true
728
- warn(`[robloxstudio-mcp] proxy register failed for {player.Name}: {formatPostJsonFailure("/ready", ok, res)}`)
729
- 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
730
707
  end
731
- local body = HttpService:JSONDecode(res.Body)
732
- local _condition = body.assignedRole
733
- if _condition == nil then
734
- _condition = "client"
708
+ if _condition then
709
+ return {
710
+ error = `Client proxy {target} ({logicalSessionId}) is not registered.`,
711
+ }
735
712
  end
736
- local assigned = _condition
737
- local _player_1 = player
738
- local _arg1 = {
739
- pluginSessionId = proxyId,
740
- 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,
741
743
  }
742
- proxyByPlayer[_player_1] = _arg1
743
- local _player_2 = player
744
- if proxyRegisterFailuresByPlayer[_player_2] ~= nil then
745
- local _player_3 = player
746
- proxyRegisterFailuresByPlayer[_player_3] = nil
747
- 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
+ }
748
752
  end
749
- task.spawn(pollProxy, proxyId, player, rf)
753
+ return if invokeResult ~= nil then invokeResult else {
754
+ success = false,
755
+ error = "nil response",
756
+ }
750
757
  end
751
- -- (Removed: startEditProxyLoop. The play-server DM no longer registers an
752
- -- "edit-proxy" peer with the MCP server. stop_playtest now uses a cross-DM
753
- -- plugin:SetSetting request consumed by StopPlayMonitor in the play-server DM,
754
- -- which doesn't depend on MCP server state or peer registration at all.)
755
758
  local function setupServerBroker()
756
759
  if serverBrokerStarted then
757
760
  return nil
@@ -782,11 +785,11 @@ local function setupServerBroker()
782
785
  end)
783
786
  end
784
787
  return {
785
- MCP_URL = DEFAULT_MCP_URL,
786
788
  DEFAULT_MCP_URL = DEFAULT_MCP_URL,
787
- getServerUrl = getServerUrl,
788
789
  setServerUrl = setServerUrl,
789
790
  disconnectAllProxies = disconnectAllProxies,
791
+ refreshAllLogicalRegistrations = refreshAllLogicalRegistrations,
792
+ dispatchClientRequest = dispatchClientRequest,
790
793
  forkRole = forkRole,
791
794
  setupClientBroker = setupClientBroker,
792
795
  setupServerBroker = setupServerBroker,
@@ -802,11 +805,10 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
802
805
  local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
803
806
  local HttpService = _services.HttpService
804
807
  local RunService = _services.RunService
805
- local ServerStorage = _services.ServerStorage
806
808
  local State = TS.import(script, script.Parent, "State")
807
809
  local Utils = TS.import(script, script.Parent, "Utils")
808
810
  local UI = TS.import(script, script.Parent, "UI")
809
- local cleanupLegacyEditBridges = TS.import(script, script.Parent, "EvalBridges").cleanupLegacyEditBridges
811
+ local cleanupEditBridgeArtifacts = TS.import(script, script.Parent, "EvalBridges").cleanupEditBridgeArtifacts
810
812
  local QueryHandlers = TS.import(script, script.Parent, "handlers", "QueryHandlers")
811
813
  local PropertyHandlers = TS.import(script, script.Parent, "handlers", "PropertyHandlers")
812
814
  local ScriptHandlers = TS.import(script, script.Parent, "handlers", "ScriptHandlers")
@@ -826,79 +828,11 @@ local GenerateModelHandlers = TS.import(script, script.Parent, "handlers", "Gene
826
828
  local EvalRuntimeHandlers = TS.import(script, script.Parent, "handlers", "EvalRuntimeHandlers")
827
829
  local ClientBroker = TS.import(script, script.Parent, "ClientBroker")
828
830
  local ServerUrlSettings = TS.import(script, script.Parent, "ServerUrlSettings")
829
- local HttpDiagnostics = TS.import(script, script.Parent, "HttpDiagnostics")
830
- -- Per-plugin-load random GUID. Used as the /poll URL param so the server
831
- -- can tell our polls apart from any other plugin's polls. Not user-facing —
832
- -- MCP tools and the LLM operate on instanceId (the place identifier).
833
- local pluginSessionId = HttpService:GenerateGUID(false)
834
- -- Place-level identifier shared by every plugin running in DataModels of
835
- -- the same place file (edit DM + playtest server DM + playtest clients).
836
- -- Format: "place:<PlaceId>" when published, "anon:<UUID>" for unpublished
837
- -- places where the UUID lives on ServerStorage's __MCPPlaceId attribute
838
- -- and travels with the .rbxl.
839
- local MCP_PLACE_ID_ATTRIBUTE = "__MCPPlaceId"
840
- local function computeInstanceId()
841
- if game.PlaceId ~= 0 then
842
- return `place:{tostring(game.PlaceId)}`
843
- end
844
- local existing = ServerStorage:GetAttribute(MCP_PLACE_ID_ATTRIBUTE)
845
- if type(existing) == "string" and existing ~= "" then
846
- return `anon:{existing}`
847
- end
848
- local fresh = HttpService:GenerateGUID(false)
849
- pcall(function()
850
- return ServerStorage:SetAttribute(MCP_PLACE_ID_ATTRIBUTE, fresh)
851
- end)
852
- return `anon:{fresh}`
853
- end
831
+ local PluginSession = TS.import(script, script.Parent, "PluginSession")
832
+ local StudioEventStream = TS.import(script, script.Parent, "StudioEventStream")
854
833
  local assignedRole
855
- local hasVersionMismatch = false
856
- local lastVersionMismatchWarningKey
857
834
  local lastReadyInstanceId
858
- local readyFailureLogKeys = {}
859
- local retryingDuplicateReady = false
860
- local pollEpoch = 0
861
- -- Cache the published place name from MarketplaceService:GetProductInfo so
862
- -- /ready can carry a friendly identifier (e.g. "Natural Disasters") distinct
863
- -- from game.Name (the DataModel name, often "Place1" in edit). We only fetch
864
- -- once per plugin load; the published name doesn't change mid-session.
865
- local cachedPlaceName
866
- local cachedPlaceNamePlaceId
867
- local function resolvePlaceName()
868
- if cachedPlaceName ~= nil and cachedPlaceNamePlaceId == game.PlaceId then
869
- return cachedPlaceName
870
- end
871
- cachedPlaceName = nil
872
- cachedPlaceNamePlaceId = game.PlaceId
873
- if game.PlaceId == 0 then
874
- cachedPlaceName = game.Name
875
- return cachedPlaceName
876
- end
877
- local MarketplaceService = game:GetService("MarketplaceService")
878
- local ok, info = pcall(function()
879
- return MarketplaceService:GetProductInfo(game.PlaceId)
880
- end)
881
- if ok and info ~= nil then
882
- local name = info.Name
883
- if type(name) == "string" and name ~= "" then
884
- cachedPlaceName = name
885
- return cachedPlaceName
886
- end
887
- end
888
- -- Don't cache failures — could be transient (offline, rate-limited).
889
- -- Next /ready will retry. Return game.Name as fallback.
890
- return game.Name
891
- end
892
- local function detectRole()
893
- if not RunService:IsRunning() then
894
- return "edit"
895
- end
896
- if RunService:IsServer() then
897
- return "server"
898
- end
899
- return "client"
900
- end
901
- local initialRole = detectRole()
835
+ local initialRole = PluginSession.getRole()
902
836
  local routeMap = {
903
837
  ["/api/file-tree"] = QueryHandlers.getFileTree,
904
838
  ["/api/search-files"] = QueryHandlers.searchFiles,
@@ -958,35 +892,6 @@ local function processRequest(request)
958
892
  }
959
893
  end
960
894
  end
961
- local function sendResponse(conn, requestId, responseData)
962
- local responseUrl = `{conn.serverUrl}/response`
963
- local encodeOk, encoded = pcall(function()
964
- return HttpService:JSONEncode({
965
- requestId = requestId,
966
- response = responseData,
967
- })
968
- end)
969
- local body = if encodeOk then encoded else HttpService:JSONEncode({
970
- requestId = requestId,
971
- error = `Plugin response serialization failed: {tostring(encoded)}`,
972
- })
973
- if not encodeOk then
974
- warn(`[robloxstudio-mcp] Failed to serialize response {requestId}: {tostring(encoded)}`)
975
- end
976
- local requestOk, requestResult = pcall(function()
977
- return HttpService:RequestAsync({
978
- Url = responseUrl,
979
- Method = "POST",
980
- Headers = {
981
- ["Content-Type"] = "application/json",
982
- },
983
- Body = body,
984
- })
985
- end)
986
- if not requestOk or not requestResult.Success then
987
- warn(`[robloxstudio-mcp] Failed to deliver response {requestId}: {HttpDiagnostics.formatRequestFailure(responseUrl, requestOk, requestResult)}`)
988
- end
989
- end
990
895
  local function getConnectionStatus()
991
896
  local conn = State.getActiveConnection()
992
897
  if not conn.isActive then
@@ -1000,341 +905,141 @@ local function getConnectionStatus()
1000
905
  end
1001
906
  return "connecting"
1002
907
  end
1003
- -- Throttle for re-issuing /ready after the server reports knownInstance=false.
1004
- -- Without this, every poll during the brief window where the server has just
1005
- -- restarted but hasn't seen our re-ready yet would fire a duplicate /ready.
1006
- local lastReadyPostAt = 0
1007
- -- game.Name and game.PlaceId can both settle after plugin load. PlaceId also
1008
- -- changes when an unpublished file is published while MCP is already active.
1009
- -- Re-fire /ready so the bridge can migrate anon:<uuid> to place:<PlaceId>.
1010
- local nameChangeConn
1011
- local placeIdChangeConn
1012
- local sendReady
1013
- local function ensureIdentityWatcher(conn)
1014
- if not nameChangeConn then
1015
- local okSig, signal = pcall(function()
1016
- return game:GetPropertyChangedSignal("Name")
1017
- end)
1018
- if okSig and signal then
1019
- nameChangeConn = signal:Connect(function()
1020
- -- sendReady has its own 2s throttle, so rapid burst changes coalesce.
1021
- sendReady(conn)
1022
- end)
1023
- 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)
1024
911
  end
1025
- if not placeIdChangeConn then
1026
- local okSig, signal = pcall(function()
1027
- return game:GetPropertyChangedSignal("PlaceId")
1028
- end)
1029
- if okSig and signal then
1030
- placeIdChangeConn = signal:Connect(function()
1031
- cachedPlaceName = nil
1032
- cachedPlaceNamePlaceId = nil
1033
- sendReady(conn)
1034
- end)
1035
- end
912
+ local _condition = assignedRole
913
+ if _condition == nil then
914
+ _condition = PluginSession.getRole()
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
+ }
1036
921
  end
922
+ return processRequest({
923
+ endpoint = request.endpoint,
924
+ data = request.data,
925
+ })
1037
926
  end
1038
- function sendReady(conn)
1039
- local now = tick()
1040
- -- Normal identity refreshes stay conservatively throttled. Once a stale
1041
- -- predecessor causes 409, retry once per second so takeover follows the
1042
- -- server's short inactivity lease without another two seconds of jitter.
1043
- local readyInterval = if retryingDuplicateReady then 1 else 2
1044
- if now - lastReadyPostAt < readyInterval then
927
+ local function handleReady(response)
928
+ local conn = State.getActiveConnection()
929
+ if not conn.isActive then
1045
930
  return nil
1046
931
  end
1047
- lastReadyPostAt = now
1048
- local instanceId = computeInstanceId()
1049
- task.spawn(function()
1050
- local readyOk, readyResult = pcall(function()
1051
- return HttpService:RequestAsync({
1052
- Url = `{conn.serverUrl}/ready`,
1053
- Method = "POST",
1054
- Headers = {
1055
- ["Content-Type"] = "application/json",
1056
- },
1057
- Body = HttpService:JSONEncode({
1058
- pluginSessionId = pluginSessionId,
1059
- instanceId = instanceId,
1060
- role = detectRole(),
1061
- placeId = game.PlaceId,
1062
- placeName = resolvePlaceName(),
1063
- dataModelName = game.Name,
1064
- isRunning = RunService:IsRunning(),
1065
- pluginVersion = State.CURRENT_VERSION,
1066
- pluginVariant = State.PLUGIN_VARIANT,
1067
- pluginReady = true,
1068
- timestamp = tick(),
1069
- }),
1070
- })
1071
- end)
1072
- local readyUrl = `{conn.serverUrl}/ready`
1073
- local readyRole = detectRole()
1074
- local readyLogKey = `{conn.serverUrl}|{instanceId}|{readyRole}`
1075
- if not readyOk then
1076
- local shouldLog = not (readyFailureLogKeys[readyLogKey] ~= nil)
1077
- readyFailureLogKeys[readyLogKey] = true
1078
- if shouldLog then
1079
- warn(`[robloxstudio-mcp] /ready failed for {instanceId}/{readyRole}: {HttpDiagnostics.formatRequestFailure(readyUrl, readyOk, readyResult)}`)
1080
- end
1081
- return nil
1082
- end
1083
- if not readyResult.Success then
1084
- local reason = HttpDiagnostics.formatRequestFailure(readyUrl, true, readyResult)
1085
- local shouldLog = not (readyFailureLogKeys[readyLogKey] ~= nil)
1086
- readyFailureLogKeys[readyLogKey] = true
1087
- -- A predecessor can remain registered briefly when Studio exits before
1088
- -- its asynchronous /disconnect completes. Keep polling and retrying
1089
- -- /ready: the server will take us over once the predecessor has stopped
1090
- -- polling, while a genuinely active duplicate continues to hold routing.
1091
- if readyResult.StatusCode == 409 then
1092
- retryingDuplicateReady = true
1093
- local ui = UI.getElements()
1094
- ui.statusLabel.Text = "Waiting for previous instance"
1095
- ui.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
1096
- ui.detailStatusLabel.Text = reason
1097
- ui.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
1098
- if shouldLog then
1099
- warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{readyRole}: {reason}`)
1100
- end
1101
- return nil
1102
- end
1103
- if shouldLog then
1104
- warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{readyRole}: {reason}`)
1105
- end
1106
- return nil
1107
- end
1108
- retryingDuplicateReady = false
1109
- local parseOk, readyData = pcall(function()
1110
- return HttpService:JSONDecode(readyResult.Body)
1111
- end)
1112
- local _value = parseOk and readyData.assignedRole
1113
- if _value ~= "" and _value then
1114
- assignedRole = readyData.assignedRole
1115
- end
1116
- local _condition = parseOk
1117
- if _condition then
1118
- local _instanceId = readyData.instanceId
1119
- _condition = type(_instanceId) == "string"
1120
- if _condition then
1121
- _condition = readyData.instanceId ~= ""
1122
- end
1123
- end
1124
- lastReadyInstanceId = if _condition then readyData.instanceId else instanceId
1125
- ServerUrlSettings.rememberServerUrl(conn.serverUrl)
1126
- local _condition_1 = assignedRole
1127
- if _condition_1 == nil then
1128
- _condition_1 = detectRole()
1129
- end
1130
- local connectedRole = _condition_1
1131
- if readyFailureLogKeys[readyLogKey] ~= nil then
1132
- readyFailureLogKeys[readyLogKey] = nil
1133
- print(`[robloxstudio-mcp] /ready connected for {instanceId}/{connectedRole} via {conn.serverUrl}`)
1134
- end
1135
- end)
932
+ assignedRole = response.assignedRole
933
+ lastReadyInstanceId = response.instanceId
934
+ ServerUrlSettings.rememberServerUrl(conn.serverUrl)
935
+ ClientBroker.refreshAllLogicalRegistrations()
1136
936
  end
1137
- local function pollForRequests()
937
+ local function handleStatus(status)
1138
938
  local conn = State.getActiveConnection()
1139
939
  if not conn.isActive then
1140
940
  return nil
1141
941
  end
1142
- if conn.isPolling then
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()
950
+ end
951
+ UI.updateUIState()
952
+ UI.updateToolbarIcon()
953
+ end
954
+ local function handleHeartbeat(_timestamp)
955
+ if not State.getActiveConnection().isActive then
1143
956
  return nil
1144
957
  end
1145
- local epoch = pollEpoch
1146
- conn.isPolling = true
1147
- local success, result = pcall(function()
1148
- local requestOptions = {
1149
- Url = `{conn.serverUrl}/poll?pluginSessionId={pluginSessionId}&pollMode=long`,
1150
- Method = "GET",
1151
- Headers = {
1152
- ["Content-Type"] = "application/json",
1153
- },
1154
- Timeout = State.POLL_REQUEST_TIMEOUT_SECONDS,
1155
- }
1156
- return HttpService:RequestAsync(requestOptions)
1157
- end)
1158
- -- A held request can finish after deactivate/reactivate. Only the current
1159
- -- lifecycle epoch may mutate connection state or clear a newer poll's lock.
1160
- if epoch ~= pollEpoch or not conn.isActive then
958
+ UI.updateUIState()
959
+ end
960
+ local function handleTransportUpdate(update)
961
+ local conn = State.getActiveConnection()
962
+ if not conn.isActive then
1161
963
  return nil
1162
964
  end
1163
- conn.isPolling = false
1164
- local ui = UI.getElements()
1165
- UI.updateToolbarIcon()
1166
- if success and (result.Success or result.StatusCode == 503) then
965
+ if update.state == "open" then
966
+ conn.lastHttpOk = true
967
+ conn.lastMcpOk = false
1167
968
  conn.consecutiveFailures = 0
1168
969
  conn.currentRetryDelay = 0.5
1169
- conn.lastSuccessfulConnection = tick()
1170
- local data = HttpService:JSONDecode(result.Body)
1171
- conn.lastPoll = tick()
1172
- if result.Success and data.pollMode == "long" and data.knownInstance ~= false and data.request == nil then
1173
- -- The server held this request until work or timeout, so immediately
1174
- -- replace it instead of reintroducing the legacy 500 ms idle gap.
1175
- conn.lastPoll = 0
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
1176
977
  end
1177
- local mcpConnected = data.mcpConnected == true
1178
- conn.lastHttpOk = true
1179
- conn.lastMcpOk = mcpConnected
1180
- local _condition = data.serverVersion
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
1181
987
  if _condition == nil then
1182
- _condition = "unknown"
1183
- end
1184
- local serverVersion = _condition
1185
- if data.versionMismatch == true then
1186
- hasVersionMismatch = true
1187
- local warningKey = `{State.CURRENT_VERSION}:{serverVersion}`
1188
- if lastVersionMismatchWarningKey ~= warningKey then
1189
- lastVersionMismatchWarningKey = warningKey
1190
- 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.`)
1191
- end
1192
- UI.showBanner("version-mismatch", `Plugin v{State.CURRENT_VERSION} / MCP v{serverVersion} mismatch`)
1193
- elseif hasVersionMismatch then
1194
- hasVersionMismatch = false
1195
- UI.hideBanner("version-mismatch")
1196
- end
1197
- -- Server tells us when its in-memory instances map doesn't have us
1198
- -- (e.g. after an MCP process restart). Re-issue /ready immediately so
1199
- -- target=server/client-N start routing again. The throttle inside
1200
- -- sendReady() prevents duplicate registrations while the server
1201
- -- catches up.
1202
- if data.knownInstance == false then
1203
- sendReady(conn)
1204
- end
1205
- local el = ui
1206
- el.step1Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
1207
- el.step1Label.Text = "HTTP server (OK)"
1208
- local _condition_1 = mcpConnected
1209
- if _condition_1 then
1210
- local _value = (string.find(el.statusLabel.Text, "Connected"))
1211
- _condition_1 = not (_value ~= 0 and _value == _value and _value)
988
+ _condition = "The previous plugin instance is still active."
1212
989
  end
1213
- if _condition_1 then
1214
- el.statusLabel.Text = "Connected"
1215
- el.statusLabel.TextColor3 = Color3.fromRGB(34, 197, 94)
1216
- el.statusIndicator.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
1217
- el.statusPulse.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
1218
- el.statusText.Text = "ONLINE"
1219
- el.detailStatusLabel.Text = "HTTP: OK MCP: OK"
1220
- el.detailStatusLabel.TextColor3 = Color3.fromRGB(34, 197, 94)
1221
- el.step2Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
1222
- el.step2Label.Text = "MCP bridge (OK)"
1223
- el.step3Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
1224
- el.step3Label.Text = "Commands (OK)"
1225
- conn.mcpWaitStartTime = nil
1226
- el.troubleshootLabel.Visible = false
1227
- UI.stopPulseAnimation()
1228
- elseif not mcpConnected then
1229
- el.statusLabel.Text = "Waiting for MCP server"
1230
- el.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
1231
- el.statusIndicator.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
1232
- el.statusPulse.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
1233
- el.statusText.Text = "WAITING"
1234
- el.detailStatusLabel.Text = "HTTP: OK MCP: ..."
1235
- el.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
1236
- el.step2Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
1237
- el.step2Label.Text = "MCP bridge (waiting...)"
1238
- el.step3Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
1239
- el.step3Label.Text = "Commands (waiting...)"
1240
- if conn.mcpWaitStartTime == nil then
1241
- conn.mcpWaitStartTime = tick()
1242
- end
1243
- local _exp = tick()
1244
- local _condition_2 = conn.mcpWaitStartTime
1245
- if _condition_2 == nil then
1246
- _condition_2 = tick()
1247
- end
1248
- local elapsed = _exp - _condition_2
1249
- el.troubleshootLabel.Visible = elapsed > 8
1250
- UI.startPulseAnimation()
1251
- end
1252
- if data.request and mcpConnected then
1253
- task.spawn(function()
1254
- local ok, response = pcall(function()
1255
- return processRequest(data.request)
1256
- end)
1257
- if ok then
1258
- sendResponse(conn, data.requestId, response)
1259
- else
1260
- sendResponse(conn, data.requestId, {
1261
- error = tostring(response),
1262
- })
1263
- 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()
1264
1004
  end)
1265
1005
  end
1266
- elseif conn.isActive then
1267
- conn.lastPoll = tick()
1268
- conn.consecutiveFailures += 1
1269
- if conn.consecutiveFailures > 1 then
1270
- conn.currentRetryDelay = math.min(conn.currentRetryDelay * conn.retryBackoffMultiplier, conn.maxRetryDelay)
1271
- end
1272
- local el = ui
1273
- if conn.consecutiveFailures >= conn.maxFailuresBeforeError then
1274
- el.statusLabel.Text = "Server unavailable"
1275
- el.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
1276
- el.statusIndicator.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
1277
- el.statusPulse.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
1278
- el.statusText.Text = "ERROR"
1279
- el.detailStatusLabel.Text = "HTTP: X MCP: X"
1280
- el.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
1281
- el.step1Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
1282
- el.step1Label.Text = "HTTP server (error)"
1283
- el.step2Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
1284
- el.step2Label.Text = "MCP bridge (error)"
1285
- el.step3Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
1286
- el.step3Label.Text = "Commands (error)"
1287
- conn.mcpWaitStartTime = nil
1288
- el.troubleshootLabel.Visible = false
1289
- UI.stopPulseAnimation()
1290
- elseif conn.consecutiveFailures > 5 then
1291
- local waitTime = math.ceil(conn.currentRetryDelay)
1292
- el.statusLabel.Text = `Retrying ({waitTime}s)`
1293
- el.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
1294
- el.statusIndicator.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
1295
- el.statusPulse.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
1296
- el.statusText.Text = "RETRY"
1297
- el.detailStatusLabel.Text = "HTTP: ... MCP: ..."
1298
- el.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
1299
- el.step1Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
1300
- el.step1Label.Text = "HTTP server (retrying...)"
1301
- el.step2Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
1302
- el.step2Label.Text = "MCP bridge (retrying...)"
1303
- el.step3Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
1304
- el.step3Label.Text = "Commands (retrying...)"
1305
- conn.mcpWaitStartTime = nil
1306
- el.troubleshootLabel.Visible = false
1307
- UI.startPulseAnimation()
1308
- elseif conn.consecutiveFailures > 1 then
1309
- el.statusLabel.Text = `Connecting (attempt {conn.consecutiveFailures})`
1310
- el.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
1311
- el.statusIndicator.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
1312
- el.statusPulse.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
1313
- el.statusText.Text = "CONNECTING"
1314
- el.detailStatusLabel.Text = "HTTP: ... MCP: ..."
1315
- el.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
1316
- el.step1Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
1317
- el.step1Label.Text = "HTTP server (connecting...)"
1318
- el.step2Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
1319
- el.step2Label.Text = "MCP bridge (connecting...)"
1320
- el.step3Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
1321
- el.step3Label.Text = "Commands (connecting...)"
1322
- conn.mcpWaitStartTime = nil
1323
- el.troubleshootLabel.Visible = false
1324
- 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)
1325
1017
  end
1326
1018
  end
1327
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
1328
1030
  local deactivatePlugin
1329
1031
  local function activatePlugin()
1330
1032
  local conn = State.getActiveConnection()
1033
+ if conn.isActive then
1034
+ return nil
1035
+ end
1331
1036
  local ui = UI.getElements()
1332
- pollEpoch += 1
1333
- conn.isPolling = false
1334
- conn.lastPoll = 0
1335
1037
  conn.isActive = true
1336
1038
  conn.consecutiveFailures = 0
1337
1039
  conn.currentRetryDelay = 0.5
1040
+ conn.lastHttpOk = false
1041
+ conn.lastMcpOk = false
1042
+ conn.mcpWaitStartTime = nil
1338
1043
  local normalizedUrl = ServerUrlSettings.normalizeServerUrl(ui.urlInput.Text)
1339
1044
  conn.serverUrl = if normalizedUrl ~= "" then normalizedUrl else conn.serverUrl
1340
1045
  if conn.serverUrl == "" then
@@ -1345,65 +1050,60 @@ local function activatePlugin()
1345
1050
  if port ~= nil then
1346
1051
  conn.port = port
1347
1052
  end
1053
+ ClientBroker.setServerUrl(conn.serverUrl)
1054
+ lastReadyInstanceId = PluginSession.getInstanceId()
1348
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
+ })
1349
1064
  if not conn.heartbeatConnection then
1350
1065
  conn.heartbeatConnection = RunService.Heartbeat:Connect(function()
1351
- local now = tick()
1352
1066
  if initialRole == "server" and not RunService:IsRunning() then
1353
1067
  ClientBroker.disconnectAllProxies()
1354
1068
  deactivatePlugin()
1355
1069
  return nil
1356
1070
  end
1357
- local currentInstanceId = computeInstanceId()
1071
+ local currentInstanceId = PluginSession.getInstanceId()
1358
1072
  if lastReadyInstanceId ~= nil and currentInstanceId ~= lastReadyInstanceId then
1359
- cachedPlaceName = nil
1360
- cachedPlaceNamePlaceId = nil
1361
- sendReady(conn)
1362
- end
1363
- local currentInterval = if conn.consecutiveFailures > 5 then conn.currentRetryDelay else conn.pollInterval
1364
- if now - conn.lastPoll > currentInterval then
1365
- conn.lastPoll = now
1366
- pollForRequests()
1073
+ lastReadyInstanceId = currentInstanceId
1074
+ PluginSession.invalidatePlaceName()
1075
+ StudioEventStream.refresh()
1367
1076
  end
1368
1077
  end)
1369
1078
  end
1370
- -- Initial /ready; pollForRequests will also re-fire ready if the server
1371
- -- later reports knownInstance=false (process restart, etc).
1372
- sendReady(conn)
1373
- -- Remove legacy edit-mode eval bridge scripts from older plugin builds.
1374
- -- Current bridges are created only in running play DataModels.
1375
1079
  if not RunService:IsRunning() then
1376
- task.spawn(cleanupLegacyEditBridges)
1080
+ task.spawn(cleanupEditBridgeArtifacts)
1377
1081
  end
1378
- -- Watch identity fields so stale name or anon instance ids are refreshed.
1379
- ensureIdentityWatcher(conn)
1082
+ ensureIdentityWatchers()
1380
1083
  end
1381
1084
  function deactivatePlugin()
1382
1085
  local conn = State.getActiveConnection()
1383
- pollEpoch += 1
1384
- conn.isPolling = false
1086
+ if not conn.isActive then
1087
+ return nil
1088
+ end
1385
1089
  conn.isActive = false
1090
+ conn.lastHttpOk = false
1386
1091
  conn.lastMcpOk = false
1387
- UI.updateUIState()
1388
- pcall(function()
1389
- HttpService:RequestAsync({
1390
- Url = `{conn.serverUrl}/disconnect`,
1391
- Method = "POST",
1392
- Headers = {
1393
- ["Content-Type"] = "application/json",
1394
- },
1395
- Body = HttpService:JSONEncode({
1396
- pluginSessionId = pluginSessionId,
1397
- timestamp = tick(),
1398
- }),
1399
- })
1400
- end)
1092
+ conn.mcpWaitStartTime = nil
1093
+ StudioEventStream.stop()
1094
+ disconnectIdentityWatchers()
1095
+ if initialRole == "server" then
1096
+ ClientBroker.disconnectAllProxies()
1097
+ end
1401
1098
  if conn.heartbeatConnection then
1402
1099
  conn.heartbeatConnection:Disconnect()
1403
1100
  conn.heartbeatConnection = nil
1404
1101
  end
1102
+ lastReadyInstanceId = nil
1103
+ assignedRole = nil
1405
1104
  conn.consecutiveFailures = 0
1406
1105
  conn.currentRetryDelay = 0.5
1106
+ UI.updateUIState()
1407
1107
  end
1408
1108
  local function deactivateAll()
1409
1109
  local conn = State.getActiveConnection()
@@ -1437,9 +1137,7 @@ local function checkForUpdates()
1437
1137
  if _condition ~= "" and _condition then
1438
1138
  local latestVersion = data.version
1439
1139
  if Utils.compareVersions(State.CURRENT_VERSION, latestVersion) < 0 then
1440
- if not hasVersionMismatch then
1441
- UI.showBanner("update", `v{latestVersion} available - github.com/chrrxs/robloxstudio-mcp`)
1442
- end
1140
+ UI.showBanner("update", `v{latestVersion} available - github.com/chrrxs/robloxstudio-mcp`)
1443
1141
  end
1444
1142
  end
1445
1143
  end
@@ -1575,9 +1273,9 @@ local function computeBridgeStamp()
1575
1273
  for i = 1, #combined do
1576
1274
  h = (h * 33 + (string.byte(combined, i))) % 2147483647
1577
1275
  end
1578
- -- "3.0.2" is replaced with the package version at package time
1276
+ -- "3.0.3" is replaced with the package version at package time
1579
1277
  -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1580
- return `{tostring(h)}-3.0.2`
1278
+ return `{tostring(h)}-3.0.3`
1581
1279
  end
1582
1280
  local BRIDGE_STAMP = computeBridgeStamp()
1583
1281
  local function setSource(scriptInst, source)
@@ -1593,7 +1291,7 @@ local function setSource(scriptInst, source)
1593
1291
  scriptInst.Source = source
1594
1292
  end
1595
1293
  end
1596
- local function findLegacyEditBridges()
1294
+ local function findEditBridgeArtifacts()
1597
1295
  local sps = getStarterPlayerScripts()
1598
1296
  return {
1599
1297
  server = ServerScriptService:FindFirstChild(SERVER_SCRIPT_NAME),
@@ -1608,11 +1306,11 @@ local function destroyIfPresent(parent, name)
1608
1306
  end)
1609
1307
  end
1610
1308
  end
1611
- local function cleanupLegacyEditBridges()
1309
+ local function cleanupEditBridgeArtifacts()
1612
1310
  if RunService:IsRunning() then
1613
1311
  return nil
1614
1312
  end
1615
- local _binding = findLegacyEditBridges()
1313
+ local _binding = findEditBridgeArtifacts()
1616
1314
  local server = _binding.server
1617
1315
  local client = _binding.client
1618
1316
  if server then
@@ -1723,7 +1421,7 @@ local function ensureRuntimeBridgeInstalled()
1723
1421
  return installClientRuntimeBridge()
1724
1422
  end
1725
1423
  return {
1726
- cleanupLegacyEditBridges = cleanupLegacyEditBridges,
1424
+ cleanupEditBridgeArtifacts = cleanupEditBridgeArtifacts,
1727
1425
  ensureRuntimeBridgeInstalled = ensureRuntimeBridgeInstalled,
1728
1426
  BRIDGE_NAMES = BRIDGE_NAMES,
1729
1427
  }
@@ -2702,6 +2400,8 @@ local RenderMonitor = TS.import(script, script.Parent.Parent, "RenderMonitor")
2702
2400
  local CaptureService = game:GetService("CaptureService")
2703
2401
  local AssetService = game:GetService("AssetService")
2704
2402
  local MAX_TILE_SIZE = 1024
2403
+ local MAX_RAW_PIXEL_BYTES = 36 * 1024 * 1024
2404
+ local MAX_CREATED_IMAGE_DIM = 2048
2705
2405
  local BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
2706
2406
  local PAD_BYTE = (string.byte("="))
2707
2407
  local B64 = {}
@@ -2853,14 +2553,39 @@ local function readContentToBase64(contentId)
2853
2553
  error = `Failed to create EditableImage from screenshot. Enable EditableImage API: Game Settings > Security > 'Allow Mesh / Image APIs'. ({tostring(editableResult)})`,
2854
2554
  }
2855
2555
  end
2856
- local editableImage = editableResult
2857
- local imgSize = editableImage.Size
2858
- local w = math.floor(imgSize.X)
2859
- 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
2860
2585
  local readOk, pixelBuffer = pcall(function()
2861
- return readPixelsTiled(editableImage, w, h)
2586
+ return readPixelsTiled(sourceImage, w, h)
2862
2587
  end)
2863
- editableImage:Destroy()
2588
+ sourceImage:Destroy()
2864
2589
  if not readOk then
2865
2590
  return {
2866
2591
  error = `Failed to read pixel data: {tostring(pixelBuffer)}`,
@@ -2872,6 +2597,8 @@ local function readContentToBase64(contentId)
2872
2597
  width = w,
2873
2598
  height = h,
2874
2599
  data = base64Data,
2600
+ nativeWidth = nativeW,
2601
+ nativeHeight = nativeH,
2875
2602
  }
2876
2603
  end
2877
2604
  -- Edit-mode single shot: capture and read back in the same (edit) context.
@@ -7014,7 +6741,7 @@ local function setScriptSource(requestData)
7014
6741
  error = `Instance is not a script-like object: {instance.ClassName}`,
7015
6742
  }
7016
6743
  end
7017
- -- Communication has already JSON-decoded the poll payload; source text is exact at this boundary.
6744
+ -- Communication has already JSON-decoded the transport payload; source text is exact at this boundary.
7018
6745
  local sourceToSet = newSource
7019
6746
  local recordingId = beginRecording(`Set script source: {instance.Name}`)
7020
6747
  local readSuccess, readResult = pcall(function()
@@ -9067,6 +8794,98 @@ return {
9067
8794
  </Properties>
9068
8795
  </Item>
9069
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">
9070
8889
  <Properties>
9071
8890
  <string name="Name">Recording</string>
9072
8891
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9096,7 +8915,7 @@ return {
9096
8915
  ]]></string>
9097
8916
  </Properties>
9098
8917
  </Item>
9099
- <Item class="ModuleScript" referent="27">
8918
+ <Item class="ModuleScript" referent="28">
9100
8919
  <Properties>
9101
8920
  <string name="Name">RenderMonitor</string>
9102
8921
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9164,7 +8983,7 @@ return {
9164
8983
  ]]></string>
9165
8984
  </Properties>
9166
8985
  </Item>
9167
- <Item class="ModuleScript" referent="28">
8986
+ <Item class="ModuleScript" referent="29">
9168
8987
  <Properties>
9169
8988
  <string name="Name">RuntimeLogBuffer</string>
9170
8989
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9419,7 +9238,7 @@ return {
9419
9238
  ]]></string>
9420
9239
  </Properties>
9421
9240
  </Item>
9422
- <Item class="ModuleScript" referent="29">
9241
+ <Item class="ModuleScript" referent="30">
9423
9242
  <Properties>
9424
9243
  <string name="Name">ServerUrlSettings</string>
9425
9244
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9427,7 +9246,6 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
9427
9246
  local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
9428
9247
  local HttpService = _services.HttpService
9429
9248
  local ServerStorage = _services.ServerStorage
9430
- local LEGACY_SETTING_KEY_PREFIX = "MCP_SERVER_URL_"
9431
9249
  local SETTING_KEY_PREFIX = "MCP_LAST_SUCCESSFUL_SERVER_URL_"
9432
9250
  local GLOBAL_SETTING_KEY = "MCP_LAST_SUCCESSFUL_SERVER_URL_GLOBAL_V1"
9433
9251
  local pluginRef
@@ -9497,9 +9315,6 @@ end
9497
9315
  local function settingKey(instanceId)
9498
9316
  return SETTING_KEY_PREFIX .. instanceId
9499
9317
  end
9500
- local function legacySettingKey(instanceId)
9501
- return LEGACY_SETTING_KEY_PREFIX .. instanceId
9502
- end
9503
9318
  local function readSettingString(key)
9504
9319
  if not pluginRef then
9505
9320
  return nil
@@ -9531,7 +9346,6 @@ local function rememberServerUrl(serverUrl)
9531
9346
  createAnonymous = true,
9532
9347
  }) do
9533
9348
  writeSettingString(settingKey(instanceId), normalized)
9534
- writeSettingString(legacySettingKey(instanceId), normalized)
9535
9349
  end
9536
9350
  end
9537
9351
  local function readServerUrl()
@@ -9551,12 +9365,6 @@ local function readServerUrl()
9551
9365
  if globalRemembered ~= nil then
9552
9366
  return globalRemembered
9553
9367
  end
9554
- for _, instanceId in computeInstanceIds() do
9555
- local legacyRemembered = readSettingString(legacySettingKey(instanceId))
9556
- if legacyRemembered ~= nil then
9557
- return legacyRemembered
9558
- end
9559
- end
9560
9368
  return nil
9561
9369
  end
9562
9370
  return {
@@ -9569,31 +9377,24 @@ return {
9569
9377
  ]]></string>
9570
9378
  </Properties>
9571
9379
  </Item>
9572
- <Item class="ModuleScript" referent="30">
9380
+ <Item class="ModuleScript" referent="31">
9573
9381
  <Properties>
9574
9382
  <string name="Name">State</string>
9575
9383
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
9576
- local CURRENT_VERSION = "3.0.2"
9384
+ local CURRENT_VERSION = "3.0.3"
9577
9385
  local PLUGIN_VARIANT = "main"
9578
9386
  local BASE_PORT = 58741
9579
- local POLL_REQUEST_TIMEOUT_SECONDS = 20
9580
9387
  local function createConnection(port)
9581
9388
  return {
9582
9389
  port = port,
9583
9390
  serverUrl = `http://localhost:{port}`,
9584
9391
  isActive = false,
9585
- pollInterval = 0.5,
9586
- lastPoll = 0,
9587
9392
  consecutiveFailures = 0,
9588
9393
  maxFailuresBeforeError = 50,
9589
- lastSuccessfulConnection = 0,
9590
9394
  currentRetryDelay = 0.5,
9591
- maxRetryDelay = 5,
9592
- retryBackoffMultiplier = 1.2,
9593
9395
  lastHttpOk = false,
9594
9396
  lastMcpOk = false,
9595
9397
  mcpWaitStartTime = nil,
9596
- isPolling = false,
9597
9398
  heartbeatConnection = nil,
9598
9399
  }
9599
9400
  end
@@ -9605,13 +9406,12 @@ return {
9605
9406
  CURRENT_VERSION = CURRENT_VERSION,
9606
9407
  PLUGIN_VARIANT = PLUGIN_VARIANT,
9607
9408
  BASE_PORT = BASE_PORT,
9608
- POLL_REQUEST_TIMEOUT_SECONDS = POLL_REQUEST_TIMEOUT_SECONDS,
9609
9409
  getActiveConnection = getActiveConnection,
9610
9410
  }
9611
9411
  ]]></string>
9612
9412
  </Properties>
9613
9413
  </Item>
9614
- <Item class="ModuleScript" referent="31">
9414
+ <Item class="ModuleScript" referent="32">
9615
9415
  <Properties>
9616
9416
  <string name="Name">StopPlayMonitor</string>
9617
9417
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9664,7 +9464,7 @@ local endTestIssued = false
9664
9464
  local function init(p)
9665
9465
  pluginRef = p
9666
9466
  end
9667
- -- Mirror of Communication.computeInstanceId(). Duplicated here because
9467
+ -- Mirror of PluginSession's place identity rules. Duplicated here because
9668
9468
  -- StopPlayMonitor runs in both edit and play-server DMs, and both must
9669
9469
  -- agree on the place identifier (published places: placeId; unpublished:
9670
9470
  -- UUID on ServerStorage's __MCPPlaceId attribute, travels with the .rbxl
@@ -9820,16 +9620,9 @@ local function startMonitor()
9820
9620
  task.spawn(function()
9821
9621
  while true do
9822
9622
  for _, myKey in settingKeys() do
9823
- local value = readSetting(myKey)
9824
- if value == true then
9825
- -- Legacy boolean requests are ambiguous and may be stale from
9826
- -- a prior crashed session. New stop requests use token payloads.
9827
- writeSetting(myKey, false)
9828
- else
9829
- local payload = decodePayload(value)
9830
- if payload then
9831
- handleStopRequest(myKey, payload)
9832
- end
9623
+ local payload = decodePayload(readSetting(myKey))
9624
+ if payload then
9625
+ handleStopRequest(myKey, payload)
9833
9626
  end
9834
9627
  end
9835
9628
  task.wait(POLL_INTERVAL_SEC)
@@ -9909,7 +9702,618 @@ return {
9909
9702
  ]]></string>
9910
9703
  </Properties>
9911
9704
  </Item>
9912
- <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">
9913
10317
  <Properties>
9914
10318
  <string name="Name">UI</string>
9915
10319
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -10461,7 +10865,7 @@ return {
10461
10865
  ]]></string>
10462
10866
  </Properties>
10463
10867
  </Item>
10464
- <Item class="ModuleScript" referent="33">
10868
+ <Item class="ModuleScript" referent="35">
10465
10869
  <Properties>
10466
10870
  <string name="Name">Utils</string>
10467
10871
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -11306,11 +11710,11 @@ return {
11306
11710
  </Properties>
11307
11711
  </Item>
11308
11712
  </Item>
11309
- <Item class="Folder" referent="38">
11713
+ <Item class="Folder" referent="40">
11310
11714
  <Properties>
11311
11715
  <string name="Name">include</string>
11312
11716
  </Properties>
11313
- <Item class="ModuleScript" referent="34">
11717
+ <Item class="ModuleScript" referent="36">
11314
11718
  <Properties>
11315
11719
  <string name="Name">LibMP</string>
11316
11720
  <string name="Source"><![CDATA[-- =============================================================================
@@ -167694,7 +168098,7 @@ return LibMP
167694
168098
  ]]></string>
167695
168099
  </Properties>
167696
168100
  </Item>
167697
- <Item class="ModuleScript" referent="35">
168101
+ <Item class="ModuleScript" referent="37">
167698
168102
  <Properties>
167699
168103
  <string name="Name">Promise</string>
167700
168104
  <string name="Source"><![CDATA[--[[
@@ -169768,7 +170172,7 @@ return Promise
169768
170172
  ]]></string>
169769
170173
  </Properties>
169770
170174
  </Item>
169771
- <Item class="ModuleScript" referent="36">
170175
+ <Item class="ModuleScript" referent="38">
169772
170176
  <Properties>
169773
170177
  <string name="Name">RuntimeLib</string>
169774
170178
  <string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
@@ -170035,15 +170439,15 @@ return TS
170035
170439
  </Properties>
170036
170440
  </Item>
170037
170441
  </Item>
170038
- <Item class="Folder" referent="39">
170442
+ <Item class="Folder" referent="41">
170039
170443
  <Properties>
170040
170444
  <string name="Name">node_modules</string>
170041
170445
  </Properties>
170042
- <Item class="Folder" referent="40">
170446
+ <Item class="Folder" referent="42">
170043
170447
  <Properties>
170044
170448
  <string name="Name">@rbxts</string>
170045
170449
  </Properties>
170046
- <Item class="ModuleScript" referent="37">
170450
+ <Item class="ModuleScript" referent="39">
170047
170451
  <Properties>
170048
170452
  <string name="Name">services</string>
170049
170453
  <string name="Source"><![CDATA[return setmetatable({}, {