@chrrxs/robloxstudio-mcp 3.0.2 → 3.0.4

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,
@@ -946,47 +880,18 @@ local routeMap = {
946
880
  ["/api/get-memory-breakdown"] = MemoryHandlers.getMemoryBreakdown,
947
881
  ["/api/get-scene-analysis"] = SceneAnalysisHandlers.getSceneAnalysis,
948
882
  }
949
- local function processRequest(request)
883
+ local function processRequest(request, context)
950
884
  local endpoint = request.endpoint
951
885
  local data = request.data or {}
952
886
  local handler = routeMap[endpoint]
953
887
  if handler then
954
- return handler(data)
888
+ return handler(data, context)
955
889
  else
956
890
  return {
957
891
  error = `Unknown endpoint: {endpoint}`,
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, context)
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
+ }, context)
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
@@ -1456,6 +1154,81 @@ return {
1456
1154
  </Properties>
1457
1155
  </Item>
1458
1156
  <Item class="ModuleScript" referent="5">
1157
+ <Properties>
1158
+ <string name="Name">CooperativeJobRunner</string>
1159
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
1160
+ local DEADLINE_EXCEEDED = "__RSMCP_COOPERATIVE_JOB_DEADLINE_EXCEEDED__"
1161
+ local CANCELLED = "__RSMCP_COOPERATIVE_JOB_CANCELLED__"
1162
+ local MAX_SLICE_SECONDS = 0.008
1163
+ local CLOCK_CHECK_INTERVAL = 64
1164
+ local activeJobs = {}
1165
+ local function runExclusive(key, execution, work)
1166
+ local _key = key
1167
+ local activeRequestId = activeJobs[_key]
1168
+ if activeRequestId ~= nil then
1169
+ return {
1170
+ error = "plugin_busy",
1171
+ activeRequestId = activeRequestId,
1172
+ }
1173
+ end
1174
+ local _key_1 = key
1175
+ local _requestId = execution.requestId
1176
+ activeJobs[_key_1] = _requestId
1177
+ local sliceStartedAt = os.clock()
1178
+ local operationsUntilClockCheck = 0
1179
+ local control = {
1180
+ checkpoint = function(self)
1181
+ local _result = execution.isCancelled
1182
+ if _result ~= nil then
1183
+ _result = _result()
1184
+ end
1185
+ if _result then
1186
+ error(CANCELLED, 0)
1187
+ end
1188
+ if operationsUntilClockCheck > 0 then
1189
+ operationsUntilClockCheck -= 1
1190
+ return nil
1191
+ end
1192
+ operationsUntilClockCheck = CLOCK_CHECK_INTERVAL - 1
1193
+ local now = os.clock()
1194
+ if execution.deadlineAt ~= nil and now >= execution.deadlineAt then
1195
+ error(DEADLINE_EXCEEDED, 0)
1196
+ end
1197
+ if now - sliceStartedAt >= MAX_SLICE_SECONDS then
1198
+ task.wait()
1199
+ sliceStartedAt = os.clock()
1200
+ end
1201
+ end,
1202
+ }
1203
+ local ok, result = pcall(function()
1204
+ return work(control)
1205
+ end)
1206
+ local _key_2 = key
1207
+ activeJobs[_key_2] = nil
1208
+ if not ok then
1209
+ if result == DEADLINE_EXCEEDED then
1210
+ return {
1211
+ error = "deadline_exceeded",
1212
+ requestId = execution.requestId,
1213
+ }
1214
+ end
1215
+ if result == CANCELLED then
1216
+ return {
1217
+ error = "cancelled",
1218
+ requestId = execution.requestId,
1219
+ }
1220
+ end
1221
+ error(result, 0)
1222
+ end
1223
+ return result
1224
+ end
1225
+ return {
1226
+ runExclusive = runExclusive,
1227
+ }
1228
+ ]]></string>
1229
+ </Properties>
1230
+ </Item>
1231
+ <Item class="ModuleScript" referent="6">
1459
1232
  <Properties>
1460
1233
  <string name="Name">EvalBridges</string>
1461
1234
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -1575,9 +1348,9 @@ local function computeBridgeStamp()
1575
1348
  for i = 1, #combined do
1576
1349
  h = (h * 33 + (string.byte(combined, i))) % 2147483647
1577
1350
  end
1578
- -- "3.0.2" is replaced with the package version at package time
1351
+ -- "3.0.4" is replaced with the package version at package time
1579
1352
  -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1580
- return `{tostring(h)}-3.0.2`
1353
+ return `{tostring(h)}-3.0.4`
1581
1354
  end
1582
1355
  local BRIDGE_STAMP = computeBridgeStamp()
1583
1356
  local function setSource(scriptInst, source)
@@ -1593,7 +1366,7 @@ local function setSource(scriptInst, source)
1593
1366
  scriptInst.Source = source
1594
1367
  end
1595
1368
  end
1596
- local function findLegacyEditBridges()
1369
+ local function findEditBridgeArtifacts()
1597
1370
  local sps = getStarterPlayerScripts()
1598
1371
  return {
1599
1372
  server = ServerScriptService:FindFirstChild(SERVER_SCRIPT_NAME),
@@ -1608,11 +1381,11 @@ local function destroyIfPresent(parent, name)
1608
1381
  end)
1609
1382
  end
1610
1383
  end
1611
- local function cleanupLegacyEditBridges()
1384
+ local function cleanupEditBridgeArtifacts()
1612
1385
  if RunService:IsRunning() then
1613
1386
  return nil
1614
1387
  end
1615
- local _binding = findLegacyEditBridges()
1388
+ local _binding = findEditBridgeArtifacts()
1616
1389
  local server = _binding.server
1617
1390
  local client = _binding.client
1618
1391
  if server then
@@ -1723,18 +1496,18 @@ local function ensureRuntimeBridgeInstalled()
1723
1496
  return installClientRuntimeBridge()
1724
1497
  end
1725
1498
  return {
1726
- cleanupLegacyEditBridges = cleanupLegacyEditBridges,
1499
+ cleanupEditBridgeArtifacts = cleanupEditBridgeArtifacts,
1727
1500
  ensureRuntimeBridgeInstalled = ensureRuntimeBridgeInstalled,
1728
1501
  BRIDGE_NAMES = BRIDGE_NAMES,
1729
1502
  }
1730
1503
  ]]></string>
1731
1504
  </Properties>
1732
1505
  </Item>
1733
- <Item class="Folder" referent="6">
1506
+ <Item class="Folder" referent="7">
1734
1507
  <Properties>
1735
1508
  <string name="Name">handlers</string>
1736
1509
  </Properties>
1737
- <Item class="ModuleScript" referent="7">
1510
+ <Item class="ModuleScript" referent="8">
1738
1511
  <Properties>
1739
1512
  <string name="Name">AssetHandlers</string>
1740
1513
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -2175,7 +1948,7 @@ return {
2175
1948
  ]]></string>
2176
1949
  </Properties>
2177
1950
  </Item>
2178
- <Item class="ModuleScript" referent="8">
1951
+ <Item class="ModuleScript" referent="9">
2179
1952
  <Properties>
2180
1953
  <string name="Name">BreakpointHandlers</string>
2181
1954
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -2693,7 +2466,7 @@ return {
2693
2466
  ]]></string>
2694
2467
  </Properties>
2695
2468
  </Item>
2696
- <Item class="ModuleScript" referent="9">
2469
+ <Item class="ModuleScript" referent="10">
2697
2470
  <Properties>
2698
2471
  <string name="Name">CaptureHandlers</string>
2699
2472
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -2702,6 +2475,8 @@ local RenderMonitor = TS.import(script, script.Parent.Parent, "RenderMonitor")
2702
2475
  local CaptureService = game:GetService("CaptureService")
2703
2476
  local AssetService = game:GetService("AssetService")
2704
2477
  local MAX_TILE_SIZE = 1024
2478
+ local MAX_RAW_PIXEL_BYTES = 36 * 1024 * 1024
2479
+ local MAX_CREATED_IMAGE_DIM = 2048
2705
2480
  local BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
2706
2481
  local PAD_BYTE = (string.byte("="))
2707
2482
  local B64 = {}
@@ -2853,14 +2628,39 @@ local function readContentToBase64(contentId)
2853
2628
  error = `Failed to create EditableImage from screenshot. Enable EditableImage API: Game Settings > Security > 'Allow Mesh / Image APIs'. ({tostring(editableResult)})`,
2854
2629
  }
2855
2630
  end
2856
- local editableImage = editableResult
2857
- local imgSize = editableImage.Size
2858
- local w = math.floor(imgSize.X)
2859
- local h = math.floor(imgSize.Y)
2631
+ local sourceImage = editableResult
2632
+ local imgSize = sourceImage.Size
2633
+ local nativeW = math.floor(imgSize.X)
2634
+ local nativeH = math.floor(imgSize.Y)
2635
+ local w = nativeW
2636
+ local h = nativeH
2637
+ if nativeW * nativeH * 4 > MAX_RAW_PIXEL_BYTES then
2638
+ local scale = math.min(math.sqrt(MAX_RAW_PIXEL_BYTES / (nativeW * nativeH * 4)), MAX_CREATED_IMAGE_DIM / math.max(nativeW, nativeH))
2639
+ w = math.max(1, math.floor(nativeW * scale))
2640
+ h = math.max(1, math.floor(nativeH * scale))
2641
+ local scaleOk, scaledResult = pcall(function()
2642
+ local target = AssetService:CreateEditableImage({
2643
+ Size = Vector2.new(w, h),
2644
+ })
2645
+ target:DrawImageTransformed(Vector2.new(0, 0), Vector2.new(w / nativeW, h / nativeH), 0, sourceImage, {
2646
+ CombineType = Enum.ImageCombineType.AlphaBlend,
2647
+ SamplingMode = Enum.ResamplerMode.Default,
2648
+ PivotPoint = Vector2.new(0, 0),
2649
+ })
2650
+ return target
2651
+ end)
2652
+ sourceImage:Destroy()
2653
+ if not scaleOk then
2654
+ return {
2655
+ error = `Screenshot is {nativeW}x{nativeH} (too large to transfer raw) and downscaling failed: {tostring(scaledResult)}`,
2656
+ }
2657
+ end
2658
+ sourceImage = scaledResult
2659
+ end
2860
2660
  local readOk, pixelBuffer = pcall(function()
2861
- return readPixelsTiled(editableImage, w, h)
2661
+ return readPixelsTiled(sourceImage, w, h)
2862
2662
  end)
2863
- editableImage:Destroy()
2663
+ sourceImage:Destroy()
2864
2664
  if not readOk then
2865
2665
  return {
2866
2666
  error = `Failed to read pixel data: {tostring(pixelBuffer)}`,
@@ -2872,6 +2672,8 @@ local function readContentToBase64(contentId)
2872
2672
  width = w,
2873
2673
  height = h,
2874
2674
  data = base64Data,
2675
+ nativeWidth = nativeW,
2676
+ nativeHeight = nativeH,
2875
2677
  }
