@chrrxs/robloxstudio-mcp 3.0.2 → 3.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +2778 -440
- package/package.json +1 -1
- package/studio-plugin/MCPPlugin.rbxmx +1135 -731
|
@@ -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
|
|
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
|
-
|
|
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
|
|
327
|
-
-- in ReplicatedStorage; each player gets a proxy
|
|
328
|
-
-- MCP side,
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
574
|
-
|
|
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
|
-
|
|
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
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
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
|
|
616
|
-
|
|
617
|
-
break
|
|
594
|
+
if _condition then
|
|
595
|
+
return nil
|
|
618
596
|
end
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
local
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
|
|
644
|
-
|
|
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
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
task.
|
|
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
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
675
|
+
local entry = {
|
|
676
|
+
player = player,
|
|
677
|
+
remote = rf,
|
|
678
|
+
pluginSessionId = HttpService:GenerateGUID(false),
|
|
717
679
|
role = "client",
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
708
|
+
if _condition then
|
|
709
|
+
return {
|
|
710
|
+
error = `Client proxy {target} ({logicalSessionId}) is not registered.`,
|
|
711
|
+
}
|
|
735
712
|
end
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
830
|
-
|
|
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
|
|
859
|
-
local retryingDuplicateReady = false
|
|
860
|
-
local pollEpoch = 0
|
|
861
|
-
-- Cache the published place name from MarketplaceService:GetProductInfo so
|
|
862
|
-
-- /ready can carry a friendly identifier (e.g. "Natural Disasters") distinct
|
|
863
|
-
-- from game.Name (the DataModel name, often "Place1" in edit). We only fetch
|
|
864
|
-
-- once per plugin load; the published name doesn't change mid-session.
|
|
865
|
-
local cachedPlaceName
|
|
866
|
-
local cachedPlaceNamePlaceId
|
|
867
|
-
local function resolvePlaceName()
|
|
868
|
-
if cachedPlaceName ~= nil and cachedPlaceNamePlaceId == game.PlaceId then
|
|
869
|
-
return cachedPlaceName
|
|
870
|
-
end
|
|
871
|
-
cachedPlaceName = nil
|
|
872
|
-
cachedPlaceNamePlaceId = game.PlaceId
|
|
873
|
-
if game.PlaceId == 0 then
|
|
874
|
-
cachedPlaceName = game.Name
|
|
875
|
-
return cachedPlaceName
|
|
876
|
-
end
|
|
877
|
-
local MarketplaceService = game:GetService("MarketplaceService")
|
|
878
|
-
local ok, info = pcall(function()
|
|
879
|
-
return MarketplaceService:GetProductInfo(game.PlaceId)
|
|
880
|
-
end)
|
|
881
|
-
if ok and info ~= nil then
|
|
882
|
-
local name = info.Name
|
|
883
|
-
if type(name) == "string" and name ~= "" then
|
|
884
|
-
cachedPlaceName = name
|
|
885
|
-
return cachedPlaceName
|
|
886
|
-
end
|
|
887
|
-
end
|
|
888
|
-
-- Don't cache failures — could be transient (offline, rate-limited).
|
|
889
|
-
-- Next /ready will retry. Return game.Name as fallback.
|
|
890
|
-
return game.Name
|
|
891
|
-
end
|
|
892
|
-
local function detectRole()
|
|
893
|
-
if not RunService:IsRunning() then
|
|
894
|
-
return "edit"
|
|
895
|
-
end
|
|
896
|
-
if RunService:IsServer() then
|
|
897
|
-
return "server"
|
|
898
|
-
end
|
|
899
|
-
return "client"
|
|
900
|
-
end
|
|
901
|
-
local initialRole = detectRole()
|
|
835
|
+
local initialRole = PluginSession.getRole()
|
|
902
836
|
local routeMap = {
|
|
903
837
|
["/api/file-tree"] = QueryHandlers.getFileTree,
|
|
904
838
|
["/api/search-files"] = QueryHandlers.searchFiles,
|
|
@@ -958,35 +892,6 @@ local function processRequest(request)
|
|
|
958
892
|
}
|
|
959
893
|
end
|
|
960
894
|
end
|
|
961
|
-
local function sendResponse(conn, requestId, responseData)
|
|
962
|
-
local responseUrl = `{conn.serverUrl}/response`
|
|
963
|
-
local encodeOk, encoded = pcall(function()
|
|
964
|
-
return HttpService:JSONEncode({
|
|
965
|
-
requestId = requestId,
|
|
966
|
-
response = responseData,
|
|
967
|
-
})
|
|
968
|
-
end)
|
|
969
|
-
local body = if encodeOk then encoded else HttpService:JSONEncode({
|
|
970
|
-
requestId = requestId,
|
|
971
|
-
error = `Plugin response serialization failed: {tostring(encoded)}`,
|
|
972
|
-
})
|
|
973
|
-
if not encodeOk then
|
|
974
|
-
warn(`[robloxstudio-mcp] Failed to serialize response {requestId}: {tostring(encoded)}`)
|
|
975
|
-
end
|
|
976
|
-
local requestOk, requestResult = pcall(function()
|
|
977
|
-
return HttpService:RequestAsync({
|
|
978
|
-
Url = responseUrl,
|
|
979
|
-
Method = "POST",
|
|
980
|
-
Headers = {
|
|
981
|
-
["Content-Type"] = "application/json",
|
|
982
|
-
},
|
|
983
|
-
Body = body,
|
|
984
|
-
})
|
|
985
|
-
end)
|
|
986
|
-
if not requestOk or not requestResult.Success then
|
|
987
|
-
warn(`[robloxstudio-mcp] Failed to deliver response {requestId}: {HttpDiagnostics.formatRequestFailure(responseUrl, requestOk, requestResult)}`)
|
|
988
|
-
end
|
|
989
|
-
end
|
|
990
895
|
local function getConnectionStatus()
|
|
991
896
|
local conn = State.getActiveConnection()
|
|
992
897
|
if not conn.isActive then
|
|
@@ -1000,341 +905,141 @@ local function getConnectionStatus()
|
|
|
1000
905
|
end
|
|
1001
906
|
return "connecting"
|
|
1002
907
|
end
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
local lastReadyPostAt = 0
|
|
1007
|
-
-- game.Name and game.PlaceId can both settle after plugin load. PlaceId also
|
|
1008
|
-
-- changes when an unpublished file is published while MCP is already active.
|
|
1009
|
-
-- Re-fire /ready so the bridge can migrate anon:<uuid> to place:<PlaceId>.
|
|
1010
|
-
local nameChangeConn
|
|
1011
|
-
local placeIdChangeConn
|
|
1012
|
-
local sendReady
|
|
1013
|
-
local function ensureIdentityWatcher(conn)
|
|
1014
|
-
if not nameChangeConn then
|
|
1015
|
-
local okSig, signal = pcall(function()
|
|
1016
|
-
return game:GetPropertyChangedSignal("Name")
|
|
1017
|
-
end)
|
|
1018
|
-
if okSig and signal then
|
|
1019
|
-
nameChangeConn = signal:Connect(function()
|
|
1020
|
-
-- sendReady has its own 2s throttle, so rapid burst changes coalesce.
|
|
1021
|
-
sendReady(conn)
|
|
1022
|
-
end)
|
|
1023
|
-
end
|
|
908
|
+
local function dispatchStreamRequest(request)
|
|
909
|
+
if request.logicalSessionId ~= PluginSession.id then
|
|
910
|
+
return ClientBroker.dispatchClientRequest(request.logicalSessionId, request.target, request.endpoint, request.data)
|
|
1024
911
|
end
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
end)
|
|
1035
|
-
end
|
|
912
|
+
local _condition = assignedRole
|
|
913
|
+
if _condition == nil then
|
|
914
|
+
_condition = PluginSession.getRole()
|
|
915
|
+
end
|
|
916
|
+
local localRole = _condition
|
|
917
|
+
if request.target ~= localRole then
|
|
918
|
+
return {
|
|
919
|
+
error = `Physical plugin session is registered as {localRole}, not {request.target}.`,
|
|
920
|
+
}
|
|
1036
921
|
end
|
|
922
|
+
return processRequest({
|
|
923
|
+
endpoint = request.endpoint,
|
|
924
|
+
data = request.data,
|
|
925
|
+
})
|
|
1037
926
|
end
|
|
1038
|
-
function
|
|
1039
|
-
local
|
|
1040
|
-
|
|
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
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
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
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
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.
|
|
1170
|
-
|
|
1171
|
-
conn.
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
conn.
|
|
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
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
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 = "
|
|
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
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
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
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
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 =
|
|
1071
|
+
local currentInstanceId = PluginSession.getInstanceId()
|
|
1358
1072
|
if lastReadyInstanceId ~= nil and currentInstanceId ~= lastReadyInstanceId then
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
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(
|
|
1080
|
+
task.spawn(cleanupEditBridgeArtifacts)
|
|
1377
1081
|
end
|
|
1378
|
-
|
|
1379
|
-
ensureIdentityWatcher(conn)
|
|
1082
|
+
ensureIdentityWatchers()
|
|
1380
1083
|
end
|
|
1381
1084
|
function deactivatePlugin()
|
|
1382
1085
|
local conn = State.getActiveConnection()
|
|
1383
|
-
|
|
1384
|
-
|
|
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
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
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
|
-
|
|
1441
|
-
UI.showBanner("update", `v{latestVersion} available - github.com/chrrxs/robloxstudio-mcp`)
|
|
1442
|
-
end
|
|
1140
|
+
UI.showBanner("update", `v{latestVersion} available - github.com/chrrxs/robloxstudio-mcp`)
|
|
1443
1141
|
end
|
|
1444
1142
|
end
|
|
1445
1143
|
end
|
|
@@ -1575,9 +1273,9 @@ local function computeBridgeStamp()
|
|
|
1575
1273
|
for i = 1, #combined do
|
|
1576
1274
|
h = (h * 33 + (string.byte(combined, i))) % 2147483647
|
|
1577
1275
|
end
|
|
1578
|
-
-- "3.0.
|
|
1276
|
+
-- "3.0.3" is replaced with the package version at package time
|
|
1579
1277
|
-- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
|
|
1580
|
-
return `{tostring(h)}-3.0.
|
|
1278
|
+
return `{tostring(h)}-3.0.3`
|
|
1581
1279
|
end
|
|
1582
1280
|
local BRIDGE_STAMP = computeBridgeStamp()
|
|
1583
1281
|
local function setSource(scriptInst, source)
|
|
@@ -1593,7 +1291,7 @@ local function setSource(scriptInst, source)
|
|
|
1593
1291
|
scriptInst.Source = source
|
|
1594
1292
|
end
|
|
1595
1293
|
end
|
|
1596
|
-
local function
|
|
1294
|
+
local function findEditBridgeArtifacts()
|
|
1597
1295
|
local sps = getStarterPlayerScripts()
|
|
1598
1296
|
return {
|
|
1599
1297
|
server = ServerScriptService:FindFirstChild(SERVER_SCRIPT_NAME),
|
|
@@ -1608,11 +1306,11 @@ local function destroyIfPresent(parent, name)
|
|
|
1608
1306
|
end)
|
|
1609
1307
|
end
|
|
1610
1308
|
end
|
|
1611
|
-
local function
|
|
1309
|
+
local function cleanupEditBridgeArtifacts()
|
|
1612
1310
|
if RunService:IsRunning() then
|
|
1613
1311
|
return nil
|
|
1614
1312
|
end
|
|
1615
|
-
local _binding =
|
|
1313
|
+
local _binding = findEditBridgeArtifacts()
|
|
1616
1314
|
local server = _binding.server
|
|
1617
1315
|
local client = _binding.client
|
|
1618
1316
|
if server then
|
|
@@ -1723,7 +1421,7 @@ local function ensureRuntimeBridgeInstalled()
|
|
|
1723
1421
|
return installClientRuntimeBridge()
|
|
1724
1422
|
end
|
|
1725
1423
|
return {
|
|
1726
|
-
|
|
1424
|
+
cleanupEditBridgeArtifacts = cleanupEditBridgeArtifacts,
|
|
1727
1425
|
ensureRuntimeBridgeInstalled = ensureRuntimeBridgeInstalled,
|
|
1728
1426
|
BRIDGE_NAMES = BRIDGE_NAMES,
|
|
1729
1427
|
}
|
|
@@ -2702,6 +2400,8 @@ local RenderMonitor = TS.import(script, script.Parent.Parent, "RenderMonitor")
|
|
|
2702
2400
|
local CaptureService = game:GetService("CaptureService")
|
|
2703
2401
|
local AssetService = game:GetService("AssetService")
|
|
2704
2402
|
local MAX_TILE_SIZE = 1024
|
|
2403
|
+
local MAX_RAW_PIXEL_BYTES = 36 * 1024 * 1024
|
|
2404
|
+
local MAX_CREATED_IMAGE_DIM = 2048
|
|
2705
2405
|
local BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
|
2706
2406
|
local PAD_BYTE = (string.byte("="))
|
|
2707
2407
|
local B64 = {}
|
|
@@ -2853,14 +2553,39 @@ local function readContentToBase64(contentId)
|
|
|
2853
2553
|
error = `Failed to create EditableImage from screenshot. Enable EditableImage API: Game Settings > Security > 'Allow Mesh / Image APIs'. ({tostring(editableResult)})`,
|
|
2854
2554
|
}
|
|
2855
2555
|
end
|
|
2856
|
-
local
|
|
2857
|
-
local imgSize =
|
|
2858
|
-
local
|
|
2859
|
-
local
|
|
2556
|
+
local sourceImage = editableResult
|
|
2557
|
+
local imgSize = sourceImage.Size
|
|
2558
|
+
local nativeW = math.floor(imgSize.X)
|
|
2559
|
+
local nativeH = math.floor(imgSize.Y)
|
|
2560
|
+
local w = nativeW
|
|
2561
|
+
local h = nativeH
|
|
2562
|
+
if nativeW * nativeH * 4 > MAX_RAW_PIXEL_BYTES then
|
|
2563
|
+
local scale = math.min(math.sqrt(MAX_RAW_PIXEL_BYTES / (nativeW * nativeH * 4)), MAX_CREATED_IMAGE_DIM / math.max(nativeW, nativeH))
|
|
2564
|
+
w = math.max(1, math.floor(nativeW * scale))
|
|
2565
|
+
h = math.max(1, math.floor(nativeH * scale))
|
|
2566
|
+
local scaleOk, scaledResult = pcall(function()
|
|
2567
|
+
local target = AssetService:CreateEditableImage({
|
|
2568
|
+
Size = Vector2.new(w, h),
|
|
2569
|
+
})
|
|
2570
|
+
target:DrawImageTransformed(Vector2.new(0, 0), Vector2.new(w / nativeW, h / nativeH), 0, sourceImage, {
|
|
2571
|
+
CombineType = Enum.ImageCombineType.AlphaBlend,
|
|
2572
|
+
SamplingMode = Enum.ResamplerMode.Default,
|
|
2573
|
+
PivotPoint = Vector2.new(0, 0),
|
|
2574
|
+
})
|
|
2575
|
+
return target
|
|
2576
|
+
end)
|
|
2577
|
+
sourceImage:Destroy()
|
|
2578
|
+
if not scaleOk then
|
|
2579
|
+
return {
|
|
2580
|
+
error = `Screenshot is {nativeW}x{nativeH} (too large to transfer raw) and downscaling failed: {tostring(scaledResult)}`,
|
|
2581
|
+
}
|
|
2582
|
+
end
|
|
2583
|
+
sourceImage = scaledResult
|
|
2584
|
+
end
|
|
2860
2585
|
local readOk, pixelBuffer = pcall(function()
|
|
2861
|
-
return readPixelsTiled(
|
|
2586
|
+
return readPixelsTiled(sourceImage, w, h)
|
|
2862
2587
|
end)
|
|
2863
|
-
|
|
2588
|
+
sourceImage:Destroy()
|
|
2864
2589
|
if not readOk then
|
|
2865
2590
|
return {
|
|
2866
2591
|
error = `Failed to read pixel data: {tostring(pixelBuffer)}`,
|
|
@@ -2872,6 +2597,8 @@ local function readContentToBase64(contentId)
|
|
|
2872
2597
|
width = w,
|
|
2873
2598
|
height = h,
|
|
2874
2599
|
data = base64Data,
|
|
2600
|
+
nativeWidth = nativeW,
|
|
2601
|
+
nativeHeight = nativeH,
|
|
2875
2602
|
}
|
|
2876
2603
|
end
|
|
2877
2604
|
-- Edit-mode single shot: capture and read back in the same (edit) context.
|
|
@@ -7014,7 +6741,7 @@ local function setScriptSource(requestData)
|
|
|
7014
6741
|
error = `Instance is not a script-like object: {instance.ClassName}`,
|
|
7015
6742
|
}
|
|
7016
6743
|
end
|
|
7017
|
-
-- Communication has already JSON-decoded the
|
|
6744
|
+
-- Communication has already JSON-decoded the transport payload; source text is exact at this boundary.
|
|
7018
6745
|
local sourceToSet = newSource
|
|
7019
6746
|
local recordingId = beginRecording(`Set script source: {instance.Name}`)
|
|
7020
6747
|
local readSuccess, readResult = pcall(function()
|
|
@@ -9067,6 +8794,98 @@ return {
|
|
|
9067
8794
|
</Properties>
|
|
9068
8795
|
</Item>
|
|
9069
8796
|
<Item class="ModuleScript" referent="26">
|
|
8797
|
+
<Properties>
|
|
8798
|
+
<string name="Name">PluginSession</string>
|
|
8799
|
+
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
8800
|
+
local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
8801
|
+
local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
|
|
8802
|
+
local HttpService = _services.HttpService
|
|
8803
|
+
local RunService = _services.RunService
|
|
8804
|
+
local ServerStorage = _services.ServerStorage
|
|
8805
|
+
local State = TS.import(script, script.Parent, "State")
|
|
8806
|
+
local MCP_PLACE_ID_ATTRIBUTE = "__MCPPlaceId"
|
|
8807
|
+
local id = HttpService:GenerateGUID(false)
|
|
8808
|
+
local cachedPlaceName
|
|
8809
|
+
local cachedPlaceNamePlaceId
|
|
8810
|
+
local function getInstanceId()
|
|
8811
|
+
if game.PlaceId ~= 0 then
|
|
8812
|
+
return `place:{tostring(game.PlaceId)}`
|
|
8813
|
+
end
|
|
8814
|
+
local existing = ServerStorage:GetAttribute(MCP_PLACE_ID_ATTRIBUTE)
|
|
8815
|
+
if type(existing) == "string" and existing ~= "" then
|
|
8816
|
+
return `anon:{existing}`
|
|
8817
|
+
end
|
|
8818
|
+
local fresh = HttpService:GenerateGUID(false)
|
|
8819
|
+
pcall(function()
|
|
8820
|
+
return ServerStorage:SetAttribute(MCP_PLACE_ID_ATTRIBUTE, fresh)
|
|
8821
|
+
end)
|
|
8822
|
+
return `anon:{fresh}`
|
|
8823
|
+
end
|
|
8824
|
+
local function getRole()
|
|
8825
|
+
if not RunService:IsRunning() then
|
|
8826
|
+
return "edit"
|
|
8827
|
+
end
|
|
8828
|
+
if RunService:IsServer() then
|
|
8829
|
+
return "server"
|
|
8830
|
+
end
|
|
8831
|
+
return "client"
|
|
8832
|
+
end
|
|
8833
|
+
local function invalidatePlaceName()
|
|
8834
|
+
cachedPlaceName = nil
|
|
8835
|
+
cachedPlaceNamePlaceId = nil
|
|
8836
|
+
end
|
|
8837
|
+
local function getPlaceName()
|
|
8838
|
+
if cachedPlaceName ~= nil and cachedPlaceNamePlaceId == game.PlaceId then
|
|
8839
|
+
return cachedPlaceName
|
|
8840
|
+
end
|
|
8841
|
+
invalidatePlaceName()
|
|
8842
|
+
cachedPlaceNamePlaceId = game.PlaceId
|
|
8843
|
+
if game.PlaceId == 0 then
|
|
8844
|
+
cachedPlaceName = game.Name
|
|
8845
|
+
return cachedPlaceName
|
|
8846
|
+
end
|
|
8847
|
+
local MarketplaceService = game:GetService("MarketplaceService")
|
|
8848
|
+
local ok, info = pcall(function()
|
|
8849
|
+
return MarketplaceService:GetProductInfo(game.PlaceId)
|
|
8850
|
+
end)
|
|
8851
|
+
if ok and info ~= nil then
|
|
8852
|
+
-- GetProductInfo's generated type is broader than the place metadata returned here.
|
|
8853
|
+
local placeInfo = info
|
|
8854
|
+
local name = placeInfo.Name
|
|
8855
|
+
if type(name) == "string" and name ~= "" then
|
|
8856
|
+
cachedPlaceName = name
|
|
8857
|
+
return cachedPlaceName
|
|
8858
|
+
end
|
|
8859
|
+
end
|
|
8860
|
+
return game.Name
|
|
8861
|
+
end
|
|
8862
|
+
local function createReadyPayload(pluginSessionId, role)
|
|
8863
|
+
return {
|
|
8864
|
+
pluginSessionId = pluginSessionId,
|
|
8865
|
+
physicalSessionId = id,
|
|
8866
|
+
instanceId = getInstanceId(),
|
|
8867
|
+
role = role,
|
|
8868
|
+
placeId = game.PlaceId,
|
|
8869
|
+
placeName = getPlaceName(),
|
|
8870
|
+
dataModelName = game.Name,
|
|
8871
|
+
isRunning = RunService:IsRunning(),
|
|
8872
|
+
pluginVersion = State.CURRENT_VERSION,
|
|
8873
|
+
pluginVariant = State.PLUGIN_VARIANT,
|
|
8874
|
+
timestamp = tick(),
|
|
8875
|
+
}
|
|
8876
|
+
end
|
|
8877
|
+
return {
|
|
8878
|
+
id = id,
|
|
8879
|
+
getInstanceId = getInstanceId,
|
|
8880
|
+
getRole = getRole,
|
|
8881
|
+
getPlaceName = getPlaceName,
|
|
8882
|
+
invalidatePlaceName = invalidatePlaceName,
|
|
8883
|
+
createReadyPayload = createReadyPayload,
|
|
8884
|
+
}
|
|
8885
|
+
]]></string>
|
|
8886
|
+
</Properties>
|
|
8887
|
+
</Item>
|
|
8888
|
+
<Item class="ModuleScript" referent="27">
|
|
9070
8889
|
<Properties>
|
|
9071
8890
|
<string name="Name">Recording</string>
|
|
9072
8891
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -9096,7 +8915,7 @@ return {
|
|
|
9096
8915
|
]]></string>
|
|
9097
8916
|
</Properties>
|
|
9098
8917
|
</Item>
|
|
9099
|
-
<Item class="ModuleScript" referent="
|
|
8918
|
+
<Item class="ModuleScript" referent="28">
|
|
9100
8919
|
<Properties>
|
|
9101
8920
|
<string name="Name">RenderMonitor</string>
|
|
9102
8921
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -9164,7 +8983,7 @@ return {
|
|
|
9164
8983
|
]]></string>
|
|
9165
8984
|
</Properties>
|
|
9166
8985
|
</Item>
|
|
9167
|
-
<Item class="ModuleScript" referent="
|
|
8986
|
+
<Item class="ModuleScript" referent="29">
|
|
9168
8987
|
<Properties>
|
|
9169
8988
|
<string name="Name">RuntimeLogBuffer</string>
|
|
9170
8989
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -9419,7 +9238,7 @@ return {
|
|
|
9419
9238
|
]]></string>
|
|
9420
9239
|
</Properties>
|
|
9421
9240
|
</Item>
|
|
9422
|
-
<Item class="ModuleScript" referent="
|
|
9241
|
+
<Item class="ModuleScript" referent="30">
|
|
9423
9242
|
<Properties>
|
|
9424
9243
|
<string name="Name">ServerUrlSettings</string>
|
|
9425
9244
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -9427,7 +9246,6 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
|
9427
9246
|
local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
|
|
9428
9247
|
local HttpService = _services.HttpService
|
|
9429
9248
|
local ServerStorage = _services.ServerStorage
|
|
9430
|
-
local LEGACY_SETTING_KEY_PREFIX = "MCP_SERVER_URL_"
|
|
9431
9249
|
local SETTING_KEY_PREFIX = "MCP_LAST_SUCCESSFUL_SERVER_URL_"
|
|
9432
9250
|
local GLOBAL_SETTING_KEY = "MCP_LAST_SUCCESSFUL_SERVER_URL_GLOBAL_V1"
|
|
9433
9251
|
local pluginRef
|
|
@@ -9497,9 +9315,6 @@ end
|
|
|
9497
9315
|
local function settingKey(instanceId)
|
|
9498
9316
|
return SETTING_KEY_PREFIX .. instanceId
|
|
9499
9317
|
end
|
|
9500
|
-
local function legacySettingKey(instanceId)
|
|
9501
|
-
return LEGACY_SETTING_KEY_PREFIX .. instanceId
|
|
9502
|
-
end
|
|
9503
9318
|
local function readSettingString(key)
|
|
9504
9319
|
if not pluginRef then
|
|
9505
9320
|
return nil
|
|
@@ -9531,7 +9346,6 @@ local function rememberServerUrl(serverUrl)
|
|
|
9531
9346
|
createAnonymous = true,
|
|
9532
9347
|
}) do
|
|
9533
9348
|
writeSettingString(settingKey(instanceId), normalized)
|
|
9534
|
-
writeSettingString(legacySettingKey(instanceId), normalized)
|
|
9535
9349
|
end
|
|
9536
9350
|
end
|
|
9537
9351
|
local function readServerUrl()
|
|
@@ -9551,12 +9365,6 @@ local function readServerUrl()
|
|
|
9551
9365
|
if globalRemembered ~= nil then
|
|
9552
9366
|
return globalRemembered
|
|
9553
9367
|
end
|
|
9554
|
-
for _, instanceId in computeInstanceIds() do
|
|
9555
|
-
local legacyRemembered = readSettingString(legacySettingKey(instanceId))
|
|
9556
|
-
if legacyRemembered ~= nil then
|
|
9557
|
-
return legacyRemembered
|
|
9558
|
-
end
|
|
9559
|
-
end
|
|
9560
9368
|
return nil
|
|
9561
9369
|
end
|
|
9562
9370
|
return {
|
|
@@ -9569,31 +9377,24 @@ return {
|
|
|
9569
9377
|
]]></string>
|
|
9570
9378
|
</Properties>
|
|
9571
9379
|
</Item>
|
|
9572
|
-
<Item class="ModuleScript" referent="
|
|
9380
|
+
<Item class="ModuleScript" referent="31">
|
|
9573
9381
|
<Properties>
|
|
9574
9382
|
<string name="Name">State</string>
|
|
9575
9383
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
9576
|
-
local CURRENT_VERSION = "3.0.
|
|
9384
|
+
local CURRENT_VERSION = "3.0.3"
|
|
9577
9385
|
local PLUGIN_VARIANT = "main"
|
|
9578
9386
|
local BASE_PORT = 58741
|
|
9579
|
-
local POLL_REQUEST_TIMEOUT_SECONDS = 20
|
|
9580
9387
|
local function createConnection(port)
|
|
9581
9388
|
return {
|
|
9582
9389
|
port = port,
|
|
9583
9390
|
serverUrl = `http://localhost:{port}`,
|
|
9584
9391
|
isActive = false,
|
|
9585
|
-
pollInterval = 0.5,
|
|
9586
|
-
lastPoll = 0,
|
|
9587
9392
|
consecutiveFailures = 0,
|
|
9588
9393
|
maxFailuresBeforeError = 50,
|
|
9589
|
-
lastSuccessfulConnection = 0,
|
|
9590
9394
|
currentRetryDelay = 0.5,
|
|
9591
|
-
maxRetryDelay = 5,
|
|
9592
|
-
retryBackoffMultiplier = 1.2,
|
|
9593
9395
|
lastHttpOk = false,
|
|
9594
9396
|
lastMcpOk = false,
|
|
9595
9397
|
mcpWaitStartTime = nil,
|
|
9596
|
-
isPolling = false,
|
|
9597
9398
|
heartbeatConnection = nil,
|
|
9598
9399
|
}
|
|
9599
9400
|
end
|
|
@@ -9605,13 +9406,12 @@ return {
|
|
|
9605
9406
|
CURRENT_VERSION = CURRENT_VERSION,
|
|
9606
9407
|
PLUGIN_VARIANT = PLUGIN_VARIANT,
|
|
9607
9408
|
BASE_PORT = BASE_PORT,
|
|
9608
|
-
POLL_REQUEST_TIMEOUT_SECONDS = POLL_REQUEST_TIMEOUT_SECONDS,
|
|
9609
9409
|
getActiveConnection = getActiveConnection,
|
|
9610
9410
|
}
|
|
9611
9411
|
]]></string>
|
|
9612
9412
|
</Properties>
|
|
9613
9413
|
</Item>
|
|
9614
|
-
<Item class="ModuleScript" referent="
|
|
9414
|
+
<Item class="ModuleScript" referent="32">
|
|
9615
9415
|
<Properties>
|
|
9616
9416
|
<string name="Name">StopPlayMonitor</string>
|
|
9617
9417
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -9664,7 +9464,7 @@ local endTestIssued = false
|
|
|
9664
9464
|
local function init(p)
|
|
9665
9465
|
pluginRef = p
|
|
9666
9466
|
end
|
|
9667
|
-
-- Mirror of
|
|
9467
|
+
-- Mirror of PluginSession's place identity rules. Duplicated here because
|
|
9668
9468
|
-- StopPlayMonitor runs in both edit and play-server DMs, and both must
|
|
9669
9469
|
-- agree on the place identifier (published places: placeId; unpublished:
|
|
9670
9470
|
-- UUID on ServerStorage's __MCPPlaceId attribute, travels with the .rbxl
|
|
@@ -9820,16 +9620,9 @@ local function startMonitor()
|
|
|
9820
9620
|
task.spawn(function()
|
|
9821
9621
|
while true do
|
|
9822
9622
|
for _, myKey in settingKeys() do
|
|
9823
|
-
local
|
|
9824
|
-
if
|
|
9825
|
-
|
|
9826
|
-
-- a prior crashed session. New stop requests use token payloads.
|
|
9827
|
-
writeSetting(myKey, false)
|
|
9828
|
-
else
|
|
9829
|
-
local payload = decodePayload(value)
|
|
9830
|
-
if payload then
|
|
9831
|
-
handleStopRequest(myKey, payload)
|
|
9832
|
-
end
|
|
9623
|
+
local payload = decodePayload(readSetting(myKey))
|
|
9624
|
+
if payload then
|
|
9625
|
+
handleStopRequest(myKey, payload)
|
|
9833
9626
|
end
|
|
9834
9627
|
end
|
|
9835
9628
|
task.wait(POLL_INTERVAL_SEC)
|
|
@@ -9909,7 +9702,618 @@ return {
|
|
|
9909
9702
|
]]></string>
|
|
9910
9703
|
</Properties>
|
|
9911
9704
|
</Item>
|
|
9912
|
-
<Item class="ModuleScript" referent="
|
|
9705
|
+
<Item class="ModuleScript" referent="33">
|
|
9706
|
+
<Properties>
|
|
9707
|
+
<string name="Name">StudioEventStream</string>
|
|
9708
|
+
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
9709
|
+
local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
9710
|
+
local HttpService = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services").HttpService
|
|
9711
|
+
local HttpDiagnostics = TS.import(script, script.Parent, "HttpDiagnostics")
|
|
9712
|
+
local PluginSession = TS.import(script, script.Parent, "PluginSession")
|
|
9713
|
+
local INITIAL_RECONNECT_DELAY_SECONDS = 0.5
|
|
9714
|
+
local MAX_RECONNECT_DELAY_SECONDS = 5
|
|
9715
|
+
local INITIAL_RESPONSE_RETRY_DELAY_SECONDS = 0.5
|
|
9716
|
+
local MAX_RESPONSE_RETRY_DELAY_SECONDS = 5
|
|
9717
|
+
local MAX_TERMINAL_RESPONSES = 256
|
|
9718
|
+
local STREAM_SILENCE_TIMEOUT_SECONDS = 20
|
|
9719
|
+
local options
|
|
9720
|
+
local active = false
|
|
9721
|
+
local generation = 0
|
|
9722
|
+
local reconnectAttempt = 0
|
|
9723
|
+
local streamClient
|
|
9724
|
+
local streamConnections = {}
|
|
9725
|
+
local lastValidEventAt = 0
|
|
9726
|
+
local inFlightRequestIds = {}
|
|
9727
|
+
local pendingResponses = {}
|
|
9728
|
+
local terminalResponseIds = {}
|
|
9729
|
+
local terminalResponseOrder = {}
|
|
9730
|
+
local readyFailureLogKeys = {}
|
|
9731
|
+
local function decodeMessage(message)
|
|
9732
|
+
-- Studio versions in the supported channel have surfaced either the SSE
|
|
9733
|
+
-- data payload or the complete single-line `data:` frame. The bridge emits
|
|
9734
|
+
-- one JSON data line per event, so normalize both forms before decoding.
|
|
9735
|
+
local payload = message
|
|
9736
|
+
local normalized = (string.gsub((string.gsub(message, "\r\n", "\n")), "\r", "\n"))
|
|
9737
|
+
if string.sub(normalized, 1, 5) == "data:" then
|
|
9738
|
+
payload = (string.gsub((string.gsub(string.sub(normalized, 6), "^%s+", "")), "%s+$", ""))
|
|
9739
|
+
end
|
|
9740
|
+
local decodeOk, decoded = pcall(function()
|
|
9741
|
+
return HttpService:JSONDecode(payload)
|
|
9742
|
+
end)
|
|
9743
|
+
if not decodeOk or not (type(decoded) == "table") then
|
|
9744
|
+
return nil
|
|
9745
|
+
end
|
|
9746
|
+
local envelope = decoded
|
|
9747
|
+
if envelope.kind == "heartbeat" then
|
|
9748
|
+
local _timestamp = envelope.timestamp
|
|
9749
|
+
if not (type(_timestamp) == "number") then
|
|
9750
|
+
return nil
|
|
9751
|
+
end
|
|
9752
|
+
return {
|
|
9753
|
+
kind = "heartbeat",
|
|
9754
|
+
timestamp = envelope.timestamp,
|
|
9755
|
+
}
|
|
9756
|
+
end
|
|
9757
|
+
if envelope.kind == "status" then
|
|
9758
|
+
local _knownInstance = envelope.knownInstance
|
|
9759
|
+
local _condition = not (type(_knownInstance) == "boolean")
|
|
9760
|
+
if not _condition then
|
|
9761
|
+
local _mcpConnected = envelope.mcpConnected
|
|
9762
|
+
_condition = not (type(_mcpConnected) == "boolean")
|
|
9763
|
+
end
|
|
9764
|
+
if _condition then
|
|
9765
|
+
return nil
|
|
9766
|
+
end
|
|
9767
|
+
local _object = {
|
|
9768
|
+
kind = "status",
|
|
9769
|
+
knownInstance = envelope.knownInstance,
|
|
9770
|
+
mcpConnected = envelope.mcpConnected,
|
|
9771
|
+
}
|
|
9772
|
+
local _left = "serverVersion"
|
|
9773
|
+
local _serverVersion = envelope.serverVersion
|
|
9774
|
+
_object[_left] = if type(_serverVersion) == "string" then envelope.serverVersion else nil
|
|
9775
|
+
local _left_1 = "pluginVersion"
|
|
9776
|
+
local _pluginVersion = envelope.pluginVersion
|
|
9777
|
+
_object[_left_1] = if type(_pluginVersion) == "string" then envelope.pluginVersion else nil
|
|
9778
|
+
local _left_2 = "pluginVariant"
|
|
9779
|
+
local _pluginVariant = envelope.pluginVariant
|
|
9780
|
+
_object[_left_2] = if type(_pluginVariant) == "string" then envelope.pluginVariant else nil
|
|
9781
|
+
return _object
|
|
9782
|
+
end
|
|
9783
|
+
if envelope.kind == "request" then
|
|
9784
|
+
local _requestId = envelope.requestId
|
|
9785
|
+
local _condition = not (type(_requestId) == "string")
|
|
9786
|
+
if not _condition then
|
|
9787
|
+
local _logicalSessionId = envelope.logicalSessionId
|
|
9788
|
+
_condition = not (type(_logicalSessionId) == "string")
|
|
9789
|
+
if not _condition then
|
|
9790
|
+
local _target = envelope.target
|
|
9791
|
+
_condition = not (type(_target) == "string")
|
|
9792
|
+
if not _condition then
|
|
9793
|
+
local _endpoint = envelope.endpoint
|
|
9794
|
+
_condition = not (type(_endpoint) == "string")
|
|
9795
|
+
end
|
|
9796
|
+
end
|
|
9797
|
+
end
|
|
9798
|
+
if _condition then
|
|
9799
|
+
return nil
|
|
9800
|
+
end
|
|
9801
|
+
local data
|
|
9802
|
+
local _data = envelope.data
|
|
9803
|
+
if type(_data) == "table" then
|
|
9804
|
+
data = envelope.data
|
|
9805
|
+
end
|
|
9806
|
+
return {
|
|
9807
|
+
kind = "request",
|
|
9808
|
+
requestId = envelope.requestId,
|
|
9809
|
+
logicalSessionId = envelope.logicalSessionId,
|
|
9810
|
+
target = envelope.target,
|
|
9811
|
+
endpoint = envelope.endpoint,
|
|
9812
|
+
data = data,
|
|
9813
|
+
}
|
|
9814
|
+
end
|
|
9815
|
+
return nil
|
|
9816
|
+
end
|
|
9817
|
+
local function closeCurrentStream()
|
|
9818
|
+
local current = streamClient
|
|
9819
|
+
streamClient = nil
|
|
9820
|
+
for _, connection in streamConnections do
|
|
9821
|
+
connection:Disconnect()
|
|
9822
|
+
end
|
|
9823
|
+
streamConnections = {}
|
|
9824
|
+
if current ~= nil then
|
|
9825
|
+
pcall(function()
|
|
9826
|
+
return current:Close()
|
|
9827
|
+
end)
|
|
9828
|
+
end
|
|
9829
|
+
end
|
|
9830
|
+
local function responseRetryDelay(attempt)
|
|
9831
|
+
return math.min(INITIAL_RESPONSE_RETRY_DELAY_SECONDS * math.pow(2, math.max(attempt - 1, 0)), MAX_RESPONSE_RETRY_DELAY_SECONDS)
|
|
9832
|
+
end
|
|
9833
|
+
local function parseResponseDisposition(success, body)
|
|
9834
|
+
local decodeOk, decoded = pcall(function()
|
|
9835
|
+
return HttpService:JSONDecode(body)
|
|
9836
|
+
end)
|
|
9837
|
+
if not decodeOk or not (type(decoded) == "table") then
|
|
9838
|
+
return nil
|
|
9839
|
+
end
|
|
9840
|
+
local acknowledgement = decoded
|
|
9841
|
+
local disposition = acknowledgement.disposition
|
|
9842
|
+
if disposition == "accepted" or disposition == "already_settled" or disposition == "unknown" then
|
|
9843
|
+
return disposition
|
|
9844
|
+
end
|
|
9845
|
+
if success and acknowledgement.success == true and disposition == nil then
|
|
9846
|
+
return "accepted"
|
|
9847
|
+
end
|
|
9848
|
+
return nil
|
|
9849
|
+
end
|
|
9850
|
+
local function rememberTerminalResponse(requestId)
|
|
9851
|
+
local _requestId = requestId
|
|
9852
|
+
if terminalResponseIds[_requestId] ~= nil then
|
|
9853
|
+
return nil
|
|
9854
|
+
end
|
|
9855
|
+
local _requestId_1 = requestId
|
|
9856
|
+
terminalResponseIds[_requestId_1] = true
|
|
9857
|
+
local _requestId_2 = requestId
|
|
9858
|
+
table.insert(terminalResponseOrder, _requestId_2)
|
|
9859
|
+
while #terminalResponseOrder > MAX_TERMINAL_RESPONSES do
|
|
9860
|
+
local oldest = table.remove(terminalResponseOrder, 1)
|
|
9861
|
+
if oldest ~= nil then
|
|
9862
|
+
terminalResponseIds[oldest] = nil
|
|
9863
|
+
end
|
|
9864
|
+
end
|
|
9865
|
+
end
|
|
9866
|
+
local function settleResponse(requestId, entry, disposition)
|
|
9867
|
+
local _requestId = requestId
|
|
9868
|
+
if pendingResponses[_requestId] ~= entry then
|
|
9869
|
+
return nil
|
|
9870
|
+
end
|
|
9871
|
+
local _requestId_1 = requestId
|
|
9872
|
+
pendingResponses[_requestId_1] = nil
|
|
9873
|
+
rememberTerminalResponse(requestId)
|
|
9874
|
+
if disposition == "unknown" then
|
|
9875
|
+
warn(`[robloxstudio-mcp] Server no longer recognizes response {requestId}; dropping stored result`)
|
|
9876
|
+
end
|
|
9877
|
+
end
|
|
9878
|
+
local function postPendingResponse(requestId, entry)
|
|
9879
|
+
local currentOptions = options
|
|
9880
|
+
local _condition = not active or currentOptions == nil
|
|
9881
|
+
if not _condition then
|
|
9882
|
+
local _requestId = requestId
|
|
9883
|
+
_condition = pendingResponses[_requestId] ~= entry
|
|
9884
|
+
if not _condition then
|
|
9885
|
+
_condition = entry.posting
|
|
9886
|
+
end
|
|
9887
|
+
end
|
|
9888
|
+
if _condition then
|
|
9889
|
+
return nil
|
|
9890
|
+
end
|
|
9891
|
+
entry.posting = true
|
|
9892
|
+
entry.retryToken += 1
|
|
9893
|
+
task.spawn(function()
|
|
9894
|
+
local _condition_1 = not active or options ~= currentOptions
|
|
9895
|
+
if not _condition_1 then
|
|
9896
|
+
local _requestId = requestId
|
|
9897
|
+
_condition_1 = pendingResponses[_requestId] ~= entry
|
|
9898
|
+
end
|
|
9899
|
+
if _condition_1 then
|
|
9900
|
+
entry.posting = false
|
|
9901
|
+
return nil
|
|
9902
|
+
end
|
|
9903
|
+
local responseUrl = `{currentOptions.serverUrl}/response`
|
|
9904
|
+
local requestOk, requestResult = pcall(function()
|
|
9905
|
+
return HttpService:RequestAsync({
|
|
9906
|
+
Url = responseUrl,
|
|
9907
|
+
Method = "POST",
|
|
9908
|
+
Headers = {
|
|
9909
|
+
["Content-Type"] = "application/json",
|
|
9910
|
+
},
|
|
9911
|
+
Body = entry.body,
|
|
9912
|
+
})
|
|
9913
|
+
end)
|
|
9914
|
+
local _requestId = requestId
|
|
9915
|
+
if pendingResponses[_requestId] ~= entry then
|
|
9916
|
+
return nil
|
|
9917
|
+
end
|
|
9918
|
+
entry.posting = false
|
|
9919
|
+
local failure
|
|
9920
|
+
if not requestOk then
|
|
9921
|
+
failure = HttpDiagnostics.formatRequestFailure(responseUrl, false, requestResult)
|
|
9922
|
+
else
|
|
9923
|
+
local disposition = parseResponseDisposition(requestResult.Success, requestResult.Body)
|
|
9924
|
+
if disposition ~= nil then
|
|
9925
|
+
settleResponse(requestId, entry, disposition)
|
|
9926
|
+
return nil
|
|
9927
|
+
end
|
|
9928
|
+
failure = if requestResult.Success then "Invalid /response acknowledgement" else HttpDiagnostics.formatRequestFailure(responseUrl, true, requestResult)
|
|
9929
|
+
end
|
|
9930
|
+
warn(`[robloxstudio-mcp] Failed to deliver response {requestId}: {failure}`)
|
|
9931
|
+
entry.retryAttempt += 1
|
|
9932
|
+
local _condition_2 = not active
|
|
9933
|
+
if not _condition_2 then
|
|
9934
|
+
local _requestId_1 = requestId
|
|
9935
|
+
_condition_2 = pendingResponses[_requestId_1] ~= entry
|
|
9936
|
+
end
|
|
9937
|
+
if _condition_2 then
|
|
9938
|
+
return nil
|
|
9939
|
+
end
|
|
9940
|
+
entry.retryToken += 1
|
|
9941
|
+
local retryToken = entry.retryToken
|
|
9942
|
+
local delay = responseRetryDelay(entry.retryAttempt)
|
|
9943
|
+
task.delay(delay, function()
|
|
9944
|
+
local _condition_3 = not active
|
|
9945
|
+
if not _condition_3 then
|
|
9946
|
+
local _requestId_1 = requestId
|
|
9947
|
+
_condition_3 = pendingResponses[_requestId_1] ~= entry
|
|
9948
|
+
if not _condition_3 then
|
|
9949
|
+
_condition_3 = entry.retryToken ~= retryToken
|
|
9950
|
+
end
|
|
9951
|
+
end
|
|
9952
|
+
if _condition_3 then
|
|
9953
|
+
return nil
|
|
9954
|
+
end
|
|
9955
|
+
postPendingResponse(requestId, entry)
|
|
9956
|
+
end)
|
|
9957
|
+
end)
|
|
9958
|
+
end
|
|
9959
|
+
local function resumePendingResponses()
|
|
9960
|
+
for requestId, entry in pendingResponses do
|
|
9961
|
+
postPendingResponse(requestId, entry)
|
|
9962
|
+
end
|
|
9963
|
+
end
|
|
9964
|
+
local function encodeResponse(requestId, response)
|
|
9965
|
+
local encodeOk, encoded = pcall(function()
|
|
9966
|
+
return HttpService:JSONEncode({
|
|
9967
|
+
requestId = requestId,
|
|
9968
|
+
response = response,
|
|
9969
|
+
})
|
|
9970
|
+
end)
|
|
9971
|
+
if encodeOk then
|
|
9972
|
+
return encoded
|
|
9973
|
+
end
|
|
9974
|
+
warn(`[robloxstudio-mcp] Failed to serialize response {requestId}: {tostring(encoded)}`)
|
|
9975
|
+
return HttpService:JSONEncode({
|
|
9976
|
+
requestId = requestId,
|
|
9977
|
+
error = `Plugin response serialization failed: {tostring(encoded)}`,
|
|
9978
|
+
})
|
|
9979
|
+
end
|
|
9980
|
+
local function dispatchRequest(request)
|
|
9981
|
+
local _requestId = request.requestId
|
|
9982
|
+
local _condition = terminalResponseIds[_requestId] ~= nil
|
|
9983
|
+
if not _condition then
|
|
9984
|
+
local _requestId_1 = request.requestId
|
|
9985
|
+
_condition = pendingResponses[_requestId_1] ~= nil
|
|
9986
|
+
if not _condition then
|
|
9987
|
+
local _requestId_2 = request.requestId
|
|
9988
|
+
_condition = inFlightRequestIds[_requestId_2] ~= nil
|
|
9989
|
+
end
|
|
9990
|
+
end
|
|
9991
|
+
if _condition then
|
|
9992
|
+
return nil
|
|
9993
|
+
end
|
|
9994
|
+
local dispatchOptions = options
|
|
9995
|
+
if not active or dispatchOptions == nil then
|
|
9996
|
+
return nil
|
|
9997
|
+
end
|
|
9998
|
+
local _requestId_1 = request.requestId
|
|
9999
|
+
inFlightRequestIds[_requestId_1] = true
|
|
10000
|
+
task.spawn(function()
|
|
10001
|
+
local dispatchOk, response = pcall(function()
|
|
10002
|
+
return dispatchOptions.dispatchRequest(request)
|
|
10003
|
+
end)
|
|
10004
|
+
local responseData = if dispatchOk then response else {
|
|
10005
|
+
error = tostring(response),
|
|
10006
|
+
}
|
|
10007
|
+
local entry = {
|
|
10008
|
+
body = encodeResponse(request.requestId, responseData),
|
|
10009
|
+
retryAttempt = 0,
|
|
10010
|
+
posting = false,
|
|
10011
|
+
retryToken = 0,
|
|
10012
|
+
}
|
|
10013
|
+
local _requestId_2 = request.requestId
|
|
10014
|
+
pendingResponses[_requestId_2] = entry
|
|
10015
|
+
local _requestId_3 = request.requestId
|
|
10016
|
+
inFlightRequestIds[_requestId_3] = nil
|
|
10017
|
+
postPendingResponse(request.requestId, entry)
|
|
10018
|
+
end)
|
|
10019
|
+
end
|
|
10020
|
+
local function invokeCallback(name, callback)
|
|
10021
|
+
local callbackOk, callbackError = pcall(callback)
|
|
10022
|
+
if not callbackOk then
|
|
10023
|
+
warn(`[robloxstudio-mcp] {name} callback failed: {tostring(callbackError)}`)
|
|
10024
|
+
end
|
|
10025
|
+
end
|
|
10026
|
+
local function reportTransport(update)
|
|
10027
|
+
local currentOptions = options
|
|
10028
|
+
if active and currentOptions ~= nil then
|
|
10029
|
+
invokeCallback("event stream transport", function()
|
|
10030
|
+
return currentOptions.onTransportUpdate(update)
|
|
10031
|
+
end)
|
|
10032
|
+
end
|
|
10033
|
+
end
|
|
10034
|
+
local function reconnectDelay(attempt)
|
|
10035
|
+
return math.min(INITIAL_RECONNECT_DELAY_SECONDS * math.pow(2, math.max(attempt - 1, 0)), MAX_RECONNECT_DELAY_SECONDS)
|
|
10036
|
+
end
|
|
10037
|
+
local connect
|
|
10038
|
+
local function connectAfter(delaySeconds, expectedGeneration)
|
|
10039
|
+
task.delay(delaySeconds, function()
|
|
10040
|
+
if not active or generation ~= expectedGeneration then
|
|
10041
|
+
return nil
|
|
10042
|
+
end
|
|
10043
|
+
connect(expectedGeneration)
|
|
10044
|
+
end)
|
|
10045
|
+
end
|
|
10046
|
+
local function scheduleReconnect(expectedGeneration, detail, duplicate)
|
|
10047
|
+
if duplicate == nil then
|
|
10048
|
+
duplicate = false
|
|
10049
|
+
end
|
|
10050
|
+
if not active or generation ~= expectedGeneration then
|
|
10051
|
+
return nil
|
|
10052
|
+
end
|
|
10053
|
+
generation += 1
|
|
10054
|
+
closeCurrentStream()
|
|
10055
|
+
reconnectAttempt += 1
|
|
10056
|
+
local delay = if duplicate then 1 else reconnectDelay(reconnectAttempt)
|
|
10057
|
+
reportTransport({
|
|
10058
|
+
state = if duplicate then "waiting-duplicate" else "retrying",
|
|
10059
|
+
attempt = reconnectAttempt,
|
|
10060
|
+
retryDelay = delay,
|
|
10061
|
+
detail = detail,
|
|
10062
|
+
})
|
|
10063
|
+
connectAfter(delay, generation)
|
|
10064
|
+
end
|
|
10065
|
+
local function watchForSilence(expectedGeneration, expectedClient)
|
|
10066
|
+
local elapsed = tick() - lastValidEventAt
|
|
10067
|
+
local delay = math.max(STREAM_SILENCE_TIMEOUT_SECONDS - elapsed, 0.1)
|
|
10068
|
+
task.delay(delay, function()
|
|
10069
|
+
if not active or generation ~= expectedGeneration or streamClient ~= expectedClient then
|
|
10070
|
+
return nil
|
|
10071
|
+
end
|
|
10072
|
+
local silentFor = tick() - lastValidEventAt
|
|
10073
|
+
if silentFor >= STREAM_SILENCE_TIMEOUT_SECONDS then
|
|
10074
|
+
scheduleReconnect(expectedGeneration, `Event stream silent for {math.floor(silentFor)} seconds`)
|
|
10075
|
+
return nil
|
|
10076
|
+
end
|
|
10077
|
+
watchForSilence(expectedGeneration, expectedClient)
|
|
10078
|
+
end)
|
|
10079
|
+
end
|
|
10080
|
+
local function parseReadyResponse(body)
|
|
10081
|
+
local decodeOk, decoded = pcall(function()
|
|
10082
|
+
return HttpService:JSONDecode(body)
|
|
10083
|
+
end)
|
|
10084
|
+
if not decodeOk or not (type(decoded) == "table") then
|
|
10085
|
+
return nil
|
|
10086
|
+
end
|
|
10087
|
+
local value = decoded
|
|
10088
|
+
local _condition = value.success ~= true
|
|
10089
|
+
if not _condition then
|
|
10090
|
+
local _assignedRole = value.assignedRole
|
|
10091
|
+
_condition = not (type(_assignedRole) == "string")
|
|
10092
|
+
if not _condition then
|
|
10093
|
+
_condition = value.assignedRole == ""
|
|
10094
|
+
if not _condition then
|
|
10095
|
+
local _instanceId = value.instanceId
|
|
10096
|
+
_condition = not (type(_instanceId) == "string")
|
|
10097
|
+
if not _condition then
|
|
10098
|
+
_condition = value.instanceId == ""
|
|
10099
|
+
if not _condition then
|
|
10100
|
+
local _serverVersion = value.serverVersion
|
|
10101
|
+
_condition = not (type(_serverVersion) == "string")
|
|
10102
|
+
if not _condition then
|
|
10103
|
+
_condition = value.serverVersion == ""
|
|
10104
|
+
end
|
|
10105
|
+
end
|
|
10106
|
+
end
|
|
10107
|
+
end
|
|
10108
|
+
end
|
|
10109
|
+
end
|
|
10110
|
+
if _condition then
|
|
10111
|
+
return nil
|
|
10112
|
+
end
|
|
10113
|
+
return {
|
|
10114
|
+
success = true,
|
|
10115
|
+
assignedRole = value.assignedRole,
|
|
10116
|
+
instanceId = value.instanceId,
|
|
10117
|
+
serverVersion = value.serverVersion,
|
|
10118
|
+
}
|
|
10119
|
+
end
|
|
10120
|
+
local refresh
|
|
10121
|
+
function connect(expectedGeneration)
|
|
10122
|
+
local currentOptions = options
|
|
10123
|
+
if not active or generation ~= expectedGeneration or currentOptions == nil then
|
|
10124
|
+
return nil
|
|
10125
|
+
end
|
|
10126
|
+
reportTransport({
|
|
10127
|
+
state = "connecting",
|
|
10128
|
+
attempt = reconnectAttempt,
|
|
10129
|
+
retryDelay = 0,
|
|
10130
|
+
})
|
|
10131
|
+
task.spawn(function()
|
|
10132
|
+
local instanceId = PluginSession.getInstanceId()
|
|
10133
|
+
local readyUrl = `{currentOptions.serverUrl}/ready`
|
|
10134
|
+
local physicalRole = PluginSession.getRole()
|
|
10135
|
+
local readyPayload = PluginSession.createReadyPayload(PluginSession.id, physicalRole)
|
|
10136
|
+
readyPayload.pluginReady = true
|
|
10137
|
+
if not active or generation ~= expectedGeneration or options ~= currentOptions then
|
|
10138
|
+
return nil
|
|
10139
|
+
end
|
|
10140
|
+
local readyOk, readyResult = pcall(function()
|
|
10141
|
+
return HttpService:RequestAsync({
|
|
10142
|
+
Url = readyUrl,
|
|
10143
|
+
Method = "POST",
|
|
10144
|
+
Headers = {
|
|
10145
|
+
["Content-Type"] = "application/json",
|
|
10146
|
+
},
|
|
10147
|
+
Body = HttpService:JSONEncode(readyPayload),
|
|
10148
|
+
})
|
|
10149
|
+
end)
|
|
10150
|
+
if not active or generation ~= expectedGeneration or options ~= currentOptions then
|
|
10151
|
+
return nil
|
|
10152
|
+
end
|
|
10153
|
+
local readyLogKey = `{currentOptions.serverUrl}|{instanceId}|{physicalRole}`
|
|
10154
|
+
if not readyOk then
|
|
10155
|
+
local detail = HttpDiagnostics.formatRequestFailure(readyUrl, false, readyResult)
|
|
10156
|
+
if not (readyFailureLogKeys[readyLogKey] ~= nil) then
|
|
10157
|
+
readyFailureLogKeys[readyLogKey] = true
|
|
10158
|
+
warn(`[robloxstudio-mcp] /ready failed for {instanceId}/{physicalRole}: {detail}`)
|
|
10159
|
+
end
|
|
10160
|
+
scheduleReconnect(expectedGeneration, detail)
|
|
10161
|
+
return nil
|
|
10162
|
+
end
|
|
10163
|
+
if not readyResult.Success then
|
|
10164
|
+
local detail = HttpDiagnostics.formatRequestFailure(readyUrl, true, readyResult)
|
|
10165
|
+
if not (readyFailureLogKeys[readyLogKey] ~= nil) then
|
|
10166
|
+
readyFailureLogKeys[readyLogKey] = true
|
|
10167
|
+
warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{physicalRole}: {detail}`)
|
|
10168
|
+
end
|
|
10169
|
+
scheduleReconnect(expectedGeneration, detail, readyResult.StatusCode == 409)
|
|
10170
|
+
return nil
|
|
10171
|
+
end
|
|
10172
|
+
local readyData = parseReadyResponse(readyResult.Body)
|
|
10173
|
+
if readyData == nil then
|
|
10174
|
+
scheduleReconnect(expectedGeneration, "Invalid /ready response: expected the bundled server protocol")
|
|
10175
|
+
return nil
|
|
10176
|
+
end
|
|
10177
|
+
if readyFailureLogKeys[readyLogKey] ~= nil then
|
|
10178
|
+
readyFailureLogKeys[readyLogKey] = nil
|
|
10179
|
+
print(`[robloxstudio-mcp] /ready connected for {instanceId}/{readyData.assignedRole} via {currentOptions.serverUrl}`)
|
|
10180
|
+
end
|
|
10181
|
+
invokeCallback("event stream ready", function()
|
|
10182
|
+
return currentOptions.onReady(readyData)
|
|
10183
|
+
end)
|
|
10184
|
+
local createOk, createdClient = pcall(function()
|
|
10185
|
+
return HttpService:CreateWebStreamClient(Enum.WebStreamClientType.SSE, {
|
|
10186
|
+
Url = `{currentOptions.serverUrl}/events?pluginSessionId={PluginSession.id}`,
|
|
10187
|
+
Method = "GET",
|
|
10188
|
+
Headers = {
|
|
10189
|
+
Accept = "text/event-stream",
|
|
10190
|
+
},
|
|
10191
|
+
})
|
|
10192
|
+
end)
|
|
10193
|
+
if not createOk then
|
|
10194
|
+
scheduleReconnect(expectedGeneration, `Failed to create event stream: {tostring(createdClient)}`)
|
|
10195
|
+
return nil
|
|
10196
|
+
end
|
|
10197
|
+
if not active or generation ~= expectedGeneration or options ~= currentOptions then
|
|
10198
|
+
pcall(function()
|
|
10199
|
+
return createdClient:Close()
|
|
10200
|
+
end)
|
|
10201
|
+
return nil
|
|
10202
|
+
end
|
|
10203
|
+
streamClient = createdClient
|
|
10204
|
+
streamConnections = { createdClient.Opened:Connect(function(statusCode, _headers)
|
|
10205
|
+
if not active or generation ~= expectedGeneration or streamClient ~= createdClient then
|
|
10206
|
+
return nil
|
|
10207
|
+
end
|
|
10208
|
+
lastValidEventAt = tick()
|
|
10209
|
+
if statusCode < 200 or statusCode >= 300 then
|
|
10210
|
+
scheduleReconnect(expectedGeneration, `Event stream opened with HTTP {statusCode}`)
|
|
10211
|
+
return nil
|
|
10212
|
+
end
|
|
10213
|
+
reconnectAttempt = 0
|
|
10214
|
+
reportTransport({
|
|
10215
|
+
state = "open",
|
|
10216
|
+
attempt = 0,
|
|
10217
|
+
retryDelay = 0,
|
|
10218
|
+
})
|
|
10219
|
+
resumePendingResponses()
|
|
10220
|
+
end), createdClient.MessageReceived:Connect(function(message)
|
|
10221
|
+
if not active or generation ~= expectedGeneration or streamClient ~= createdClient then
|
|
10222
|
+
return nil
|
|
10223
|
+
end
|
|
10224
|
+
local event = decodeMessage(message)
|
|
10225
|
+
if event == nil then
|
|
10226
|
+
return nil
|
|
10227
|
+
end
|
|
10228
|
+
lastValidEventAt = tick()
|
|
10229
|
+
if event.kind == "heartbeat" then
|
|
10230
|
+
invokeCallback("event stream heartbeat", function()
|
|
10231
|
+
return currentOptions.onHeartbeat(event.timestamp)
|
|
10232
|
+
end)
|
|
10233
|
+
return nil
|
|
10234
|
+
end
|
|
10235
|
+
if event.kind == "request" then
|
|
10236
|
+
dispatchRequest(event)
|
|
10237
|
+
return nil
|
|
10238
|
+
end
|
|
10239
|
+
invokeCallback("event stream status", function()
|
|
10240
|
+
return currentOptions.onStatus(event)
|
|
10241
|
+
end)
|
|
10242
|
+
if not event.knownInstance then
|
|
10243
|
+
refresh()
|
|
10244
|
+
end
|
|
10245
|
+
end), createdClient.Error:Connect(function(statusCode, message)
|
|
10246
|
+
if not active or generation ~= expectedGeneration or streamClient ~= createdClient then
|
|
10247
|
+
return nil
|
|
10248
|
+
end
|
|
10249
|
+
local detail = if statusCode == 404 then `Event stream session is not registered: {message}` else `Event stream error {statusCode}: {message}`
|
|
10250
|
+
scheduleReconnect(expectedGeneration, detail)
|
|
10251
|
+
end), createdClient.Closed:Connect(function()
|
|
10252
|
+
if not active or generation ~= expectedGeneration or streamClient ~= createdClient then
|
|
10253
|
+
return nil
|
|
10254
|
+
end
|
|
10255
|
+
scheduleReconnect(expectedGeneration, "Event stream closed")
|
|
10256
|
+
end) }
|
|
10257
|
+
lastValidEventAt = tick()
|
|
10258
|
+
watchForSilence(expectedGeneration, createdClient)
|
|
10259
|
+
end)
|
|
10260
|
+
end
|
|
10261
|
+
local stop
|
|
10262
|
+
local function start(newOptions)
|
|
10263
|
+
if active then
|
|
10264
|
+
stop()
|
|
10265
|
+
end
|
|
10266
|
+
options = newOptions
|
|
10267
|
+
active = true
|
|
10268
|
+
reconnectAttempt = 0
|
|
10269
|
+
generation += 1
|
|
10270
|
+
connect(generation)
|
|
10271
|
+
end
|
|
10272
|
+
function refresh()
|
|
10273
|
+
if not active or options == nil then
|
|
10274
|
+
return nil
|
|
10275
|
+
end
|
|
10276
|
+
generation += 1
|
|
10277
|
+
closeCurrentStream()
|
|
10278
|
+
reconnectAttempt = 0
|
|
10279
|
+
connect(generation)
|
|
10280
|
+
end
|
|
10281
|
+
function stop()
|
|
10282
|
+
if not active then
|
|
10283
|
+
return nil
|
|
10284
|
+
end
|
|
10285
|
+
local currentOptions = options
|
|
10286
|
+
active = false
|
|
10287
|
+
generation += 1
|
|
10288
|
+
closeCurrentStream()
|
|
10289
|
+
table.clear(readyFailureLogKeys)
|
|
10290
|
+
options = nil
|
|
10291
|
+
reconnectAttempt = 0
|
|
10292
|
+
if currentOptions ~= nil then
|
|
10293
|
+
pcall(function()
|
|
10294
|
+
return HttpService:RequestAsync({
|
|
10295
|
+
Url = `{currentOptions.serverUrl}/disconnect`,
|
|
10296
|
+
Method = "POST",
|
|
10297
|
+
Headers = {
|
|
10298
|
+
["Content-Type"] = "application/json",
|
|
10299
|
+
},
|
|
10300
|
+
Body = HttpService:JSONEncode({
|
|
10301
|
+
pluginSessionId = PluginSession.id,
|
|
10302
|
+
timestamp = tick(),
|
|
10303
|
+
}),
|
|
10304
|
+
})
|
|
10305
|
+
end)
|
|
10306
|
+
end
|
|
10307
|
+
end
|
|
10308
|
+
return {
|
|
10309
|
+
start = start,
|
|
10310
|
+
refresh = refresh,
|
|
10311
|
+
stop = stop,
|
|
10312
|
+
}
|
|
10313
|
+
]]></string>
|
|
10314
|
+
</Properties>
|
|
10315
|
+
</Item>
|
|
10316
|
+
<Item class="ModuleScript" referent="34">
|
|
9913
10317
|
<Properties>
|
|
9914
10318
|
<string name="Name">UI</string>
|
|
9915
10319
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -10461,7 +10865,7 @@ return {
|
|
|
10461
10865
|
]]></string>
|
|
10462
10866
|
</Properties>
|
|
10463
10867
|
</Item>
|
|
10464
|
-
<Item class="ModuleScript" referent="
|
|
10868
|
+
<Item class="ModuleScript" referent="35">
|
|
10465
10869
|
<Properties>
|
|
10466
10870
|
<string name="Name">Utils</string>
|
|
10467
10871
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -11306,11 +11710,11 @@ return {
|
|
|
11306
11710
|
</Properties>
|
|
11307
11711
|
</Item>
|
|
11308
11712
|
</Item>
|
|
11309
|
-
<Item class="Folder" referent="
|
|
11713
|
+
<Item class="Folder" referent="40">
|
|
11310
11714
|
<Properties>
|
|
11311
11715
|
<string name="Name">include</string>
|
|
11312
11716
|
</Properties>
|
|
11313
|
-
<Item class="ModuleScript" referent="
|
|
11717
|
+
<Item class="ModuleScript" referent="36">
|
|
11314
11718
|
<Properties>
|
|
11315
11719
|
<string name="Name">LibMP</string>
|
|
11316
11720
|
<string name="Source"><![CDATA[-- =============================================================================
|
|
@@ -167694,7 +168098,7 @@ return LibMP
|
|
|
167694
168098
|
]]></string>
|
|
167695
168099
|
</Properties>
|
|
167696
168100
|
</Item>
|
|
167697
|
-
<Item class="ModuleScript" referent="
|
|
168101
|
+
<Item class="ModuleScript" referent="37">
|
|
167698
168102
|
<Properties>
|
|
167699
168103
|
<string name="Name">Promise</string>
|
|
167700
168104
|
<string name="Source"><![CDATA[--[[
|
|
@@ -169768,7 +170172,7 @@ return Promise
|
|
|
169768
170172
|
]]></string>
|
|
169769
170173
|
</Properties>
|
|
169770
170174
|
</Item>
|
|
169771
|
-
<Item class="ModuleScript" referent="
|
|
170175
|
+
<Item class="ModuleScript" referent="38">
|
|
169772
170176
|
<Properties>
|
|
169773
170177
|
<string name="Name">RuntimeLib</string>
|
|
169774
170178
|
<string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
|
|
@@ -170035,15 +170439,15 @@ return TS
|
|
|
170035
170439
|
</Properties>
|
|
170036
170440
|
</Item>
|
|
170037
170441
|
</Item>
|
|
170038
|
-
<Item class="Folder" referent="
|
|
170442
|
+
<Item class="Folder" referent="41">
|
|
170039
170443
|
<Properties>
|
|
170040
170444
|
<string name="Name">node_modules</string>
|
|
170041
170445
|
</Properties>
|
|
170042
|
-
<Item class="Folder" referent="
|
|
170446
|
+
<Item class="Folder" referent="42">
|
|
170043
170447
|
<Properties>
|
|
170044
170448
|
<string name="Name">@rbxts</string>
|
|
170045
170449
|
</Properties>
|
|
170046
|
-
<Item class="ModuleScript" referent="
|
|
170450
|
+
<Item class="ModuleScript" referent="39">
|
|
170047
170451
|
<Properties>
|
|
170048
170452
|
<string name="Name">services</string>
|
|
170049
170453
|
<string name="Source"><![CDATA[return setmetatable({}, {
|