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