2876
2678
  end
2877
2679
  -- Edit-mode single shot: capture and read back in the same (edit) context.
@@ -2908,7 +2710,7 @@ return {
2908
2710
  ]]></string>
2909
2711
  </Properties>
2910
2712
  </Item>
2911
- <Item class="ModuleScript" referent="10">
2713
+ <Item class="ModuleScript" referent="11">
2912
2714
  <Properties>
2913
2715
  <string name="Name">EvalRuntimeHandlers</string>
2914
2716
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -3062,7 +2864,7 @@ return {
3062
2864
  ]]></string>
3063
2865
  </Properties>
3064
2866
  </Item>
3065
- <Item class="ModuleScript" referent="11">
2867
+ <Item class="ModuleScript" referent="12">
3066
2868
  <Properties>
3067
2869
  <string name="Name">GenerateModelHandlers</string>
3068
2870
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -3267,7 +3069,7 @@ return {
3267
3069
  ]]></string>
3268
3070
  </Properties>
3269
3071
  </Item>
3270
- <Item class="ModuleScript" referent="12">
3072
+ <Item class="ModuleScript" referent="13">
3271
3073
  <Properties>
3272
3074
  <string name="Name">InputHandlers</string>
3273
3075
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -3467,7 +3269,7 @@ return {
3467
3269
  ]]></string>
3468
3270
  </Properties>
3469
3271
  </Item>
3470
- <Item class="ModuleScript" referent="13">
3272
+ <Item class="ModuleScript" referent="14">
3471
3273
  <Properties>
3472
3274
  <string name="Name">LogHandlers</string>
3473
3275
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -3493,7 +3295,7 @@ return {
3493
3295
  ]]></string>
3494
3296
  </Properties>
3495
3297
  </Item>
3496
- <Item class="ModuleScript" referent="14">
3298
+ <Item class="ModuleScript" referent="15">
3497
3299
  <Properties>
3498
3300
  <string name="Name">MemoryHandlers</string>
3499
3301
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -3561,7 +3363,7 @@ return {
3561
3363
  ]]></string>
3562
3364
  </Properties>
3563
3365
  </Item>
3564
- <Item class="ModuleScript" referent="15">
3366
+ <Item class="ModuleScript" referent="16">
3565
3367
  <Properties>
3566
3368
  <string name="Name">MetadataHandlers</string>
3567
3369
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -3892,7 +3694,7 @@ return {
3892
3694
  ]]></string>
3893
3695
  </Properties>
3894
3696
  </Item>
3895
- <Item class="ModuleScript" referent="16">
3697
+ <Item class="ModuleScript" referent="17">
3896
3698
  <Properties>
3897
3699
  <string name="Name">MicroProfilerHandlers</string>
3898
3700
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -5645,7 +5447,7 @@ return {
5645
5447
  ]]></string>
5646
5448
  </Properties>
5647
5449
  </Item>
5648
- <Item class="ModuleScript" referent="17">
5450
+ <Item class="ModuleScript" referent="18">
5649
5451
  <Properties>
5650
5452
  <string name="Name">PropertyHandlers</string>
5651
5453
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -5730,12 +5532,14 @@ return {
5730
5532
  ]]></string>
5731
5533
  </Properties>
5732
5534
  </Item>
5733
- <Item class="ModuleScript" referent="18">
5535
+ <Item class="ModuleScript" referent="19">
5734
5536
  <Properties>
5735
5537
  <string name="Name">QueryHandlers</string>
5736
5538
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
5737
5539
  local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
5738
5540
  local Utils = TS.import(script, script.Parent.Parent, "Utils")
5541
+ local CooperativeJobRunner = TS.import(script, script.Parent.Parent, "CooperativeJobRunner")
5542
+ local ScriptSearch = TS.import(script, script.Parent.Parent, "ScriptSearch")
5739
5543
  local _binding = Utils
5740
5544
  local getInstancePath = _binding.getInstancePath
5741
5545
  local getInstanceByPath = _binding.getInstanceByPath
@@ -6328,265 +6132,46 @@ local function getProjectStructure(requestData)
6328
6132
  result.timestamp = tick()
6329
6133
  return result
6330
6134
  end
6331
- -- Split a Lua pattern on TOP-LEVEL "|" into alternatives. Lua patterns have no
6332
- -- alternation operator, so "foo|bar" would otherwise be matched as the literal
6333
- -- text "foo|bar" and silently never hit. "%|" stays a literal pipe, and "%bxy"
6334
- -- keeps both balanced-match delimiter characters.
6335
- local function splitLuaAlternation(pattern)
6336
- local parts = {}
6337
- local current = ""
6338
- local i = 1
6339
- local n = #pattern
6340
- local inCharClass = false
6341
- while i <= n do
6342
- local c = string.sub(pattern, i, i)
6343
- if c == "%" then
6344
- if string.sub(pattern, i + 1, i + 1) == "b" then
6345
- current ..= string.sub(pattern, i, math.min(i + 3, n))
6346
- i += 4
6347
- continue
6348
- end
6349
- -- Preserve an escape pair (e.g. %|, %., %d) intact.
6350
- current ..= string.sub(pattern, i, i + 1)
6351
- i += 2
6352
- elseif c == "[" then
6353
- inCharClass = true
6354
- current ..= c
6355
- i += 1
6356
- elseif c == "]" then
6357
- inCharClass = false
6358
- current ..= c
6359
- i += 1
6360
- elseif c == "|" and not inCharClass then
6361
- local _current = current
6362
- table.insert(parts, _current)
6363
- current = ""
6364
- i += 1
6365
- else
6366
- current ..= c
6367
- i += 1
6368
- end
6369
- end
6370
- local _current = current
6371
- table.insert(parts, _current)
6372
- return parts
6373
- end
6374
- -- Return the earliest match across alternatives (mirrors regex alternation).
6375
- local function findFirstPattern(line, alternatives)
6376
- local bestStart
6377
- local bestEnd
6378
- for _, alt in alternatives do
6379
- if alt == "" then
6380
- continue
6381
- end
6382
- local s, e = string.find(line, alt)
6383
- if s ~= nil and (bestStart == nil or s < bestStart) then
6384
- bestStart = s
6385
- bestEnd = e
6135
+ local scriptSearch = ScriptSearch.createScriptSearch({
6136
+ resolveRoot = function(self, path)
6137
+ return getInstanceByPath(path)
6138
+ end,
6139
+ getChildren = function(self, instance)
6140
+ return instance:GetChildren()
6141
+ end,
6142
+ readScript = function(self, instance, classFilter)
6143
+ if not instance:IsA("LuaSourceContainer") or (classFilter ~= nil and instance.ClassName ~= classFilter) then
6144
+ return nil
6386
6145
  end
6387
- end
6388
- return { bestStart, bestEnd }
6389
- end
6390
- local function grepScripts(requestData)
6391
- local pattern = requestData.pattern
6392
- if not (pattern ~= "" and pattern) then
6393
- return {
6394
- error = "pattern is required",
6395
- }
6396
- end
6397
- local _condition = (requestData.usePattern)
6398
- if _condition == nil then
6399
- _condition = false
6400
- end
6401
- local usePattern = _condition
6402
- if usePattern and requestData.caseSensitive == false then
6403
- return {
6404
- error = "Case-insensitive Lua pattern search is not supported. Omit caseSensitive or pass caseSensitive: true with usePattern: true, or use literal search.",
6146
+ local snapshot = {
6147
+ instancePath = getInstancePath(instance),
6148
+ name = instance.Name,
6149
+ className = instance.ClassName,
6150
+ source = readScriptSource(instance),
6405
6151
  }
6406
- end
6407
- local _result
6408
- if usePattern then
6409
- _result = true
6410
- else
6411
- local _condition_1 = (requestData.caseSensitive)
6412
- if _condition_1 == nil then
6413
- _condition_1 = false
6152
+ if instance:IsA("BaseScript") then
6153
+ snapshot.enabled = instance.Enabled
6414
6154
  end
6415
- _result = _condition_1
6416
- end
6417
- local caseSensitive = _result
6418
- local _condition_1 = (requestData.contextLines)
6419
- if _condition_1 == nil then
6420
- _condition_1 = 0
6421
- end
6422
- local contextLines = _condition_1
6423
- local _condition_2 = (requestData.maxResults)
6424
- if _condition_2 == nil then
6425
- _condition_2 = 100
6426
- end
6427
- local maxResults = _condition_2
6428
- local _condition_3 = (requestData.maxResultsPerScript)
6429
- if _condition_3 == nil then
6430
- _condition_3 = 0
6431
- end
6432
- local maxResultsPerScript = _condition_3
6433
- local _condition_4 = (requestData.filesOnly)
6434
- if _condition_4 == nil then
6435
- _condition_4 = false
6436
- end
6437
- local filesOnly = _condition_4
6438
- local _condition_5 = (requestData.path)
6439
- if _condition_5 == nil then
6440
- _condition_5 = ""
6441
- end
6442
- local searchPath = _condition_5
6443
- local classFilter = requestData.classFilter
6444
- local startInstance = if searchPath ~= "" then getInstanceByPath(searchPath) else game
6445
- if not startInstance then
6446
- return {
6447
- error = `Path not found: {searchPath}`,
6448
- }
6155
+ return snapshot
6156
+ end,
6157
+ })
6158
+ local function grepScripts(requestData, execution)
6159
+ local result = CooperativeJobRunner.runExclusive("script-source-search", execution, function(control)
6160
+ return scriptSearch.search(requestData, control)
6161
+ end)
6162
+ if result.error == "plugin_busy" then
6163
+ local _object = table.clone(result)
6164
+ setmetatable(_object, nil)
6165
+ _object.message = "Another grep_scripts request is already running in this Studio DataModel. Retry after it completes."
6166
+ return _object
6449
6167
  end
6450
- -- Prepare pattern for matching
6451
- local searchPattern = if caseSensitive then pattern else string.lower(pattern)
6452
- -- Pre-split top-level "|" alternation once (pattern mode only).
6453
- local patternAlternatives = if usePattern then splitLuaAlternation(searchPattern) else nil
6454
- local results = {}
6455
- local totalMatches = 0
6456
- local scriptsSearched = 0
6457
- local hitLimit = false
6458
- local function searchInstance(instance)
6459
- if hitLimit then
6460
- return nil
6461
- end
6462
- if instance:IsA("LuaSourceContainer") then
6463
- -- Apply class filter
6464
- if classFilter ~= "" and classFilter then
6465
- local _exp = string.lower(instance.ClassName)
6466
- local _arg0 = string.lower(classFilter)
6467
- local _value = (string.find(_exp, _arg0))
6468
- if not (_value ~= 0 and _value == _value and _value) then
6469
- return nil
6470
- end
6471
- end
6472
- scriptsSearched += 1
6473
- local source = readScriptSource(instance)
6474
- local lines = Utils.splitLines(source)
6475
- local scriptMatches = {}
6476
- local scriptMatchCount = 0
6477
- for i = 0, #lines - 1 do
6478
- if hitLimit then
6479
- break
6480
- end
6481
- if maxResultsPerScript > 0 and scriptMatchCount >= maxResultsPerScript then
6482
- break
6483
- end
6484
- local line = lines[i + 1]
6485
- local searchLine = if caseSensitive then line else string.lower(line)
6486
- local matchStart
6487
- local matchEnd
6488
- if usePattern then
6489
- local _binding_1 = findFirstPattern(searchLine, patternAlternatives)
6490
- matchStart = _binding_1[1]
6491
- matchEnd = _binding_1[2]
6492
- else
6493
- matchStart, matchEnd = string.find(searchLine, searchPattern, 1, true)
6494
- end
6495
- if matchStart ~= nil then
6496
- scriptMatchCount += 1
6497
- totalMatches += 1
6498
- if totalMatches > maxResults then
6499
- hitLimit = true
6500
- break
6501
- end
6502
- if not filesOnly then
6503
- -- Gather context lines
6504
- local before = {}
6505
- local after = {}
6506
- if contextLines > 0 then
6507
- local beforeStart = math.max(0, i - contextLines)
6508
- do
6509
- local j = beforeStart
6510
- local _shouldIncrement = false
6511
- while true do
6512
- if _shouldIncrement then
6513
- j += 1
6514
- else
6515
- _shouldIncrement = true
6516
- end
6517
- if not (j < i) then
6518
- break
6519
- end
6520
- local _arg0 = lines[j + 1]
6521
- table.insert(before, _arg0)
6522
- end
6523
- end
6524
- local afterEnd = math.min(#lines - 1, i + contextLines)
6525
- do
6526
- local j = i + 1
6527
- local _shouldIncrement = false
6528
- while true do
6529
- if _shouldIncrement then
6530
- j += 1
6531
- else
6532
- _shouldIncrement = true
6533
- end
6534
- if not (j <= afterEnd) then
6535
- break
6536
- end
6537
- local _arg0 = lines[j + 1]
6538
- table.insert(after, _arg0)
6539
- end
6540
- end
6541
- end
6542
- local _arg0 = {
6543
- line = i + 1,
6544
- column = matchStart,
6545
- text = line,
6546
- before = before,
6547
- after = after,
6548
- }
6549
- table.insert(scriptMatches, _arg0)
6550
- end
6551
- end
6552
- end
6553
- if scriptMatchCount > 0 then
6554
- local scriptResult = {
6555
- instancePath = getInstancePath(instance),
6556
- name = instance.Name,
6557
- className = instance.ClassName,
6558
- matches = scriptMatches,
6559
- }
6560
- if instance:IsA("BaseScript") then
6561
- scriptResult.enabled = instance.Enabled
6562
- end
6563
- table.insert(results, scriptResult)
6564
- end
6565
- end
6566
- for _, child in instance:GetChildren() do
6567
- if hitLimit then
6568
- return nil
6569
- end
6570
- searchInstance(child)
6571
- end
6168
+ if result.error == "deadline_exceeded" then
6169
+ local _object = table.clone(result)
6170
+ setmetatable(_object, nil)
6171
+ _object.message = "grep_scripts exceeded its bridge deadline before the scan completed."
6172
+ return _object
6572
6173
  end
6573
- searchInstance(startInstance)
6574
- return {
6575
- results = results,
6576
- pattern = pattern,
6577
- totalMatches = if hitLimit then `>{maxResults}` else totalMatches,
6578
- scriptsSearched = scriptsSearched,
6579
- scriptsMatched = #results,
6580
- truncated = hitLimit,
6581
- options = {
6582
- caseSensitive = caseSensitive,
6583
- contextLines = contextLines,
6584
- usePattern = usePattern,
6585
- filesOnly = filesOnly,
6586
- maxResults = maxResults,
6587
- maxResultsPerScript = maxResultsPerScript,
6588
- },
6589
- }
6174
+ return result
6590
6175
  end
6591
6176
  return {
6592
6177
  getFileTree = getFileTree,
@@ -6602,7 +6187,7 @@ return {
6602
6187
  ]]></string>
6603
6188
  </Properties>
6604
6189
  </Item>
6605
- <Item class="ModuleScript" referent="19">
6190
+ <Item class="ModuleScript" referent="20">
6606
6191
  <Properties>
6607
6192
  <string name="Name">SceneAnalysisHandlers</string>
6608
6193
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -6851,7 +6436,7 @@ return {
6851
6436
  ]]></string>
6852
6437
  </Properties>
6853
6438
  </Item>
6854
- <Item class="ModuleScript" referent="20">
6439
+ <Item class="ModuleScript" referent="21">
6855
6440
  <Properties>
6856
6441
  <string name="Name">ScriptHandlers</string>
6857
6442
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7014,7 +6599,7 @@ local function setScriptSource(requestData)
7014
6599
  error = `Instance is not a script-like object: {instance.ClassName}`,
7015
6600
  }
7016
6601
  end
7017
- -- Communication has already JSON-decoded the poll payload; source text is exact at this boundary.
6602
+ -- Communication has already JSON-decoded the transport payload; source text is exact at this boundary.
7018
6603
  local sourceToSet = newSource
7019
6604
  local recordingId = beginRecording(`Set script source: {instance.Name}`)
7020
6605
  local readSuccess, readResult = pcall(function()
@@ -7523,7 +7108,7 @@ return {
7523
7108
  ]]></string>
7524
7109
  </Properties>
7525
7110
  </Item>
7526
- <Item class="ModuleScript" referent="21">
7111
+ <Item class="ModuleScript" referent="22">
7527
7112
  <Properties>
7528
7113
  <string name="Name">ScriptProfilerHandlers</string>
7529
7114
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7986,7 +7571,7 @@ return {
7986
7571
  ]]></string>
7987
7572
  </Properties>
7988
7573
  </Item>
7989
- <Item class="ModuleScript" referent="22">
7574
+ <Item class="ModuleScript" referent="23">
7990
7575
  <Properties>
7991
7576
  <string name="Name">SerializationHandlers</string>
7992
7577
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -8172,7 +7757,7 @@ return {
8172
7757
  ]]></string>
8173
7758
  </Properties>
8174
7759
  </Item>
8175
- <Item class="ModuleScript" referent="23">
7760
+ <Item class="ModuleScript" referent="24">
8176
7761
  <Properties>
8177
7762
  <string name="Name">TestHandlers</string>
8178
7763
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -8551,7 +8136,7 @@ return {
8551
8136
  </Properties>
8552
8137
  </Item>
8553
8138
  </Item>
8554
- <Item class="ModuleScript" referent="24">
8139
+ <Item class="ModuleScript" referent="25">
8555
8140
  <Properties>
8556
8141
  <string name="Name">HttpDiagnostics</string>
8557
8142
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -8632,7 +8217,7 @@ return {
8632
8217
  ]]></string>
8633
8218
  </Properties>
8634
8219
  </Item>
8635
- <Item class="ModuleScript" referent="25">
8220
+ <Item class="ModuleScript" referent="26">
8636
8221
  <Properties>
8637
8222
  <string name="Name">LuauExec</string>
8638
8223
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9066,7 +8651,99 @@ return {
9066
8651
  ]]></string>
9067
8652
  </Properties>
9068
8653
  </Item>
9069
- <Item class="ModuleScript" referent="26">
8654
+ <Item class="ModuleScript" referent="27">
8655
+ <Properties>
8656
+ <string name="Name">PluginSession</string>
8657
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
8658
+ local TS = require(script.Parent.Parent.include.RuntimeLib)
8659
+ local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
8660
+ local HttpService = _services.HttpService
8661
+ local RunService = _services.RunService
8662
+ local ServerStorage = _services.ServerStorage
8663
+ local State = TS.import(script, script.Parent, "State")
8664
+ local MCP_PLACE_ID_ATTRIBUTE = "__MCPPlaceId"
8665
+ local id = HttpService:GenerateGUID(false)
8666
+ local cachedPlaceName
8667
+ local cachedPlaceNamePlaceId
8668
+ local function getInstanceId()
8669
+ if game.PlaceId ~= 0 then
8670
+ return `place:{tostring(game.PlaceId)}`
8671
+ end
8672
+ local existing = ServerStorage:GetAttribute(MCP_PLACE_ID_ATTRIBUTE)
8673
+ if type(existing) == "string" and existing ~= "" then
8674
+ return `anon:{existing}`
8675
+ end
8676
+ local fresh = HttpService:GenerateGUID(false)
8677
+ pcall(function()
8678
+ return ServerStorage:SetAttribute(MCP_PLACE_ID_ATTRIBUTE, fresh)
8679
+ end)
8680
+ return `anon:{fresh}`
8681
+ end
8682
+ local function getRole()
8683
+ if not RunService:IsRunning() then
8684
+ return "edit"
8685
+ end
8686
+ if RunService:IsServer() then
8687
+ return "server"
8688
+ end
8689
+ return "client"
8690
+ end
8691
+ local function invalidatePlaceName()
8692
+ cachedPlaceName = nil
8693
+ cachedPlaceNamePlaceId = nil
8694
+ end
8695
+ local function getPlaceName()
8696
+ if cachedPlaceName ~= nil and cachedPlaceNamePlaceId == game.PlaceId then
8697
+ return cachedPlaceName
8698
+ end
8699
+ invalidatePlaceName()
8700
+ cachedPlaceNamePlaceId = game.PlaceId
8701
+ if game.PlaceId == 0 then
8702
+ cachedPlaceName = game.Name
8703
+ return cachedPlaceName
8704
+ end
8705
+ local MarketplaceService = game:GetService("MarketplaceService")
8706
+ local ok, info = pcall(function()
8707
+ return MarketplaceService:GetProductInfo(game.PlaceId)
8708
+ end)
8709
+ if ok and info ~= nil then
8710
+ -- GetProductInfo's generated type is broader than the place metadata returned here.
8711
+ local placeInfo = info
8712
+ local name = placeInfo.Name
8713
+ if type(name) == "string" and name ~= "" then
8714
+ cachedPlaceName = name
8715
+ return cachedPlaceName
8716
+ end
8717
+ end
8718
+ return game.Name
8719
+ end
8720
+ local function createReadyPayload(pluginSessionId, role)
8721
+ return {
8722
+ pluginSessionId = pluginSessionId,
8723
+ physicalSessionId = id,
8724
+ instanceId = getInstanceId(),
8725
+ role = role,
8726
+ placeId = game.PlaceId,
8727
+ placeName = getPlaceName(),
8728
+ dataModelName = game.Name,
8729
+ isRunning = RunService:IsRunning(),
8730
+ pluginVersion = State.CURRENT_VERSION,
8731
+ pluginVariant = State.PLUGIN_VARIANT,
8732
+ timestamp = tick(),
8733
+ }
8734
+ end
8735
+ return {
8736
+ id = id,
8737
+ getInstanceId = getInstanceId,
8738
+ getRole = getRole,
8739
+ getPlaceName = getPlaceName,
8740
+ invalidatePlaceName = invalidatePlaceName,
8741
+ createReadyPayload = createReadyPayload,
8742
+ }
8743
+ ]]></string>
8744
+ </Properties>
8745
+ </Item>
8746
+ <Item class="ModuleScript" referent="28">
9070
8747
  <Properties>
9071
8748
  <string name="Name">Recording</string>
9072
8749
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9096,7 +8773,7 @@ return {
9096
8773
  ]]></string>
9097
8774
  </Properties>
9098
8775
  </Item>
9099
- <Item class="ModuleScript" referent="27">
8776
+ <Item class="ModuleScript" referent="29">
9100
8777
  <Properties>
9101
8778
  <string name="Name">RenderMonitor</string>
9102
8779
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9164,7 +8841,7 @@ return {
9164
8841
  ]]></string>
9165
8842
  </Properties>
9166
8843
  </Item>
9167
- <Item class="ModuleScript" referent="28">
8844
+ <Item class="ModuleScript" referent="30">
9168
8845
  <Properties>
9169
8846
  <string name="Name">RuntimeLogBuffer</string>
9170
8847
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9419,7 +9096,334 @@ return {
9419
9096
  ]]></string>
9420
9097
  </Properties>
9421
9098
  </Item>
9422
- <Item class="ModuleScript" referent="29">
9099
+ <Item class="ModuleScript" referent="31">
9100
+ <Properties>
9101
+ <string name="Name">ScriptSearch</string>
9102
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
9103
+ local function splitLuaAlternation(pattern)
9104
+ local parts = {}
9105
+ local current = ""
9106
+ local index = 1
9107
+ local inCharacterClass = false
9108
+ while index <= #pattern do
9109
+ local character = string.sub(pattern, index, index)
9110
+ if character == "%" then
9111
+ if string.sub(pattern, index + 1, index + 1) == "b" then
9112
+ current ..= string.sub(pattern, index, math.min(index + 3, #pattern))
9113
+ index += 4
9114
+ else
9115
+ current ..= string.sub(pattern, index, index + 1)
9116
+ index += 2
9117
+ end
9118
+ elseif character == "[" then
9119
+ inCharacterClass = true
9120
+ current ..= character
9121
+ index += 1
9122
+ elseif character == "]" then
9123
+ inCharacterClass = false
9124
+ current ..= character
9125
+ index += 1
9126
+ elseif character == "|" and not inCharacterClass then
9127
+ local _current = current
9128
+ table.insert(parts, _current)
9129
+ current = ""
9130
+ index += 1
9131
+ else
9132
+ current ..= character
9133
+ index += 1
9134
+ end
9135
+ end
9136
+ local _current = current
9137
+ table.insert(parts, _current)
9138
+ return parts
9139
+ end
9140
+ local function findFirstPattern(line, alternatives, control)
9141
+ local earliest
9142
+ for _, alternative in alternatives do
9143
+ control:checkpoint()
9144
+ if alternative == "" then
9145
+ continue
9146
+ end
9147
+ local start = string.find(line, alternative)
9148
+ if start ~= nil and (earliest == nil or start < earliest) then
9149
+ earliest = start
9150
+ end
9151
+ end
9152
+ return earliest
9153
+ end
9154
+ local function readLine(source, start)
9155
+ local boundary = string.find(source, "[\r\n]", start)
9156
+ if boundary == nil then
9157
+ return { string.sub(source, start), nil }
9158
+ end
9159
+ local nextStart = boundary + 1
9160
+ if string.sub(source, boundary, boundary) == "\r" and string.sub(source, nextStart, nextStart) == "\n" then
9161
+ nextStart += 1
9162
+ end
9163
+ return { string.sub(source, start, boundary - 1), if nextStart <= #source then nextStart else nil }
9164
+ end
9165
+ local MAX_RESULTS = 10_000
9166
+ local LITERAL_CHUNK_BYTES = 64 * 1024
9167
+ local MAX_PATTERN_BYTES = 4096
9168
+ local MAX_CONTEXT_LINES = 100
9169
+ local function sourceCanMatchLiteral(source, pattern, caseSensitive, control)
9170
+ if #source == 0 then
9171
+ return false
9172
+ end
9173
+ local overlapBytes = math.max(#pattern - 1, 0)
9174
+ local chunkStart = 1
9175
+ while chunkStart <= #source do
9176
+ control:checkpoint()
9177
+ local chunkEnd = math.min(#source, chunkStart + LITERAL_CHUNK_BYTES + overlapBytes - 1)
9178
+ local chunk = string.sub(source, chunkStart, chunkEnd)
9179
+ local candidate = if caseSensitive then chunk else string.lower(chunk)
9180
+ if (string.find(candidate, pattern, 1, true)) ~= nil then
9181
+ return true
9182
+ end
9183
+ if chunkEnd >= #source then
9184
+ return false
9185
+ end
9186
+ chunkStart += LITERAL_CHUNK_BYTES
9187
+ end
9188
+ return false
9189
+ end
9190
+ local function createScriptSearch(corpus)
9191
+ local function search(requestData, control)
9192
+ local requestedPattern = requestData.pattern
9193
+ if not (type(requestedPattern) == "string") or requestedPattern == "" then
9194
+ return {
9195
+ error = "pattern is required",
9196
+ }
9197
+ end
9198
+ if #requestedPattern > MAX_PATTERN_BYTES then
9199
+ return {
9200
+ error = "invalid_request",
9201
+ message = `pattern must contain between 1 and {MAX_PATTERN_BYTES} bytes`,
9202
+ }
9203
+ end
9204
+ local pattern = requestedPattern
9205
+ for _, optionName in { "usePattern", "caseSensitive", "filesOnly" } do
9206
+ local option = requestData[optionName]
9207
+ if option ~= nil and not (type(option) == "boolean") then
9208
+ return {
9209
+ error = "invalid_request",
9210
+ message = `{optionName} must be a boolean`,
9211
+ }
9212
+ end
9213
+ end
9214
+ local _condition = (requestData.usePattern)
9215
+ if _condition == nil then
9216
+ _condition = false
9217
+ end
9218
+ local usePattern = _condition
9219
+ if usePattern and requestData.caseSensitive == false then
9220
+ return {
9221
+ error = "Case-insensitive Lua pattern search is not supported.",
9222
+ }
9223
+ end
9224
+ local _result
9225
+ if usePattern then
9226
+ _result = true
9227
+ else
9228
+ local _condition_1 = (requestData.caseSensitive)
9229
+ if _condition_1 == nil then
9230
+ _condition_1 = false
9231
+ end
9232
+ _result = _condition_1
9233
+ end
9234
+ local caseSensitive = _result
9235
+ local _condition_1 = (requestData.filesOnly)
9236
+ if _condition_1 == nil then
9237
+ _condition_1 = false
9238
+ end
9239
+ local filesOnly = _condition_1
9240
+ local requestedContextLines = requestData.contextLines
9241
+ if requestedContextLines ~= nil and (not (type(requestedContextLines) == "number") or math.floor(requestedContextLines) ~= requestedContextLines or requestedContextLines < 0 or requestedContextLines > MAX_CONTEXT_LINES) then
9242
+ return {
9243
+ error = "invalid_request",
9244
+ message = `contextLines must be an integer between 0 and {MAX_CONTEXT_LINES}`,
9245
+ }
9246
+ end
9247
+ local _condition_2 = requestedContextLines
9248
+ if _condition_2 == nil then
9249
+ _condition_2 = 0
9250
+ end
9251
+ local contextLines = _condition_2
9252
+ local requestedMaxResults = requestData.maxResults
9253
+ if requestedMaxResults ~= nil and (not (type(requestedMaxResults) == "number") or math.floor(requestedMaxResults) ~= requestedMaxResults or requestedMaxResults < 1 or requestedMaxResults > MAX_RESULTS) then
9254
+ return {
9255
+ error = "invalid_request",
9256
+ message = `maxResults must be an integer between 1 and {MAX_RESULTS}`,
9257
+ }
9258
+ end
9259
+ local _condition_3 = requestedMaxResults
9260
+ if _condition_3 == nil then
9261
+ _condition_3 = 100
9262
+ end
9263
+ local maxResults = _condition_3
9264
+ local requestedMaxResultsPerScript = requestData.maxResultsPerScript
9265
+ if requestedMaxResultsPerScript ~= nil and (not (type(requestedMaxResultsPerScript) == "number") or math.floor(requestedMaxResultsPerScript) ~= requestedMaxResultsPerScript or requestedMaxResultsPerScript < 0 or requestedMaxResultsPerScript > MAX_RESULTS) then
9266
+ return {
9267
+ error = "invalid_request",
9268
+ message = `maxResultsPerScript must be an integer between 0 and {MAX_RESULTS}`,
9269
+ }
9270
+ end
9271
+ local _condition_4 = requestedMaxResultsPerScript
9272
+ if _condition_4 == nil then
9273
+ _condition_4 = 0
9274
+ end
9275
+ local maxResultsPerScript = _condition_4
9276
+ local classFilter = requestData.classFilter
9277
+ local _condition_5 = (requestData.path)
9278
+ if _condition_5 == nil then
9279
+ _condition_5 = ""
9280
+ end
9281
+ local searchPath = _condition_5
9282
+ local root = corpus:resolveRoot(searchPath)
9283
+ if root == nil then
9284
+ return {
9285
+ error = `Path not found: {searchPath}`,
9286
+ }
9287
+ end
9288
+ local searchPattern = if caseSensitive then pattern else string.lower(pattern)
9289
+ local patternAlternatives = if usePattern then splitLuaAlternation(searchPattern) else nil
9290
+ local results = {}
9291
+ local stack = { root }
9292
+ local totalMatches = 0
9293
+ local scriptsSearched = 0
9294
+ local hitLimit = false
9295
+ while #stack > 0 and not hitLimit do
9296
+ control:checkpoint()
9297
+ -- ▼ Array.pop ▼
9298
+ local _length = #stack
9299
+ local _result_1 = stack[_length]
9300
+ stack[_length] = nil
9301
+ -- ▲ Array.pop ▲
9302
+ local instance = _result_1
9303
+ local snapshot = corpus:readScript(instance, classFilter)
9304
+ if snapshot ~= nil and (classFilter == nil or snapshot.className == classFilter) then
9305
+ scriptsSearched += 1
9306
+ local scriptMatches = {}
9307
+ local before = {}
9308
+ local pendingAfter = {}
9309
+ local scriptMatchCount = 0
9310
+ local lineNumber = 1
9311
+ local lineStart = 1
9312
+ local canMatch = usePattern or sourceCanMatchLiteral(snapshot.source, searchPattern, caseSensitive, control)
9313
+ if canMatch then
9314
+ while lineStart ~= nil and not hitLimit do
9315
+ control:checkpoint()
9316
+ local _binding = readLine(snapshot.source, lineStart)
9317
+ local line = _binding[1]
9318
+ local nextLineStart = _binding[2]
9319
+ for _, pending in pendingAfter do
9320
+ if pending.remaining > 0 then
9321
+ local _exp = pending.match.after
9322
+ table.insert(_exp, line)
9323
+ pending.remaining -= 1
9324
+ end
9325
+ end
9326
+ while #pendingAfter > 0 and pendingAfter[1].remaining == 0 do
9327
+ table.remove(pendingAfter, 1)
9328
+ end
9329
+ local candidate = if caseSensitive then line else string.lower(line)
9330
+ local matchStart = if usePattern then findFirstPattern(candidate, patternAlternatives, control) else (string.find(candidate, searchPattern, 1, true))
9331
+ if matchStart ~= nil and (maxResultsPerScript == 0 or scriptMatchCount < maxResultsPerScript) then
9332
+ scriptMatchCount += 1
9333
+ totalMatches += 1
9334
+ if totalMatches > maxResults then
9335
+ hitLimit = true
9336
+ break
9337
+ end
9338
+ if not filesOnly then
9339
+ local _object = {
9340
+ line = lineNumber,
9341
+ column = matchStart,
9342
+ text = line,
9343
+ }
9344
+ local _left = "before"
9345
+ local _array = {}
9346
+ local _length_1 = #_array
9347
+ table.move(before, 1, #before, _length_1 + 1, _array)
9348
+ _object[_left] = _array
9349
+ _object.after = {}
9350
+ local lineMatch = _object
9351
+ table.insert(scriptMatches, lineMatch)
9352
+ if contextLines > 0 then
9353
+ local _arg0 = {
9354
+ match = lineMatch,
9355
+ remaining = contextLines,
9356
+ }
9357
+ table.insert(pendingAfter, _arg0)
9358
+ end
9359
+ end
9360
+ end
9361
+ if maxResultsPerScript > 0 and scriptMatchCount >= maxResultsPerScript and #pendingAfter == 0 then
9362
+ break
9363
+ end
9364
+ if contextLines > 0 then
9365
+ table.insert(before, line)
9366
+ while #before > contextLines do
9367
+ table.remove(before, 1)
9368
+ end
9369
+ end
9370
+ lineStart = nextLineStart
9371
+ if nextLineStart == nil then
9372
+ break
9373
+ end
9374
+ lineNumber += 1
9375
+ end
9376
+ end
9377
+ if scriptMatchCount > 0 then
9378
+ local scriptResult = {
9379
+ instancePath = snapshot.instancePath,
9380
+ name = snapshot.name,
9381
+ className = snapshot.className,
9382
+ matches = scriptMatches,
9383
+ }
9384
+ if snapshot.enabled ~= nil then
9385
+ scriptResult.enabled = snapshot.enabled
9386
+ end
9387
+ table.insert(results, scriptResult)
9388
+ end
9389
+ end
9390
+ local children = {}
9391
+ for _, child in corpus:getChildren(instance) do
9392
+ table.insert(children, child)
9393
+ end
9394
+ for index = #children - 1, 0, -1 do
9395
+ local _arg0 = children[index + 1]
9396
+ table.insert(stack, _arg0)
9397
+ end
9398
+ end
9399
+ return {
9400
+ results = results,
9401
+ pattern = pattern,
9402
+ totalMatches = if hitLimit then `>{maxResults}` else totalMatches,
9403
+ scriptsSearched = scriptsSearched,
9404
+ scriptsMatched = #results,
9405
+ truncated = hitLimit,
9406
+ options = {
9407
+ caseSensitive = caseSensitive,
9408
+ contextLines = contextLines,
9409
+ usePattern = usePattern,
9410
+ filesOnly = filesOnly,
9411
+ maxResults = maxResults,
9412
+ maxResultsPerScript = maxResultsPerScript,
9413
+ },
9414
+ }
9415
+ end
9416
+ return {
9417
+ search = search,
9418
+ }
9419
+ end
9420
+ return {
9421
+ createScriptSearch = createScriptSearch,
9422
+ }
9423
+ ]]></string>
9424
+ </Properties>
9425
+ </Item>
9426
+ <Item class="ModuleScript" referent="32">
9423
9427
  <Properties>
9424
9428
  <string name="Name">ServerUrlSettings</string>
9425
9429
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9427,7 +9431,6 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
9427
9431
  local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
9428
9432
  local HttpService = _services.HttpService
9429
9433
  local ServerStorage = _services.ServerStorage
9430
- local LEGACY_SETTING_KEY_PREFIX = "MCP_SERVER_URL_"
9431
9434
  local SETTING_KEY_PREFIX = "MCP_LAST_SUCCESSFUL_SERVER_URL_"
9432
9435
  local GLOBAL_SETTING_KEY = "MCP_LAST_SUCCESSFUL_SERVER_URL_GLOBAL_V1"
9433
9436
  local pluginRef
@@ -9497,9 +9500,6 @@ end
9497
9500
  local function settingKey(instanceId)
9498
9501
  return SETTING_KEY_PREFIX .. instanceId
9499
9502
  end
9500
- local function legacySettingKey(instanceId)
9501
- return LEGACY_SETTING_KEY_PREFIX .. instanceId
9502
- end
9503
9503
  local function readSettingString(key)
9504
9504
  if not pluginRef then
9505
9505
  return nil
@@ -9531,7 +9531,6 @@ local function rememberServerUrl(serverUrl)
9531
9531
  createAnonymous = true,
9532
9532
  }) do
9533
9533
  writeSettingString(settingKey(instanceId), normalized)
9534
- writeSettingString(legacySettingKey(instanceId), normalized)
9535
9534
  end
9536
9535
  end
9537
9536
  local function readServerUrl()
@@ -9551,12 +9550,6 @@ local function readServerUrl()
9551
9550
  if globalRemembered ~= nil then
9552
9551
  return globalRemembered
9553
9552
  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
9553
  return nil
9561
9554
  end
9562
9555
  return {
@@ -9569,31 +9562,24 @@ return {
9569
9562
  ]]></string>
9570
9563
  </Properties>
9571
9564
  </Item>
9572
- <Item class="ModuleScript" referent="30">
9565
+ <Item class="ModuleScript" referent="33">
9573
9566
  <Properties>
9574
9567
  <string name="Name">State</string>
9575
9568
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
9576
- local CURRENT_VERSION = "3.0.2"
9569
+ local CURRENT_VERSION = "3.0.4"
9577
9570
  local PLUGIN_VARIANT = "main"
9578
9571
  local BASE_PORT = 58741
9579
- local POLL_REQUEST_TIMEOUT_SECONDS = 20
9580
9572
  local function createConnection(port)
9581
9573
  return {
9582
9574
  port = port,
9583
9575
  serverUrl = `http://localhost:{port}`,
9584
9576
  isActive = false,
9585
- pollInterval = 0.5,
9586
- lastPoll = 0,
9587
9577
  consecutiveFailures = 0,
9588
9578
  maxFailuresBeforeError = 50,
9589
- lastSuccessfulConnection = 0,
9590
9579
  currentRetryDelay = 0.5,
9591
- maxRetryDelay = 5,
9592
- retryBackoffMultiplier = 1.2,
9593
9580
  lastHttpOk = false,
9594
9581
  lastMcpOk = false,
9595
9582
  mcpWaitStartTime = nil,
9596
- isPolling = false,
9597
9583
  heartbeatConnection = nil,
9598
9584
  }
9599
9585
  end
@@ -9605,13 +9591,12 @@ return {
9605
9591
  CURRENT_VERSION = CURRENT_VERSION,
9606
9592
  PLUGIN_VARIANT = PLUGIN_VARIANT,
9607
9593
  BASE_PORT = BASE_PORT,
9608
- POLL_REQUEST_TIMEOUT_SECONDS = POLL_REQUEST_TIMEOUT_SECONDS,
9609
9594
  getActiveConnection = getActiveConnection,
9610
9595
  }
9611
9596
  ]]></string>
9612
9597
  </Properties>
9613
9598
  </Item>
9614
- <Item class="ModuleScript" referent="31">
9599
+ <Item class="ModuleScript" referent="34">
9615
9600
  <Properties>
9616
9601
  <string name="Name">StopPlayMonitor</string>
9617
9602
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9664,7 +9649,7 @@ local endTestIssued = false
9664
9649
  local function init(p)
9665
9650
  pluginRef = p
9666
9651
  end
9667
- -- Mirror of Communication.computeInstanceId(). Duplicated here because
9652
+ -- Mirror of PluginSession's place identity rules. Duplicated here because
9668
9653
  -- StopPlayMonitor runs in both edit and play-server DMs, and both must
9669
9654
  -- agree on the place identifier (published places: placeId; unpublished:
9670
9655
  -- UUID on ServerStorage's __MCPPlaceId attribute, travels with the .rbxl
@@ -9820,16 +9805,9 @@ local function startMonitor()
9820
9805
  task.spawn(function()
9821
9806
  while true do
9822
9807
  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
9808
+ local payload = decodePayload(readSetting(myKey))
9809
+ if payload then
9810
+ handleStopRequest(myKey, payload)
9833
9811
  end
9834
9812
  end
9835
9813
  task.wait(POLL_INTERVAL_SEC)
@@ -9909,7 +9887,701 @@ return {
9909
9887
  ]]></string>
9910
9888
  </Properties>
9911
9889
  </Item>
9912
- <Item class="ModuleScript" referent="32">
9890
+ <Item class="ModuleScript" referent="35">
9891
+ <Properties>
9892
+ <string name="Name">StudioEventStream</string>
9893
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
9894
+ local TS = require(script.Parent.Parent.include.RuntimeLib)
9895
+ local HttpService = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services").HttpService
9896
+ local HttpDiagnostics = TS.import(script, script.Parent, "HttpDiagnostics")
9897
+ local PluginSession = TS.import(script, script.Parent, "PluginSession")
9898
+ local INITIAL_RECONNECT_DELAY_SECONDS = 0.5
9899
+ local MAX_RECONNECT_DELAY_SECONDS = 5
9900
+ local INITIAL_RESPONSE_RETRY_DELAY_SECONDS = 0.5
9901
+ local MAX_RESPONSE_RETRY_DELAY_SECONDS = 5
9902
+ local MAX_TERMINAL_RESPONSES = 256
9903
+ local STREAM_SILENCE_TIMEOUT_SECONDS = 20
9904
+ local options
9905
+ local active = false
9906
+ local generation = 0
9907
+ local reconnectAttempt = 0
9908
+ local streamClient
9909
+ local streamConnections = {}
9910
+ local lastValidEventAt = 0
9911
+ local inFlightRequests = {}
9912
+ local pendingResponses = {}
9913
+ local terminalResponseIds = {}
9914
+ local terminalResponseOrder = {}
9915
+ local readyFailureLogKeys = {}
9916
+ local function decodeMessage(message)
9917
+ -- Studio versions in the supported channel have surfaced either the SSE
9918
+ -- data payload or the complete single-line `data:` frame. The bridge emits
9919
+ -- one JSON data line per event, so normalize both forms before decoding.
9920
+ local payload = message
9921
+ local normalized = (string.gsub((string.gsub(message, "\r\n", "\n")), "\r", "\n"))
9922
+ if string.sub(normalized, 1, 5) == "data:" then
9923
+ payload = (string.gsub((string.gsub(string.sub(normalized, 6), "^%s+", "")), "%s+$", ""))
9924
+ end
9925
+ local decodeOk, decoded = pcall(function()
9926
+ return HttpService:JSONDecode(payload)
9927
+ end)
9928
+ if not decodeOk or not (type(decoded) == "table") then
9929
+ return nil
9930
+ end
9931
+ local envelope = decoded
9932
+ if envelope.kind == "heartbeat" then
9933
+ local _timestamp = envelope.timestamp
9934
+ if not (type(_timestamp) == "number") then
9935
+ return nil
9936
+ end
9937
+ return {
9938
+ kind = "heartbeat",
9939
+ timestamp = envelope.timestamp,
9940
+ }
9941
+ end
9942
+ if envelope.kind == "status" then
9943
+ local _knownInstance = envelope.knownInstance
9944
+ local _condition = not (type(_knownInstance) == "boolean")
9945
+ if not _condition then
9946
+ local _mcpConnected = envelope.mcpConnected
9947
+ _condition = not (type(_mcpConnected) == "boolean")
9948
+ end
9949
+ if _condition then
9950
+ return nil
9951
+ end
9952
+ local _object = {
9953
+ kind = "status",
9954
+ knownInstance = envelope.knownInstance,
9955
+ mcpConnected = envelope.mcpConnected,
9956
+ }
9957
+ local _left = "serverVersion"
9958
+ local _serverVersion = envelope.serverVersion
9959
+ _object[_left] = if type(_serverVersion) == "string" then envelope.serverVersion else nil
9960
+ local _left_1 = "pluginVersion"
9961
+ local _pluginVersion = envelope.pluginVersion
9962
+ _object[_left_1] = if type(_pluginVersion) == "string" then envelope.pluginVersion else nil
9963
+ local _left_2 = "pluginVariant"
9964
+ local _pluginVariant = envelope.pluginVariant
9965
+ _object[_left_2] = if type(_pluginVariant) == "string" then envelope.pluginVariant else nil
9966
+ return _object
9967
+ end
9968
+ if envelope.kind == "cancel" then
9969
+ local _requestId = envelope.requestId
9970
+ local _condition = not (type(_requestId) == "string")
9971
+ if not _condition then
9972
+ _condition = (envelope.reason ~= "timeout" and envelope.reason ~= "aborted" and envelope.reason ~= "connection_closed")
9973
+ end
9974
+ if _condition then
9975
+ return nil
9976
+ end
9977
+ return {
9978
+ kind = "cancel",
9979
+ requestId = envelope.requestId,
9980
+ reason = envelope.reason,
9981
+ }
9982
+ end
9983
+ if envelope.kind == "request" then
9984
+ local _requestId = envelope.requestId
9985
+ local _condition = not (type(_requestId) == "string")
9986
+ if not _condition then
9987
+ local _logicalSessionId = envelope.logicalSessionId
9988
+ _condition = not (type(_logicalSessionId) == "string")
9989
+ if not _condition then
9990
+ local _target = envelope.target
9991
+ _condition = not (type(_target) == "string")
9992
+ if not _condition then
9993
+ local _endpoint = envelope.endpoint
9994
+ _condition = not (type(_endpoint) == "string")
9995
+ if not _condition then
9996
+ local _remainingMs = envelope.remainingMs
9997
+ _condition = not (type(_remainingMs) == "number")
9998
+ if not _condition then
9999
+ _condition = envelope.remainingMs < 0
10000
+ end
10001
+ end
10002
+ end
10003
+ end
10004
+ end
10005
+ if _condition then
10006
+ return nil
10007
+ end
10008
+ local data
10009
+ local _data = envelope.data
10010
+ if type(_data) == "table" then
10011
+ data = envelope.data
10012
+ end
10013
+ return {
10014
+ kind = "request",
10015
+ requestId = envelope.requestId,
10016
+ logicalSessionId = envelope.logicalSessionId,
10017
+ target = envelope.target,
10018
+ endpoint = envelope.endpoint,
10019
+ data = data,
10020
+ remainingMs = envelope.remainingMs,
10021
+ }
10022
+ end
10023
+ return nil
10024
+ end
10025
+ local function closeCurrentStream()
10026
+ local current = streamClient
10027
+ streamClient = nil
10028
+ for _, connection in streamConnections do
10029
+ connection:Disconnect()
10030
+ end
10031
+ streamConnections = {}
10032
+ if current ~= nil then
10033
+ pcall(function()
10034
+ return current:Close()
10035
+ end)
10036
+ end
10037
+ end
10038
+ local function responseRetryDelay(attempt)
10039
+ return math.min(INITIAL_RESPONSE_RETRY_DELAY_SECONDS * math.pow(2, math.max(attempt - 1, 0)), MAX_RESPONSE_RETRY_DELAY_SECONDS)
10040
+ end
10041
+ local function parseResponseDisposition(success, body)
10042
+ local decodeOk, decoded = pcall(function()
10043
+ return HttpService:JSONDecode(body)
10044
+ end)
10045
+ if not decodeOk or not (type(decoded) == "table") then
10046
+ return nil
10047
+ end
10048
+ local acknowledgement = decoded
10049
+ local disposition = acknowledgement.disposition
10050
+ if disposition == "accepted" or disposition == "already_settled" or disposition == "unknown" then
10051
+ return disposition
10052
+ end
10053
+ if success and acknowledgement.success == true and disposition == nil then
10054
+ return "accepted"
10055
+ end
10056
+ return nil
10057
+ end
10058
+ local function rememberTerminalResponse(requestId)
10059
+ local _requestId = requestId
10060
+ if terminalResponseIds[_requestId] ~= nil then
10061
+ return nil
10062
+ end
10063
+ local _requestId_1 = requestId
10064
+ terminalResponseIds[_requestId_1] = true
10065
+ local _requestId_2 = requestId
10066
+ table.insert(terminalResponseOrder, _requestId_2)
10067
+ while #terminalResponseOrder > MAX_TERMINAL_RESPONSES do
10068
+ local oldest = table.remove(terminalResponseOrder, 1)
10069
+ if oldest ~= nil then
10070
+ terminalResponseIds[oldest] = nil
10071
+ end
10072
+ end
10073
+ end
10074
+ local function settleResponse(requestId, entry, disposition)
10075
+ local _requestId = requestId
10076
+ if pendingResponses[_requestId] ~= entry then
10077
+ return nil
10078
+ end
10079
+ local _requestId_1 = requestId
10080
+ pendingResponses[_requestId_1] = nil
10081
+ rememberTerminalResponse(requestId)
10082
+ if disposition == "unknown" then
10083
+ warn(`[robloxstudio-mcp] Server no longer recognizes response {requestId}; dropping stored result`)
10084
+ end
10085
+ end
10086
+ local function postPendingResponse(requestId, entry)
10087
+ local currentOptions = options
10088
+ local _condition = not active or currentOptions == nil
10089
+ if not _condition then
10090
+ local _requestId = requestId
10091
+ _condition = pendingResponses[_requestId] ~= entry
10092
+ if not _condition then
10093
+ _condition = entry.posting
10094
+ end
10095
+ end
10096
+ if _condition then
10097
+ return nil
10098
+ end
10099
+ entry.posting = true
10100
+ entry.retryToken += 1
10101
+ task.spawn(function()
10102
+ local _condition_1 = not active or options ~= currentOptions
10103
+ if not _condition_1 then
10104
+ local _requestId = requestId
10105
+ _condition_1 = pendingResponses[_requestId] ~= entry
10106
+ end
10107
+ if _condition_1 then
10108
+ entry.posting = false
10109
+ return nil
10110
+ end
10111
+ local responseUrl = `{currentOptions.serverUrl}/response`
10112
+ local requestOk, requestResult = pcall(function()
10113
+ return HttpService:RequestAsync({
10114
+ Url = responseUrl,
10115
+ Method = "POST",
10116
+ Headers = {
10117
+ ["Content-Type"] = "application/json",
10118
+ },
10119
+ Body = entry.body,
10120
+ })
10121
+ end)
10122
+ local _requestId = requestId
10123
+ if pendingResponses[_requestId] ~= entry then
10124
+ return nil
10125
+ end
10126
+ entry.posting = false
10127
+ local failure
10128
+ if not requestOk then
10129
+ failure = HttpDiagnostics.formatRequestFailure(responseUrl, false, requestResult)
10130
+ else
10131
+ local disposition = parseResponseDisposition(requestResult.Success, requestResult.Body)
10132
+ if disposition ~= nil then
10133
+ settleResponse(requestId, entry, disposition)
10134
+ return nil
10135
+ end
10136
+ failure = if requestResult.Success then "Invalid /response acknowledgement" else HttpDiagnostics.formatRequestFailure(responseUrl, true, requestResult)
10137
+ end
10138
+ warn(`[robloxstudio-mcp] Failed to deliver response {requestId}: {failure}`)
10139
+ entry.retryAttempt += 1
10140
+ local _condition_2 = not active
10141
+ if not _condition_2 then
10142
+ local _requestId_1 = requestId
10143
+ _condition_2 = pendingResponses[_requestId_1] ~= entry
10144
+ end
10145
+ if _condition_2 then
10146
+ return nil
10147
+ end
10148
+ entry.retryToken += 1
10149
+ local retryToken = entry.retryToken
10150
+ local delay = responseRetryDelay(entry.retryAttempt)
10151
+ task.delay(delay, function()
10152
+ local _condition_3 = not active
10153
+ if not _condition_3 then
10154
+ local _requestId_1 = requestId
10155
+ _condition_3 = pendingResponses[_requestId_1] ~= entry
10156
+ if not _condition_3 then
10157
+ _condition_3 = entry.retryToken ~= retryToken
10158
+ end
10159
+ end
10160
+ if _condition_3 then
10161
+ return nil
10162
+ end
10163
+ postPendingResponse(requestId, entry)
10164
+ end)
10165
+ end)
10166
+ end
10167
+ local function resumePendingResponses()
10168
+ for requestId, entry in pendingResponses do
10169
+ postPendingResponse(requestId, entry)
10170
+ end
10171
+ end
10172
+ local function encodeResponse(requestId, response)
10173
+ local encodeOk, encoded = pcall(function()
10174
+ return HttpService:JSONEncode({
10175
+ requestId = requestId,
10176
+ response = response,
10177
+ })
10178
+ end)
10179
+ if encodeOk then
10180
+ return encoded
10181
+ end
10182
+ warn(`[robloxstudio-mcp] Failed to serialize response {requestId}: {tostring(encoded)}`)
10183
+ return HttpService:JSONEncode({
10184
+ requestId = requestId,
10185
+ error = `Plugin response serialization failed: {tostring(encoded)}`,
10186
+ })
10187
+ end
10188
+ local function cancelRequest(event)
10189
+ local _requestId = event.requestId
10190
+ local inFlight = inFlightRequests[_requestId]
10191
+ if inFlight ~= nil then
10192
+ inFlight.cancelled = true
10193
+ end
10194
+ local _requestId_1 = event.requestId
10195
+ local pending = pendingResponses[_requestId_1]
10196
+ if pending ~= nil then
10197
+ pending.retryToken += 1
10198
+ local _requestId_2 = event.requestId
10199
+ pendingResponses[_requestId_2] = nil
10200
+ end
10201
+ rememberTerminalResponse(event.requestId)
10202
+ end
10203
+ local function dispatchRequest(request)
10204
+ local _requestId = request.requestId
10205
+ local _condition = terminalResponseIds[_requestId] ~= nil
10206
+ if not _condition then
10207
+ local _requestId_1 = request.requestId
10208
+ _condition = pendingResponses[_requestId_1] ~= nil
10209
+ if not _condition then
10210
+ local _requestId_2 = request.requestId
10211
+ _condition = inFlightRequests[_requestId_2] ~= nil
10212
+ end
10213
+ end
10214
+ if _condition then
10215
+ return nil
10216
+ end
10217
+ local dispatchOptions = options
10218
+ if not active or dispatchOptions == nil then
10219
+ return nil
10220
+ end
10221
+ local inFlight = {
10222
+ cancelled = false,
10223
+ }
10224
+ local context = {
10225
+ requestId = request.requestId,
10226
+ deadlineAt = os.clock() + request.remainingMs / 1000,
10227
+ isCancelled = function()
10228
+ return inFlight.cancelled
10229
+ end,
10230
+ }
10231
+ local _requestId_1 = request.requestId
10232
+ inFlightRequests[_requestId_1] = inFlight
10233
+ task.spawn(function()
10234
+ local _condition_1 = inFlight.cancelled
10235
+ if not _condition_1 then
10236
+ local _requestId_2 = request.requestId
10237
+ _condition_1 = terminalResponseIds[_requestId_2] ~= nil
10238
+ end
10239
+ if _condition_1 then
10240
+ local _requestId_2 = request.requestId
10241
+ if inFlightRequests[_requestId_2] == inFlight then
10242
+ local _requestId_3 = request.requestId
10243
+ inFlightRequests[_requestId_3] = nil
10244
+ end
10245
+ return nil
10246
+ end
10247
+ local dispatchOk, response = pcall(function()
10248
+ return dispatchOptions.dispatchRequest(request, context)
10249
+ end)
10250
+ local _condition_2 = inFlight.cancelled
10251
+ if not _condition_2 then
10252
+ local _requestId_2 = request.requestId
10253
+ _condition_2 = terminalResponseIds[_requestId_2] ~= nil
10254
+ end
10255
+ if _condition_2 then
10256
+ local _requestId_2 = request.requestId
10257
+ if inFlightRequests[_requestId_2] == inFlight then
10258
+ local _requestId_3 = request.requestId
10259
+ inFlightRequests[_requestId_3] = nil
10260
+ end
10261
+ return nil
10262
+ end
10263
+ local responseData = if dispatchOk then response else {
10264
+ error = tostring(response),
10265
+ }
10266
+ local entry = {
10267
+ body = encodeResponse(request.requestId, responseData),
10268
+ retryAttempt = 0,
10269
+ posting = false,
10270
+ retryToken = 0,
10271
+ }
10272
+ local _requestId_2 = request.requestId
10273
+ pendingResponses[_requestId_2] = entry
10274
+ local _requestId_3 = request.requestId
10275
+ inFlightRequests[_requestId_3] = nil
10276
+ postPendingResponse(request.requestId, entry)
10277
+ end)
10278
+ end
10279
+ local function invokeCallback(name, callback)
10280
+ local callbackOk, callbackError = pcall(callback)
10281
+ if not callbackOk then
10282
+ warn(`[robloxstudio-mcp] {name} callback failed: {tostring(callbackError)}`)
10283
+ end
10284
+ end
10285
+ local function reportTransport(update)
10286
+ local currentOptions = options
10287
+ if active and currentOptions ~= nil then
10288
+ invokeCallback("event stream transport", function()
10289
+ return currentOptions.onTransportUpdate(update)
10290
+ end)
10291
+ end
10292
+ end
10293
+ local function reconnectDelay(attempt)
10294
+ return math.min(INITIAL_RECONNECT_DELAY_SECONDS * math.pow(2, math.max(attempt - 1, 0)), MAX_RECONNECT_DELAY_SECONDS)
10295
+ end
10296
+ local connect
10297
+ local function connectAfter(delaySeconds, expectedGeneration)
10298
+ task.delay(delaySeconds, function()
10299
+ if not active or generation ~= expectedGeneration then
10300
+ return nil
10301
+ end
10302
+ connect(expectedGeneration)
10303
+ end)
10304
+ end
10305
+ local function scheduleReconnect(expectedGeneration, detail, duplicate)
10306
+ if duplicate == nil then
10307
+ duplicate = false
10308
+ end
10309
+ if not active or generation ~= expectedGeneration then
10310
+ return nil
10311
+ end
10312
+ generation += 1
10313
+ closeCurrentStream()
10314
+ reconnectAttempt += 1
10315
+ local delay = if duplicate then 1 else reconnectDelay(reconnectAttempt)
10316
+ reportTransport({
10317
+ state = if duplicate then "waiting-duplicate" else "retrying",
10318
+ attempt = reconnectAttempt,
10319
+ retryDelay = delay,
10320
+ detail = detail,
10321
+ })
10322
+ connectAfter(delay, generation)
10323
+ end
10324
+ local function watchForSilence(expectedGeneration, expectedClient)
10325
+ local elapsed = tick() - lastValidEventAt
10326
+ local delay = math.max(STREAM_SILENCE_TIMEOUT_SECONDS - elapsed, 0.1)
10327
+ task.delay(delay, function()
10328
+ if not active or generation ~= expectedGeneration or streamClient ~= expectedClient then
10329
+ return nil
10330
+ end
10331
+ local silentFor = tick() - lastValidEventAt
10332
+ if silentFor >= STREAM_SILENCE_TIMEOUT_SECONDS then
10333
+ scheduleReconnect(expectedGeneration, `Event stream silent for {math.floor(silentFor)} seconds`)
10334
+ return nil
10335
+ end
10336
+ watchForSilence(expectedGeneration, expectedClient)
10337
+ end)
10338
+ end
10339
+ local function parseReadyResponse(body)
10340
+ local decodeOk, decoded = pcall(function()
10341
+ return HttpService:JSONDecode(body)
10342
+ end)
10343
+ if not decodeOk or not (type(decoded) == "table") then
10344
+ return nil
10345
+ end
10346
+ local value = decoded
10347
+ local _condition = value.success ~= true
10348
+ if not _condition then
10349
+ local _assignedRole = value.assignedRole
10350
+ _condition = not (type(_assignedRole) == "string")
10351
+ if not _condition then
10352
+ _condition = value.assignedRole == ""
10353
+ if not _condition then
10354
+ local _instanceId = value.instanceId
10355
+ _condition = not (type(_instanceId) == "string")
10356
+ if not _condition then
10357
+ _condition = value.instanceId == ""
10358
+ if not _condition then
10359
+ local _serverVersion = value.serverVersion
10360
+ _condition = not (type(_serverVersion) == "string")
10361
+ if not _condition then
10362
+ _condition = value.serverVersion == ""
10363
+ end
10364
+ end
10365
+ end
10366
+ end
10367
+ end
10368
+ end
10369
+ if _condition then
10370
+ return nil
10371
+ end
10372
+ return {
10373
+ success = true,
10374
+ assignedRole = value.assignedRole,
10375
+ instanceId = value.instanceId,
10376
+ serverVersion = value.serverVersion,
10377
+ }
10378
+ end
10379
+ local refresh
10380
+ function connect(expectedGeneration)
10381
+ local currentOptions = options
10382
+ if not active or generation ~= expectedGeneration or currentOptions == nil then
10383
+ return nil
10384
+ end
10385
+ reportTransport({
10386
+ state = "connecting",
10387
+ attempt = reconnectAttempt,
10388
+ retryDelay = 0,
10389
+ })
10390
+ task.spawn(function()
10391
+ local instanceId = PluginSession.getInstanceId()
10392
+ local readyUrl = `{currentOptions.serverUrl}/ready`
10393
+ local physicalRole = PluginSession.getRole()
10394
+ local readyPayload = PluginSession.createReadyPayload(PluginSession.id, physicalRole)
10395
+ readyPayload.pluginReady = true
10396
+ if not active or generation ~= expectedGeneration or options ~= currentOptions then
10397
+ return nil
10398
+ end
10399
+ local readyOk, readyResult = pcall(function()
10400
+ return HttpService:RequestAsync({
10401
+ Url = readyUrl,
10402
+ Method = "POST",
10403
+ Headers = {
10404
+ ["Content-Type"] = "application/json",
10405
+ },
10406
+ Body = HttpService:JSONEncode(readyPayload),
10407
+ })
10408
+ end)
10409
+ if not active or generation ~= expectedGeneration or options ~= currentOptions then
10410
+ return nil
10411
+ end
10412
+ local readyLogKey = `{currentOptions.serverUrl}|{instanceId}|{physicalRole}`
10413
+ if not readyOk then
10414
+ local detail = HttpDiagnostics.formatRequestFailure(readyUrl, false, readyResult)
10415
+ if not (readyFailureLogKeys[readyLogKey] ~= nil) then
10416
+ readyFailureLogKeys[readyLogKey] = true
10417
+ warn(`[robloxstudio-mcp] /ready failed for {instanceId}/{physicalRole}: {detail}`)
10418
+ end
10419
+ scheduleReconnect(expectedGeneration, detail)
10420
+ return nil
10421
+ end
10422
+ if not readyResult.Success then
10423
+ local detail = HttpDiagnostics.formatRequestFailure(readyUrl, true, readyResult)
10424
+ if not (readyFailureLogKeys[readyLogKey] ~= nil) then
10425
+ readyFailureLogKeys[readyLogKey] = true
10426
+ warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{physicalRole}: {detail}`)
10427
+ end
10428
+ scheduleReconnect(expectedGeneration, detail, readyResult.StatusCode == 409)
10429
+ return nil
10430
+ end
10431
+ local readyData = parseReadyResponse(readyResult.Body)
10432
+ if readyData == nil then
10433
+ scheduleReconnect(expectedGeneration, "Invalid /ready response: expected the bundled server protocol")
10434
+ return nil
10435
+ end
10436
+ if readyFailureLogKeys[readyLogKey] ~= nil then
10437
+ readyFailureLogKeys[readyLogKey] = nil
10438
+ print(`[robloxstudio-mcp] /ready connected for {instanceId}/{readyData.assignedRole} via {currentOptions.serverUrl}`)
10439
+ end
10440
+ invokeCallback("event stream ready", function()
10441
+ return currentOptions.onReady(readyData)
10442
+ end)
10443
+ local createOk, createdClient = pcall(function()
10444
+ return HttpService:CreateWebStreamClient(Enum.WebStreamClientType.SSE, {
10445
+ Url = `{currentOptions.serverUrl}/events?pluginSessionId={PluginSession.id}`,
10446
+ Method = "GET",
10447
+ Headers = {
10448
+ Accept = "text/event-stream",
10449
+ },
10450
+ })
10451
+ end)
10452
+ if not createOk then
10453
+ scheduleReconnect(expectedGeneration, `Failed to create event stream: {tostring(createdClient)}`)
10454
+ return nil
10455
+ end
10456
+ if not active or generation ~= expectedGeneration or options ~= currentOptions then
10457
+ pcall(function()
10458
+ return createdClient:Close()
10459
+ end)
10460
+ return nil
10461
+ end
10462
+ streamClient = createdClient
10463
+ streamConnections = { createdClient.Opened:Connect(function(statusCode, _headers)
10464
+ if not active or generation ~= expectedGeneration or streamClient ~= createdClient then
10465
+ return nil
10466
+ end
10467
+ lastValidEventAt = tick()
10468
+ if statusCode < 200 or statusCode >= 300 then
10469
+ scheduleReconnect(expectedGeneration, `Event stream opened with HTTP {statusCode}`)
10470
+ return nil
10471
+ end
10472
+ reconnectAttempt = 0
10473
+ reportTransport({
10474
+ state = "open",
10475
+ attempt = 0,
10476
+ retryDelay = 0,
10477
+ })
10478
+ resumePendingResponses()
10479
+ end), createdClient.MessageReceived:Connect(function(message)
10480
+ if not active or generation ~= expectedGeneration or streamClient ~= createdClient then
10481
+ return nil
10482
+ end
10483
+ local event = decodeMessage(message)
10484
+ if event == nil then
10485
+ return nil
10486
+ end
10487
+ lastValidEventAt = tick()
10488
+ if event.kind == "heartbeat" then
10489
+ invokeCallback("event stream heartbeat", function()
10490
+ return currentOptions.onHeartbeat(event.timestamp)
10491
+ end)
10492
+ return nil
10493
+ end
10494
+ if event.kind == "cancel" then
10495
+ cancelRequest(event)
10496
+ return nil
10497
+ end
10498
+ if event.kind == "request" then
10499
+ dispatchRequest(event)
10500
+ return nil
10501
+ end
10502
+ invokeCallback("event stream status", function()
10503
+ return currentOptions.onStatus(event)
10504
+ end)
10505
+ if not event.knownInstance then
10506
+ refresh()
10507
+ end
10508
+ end), createdClient.Error:Connect(function(statusCode, message)
10509
+ if not active or generation ~= expectedGeneration or streamClient ~= createdClient then
10510
+ return nil
10511
+ end
10512
+ local detail = if statusCode == 404 then `Event stream session is not registered: {message}` else `Event stream error {statusCode}: {message}`
10513
+ scheduleReconnect(expectedGeneration, detail)
10514
+ end), createdClient.Closed:Connect(function()
10515
+ if not active or generation ~= expectedGeneration or streamClient ~= createdClient then
10516
+ return nil
10517
+ end
10518
+ scheduleReconnect(expectedGeneration, "Event stream closed")
10519
+ end) }
10520
+ lastValidEventAt = tick()
10521
+ watchForSilence(expectedGeneration, createdClient)
10522
+ end)
10523
+ end
10524
+ local stop
10525
+ local function start(newOptions)
10526
+ if active then
10527
+ stop()
10528
+ end
10529
+ options = newOptions
10530
+ active = true
10531
+ reconnectAttempt = 0
10532
+ generation += 1
10533
+ connect(generation)
10534
+ end
10535
+ function refresh()
10536
+ if not active or options == nil then
10537
+ return nil
10538
+ end
10539
+ generation += 1
10540
+ closeCurrentStream()
10541
+ reconnectAttempt = 0
10542
+ connect(generation)
10543
+ end
10544
+ function stop()
10545
+ if not active then
10546
+ return nil
10547
+ end
10548
+ local currentOptions = options
10549
+ active = false
10550
+ generation += 1
10551
+ for _, inFlight in inFlightRequests do
10552
+ inFlight.cancelled = true
10553
+ end
10554
+ table.clear(inFlightRequests)
10555
+ table.clear(pendingResponses)
10556
+ closeCurrentStream()
10557
+ table.clear(readyFailureLogKeys)
10558
+ options = nil
10559
+ reconnectAttempt = 0
10560
+ if currentOptions ~= nil then
10561
+ pcall(function()
10562
+ return HttpService:RequestAsync({
10563
+ Url = `{currentOptions.serverUrl}/disconnect`,
10564
+ Method = "POST",
10565
+ Headers = {
10566
+ ["Content-Type"] = "application/json",
10567
+ },
10568
+ Body = HttpService:JSONEncode({
10569
+ pluginSessionId = PluginSession.id,
10570
+ timestamp = tick(),
10571
+ }),
10572
+ })
10573
+ end)
10574
+ end
10575
+ end
10576
+ return {
10577
+ start = start,
10578
+ refresh = refresh,
10579
+ stop = stop,
10580
+ }
10581
+ ]]></string>
10582
+ </Properties>
10583
+ </Item>
10584
+ <Item class="ModuleScript" referent="36">
9913
10585
  <Properties>
9914
10586
  <string name="Name">UI</string>
9915
10587
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -10461,7 +11133,7 @@ return {
10461
11133
  ]]></string>
10462
11134
  </Properties>
10463
11135
  </Item>
10464
- <Item class="ModuleScript" referent="33">
11136
+ <Item class="ModuleScript" referent="37">
10465
11137
  <Properties>
10466
11138
  <string name="Name">Utils</string>
10467
11139
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -10745,21 +11417,11 @@ local function splitLines(source)
10745
11417
  end
10746
11418
  local normalized = (string.gsub((string.gsub(_condition, "\r\n", "\n")), "\r", "\n"))
10747
11419
  local endsWithNewline = string.sub(normalized, -1) == "\n"
10748
- local lines = {}
10749
- local start = 1
10750
- while true do
10751
- local newlinePos = string.find(normalized, "\n", start, true)
10752
- if newlinePos ~= nil then
10753
- local _arg0 = string.sub(normalized, start, newlinePos - 1)
10754
- table.insert(lines, _arg0)
10755
- start = newlinePos + 1
10756
- else
10757
- local remainder = string.sub(normalized, start)
10758
- if remainder ~= "" or not endsWithNewline then
10759
- table.insert(lines, remainder)
10760
- end
10761
- break
10762
- end
11420
+ local lines = string.split(normalized, "\n")
11421
+ -- split() includes a final empty field for a trailing newline. Keep the
11422
+ -- existing splitLines contract, which tracks that newline separately.
11423
+ if endsWithNewline and lines[#lines] == "" then
11424
+ lines[#lines] = nil
10763
11425
  end
10764
11426
  if #lines == 0 then
10765
11427
  table.insert(lines, "")
@@ -10774,15 +11436,21 @@ local function joinLines(lines, hadTrailingNewline)
10774
11436
  return source
10775
11437
  end
10776
11438
  local function readScriptSource(instance)
10777
- local ok, result = pcall(function()
11439
+ local editorOk, editorSource = pcall(function()
11440
+ return ScriptEditorService:GetEditorSource(instance)
11441
+ end)
11442
+ if editorOk then
11443
+ return editorSource
11444
+ end
11445
+ local documentOk, documentSource = pcall(function()
10778
11446
  local doc = ScriptEditorService:FindScriptDocument(instance)
10779
11447
  if doc then
10780
11448
  return doc:GetText()
10781
11449
  end
10782
11450
  return nil
10783
11451
  end)
10784
- if ok and result ~= nil then
10785
- return result
11452
+ if documentOk and documentSource ~= nil then
11453
+ return documentSource
10786
11454
  end
10787
11455
  -- @rbxts/types does not expose PluginSecurity Source reads.
10788
11456
  local readableScript = instance
@@ -11306,11 +11974,11 @@ return {
11306
11974
  </Properties>
11307
11975
  </Item>
11308
11976
  </Item>
11309
- <Item class="Folder" referent="38">
11977
+ <Item class="Folder" referent="42">
11310
11978
  <Properties>
11311
11979
  <string name="Name">include</string>
11312
11980
  </Properties>
11313
- <Item class="ModuleScript" referent="34">
11981
+ <Item class="ModuleScript" referent="38">
11314
11982
  <Properties>
11315
11983
  <string name="Name">LibMP</string>
11316
11984
  <string name="Source"><![CDATA[-- =============================================================================
@@ -167694,7 +168362,7 @@ return LibMP
167694
168362
  ]]></string>
167695
168363
  </Properties>
167696
168364
  </Item>
167697
- <Item class="ModuleScript" referent="35">
168365
+ <Item class="ModuleScript" referent="39">
167698
168366
  <Properties>
167699
168367
  <string name="Name">Promise</string>
167700
168368
  <string name="Source"><![CDATA[--[[
@@ -169768,7 +170436,7 @@ return Promise
169768
170436
  ]]></string>
169769
170437
  </Properties>
169770
170438
  </Item>
169771
- <Item class="ModuleScript" referent="36">
170439
+ <Item class="ModuleScript" referent="40">
169772
170440
  <Properties>
169773
170441
  <string name="Name">RuntimeLib</string>
169774
170442
  <string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
@@ -170035,15 +170703,15 @@ return TS
170035
170703
  </Properties>
170036
170704
  </Item>
170037
170705
  </Item>
170038
- <Item class="Folder" referent="39">
170706
+ <Item class="Folder" referent="43">
170039
170707
  <Properties>
170040
170708
  <string name="Name">node_modules</string>
170041
170709
  </Properties>
170042
- <Item class="Folder" referent="40">
170710
+ <Item class="Folder" referent="44">
170043
170711
  <Properties>
170044
170712
  <string name="Name">@rbxts</string>
170045
170713
  </Properties>
170046
- <Item class="ModuleScript" referent="37">
170714
+ <Item class="ModuleScript" referent="41">
170047
170715
  <Properties>
170048
170716
  <string name="Name">services</string>
170049
170717
  <string name="Source"><![CDATA[return setmetatable({}, {