@chrrxs/robloxstudio-mcp 3.0.2 → 3.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +2979 -518
- package/package.json +1 -1
- package/studio-plugin/MCPPlugin.rbxmx +1694 -1026
|
@@ -12,7 +12,7 @@ local Communication = TS.import(script, script, "modules", "Communication")
|
|
|
12
12
|
local ClientBroker = TS.import(script, script, "modules", "ClientBroker")
|
|
13
13
|
local ServerUrlSettings = TS.import(script, script, "modules", "ServerUrlSettings")
|
|
14
14
|
local _EvalBridges = TS.import(script, script, "modules", "EvalBridges")
|
|
15
|
-
local
|
|
15
|
+
local cleanupEditBridgeArtifacts = _EvalBridges.cleanupEditBridgeArtifacts
|
|
16
16
|
local ensureRuntimeBridgeInstalled = _EvalBridges.ensureRuntimeBridgeInstalled
|
|
17
17
|
local RuntimeLogBuffer = TS.import(script, script, "modules", "RuntimeLogBuffer")
|
|
18
18
|
local StopPlayMonitor = TS.import(script, script, "modules", "StopPlayMonitor")
|
|
@@ -93,7 +93,7 @@ task.delay(TOOLBAR_REGISTRATION_DELAY_SECONDS, registerToolbarButton)
|
|
|
93
93
|
task.delay(2, function()
|
|
94
94
|
local role = ClientBroker.forkRole()
|
|
95
95
|
if role == "edit" then
|
|
96
|
-
|
|
96
|
+
cleanupEditBridgeArtifacts()
|
|
97
97
|
else
|
|
98
98
|
local result = ensureRuntimeBridgeInstalled()
|
|
99
99
|
if not result.installed then
|
|
@@ -265,7 +265,6 @@ local HttpService = _services.HttpService
|
|
|
265
265
|
local Players = _services.Players
|
|
266
266
|
local ReplicatedStorage = _services.ReplicatedStorage
|
|
267
267
|
local RunService = _services.RunService
|
|
268
|
-
local ServerStorage = _services.ServerStorage
|
|
269
268
|
local RuntimeLogBuffer = TS.import(script, script.Parent, "RuntimeLogBuffer")
|
|
270
269
|
local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandlers")
|
|
271
270
|
local SceneAnalysisHandlers = TS.import(script, script.Parent, "handlers", "SceneAnalysisHandlers")
|
|
@@ -277,60 +276,15 @@ local BreakpointHandlers = TS.import(script, script.Parent, "handlers", "Breakpo
|
|
|
277
276
|
local ScriptProfilerHandlers = TS.import(script, script.Parent, "handlers", "ScriptProfilerHandlers")
|
|
278
277
|
local MicroProfilerHandlers = TS.import(script, script.Parent, "handlers", "MicroProfilerHandlers")
|
|
279
278
|
local LuauExec = TS.import(script, script.Parent, "LuauExec")
|
|
280
|
-
local State = TS.import(script, script.Parent, "State")
|
|
281
279
|
local HttpDiagnostics = TS.import(script, script.Parent, "HttpDiagnostics")
|
|
280
|
+
local PluginSession = TS.import(script, script.Parent, "PluginSession")
|
|
282
281
|
local StudioTestService = game:GetService("StudioTestService")
|
|
283
|
-
-- Mirror of Communication.computeInstanceId() — duplicated here because the
|
|
284
|
-
-- client broker runs in the play-server DM where it can't easily import from
|
|
285
|
-
-- the edit-side module, and the place identifier must match what the edit-DM
|
|
286
|
-
-- plugin reports. Both use the same algorithm against the shared DataModel.
|
|
287
|
-
local function computeInstanceId()
|
|
288
|
-
if game.PlaceId ~= 0 then
|
|
289
|
-
return `place:{tostring(game.PlaceId)}`
|
|
290
|
-
end
|
|
291
|
-
local existing = ServerStorage:GetAttribute("__MCPPlaceId")
|
|
292
|
-
if type(existing) == "string" and existing ~= "" then
|
|
293
|
-
return `anon:{existing}`
|
|
294
|
-
end
|
|
295
|
-
local fresh = HttpService:GenerateGUID(false)
|
|
296
|
-
pcall(function()
|
|
297
|
-
return ServerStorage:SetAttribute("__MCPPlaceId", fresh)
|
|
298
|
-
end)
|
|
299
|
-
return `anon:{fresh}`
|
|
300
|
-
end
|
|
301
|
-
local cachedPlaceName
|
|
302
|
-
local function resolvePlaceName()
|
|
303
|
-
if cachedPlaceName ~= nil then
|
|
304
|
-
return cachedPlaceName
|
|
305
|
-
end
|
|
306
|
-
if game.PlaceId == 0 then
|
|
307
|
-
cachedPlaceName = game.Name
|
|
308
|
-
return cachedPlaceName
|
|
309
|
-
end
|
|
310
|
-
local MarketplaceService = game:GetService("MarketplaceService")
|
|
311
|
-
local ok, info = pcall(function()
|
|
312
|
-
return MarketplaceService:GetProductInfo(game.PlaceId)
|
|
313
|
-
end)
|
|
314
|
-
if ok and info ~= nil then
|
|
315
|
-
local name = info.Name
|
|
316
|
-
if type(name) == "string" and name ~= "" then
|
|
317
|
-
cachedPlaceName = name
|
|
318
|
-
return cachedPlaceName
|
|
319
|
-
end
|
|
320
|
-
end
|
|
321
|
-
return game.Name
|
|
322
|
-
end
|
|
323
282
|
-- The client peer cannot reach the MCP HTTP server - Roblox forbids
|
|
324
283
|
-- HttpService:RequestAsync from the client DM even under PluginSecurity, and
|
|
325
284
|
-- HttpEnabled reads as false there regardless of identity. So the server peer
|
|
326
|
-
-- brokers
|
|
327
|
-
-- in ReplicatedStorage; each player gets a proxy
|
|
328
|
-
-- MCP side,
|
|
329
|
-
--
|
|
330
|
-
-- (Previously the server peer also registered an "edit-proxy" role to
|
|
331
|
-
-- intercept /api/stop-playtest and call StudioTestService:EndTest. That hack
|
|
332
|
-
-- is gone: stop now uses StopPlayMonitor with plugin:SetSetting cross-DM
|
|
333
|
-
-- signaling, which works regardless of MCP server state.)
|
|
285
|
+
-- brokers client-targeted requests through a RemoteFunction it places
|
|
286
|
+
-- in ReplicatedStorage; each player gets a logical proxy registration on the
|
|
287
|
+
-- MCP side, multiplexed over the play-server peer's physical event stream.
|
|
334
288
|
local DEFAULT_MCP_URL = "http://localhost:58741"
|
|
335
289
|
local mcpUrl = DEFAULT_MCP_URL
|
|
336
290
|
local BROKER_NAME = "__MCPClientBroker"
|
|
@@ -354,37 +308,6 @@ local CLIENT_BROKER_ALLOWED_ENDPOINTS = {
|
|
|
354
308
|
["/api/simulate-keyboard-input"] = true,
|
|
355
309
|
["/api/focus-viewport"] = true,
|
|
356
310
|
}
|
|
357
|
-
-- Throttle re-ready calls per proxyId so a brief window of unknownInstance
|
|
358
|
-
-- polls doesn't cause a re-register stampede.
|
|
359
|
-
local lastReadyByProxy = {}
|
|
360
|
-
local postJson
|
|
361
|
-
local function reRegisterProxy(proxyId, role)
|
|
362
|
-
local now = tick()
|
|
363
|
-
local _proxyId = proxyId
|
|
364
|
-
local _condition = lastReadyByProxy[_proxyId]
|
|
365
|
-
if _condition == nil then
|
|
366
|
-
_condition = 0
|
|
367
|
-
end
|
|
368
|
-
local last = _condition
|
|
369
|
-
if now - last < 2 then
|
|
370
|
-
return nil
|
|
371
|
-
end
|
|
372
|
-
local _proxyId_1 = proxyId
|
|
373
|
-
lastReadyByProxy[_proxyId_1] = now
|
|
374
|
-
pcall(function()
|
|
375
|
-
return postJson("/ready", {
|
|
376
|
-
pluginSessionId = proxyId,
|
|
377
|
-
instanceId = computeInstanceId(),
|
|
378
|
-
role = role,
|
|
379
|
-
placeId = game.PlaceId,
|
|
380
|
-
placeName = resolvePlaceName(),
|
|
381
|
-
dataModelName = game.Name,
|
|
382
|
-
isRunning = RunService:IsRunning(),
|
|
383
|
-
pluginVersion = State.CURRENT_VERSION,
|
|
384
|
-
pluginVariant = State.PLUGIN_VARIANT,
|
|
385
|
-
})
|
|
386
|
-
end)
|
|
387
|
-
end
|
|
388
311
|
local function forkRole()
|
|
389
312
|
if not RunService:IsRunning() then
|
|
390
313
|
return "edit"
|
|
@@ -394,7 +317,7 @@ local function forkRole()
|
|
|
394
317
|
end
|
|
395
318
|
return "client"
|
|
396
319
|
end
|
|
397
|
-
function postJson(endpoint, body)
|
|
320
|
+
local function postJson(endpoint, body)
|
|
398
321
|
return pcall(function()
|
|
399
322
|
return HttpService:RequestAsync({
|
|
400
323
|
Url = `{mcpUrl}{endpoint}`,
|
|
@@ -414,9 +337,6 @@ local function setServerUrl(serverUrl)
|
|
|
414
337
|
mcpUrl = serverUrl
|
|
415
338
|
end
|
|
416
339
|
end
|
|
417
|
-
local function getServerUrl()
|
|
418
|
-
return mcpUrl
|
|
419
|
-
end
|
|
420
340
|
local function handleExecuteLuau(data)
|
|
421
341
|
local code = data and (data.code)
|
|
422
342
|
if type(code) == "string" == false or code == "" then
|
|
@@ -522,12 +442,16 @@ local function setupClientBroker()
|
|
|
522
442
|
return nil
|
|
523
443
|
end
|
|
524
444
|
rf.OnClientInvoke = function(payload)
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
445
|
+
local _condition = not payload
|
|
446
|
+
if not _condition then
|
|
447
|
+
local _endpoint = payload.endpoint
|
|
448
|
+
_condition = not (type(_endpoint) == "string")
|
|
449
|
+
end
|
|
450
|
+
if _condition then
|
|
451
|
+
return {
|
|
452
|
+
error = "Client broker request is missing its endpoint.",
|
|
453
|
+
}
|
|
454
|
+
end
|
|
531
455
|
if payload and payload.endpoint == "/api/get-runtime-logs" then
|
|
532
456
|
return handleGetRuntimeLogs(payload.data)
|
|
533
457
|
end
|
|
@@ -570,13 +494,19 @@ local function setupClientBroker()
|
|
|
570
494
|
if payload and payload.endpoint == "/api/eval-runtime" then
|
|
571
495
|
return EvalRuntimeHandlers.evalRuntime(payload.data or {})
|
|
572
496
|
end
|
|
573
|
-
|
|
574
|
-
|
|
497
|
+
return {
|
|
498
|
+
error = `Unsupported client broker endpoint: {payload.endpoint}`,
|
|
499
|
+
}
|
|
575
500
|
end
|
|
576
501
|
end
|
|
502
|
+
local INITIAL_PROXY_RETRY_DELAY_SECONDS = 0.5
|
|
503
|
+
local MAX_PROXY_RETRY_DELAY_SECONDS = 5
|
|
577
504
|
local proxyByPlayer = {}
|
|
505
|
+
local proxyBySessionId = {}
|
|
578
506
|
local proxyRegisterFailuresByPlayer = {}
|
|
507
|
+
local pendingProxyDisconnects = {}
|
|
579
508
|
local serverBrokerStarted = false
|
|
509
|
+
local queueProxyDisconnect
|
|
580
510
|
local function unregisterProxy(player, entry)
|
|
581
511
|
local _condition = entry
|
|
582
512
|
if _condition == nil then
|
|
@@ -587,122 +517,154 @@ local function unregisterProxy(player, entry)
|
|
|
587
517
|
if not proxy then
|
|
588
518
|
return nil
|
|
589
519
|
end
|
|
520
|
+
proxy.generation += 1
|
|
590
521
|
local _player = player
|
|
591
522
|
proxyByPlayer[_player] = nil
|
|
523
|
+
local _pluginSessionId = proxy.pluginSessionId
|
|
524
|
+
proxyBySessionId[_pluginSessionId] = nil
|
|
592
525
|
local _player_1 = player
|
|
593
526
|
proxyRegisterFailuresByPlayer[_player_1] = nil
|
|
594
|
-
|
|
595
|
-
pluginSessionId = proxy.pluginSessionId,
|
|
596
|
-
})
|
|
527
|
+
queueProxyDisconnect(proxy.pluginSessionId)
|
|
597
528
|
end
|
|
598
529
|
local function disconnectAllProxies()
|
|
599
530
|
for player, entry in proxyByPlayer do
|
|
600
531
|
unregisterProxy(player, entry)
|
|
601
532
|
end
|
|
602
533
|
table.clear(proxyByPlayer)
|
|
534
|
+
table.clear(proxyBySessionId)
|
|
603
535
|
table.clear(proxyRegisterFailuresByPlayer)
|
|
604
536
|
end
|
|
605
|
-
local function
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
537
|
+
local function proxyRetryDelay(attempt)
|
|
538
|
+
return math.min(INITIAL_PROXY_RETRY_DELAY_SECONDS * math.pow(2, math.max(attempt - 1, 0)), MAX_PROXY_RETRY_DELAY_SECONDS)
|
|
539
|
+
end
|
|
540
|
+
local function deliverProxyDisconnect(pluginSessionId, attempt)
|
|
541
|
+
local _pluginSessionId = pluginSessionId
|
|
542
|
+
if not (pendingProxyDisconnects[_pluginSessionId] ~= nil) then
|
|
543
|
+
return nil
|
|
544
|
+
end
|
|
545
|
+
local ok, response = postJson("/disconnect", {
|
|
546
|
+
pluginSessionId = pluginSessionId,
|
|
547
|
+
})
|
|
548
|
+
if ok and response and response.Success then
|
|
549
|
+
local _pluginSessionId_1 = pluginSessionId
|
|
550
|
+
pendingProxyDisconnects[_pluginSessionId_1] = nil
|
|
551
|
+
return nil
|
|
552
|
+
end
|
|
553
|
+
task.delay(proxyRetryDelay(attempt + 1), function()
|
|
554
|
+
deliverProxyDisconnect(pluginSessionId, attempt + 1)
|
|
555
|
+
end)
|
|
556
|
+
end
|
|
557
|
+
function queueProxyDisconnect(pluginSessionId)
|
|
558
|
+
local _pluginSessionId = pluginSessionId
|
|
559
|
+
if pendingProxyDisconnects[_pluginSessionId] ~= nil then
|
|
560
|
+
return nil
|
|
561
|
+
end
|
|
562
|
+
local _pluginSessionId_1 = pluginSessionId
|
|
563
|
+
pendingProxyDisconnects[_pluginSessionId_1] = true
|
|
564
|
+
task.spawn(deliverProxyDisconnect, pluginSessionId, 0)
|
|
565
|
+
end
|
|
566
|
+
local function parseAssignedRole(body)
|
|
567
|
+
local decodeOk, decoded = pcall(function()
|
|
568
|
+
return HttpService:JSONDecode(body)
|
|
569
|
+
end)
|
|
570
|
+
if not decodeOk or not (type(decoded) == "table") then
|
|
571
|
+
return nil
|
|
572
|
+
end
|
|
573
|
+
local ready = decoded
|
|
574
|
+
if ready.success ~= true then
|
|
575
|
+
return nil
|
|
576
|
+
end
|
|
577
|
+
local _assignedRole = ready.assignedRole
|
|
578
|
+
local _condition = type(_assignedRole) == "string"
|
|
579
|
+
if _condition then
|
|
580
|
+
_condition = ready.assignedRole ~= ""
|
|
581
|
+
end
|
|
582
|
+
return if _condition then ready.assignedRole else nil
|
|
583
|
+
end
|
|
584
|
+
local registerProxyEntry
|
|
585
|
+
local function scheduleProxyRetry(entry)
|
|
586
|
+
entry.retryAttempt += 1
|
|
587
|
+
local expectedGeneration = entry.generation
|
|
588
|
+
task.delay(proxyRetryDelay(entry.retryAttempt), function()
|
|
589
|
+
local _player = entry.player
|
|
590
|
+
local _condition = proxyByPlayer[_player] ~= entry
|
|
612
591
|
if not _condition then
|
|
613
|
-
|
|
592
|
+
_condition = entry.generation ~= expectedGeneration or entry.registered or entry.registering or entry.player.Parent == nil or not RunService:IsRunning()
|
|
614
593
|
end
|
|
615
|
-
if
|
|
616
|
-
|
|
617
|
-
break
|
|
594
|
+
if _condition then
|
|
595
|
+
return nil
|
|
618
596
|
end
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
local
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
_result = _result.pluginSessionId
|
|
640
|
-
end
|
|
641
|
-
_condition_1 = _result ~= proxyId
|
|
597
|
+
registerProxyEntry(entry)
|
|
598
|
+
end)
|
|
599
|
+
end
|
|
600
|
+
local function failProxyRegistration(entry, detail)
|
|
601
|
+
entry.registered = false
|
|
602
|
+
local _player = entry.player
|
|
603
|
+
if not (proxyRegisterFailuresByPlayer[_player] ~= nil) then
|
|
604
|
+
local _player_1 = entry.player
|
|
605
|
+
proxyRegisterFailuresByPlayer[_player_1] = true
|
|
606
|
+
warn(`[robloxstudio-mcp] proxy register failed for {entry.player.Name}: {detail}`)
|
|
607
|
+
end
|
|
608
|
+
scheduleProxyRetry(entry)
|
|
609
|
+
end
|
|
610
|
+
function registerProxyEntry(entry)
|
|
611
|
+
local _condition = entry.registering
|
|
612
|
+
if not _condition then
|
|
613
|
+
local _player = entry.player
|
|
614
|
+
_condition = proxyByPlayer[_player] ~= entry
|
|
615
|
+
if not _condition then
|
|
616
|
+
_condition = entry.player.Parent == nil or not RunService:IsRunning()
|
|
642
617
|
end
|
|
643
|
-
|
|
644
|
-
|
|
618
|
+
end
|
|
619
|
+
if _condition then
|
|
620
|
+
return nil
|
|
621
|
+
end
|
|
622
|
+
entry.registering = true
|
|
623
|
+
local expectedGeneration = entry.generation
|
|
624
|
+
local requestedRole = if entry.role == "client" then "client" else entry.role
|
|
625
|
+
local readyPayload = PluginSession.createReadyPayload(entry.pluginSessionId, requestedRole)
|
|
626
|
+
local _player = entry.player
|
|
627
|
+
if proxyByPlayer[_player] ~= entry then
|
|
628
|
+
return nil
|
|
629
|
+
end
|
|
630
|
+
if entry.generation ~= expectedGeneration then
|
|
631
|
+
if not entry.registering then
|
|
632
|
+
task.spawn(registerProxyEntry, entry)
|
|
645
633
|
end
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
if body.knownInstance == false then
|
|
654
|
-
reRegisterProxy(proxyId, "client")
|
|
655
|
-
end
|
|
656
|
-
if body.request and body.requestId ~= nil then
|
|
657
|
-
local request = body.request
|
|
658
|
-
local response
|
|
659
|
-
local _endpoint = request.endpoint
|
|
660
|
-
if CLIENT_BROKER_ALLOWED_ENDPOINTS[_endpoint] ~= nil then
|
|
661
|
-
-- Forward as a discriminated envelope so the client-side
|
|
662
|
-
-- OnClientInvoke knows which endpoint it's serving.
|
|
663
|
-
local envelope = {
|
|
664
|
-
endpoint = request.endpoint,
|
|
665
|
-
data = request.data,
|
|
666
|
-
}
|
|
667
|
-
local okInvoke, invokeRes = pcall(function()
|
|
668
|
-
return rf:InvokeClient(player, envelope)
|
|
669
|
-
end)
|
|
670
|
-
if okInvoke then
|
|
671
|
-
response = if invokeRes ~= nil then invokeRes else {
|
|
672
|
-
success = false,
|
|
673
|
-
error = "nil response",
|
|
674
|
-
}
|
|
675
|
-
else
|
|
676
|
-
response = {
|
|
677
|
-
success = false,
|
|
678
|
-
error = `InvokeClient failed: {tostring(invokeRes)}`,
|
|
679
|
-
}
|
|
680
|
-
end
|
|
681
|
-
else
|
|
682
|
-
local allowed = {}
|
|
683
|
-
for ep in CLIENT_BROKER_ALLOWED_ENDPOINTS do
|
|
684
|
-
table.insert(allowed, ep)
|
|
685
|
-
end
|
|
686
|
-
response = {
|
|
687
|
-
error = `Client-proxy does not forward {tostring(request.endpoint)}. ` .. `Allowed: {table.concat(allowed, ", ")}.`,
|
|
688
|
-
}
|
|
689
|
-
end
|
|
690
|
-
postJson("/response", {
|
|
691
|
-
requestId = body.requestId,
|
|
692
|
-
response = response,
|
|
693
|
-
})
|
|
694
|
-
end
|
|
695
|
-
if res.Success and body.pollMode == "long" and body.knownInstance ~= false and body.request == nil then
|
|
696
|
-
nextPollDelay = 0
|
|
697
|
-
end
|
|
698
|
-
end
|
|
634
|
+
return nil
|
|
635
|
+
end
|
|
636
|
+
local ok, res = postJson("/ready", readyPayload)
|
|
637
|
+
local _player_1 = entry.player
|
|
638
|
+
if proxyByPlayer[_player_1] ~= entry then
|
|
639
|
+
if ok and res and res.Success then
|
|
640
|
+
queueProxyDisconnect(entry.pluginSessionId)
|
|
699
641
|
end
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
task.
|
|
642
|
+
return nil
|
|
643
|
+
end
|
|
644
|
+
if entry.generation ~= expectedGeneration then
|
|
645
|
+
if not entry.registering then
|
|
646
|
+
task.spawn(registerProxyEntry, entry)
|
|
705
647
|
end
|
|
648
|
+
return nil
|
|
649
|
+
end
|
|
650
|
+
entry.registering = false
|
|
651
|
+
if not ok or not res or not res.Success then
|
|
652
|
+
failProxyRegistration(entry, formatPostJsonFailure("/ready", ok, res))
|
|
653
|
+
return nil
|
|
654
|
+
end
|
|
655
|
+
local assignedRole = parseAssignedRole(res.Body)
|
|
656
|
+
if assignedRole == nil then
|
|
657
|
+
failProxyRegistration(entry, "invalid /ready response: expected success=true and a non-empty assignedRole")
|
|
658
|
+
return nil
|
|
659
|
+
end
|
|
660
|
+
entry.role = assignedRole
|
|
661
|
+
entry.registered = true
|
|
662
|
+
entry.retryAttempt = 0
|
|
663
|
+
local _player_2 = entry.player
|
|
664
|
+
if proxyRegisterFailuresByPlayer[_player_2] ~= nil then
|
|
665
|
+
local _player_3 = entry.player
|
|
666
|
+
proxyRegisterFailuresByPlayer[_player_3] = nil
|
|
667
|
+
print(`[robloxstudio-mcp] proxy registered for {entry.player.Name} as {assignedRole} via {mcpUrl}`)
|
|
706
668
|
end
|
|
707
669
|
end
|
|
708
670
|
local function registerProxy(player, rf)
|
|
@@ -710,48 +672,89 @@ local function registerProxy(player, rf)
|
|
|
710
672
|
if proxyByPlayer[_player] ~= nil then
|
|
711
673
|
return nil
|
|
712
674
|
end
|
|
713
|
-
local
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
675
|
+
local entry = {
|
|
676
|
+
player = player,
|
|
677
|
+
remote = rf,
|
|
678
|
+
pluginSessionId = HttpService:GenerateGUID(false),
|
|
717
679
|
role = "client",
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
680
|
+
registered = false,
|
|
681
|
+
registering = false,
|
|
682
|
+
retryAttempt = 0,
|
|
683
|
+
generation = 0,
|
|
684
|
+
}
|
|
685
|
+
local _player_1 = player
|
|
686
|
+
proxyByPlayer[_player_1] = entry
|
|
687
|
+
local _pluginSessionId = entry.pluginSessionId
|
|
688
|
+
proxyBySessionId[_pluginSessionId] = entry
|
|
689
|
+
task.spawn(registerProxyEntry, entry)
|
|
690
|
+
end
|
|
691
|
+
local function refreshAllLogicalRegistrations()
|
|
692
|
+
for _, entry in proxyByPlayer do
|
|
693
|
+
entry.generation += 1
|
|
694
|
+
entry.registered = false
|
|
695
|
+
entry.registering = false
|
|
696
|
+
entry.retryAttempt = 0
|
|
697
|
+
task.spawn(registerProxyEntry, entry)
|
|
698
|
+
end
|
|
699
|
+
end
|
|
700
|
+
local function dispatchClientRequest(logicalSessionId, target, endpoint, data)
|
|
701
|
+
local _logicalSessionId = logicalSessionId
|
|
702
|
+
local entry = proxyBySessionId[_logicalSessionId]
|
|
703
|
+
local _condition = not entry
|
|
704
|
+
if not _condition then
|
|
705
|
+
local _player = entry.player
|
|
706
|
+
_condition = proxyByPlayer[_player] ~= entry
|
|
730
707
|
end
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
708
|
+
if _condition then
|
|
709
|
+
return {
|
|
710
|
+
error = `Client proxy {target} ({logicalSessionId}) is not registered.`,
|
|
711
|
+
}
|
|
735
712
|
end
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
713
|
+
if entry.role == "client" then
|
|
714
|
+
local assignedClientRole = string.match(target, "^client%-%d+$")
|
|
715
|
+
if assignedClientRole ~= nil then
|
|
716
|
+
entry.role = target
|
|
717
|
+
end
|
|
718
|
+
end
|
|
719
|
+
if entry.role ~= target then
|
|
720
|
+
return {
|
|
721
|
+
error = `Client proxy {logicalSessionId} is registered as {entry.role}, not {target}.`,
|
|
722
|
+
}
|
|
723
|
+
end
|
|
724
|
+
if entry.player.Parent == nil or not RunService:IsRunning() then
|
|
725
|
+
unregisterProxy(entry.player, entry)
|
|
726
|
+
return {
|
|
727
|
+
error = `Client proxy {target} is no longer available.`,
|
|
728
|
+
}
|
|
729
|
+
end
|
|
730
|
+
local _endpoint = endpoint
|
|
731
|
+
if not (CLIENT_BROKER_ALLOWED_ENDPOINTS[_endpoint] ~= nil) then
|
|
732
|
+
local allowed = {}
|
|
733
|
+
for allowedEndpoint in CLIENT_BROKER_ALLOWED_ENDPOINTS do
|
|
734
|
+
table.insert(allowed, allowedEndpoint)
|
|
735
|
+
end
|
|
736
|
+
return {
|
|
737
|
+
error = `Client-proxy does not forward {endpoint}. Allowed: {table.concat(allowed, ", ")}.`,
|
|
738
|
+
}
|
|
739
|
+
end
|
|
740
|
+
local envelope = {
|
|
741
|
+
endpoint = endpoint,
|
|
742
|
+
data = data,
|
|
741
743
|
}
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
744
|
+
local invokeOk, invokeResult = pcall(function()
|
|
745
|
+
return entry.remote:InvokeClient(entry.player, envelope)
|
|
746
|
+
end)
|
|
747
|
+
if not invokeOk then
|
|
748
|
+
return {
|
|
749
|
+
success = false,
|
|
750
|
+
error = `InvokeClient failed: {tostring(invokeResult)}`,
|
|
751
|
+
}
|
|
748
752
|
end
|
|
749
|
-
|
|
753
|
+
return if invokeResult ~= nil then invokeResult else {
|
|
754
|
+
success = false,
|
|
755
|
+
error = "nil response",
|
|
756
|
+
}
|
|
750
757
|
end
|
|
751
|
-
-- (Removed: startEditProxyLoop. The play-server DM no longer registers an
|
|
752
|
-
-- "edit-proxy" peer with the MCP server. stop_playtest now uses a cross-DM
|
|
753
|
-
-- plugin:SetSetting request consumed by StopPlayMonitor in the play-server DM,
|
|
754
|
-
-- which doesn't depend on MCP server state or peer registration at all.)
|
|
755
758
|
local function setupServerBroker()
|
|
756
759
|
if serverBrokerStarted then
|
|
757
760
|
return nil
|
|
@@ -782,11 +785,11 @@ local function setupServerBroker()
|
|
|
782
785
|
end)
|
|
783
786
|
end
|
|
784
787
|
return {
|
|
785
|
-
MCP_URL = DEFAULT_MCP_URL,
|
|
786
788
|
DEFAULT_MCP_URL = DEFAULT_MCP_URL,
|
|
787
|
-
getServerUrl = getServerUrl,
|
|
788
789
|
setServerUrl = setServerUrl,
|
|
789
790
|
disconnectAllProxies = disconnectAllProxies,
|
|
791
|
+
refreshAllLogicalRegistrations = refreshAllLogicalRegistrations,
|
|
792
|
+
dispatchClientRequest = dispatchClientRequest,
|
|
790
793
|
forkRole = forkRole,
|
|
791
794
|
setupClientBroker = setupClientBroker,
|
|
792
795
|
setupServerBroker = setupServerBroker,
|
|
@@ -802,11 +805,10 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
|
802
805
|
local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
|
|
803
806
|
local HttpService = _services.HttpService
|
|
804
807
|
local RunService = _services.RunService
|
|
805
|
-
local ServerStorage = _services.ServerStorage
|
|
806
808
|
local State = TS.import(script, script.Parent, "State")
|
|
807
809
|
local Utils = TS.import(script, script.Parent, "Utils")
|
|
808
810
|
local UI = TS.import(script, script.Parent, "UI")
|
|
809
|
-
local
|
|
811
|
+
local cleanupEditBridgeArtifacts = TS.import(script, script.Parent, "EvalBridges").cleanupEditBridgeArtifacts
|
|
810
812
|
local QueryHandlers = TS.import(script, script.Parent, "handlers", "QueryHandlers")
|
|
811
813
|
local PropertyHandlers = TS.import(script, script.Parent, "handlers", "PropertyHandlers")
|
|
812
814
|
local ScriptHandlers = TS.import(script, script.Parent, "handlers", "ScriptHandlers")
|
|
@@ -826,79 +828,11 @@ local GenerateModelHandlers = TS.import(script, script.Parent, "handlers", "Gene
|
|
|
826
828
|
local EvalRuntimeHandlers = TS.import(script, script.Parent, "handlers", "EvalRuntimeHandlers")
|
|
827
829
|
local ClientBroker = TS.import(script, script.Parent, "ClientBroker")
|
|
828
830
|
local ServerUrlSettings = TS.import(script, script.Parent, "ServerUrlSettings")
|
|
829
|
-
local
|
|
830
|
-
|
|
831
|
-
-- can tell our polls apart from any other plugin's polls. Not user-facing —
|
|
832
|
-
-- MCP tools and the LLM operate on instanceId (the place identifier).
|
|
833
|
-
local pluginSessionId = HttpService:GenerateGUID(false)
|
|
834
|
-
-- Place-level identifier shared by every plugin running in DataModels of
|
|
835
|
-
-- the same place file (edit DM + playtest server DM + playtest clients).
|
|
836
|
-
-- Format: "place:<PlaceId>" when published, "anon:<UUID>" for unpublished
|
|
837
|
-
-- places where the UUID lives on ServerStorage's __MCPPlaceId attribute
|
|
838
|
-
-- and travels with the .rbxl.
|
|
839
|
-
local MCP_PLACE_ID_ATTRIBUTE = "__MCPPlaceId"
|
|
840
|
-
local function computeInstanceId()
|
|
841
|
-
if game.PlaceId ~= 0 then
|
|
842
|
-
return `place:{tostring(game.PlaceId)}`
|
|
843
|
-
end
|
|
844
|
-
local existing = ServerStorage:GetAttribute(MCP_PLACE_ID_ATTRIBUTE)
|
|
845
|
-
if type(existing) == "string" and existing ~= "" then
|
|
846
|
-
return `anon:{existing}`
|
|
847
|
-
end
|
|
848
|
-
local fresh = HttpService:GenerateGUID(false)
|
|
849
|
-
pcall(function()
|
|
850
|
-
return ServerStorage:SetAttribute(MCP_PLACE_ID_ATTRIBUTE, fresh)
|
|
851
|
-
end)
|
|
852
|
-
return `anon:{fresh}`
|
|
853
|
-
end
|
|
831
|
+
local PluginSession = TS.import(script, script.Parent, "PluginSession")
|
|
832
|
+
local StudioEventStream = TS.import(script, script.Parent, "StudioEventStream")
|
|
854
833
|
local assignedRole
|
|
855
|
-
local hasVersionMismatch = false
|
|
856
|
-
local lastVersionMismatchWarningKey
|
|
857
834
|
local lastReadyInstanceId
|
|
858
|
-
local
|
|
859
|
-
local retryingDuplicateReady = false
|
|
860
|
-
local pollEpoch = 0
|
|
861
|
-
-- Cache the published place name from MarketplaceService:GetProductInfo so
|
|
862
|
-
-- /ready can carry a friendly identifier (e.g. "Natural Disasters") distinct
|
|
863
|
-
-- from game.Name (the DataModel name, often "Place1" in edit). We only fetch
|
|
864
|
-
-- once per plugin load; the published name doesn't change mid-session.
|
|
865
|
-
local cachedPlaceName
|
|
866
|
-
local cachedPlaceNamePlaceId
|
|
867
|
-
local function resolvePlaceName()
|
|
868
|
-
if cachedPlaceName ~= nil and cachedPlaceNamePlaceId == game.PlaceId then
|
|
869
|
-
return cachedPlaceName
|
|
870
|
-
end
|
|
871
|
-
cachedPlaceName = nil
|
|
872
|
-
cachedPlaceNamePlaceId = game.PlaceId
|
|
873
|
-
if game.PlaceId == 0 then
|
|
874
|
-
cachedPlaceName = game.Name
|
|
875
|
-
return cachedPlaceName
|
|
876
|
-
end
|
|
877
|
-
local MarketplaceService = game:GetService("MarketplaceService")
|
|
878
|
-
local ok, info = pcall(function()
|
|
879
|
-
return MarketplaceService:GetProductInfo(game.PlaceId)
|
|
880
|
-
end)
|
|
881
|
-
if ok and info ~= nil then
|
|
882
|
-
local name = info.Name
|
|
883
|
-
if type(name) == "string" and name ~= "" then
|
|
884
|
-
cachedPlaceName = name
|
|
885
|
-
return cachedPlaceName
|
|
886
|
-
end
|
|
887
|
-
end
|
|
888
|
-
-- Don't cache failures — could be transient (offline, rate-limited).
|
|
889
|
-
-- Next /ready will retry. Return game.Name as fallback.
|
|
890
|
-
return game.Name
|
|
891
|
-
end
|
|
892
|
-
local function detectRole()
|
|
893
|
-
if not RunService:IsRunning() then
|
|
894
|
-
return "edit"
|
|
895
|
-
end
|
|
896
|
-
if RunService:IsServer() then
|
|
897
|
-
return "server"
|
|
898
|
-
end
|
|
899
|
-
return "client"
|
|
900
|
-
end
|
|
901
|
-
local initialRole = detectRole()
|
|
835
|
+
local initialRole = PluginSession.getRole()
|
|
902
836
|
local routeMap = {
|
|
903
837
|
["/api/file-tree"] = QueryHandlers.getFileTree,
|
|
904
838
|
["/api/search-files"] = QueryHandlers.searchFiles,
|
|
@@ -946,47 +880,18 @@ local routeMap = {
|
|
|
946
880
|
["/api/get-memory-breakdown"] = MemoryHandlers.getMemoryBreakdown,
|
|
947
881
|
["/api/get-scene-analysis"] = SceneAnalysisHandlers.getSceneAnalysis,
|
|
948
882
|
}
|
|
949
|
-
local function processRequest(request)
|
|
883
|
+
local function processRequest(request, context)
|
|
950
884
|
local endpoint = request.endpoint
|
|
951
885
|
local data = request.data or {}
|
|
952
886
|
local handler = routeMap[endpoint]
|
|
953
887
|
if handler then
|
|
954
|
-
return handler(data)
|
|
888
|
+
return handler(data, context)
|
|
955
889
|
else
|
|
956
890
|
return {
|
|
957
891
|
error = `Unknown endpoint: {endpoint}`,
|
|
958
892
|
}
|
|
959
893
|
end
|
|
960
894
|
end
|
|
961
|
-
local function sendResponse(conn, requestId, responseData)
|
|
962
|
-
local responseUrl = `{conn.serverUrl}/response`
|
|
963
|
-
local encodeOk, encoded = pcall(function()
|
|
964
|
-
return HttpService:JSONEncode({
|
|
965
|
-
requestId = requestId,
|
|
966
|
-
response = responseData,
|
|
967
|
-
})
|
|
968
|
-
end)
|
|
969
|
-
local body = if encodeOk then encoded else HttpService:JSONEncode({
|
|
970
|
-
requestId = requestId,
|
|
971
|
-
error = `Plugin response serialization failed: {tostring(encoded)}`,
|
|
972
|
-
})
|
|
973
|
-
if not encodeOk then
|
|
974
|
-
warn(`[robloxstudio-mcp] Failed to serialize response {requestId}: {tostring(encoded)}`)
|
|
975
|
-
end
|
|
976
|
-
local requestOk, requestResult = pcall(function()
|
|
977
|
-
return HttpService:RequestAsync({
|
|
978
|
-
Url = responseUrl,
|
|
979
|
-
Method = "POST",
|
|
980
|
-
Headers = {
|
|
981
|
-
["Content-Type"] = "application/json",
|
|
982
|
-
},
|
|
983
|
-
Body = body,
|
|
984
|
-
})
|
|
985
|
-
end)
|
|
986
|
-
if not requestOk or not requestResult.Success then
|
|
987
|
-
warn(`[robloxstudio-mcp] Failed to deliver response {requestId}: {HttpDiagnostics.formatRequestFailure(responseUrl, requestOk, requestResult)}`)
|
|
988
|
-
end
|
|
989
|
-
end
|
|
990
895
|
local function getConnectionStatus()
|
|
991
896
|
local conn = State.getActiveConnection()
|
|
992
897
|
if not conn.isActive then
|
|
@@ -1000,341 +905,141 @@ local function getConnectionStatus()
|
|
|
1000
905
|
end
|
|
1001
906
|
return "connecting"
|
|
1002
907
|
end
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
local lastReadyPostAt = 0
|
|
1007
|
-
-- game.Name and game.PlaceId can both settle after plugin load. PlaceId also
|
|
1008
|
-
-- changes when an unpublished file is published while MCP is already active.
|
|
1009
|
-
-- Re-fire /ready so the bridge can migrate anon:<uuid> to place:<PlaceId>.
|
|
1010
|
-
local nameChangeConn
|
|
1011
|
-
local placeIdChangeConn
|
|
1012
|
-
local sendReady
|
|
1013
|
-
local function ensureIdentityWatcher(conn)
|
|
1014
|
-
if not nameChangeConn then
|
|
1015
|
-
local okSig, signal = pcall(function()
|
|
1016
|
-
return game:GetPropertyChangedSignal("Name")
|
|
1017
|
-
end)
|
|
1018
|
-
if okSig and signal then
|
|
1019
|
-
nameChangeConn = signal:Connect(function()
|
|
1020
|
-
-- sendReady has its own 2s throttle, so rapid burst changes coalesce.
|
|
1021
|
-
sendReady(conn)
|
|
1022
|
-
end)
|
|
1023
|
-
end
|
|
908
|
+
local function dispatchStreamRequest(request, context)
|
|
909
|
+
if request.logicalSessionId ~= PluginSession.id then
|
|
910
|
+
return ClientBroker.dispatchClientRequest(request.logicalSessionId, request.target, request.endpoint, request.data)
|
|
1024
911
|
end
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
end)
|
|
1035
|
-
end
|
|
912
|
+
local _condition = assignedRole
|
|
913
|
+
if _condition == nil then
|
|
914
|
+
_condition = PluginSession.getRole()
|
|
915
|
+
end
|
|
916
|
+
local localRole = _condition
|
|
917
|
+
if request.target ~= localRole then
|
|
918
|
+
return {
|
|
919
|
+
error = `Physical plugin session is registered as {localRole}, not {request.target}.`,
|
|
920
|
+
}
|
|
1036
921
|
end
|
|
922
|
+
return processRequest({
|
|
923
|
+
endpoint = request.endpoint,
|
|
924
|
+
data = request.data,
|
|
925
|
+
}, context)
|
|
1037
926
|
end
|
|
1038
|
-
function
|
|
1039
|
-
local
|
|
1040
|
-
|
|
1041
|
-
-- predecessor causes 409, retry once per second so takeover follows the
|
|
1042
|
-
-- server's short inactivity lease without another two seconds of jitter.
|
|
1043
|
-
local readyInterval = if retryingDuplicateReady then 1 else 2
|
|
1044
|
-
if now - lastReadyPostAt < readyInterval then
|
|
927
|
+
local function handleReady(response)
|
|
928
|
+
local conn = State.getActiveConnection()
|
|
929
|
+
if not conn.isActive then
|
|
1045
930
|
return nil
|
|
1046
931
|
end
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
return HttpService:RequestAsync({
|
|
1052
|
-
Url = `{conn.serverUrl}/ready`,
|
|
1053
|
-
Method = "POST",
|
|
1054
|
-
Headers = {
|
|
1055
|
-
["Content-Type"] = "application/json",
|
|
1056
|
-
},
|
|
1057
|
-
Body = HttpService:JSONEncode({
|
|
1058
|
-
pluginSessionId = pluginSessionId,
|
|
1059
|
-
instanceId = instanceId,
|
|
1060
|
-
role = detectRole(),
|
|
1061
|
-
placeId = game.PlaceId,
|
|
1062
|
-
placeName = resolvePlaceName(),
|
|
1063
|
-
dataModelName = game.Name,
|
|
1064
|
-
isRunning = RunService:IsRunning(),
|
|
1065
|
-
pluginVersion = State.CURRENT_VERSION,
|
|
1066
|
-
pluginVariant = State.PLUGIN_VARIANT,
|
|
1067
|
-
pluginReady = true,
|
|
1068
|
-
timestamp = tick(),
|
|
1069
|
-
}),
|
|
1070
|
-
})
|
|
1071
|
-
end)
|
|
1072
|
-
local readyUrl = `{conn.serverUrl}/ready`
|
|
1073
|
-
local readyRole = detectRole()
|
|
1074
|
-
local readyLogKey = `{conn.serverUrl}|{instanceId}|{readyRole}`
|
|
1075
|
-
if not readyOk then
|
|
1076
|
-
local shouldLog = not (readyFailureLogKeys[readyLogKey] ~= nil)
|
|
1077
|
-
readyFailureLogKeys[readyLogKey] = true
|
|
1078
|
-
if shouldLog then
|
|
1079
|
-
warn(`[robloxstudio-mcp] /ready failed for {instanceId}/{readyRole}: {HttpDiagnostics.formatRequestFailure(readyUrl, readyOk, readyResult)}`)
|
|
1080
|
-
end
|
|
1081
|
-
return nil
|
|
1082
|
-
end
|
|
1083
|
-
if not readyResult.Success then
|
|
1084
|
-
local reason = HttpDiagnostics.formatRequestFailure(readyUrl, true, readyResult)
|
|
1085
|
-
local shouldLog = not (readyFailureLogKeys[readyLogKey] ~= nil)
|
|
1086
|
-
readyFailureLogKeys[readyLogKey] = true
|
|
1087
|
-
-- A predecessor can remain registered briefly when Studio exits before
|
|
1088
|
-
-- its asynchronous /disconnect completes. Keep polling and retrying
|
|
1089
|
-
-- /ready: the server will take us over once the predecessor has stopped
|
|
1090
|
-
-- polling, while a genuinely active duplicate continues to hold routing.
|
|
1091
|
-
if readyResult.StatusCode == 409 then
|
|
1092
|
-
retryingDuplicateReady = true
|
|
1093
|
-
local ui = UI.getElements()
|
|
1094
|
-
ui.statusLabel.Text = "Waiting for previous instance"
|
|
1095
|
-
ui.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
|
|
1096
|
-
ui.detailStatusLabel.Text = reason
|
|
1097
|
-
ui.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
|
|
1098
|
-
if shouldLog then
|
|
1099
|
-
warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{readyRole}: {reason}`)
|
|
1100
|
-
end
|
|
1101
|
-
return nil
|
|
1102
|
-
end
|
|
1103
|
-
if shouldLog then
|
|
1104
|
-
warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{readyRole}: {reason}`)
|
|
1105
|
-
end
|
|
1106
|
-
return nil
|
|
1107
|
-
end
|
|
1108
|
-
retryingDuplicateReady = false
|
|
1109
|
-
local parseOk, readyData = pcall(function()
|
|
1110
|
-
return HttpService:JSONDecode(readyResult.Body)
|
|
1111
|
-
end)
|
|
1112
|
-
local _value = parseOk and readyData.assignedRole
|
|
1113
|
-
if _value ~= "" and _value then
|
|
1114
|
-
assignedRole = readyData.assignedRole
|
|
1115
|
-
end
|
|
1116
|
-
local _condition = parseOk
|
|
1117
|
-
if _condition then
|
|
1118
|
-
local _instanceId = readyData.instanceId
|
|
1119
|
-
_condition = type(_instanceId) == "string"
|
|
1120
|
-
if _condition then
|
|
1121
|
-
_condition = readyData.instanceId ~= ""
|
|
1122
|
-
end
|
|
1123
|
-
end
|
|
1124
|
-
lastReadyInstanceId = if _condition then readyData.instanceId else instanceId
|
|
1125
|
-
ServerUrlSettings.rememberServerUrl(conn.serverUrl)
|
|
1126
|
-
local _condition_1 = assignedRole
|
|
1127
|
-
if _condition_1 == nil then
|
|
1128
|
-
_condition_1 = detectRole()
|
|
1129
|
-
end
|
|
1130
|
-
local connectedRole = _condition_1
|
|
1131
|
-
if readyFailureLogKeys[readyLogKey] ~= nil then
|
|
1132
|
-
readyFailureLogKeys[readyLogKey] = nil
|
|
1133
|
-
print(`[robloxstudio-mcp] /ready connected for {instanceId}/{connectedRole} via {conn.serverUrl}`)
|
|
1134
|
-
end
|
|
1135
|
-
end)
|
|
932
|
+
assignedRole = response.assignedRole
|
|
933
|
+
lastReadyInstanceId = response.instanceId
|
|
934
|
+
ServerUrlSettings.rememberServerUrl(conn.serverUrl)
|
|
935
|
+
ClientBroker.refreshAllLogicalRegistrations()
|
|
1136
936
|
end
|
|
1137
|
-
local function
|
|
937
|
+
local function handleStatus(status)
|
|
1138
938
|
local conn = State.getActiveConnection()
|
|
1139
939
|
if not conn.isActive then
|
|
1140
940
|
return nil
|
|
1141
941
|
end
|
|
1142
|
-
|
|
942
|
+
conn.lastHttpOk = true
|
|
943
|
+
conn.lastMcpOk = status.mcpConnected
|
|
944
|
+
conn.consecutiveFailures = 0
|
|
945
|
+
conn.currentRetryDelay = 0.5
|
|
946
|
+
if status.mcpConnected then
|
|
947
|
+
conn.mcpWaitStartTime = nil
|
|
948
|
+
elseif conn.mcpWaitStartTime == nil then
|
|
949
|
+
conn.mcpWaitStartTime = tick()
|
|
950
|
+
end
|
|
951
|
+
UI.updateUIState()
|
|
952
|
+
UI.updateToolbarIcon()
|
|
953
|
+
end
|
|
954
|
+
local function handleHeartbeat(_timestamp)
|
|
955
|
+
if not State.getActiveConnection().isActive then
|
|
1143
956
|
return nil
|
|
1144
957
|
end
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
Method = "GET",
|
|
1151
|
-
Headers = {
|
|
1152
|
-
["Content-Type"] = "application/json",
|
|
1153
|
-
},
|
|
1154
|
-
Timeout = State.POLL_REQUEST_TIMEOUT_SECONDS,
|
|
1155
|
-
}
|
|
1156
|
-
return HttpService:RequestAsync(requestOptions)
|
|
1157
|
-
end)
|
|
1158
|
-
-- A held request can finish after deactivate/reactivate. Only the current
|
|
1159
|
-
-- lifecycle epoch may mutate connection state or clear a newer poll's lock.
|
|
1160
|
-
if epoch ~= pollEpoch or not conn.isActive then
|
|
958
|
+
UI.updateUIState()
|
|
959
|
+
end
|
|
960
|
+
local function handleTransportUpdate(update)
|
|
961
|
+
local conn = State.getActiveConnection()
|
|
962
|
+
if not conn.isActive then
|
|
1161
963
|
return nil
|
|
1162
964
|
end
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
if success and (result.Success or result.StatusCode == 503) then
|
|
965
|
+
if update.state == "open" then
|
|
966
|
+
conn.lastHttpOk = true
|
|
967
|
+
conn.lastMcpOk = false
|
|
1167
968
|
conn.consecutiveFailures = 0
|
|
1168
969
|
conn.currentRetryDelay = 0.5
|
|
1169
|
-
conn.
|
|
1170
|
-
|
|
1171
|
-
conn.
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
conn.
|
|
970
|
+
conn.mcpWaitStartTime = tick()
|
|
971
|
+
else
|
|
972
|
+
conn.lastHttpOk = false
|
|
973
|
+
conn.lastMcpOk = false
|
|
974
|
+
conn.consecutiveFailures = update.attempt
|
|
975
|
+
if update.retryDelay > 0 then
|
|
976
|
+
conn.currentRetryDelay = update.retryDelay
|
|
1176
977
|
end
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
978
|
+
conn.mcpWaitStartTime = nil
|
|
979
|
+
end
|
|
980
|
+
UI.updateUIState()
|
|
981
|
+
UI.updateToolbarIcon()
|
|
982
|
+
if update.state == "waiting-duplicate" then
|
|
983
|
+
local ui = UI.getElements()
|
|
984
|
+
ui.statusLabel.Text = "Waiting for previous instance"
|
|
985
|
+
ui.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
|
|
986
|
+
local _condition = update.detail
|
|
1181
987
|
if _condition == nil then
|
|
1182
|
-
_condition = "
|
|
1183
|
-
end
|
|
1184
|
-
local serverVersion = _condition
|
|
1185
|
-
if data.versionMismatch == true then
|
|
1186
|
-
hasVersionMismatch = true
|
|
1187
|
-
local warningKey = `{State.CURRENT_VERSION}:{serverVersion}`
|
|
1188
|
-
if lastVersionMismatchWarningKey ~= warningKey then
|
|
1189
|
-
lastVersionMismatchWarningKey = warningKey
|
|
1190
|
-
warn(`[robloxstudio-mcp] Version mismatch: Studio plugin v{State.CURRENT_VERSION} / MCP v{serverVersion}. Run npx -y @chrrxs/robloxstudio-mcp@latest --auto-install-plugin and restart Studio.`)
|
|
1191
|
-
end
|
|
1192
|
-
UI.showBanner("version-mismatch", `Plugin v{State.CURRENT_VERSION} / MCP v{serverVersion} mismatch`)
|
|
1193
|
-
elseif hasVersionMismatch then
|
|
1194
|
-
hasVersionMismatch = false
|
|
1195
|
-
UI.hideBanner("version-mismatch")
|
|
1196
|
-
end
|
|
1197
|
-
-- Server tells us when its in-memory instances map doesn't have us
|
|
1198
|
-
-- (e.g. after an MCP process restart). Re-issue /ready immediately so
|
|
1199
|
-
-- target=server/client-N start routing again. The throttle inside
|
|
1200
|
-
-- sendReady() prevents duplicate registrations while the server
|
|
1201
|
-
-- catches up.
|
|
1202
|
-
if data.knownInstance == false then
|
|
1203
|
-
sendReady(conn)
|
|
1204
|
-
end
|
|
1205
|
-
local el = ui
|
|
1206
|
-
el.step1Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
|
|
1207
|
-
el.step1Label.Text = "HTTP server (OK)"
|
|
1208
|
-
local _condition_1 = mcpConnected
|
|
1209
|
-
if _condition_1 then
|
|
1210
|
-
local _value = (string.find(el.statusLabel.Text, "Connected"))
|
|
1211
|
-
_condition_1 = not (_value ~= 0 and _value == _value and _value)
|
|
988
|
+
_condition = "The previous plugin instance is still active."
|
|
1212
989
|
end
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
UI.stopPulseAnimation()
|
|
1228
|
-
elseif not mcpConnected then
|
|
1229
|
-
el.statusLabel.Text = "Waiting for MCP server"
|
|
1230
|
-
el.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
|
|
1231
|
-
el.statusIndicator.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
|
|
1232
|
-
el.statusPulse.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
|
|
1233
|
-
el.statusText.Text = "WAITING"
|
|
1234
|
-
el.detailStatusLabel.Text = "HTTP: OK MCP: ..."
|
|
1235
|
-
el.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
|
|
1236
|
-
el.step2Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
|
|
1237
|
-
el.step2Label.Text = "MCP bridge (waiting...)"
|
|
1238
|
-
el.step3Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
|
|
1239
|
-
el.step3Label.Text = "Commands (waiting...)"
|
|
1240
|
-
if conn.mcpWaitStartTime == nil then
|
|
1241
|
-
conn.mcpWaitStartTime = tick()
|
|
1242
|
-
end
|
|
1243
|
-
local _exp = tick()
|
|
1244
|
-
local _condition_2 = conn.mcpWaitStartTime
|
|
1245
|
-
if _condition_2 == nil then
|
|
1246
|
-
_condition_2 = tick()
|
|
1247
|
-
end
|
|
1248
|
-
local elapsed = _exp - _condition_2
|
|
1249
|
-
el.troubleshootLabel.Visible = elapsed > 8
|
|
1250
|
-
UI.startPulseAnimation()
|
|
1251
|
-
end
|
|
1252
|
-
if data.request and mcpConnected then
|
|
1253
|
-
task.spawn(function()
|
|
1254
|
-
local ok, response = pcall(function()
|
|
1255
|
-
return processRequest(data.request)
|
|
1256
|
-
end)
|
|
1257
|
-
if ok then
|
|
1258
|
-
sendResponse(conn, data.requestId, response)
|
|
1259
|
-
else
|
|
1260
|
-
sendResponse(conn, data.requestId, {
|
|
1261
|
-
error = tostring(response),
|
|
1262
|
-
})
|
|
1263
|
-
end
|
|
990
|
+
ui.detailStatusLabel.Text = _condition
|
|
991
|
+
ui.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
|
|
992
|
+
end
|
|
993
|
+
end
|
|
994
|
+
local nameChangeConn
|
|
995
|
+
local placeIdChangeConn
|
|
996
|
+
local function ensureIdentityWatchers()
|
|
997
|
+
if not nameChangeConn then
|
|
998
|
+
local signalOk, signal = pcall(function()
|
|
999
|
+
return game:GetPropertyChangedSignal("Name")
|
|
1000
|
+
end)
|
|
1001
|
+
if signalOk and signal then
|
|
1002
|
+
nameChangeConn = signal:Connect(function()
|
|
1003
|
+
return StudioEventStream.refresh()
|
|
1264
1004
|
end)
|
|
1265
1005
|
end
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
el.statusPulse.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
|
|
1278
|
-
el.statusText.Text = "ERROR"
|
|
1279
|
-
el.detailStatusLabel.Text = "HTTP: X MCP: X"
|
|
1280
|
-
el.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
|
|
1281
|
-
el.step1Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
|
|
1282
|
-
el.step1Label.Text = "HTTP server (error)"
|
|
1283
|
-
el.step2Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
|
|
1284
|
-
el.step2Label.Text = "MCP bridge (error)"
|
|
1285
|
-
el.step3Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
|
|
1286
|
-
el.step3Label.Text = "Commands (error)"
|
|
1287
|
-
conn.mcpWaitStartTime = nil
|
|
1288
|
-
el.troubleshootLabel.Visible = false
|
|
1289
|
-
UI.stopPulseAnimation()
|
|
1290
|
-
elseif conn.consecutiveFailures > 5 then
|
|
1291
|
-
local waitTime = math.ceil(conn.currentRetryDelay)
|
|
1292
|
-
el.statusLabel.Text = `Retrying ({waitTime}s)`
|
|
1293
|
-
el.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
|
|
1294
|
-
el.statusIndicator.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
|
|
1295
|
-
el.statusPulse.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
|
|
1296
|
-
el.statusText.Text = "RETRY"
|
|
1297
|
-
el.detailStatusLabel.Text = "HTTP: ... MCP: ..."
|
|
1298
|
-
el.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
|
|
1299
|
-
el.step1Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
|
|
1300
|
-
el.step1Label.Text = "HTTP server (retrying...)"
|
|
1301
|
-
el.step2Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
|
|
1302
|
-
el.step2Label.Text = "MCP bridge (retrying...)"
|
|
1303
|
-
el.step3Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
|
|
1304
|
-
el.step3Label.Text = "Commands (retrying...)"
|
|
1305
|
-
conn.mcpWaitStartTime = nil
|
|
1306
|
-
el.troubleshootLabel.Visible = false
|
|
1307
|
-
UI.startPulseAnimation()
|
|
1308
|
-
elseif conn.consecutiveFailures > 1 then
|
|
1309
|
-
el.statusLabel.Text = `Connecting (attempt {conn.consecutiveFailures})`
|
|
1310
|
-
el.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
|
|
1311
|
-
el.statusIndicator.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
|
|
1312
|
-
el.statusPulse.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
|
|
1313
|
-
el.statusText.Text = "CONNECTING"
|
|
1314
|
-
el.detailStatusLabel.Text = "HTTP: ... MCP: ..."
|
|
1315
|
-
el.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
|
|
1316
|
-
el.step1Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
|
|
1317
|
-
el.step1Label.Text = "HTTP server (connecting...)"
|
|
1318
|
-
el.step2Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
|
|
1319
|
-
el.step2Label.Text = "MCP bridge (connecting...)"
|
|
1320
|
-
el.step3Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
|
|
1321
|
-
el.step3Label.Text = "Commands (connecting...)"
|
|
1322
|
-
conn.mcpWaitStartTime = nil
|
|
1323
|
-
el.troubleshootLabel.Visible = false
|
|
1324
|
-
UI.startPulseAnimation()
|
|
1006
|
+
end
|
|
1007
|
+
if not placeIdChangeConn then
|
|
1008
|
+
local signalOk, signal = pcall(function()
|
|
1009
|
+
return game:GetPropertyChangedSignal("PlaceId")
|
|
1010
|
+
end)
|
|
1011
|
+
if signalOk and signal then
|
|
1012
|
+
placeIdChangeConn = signal:Connect(function()
|
|
1013
|
+
PluginSession.invalidatePlaceName()
|
|
1014
|
+
lastReadyInstanceId = PluginSession.getInstanceId()
|
|
1015
|
+
StudioEventStream.refresh()
|
|
1016
|
+
end)
|
|
1325
1017
|
end
|
|
1326
1018
|
end
|
|
1327
1019
|
end
|
|
1020
|
+
local function disconnectIdentityWatchers()
|
|
1021
|
+
if nameChangeConn then
|
|
1022
|
+
nameChangeConn:Disconnect()
|
|
1023
|
+
nameChangeConn = nil
|
|
1024
|
+
end
|
|
1025
|
+
if placeIdChangeConn then
|
|
1026
|
+
placeIdChangeConn:Disconnect()
|
|
1027
|
+
placeIdChangeConn = nil
|
|
1028
|
+
end
|
|
1029
|
+
end
|
|
1328
1030
|
local deactivatePlugin
|
|
1329
1031
|
local function activatePlugin()
|
|
1330
1032
|
local conn = State.getActiveConnection()
|
|
1033
|
+
if conn.isActive then
|
|
1034
|
+
return nil
|
|
1035
|
+
end
|
|
1331
1036
|
local ui = UI.getElements()
|
|
1332
|
-
pollEpoch += 1
|
|
1333
|
-
conn.isPolling = false
|
|
1334
|
-
conn.lastPoll = 0
|
|
1335
1037
|
conn.isActive = true
|
|
1336
1038
|
conn.consecutiveFailures = 0
|
|
1337
1039
|
conn.currentRetryDelay = 0.5
|
|
1040
|
+
conn.lastHttpOk = false
|
|
1041
|
+
conn.lastMcpOk = false
|
|
1042
|
+
conn.mcpWaitStartTime = nil
|
|
1338
1043
|
local normalizedUrl = ServerUrlSettings.normalizeServerUrl(ui.urlInput.Text)
|
|
1339
1044
|
conn.serverUrl = if normalizedUrl ~= "" then normalizedUrl else conn.serverUrl
|
|
1340
1045
|
if conn.serverUrl == "" then
|
|
@@ -1345,65 +1050,60 @@ local function activatePlugin()
|
|
|
1345
1050
|
if port ~= nil then
|
|
1346
1051
|
conn.port = port
|
|
1347
1052
|
end
|
|
1053
|
+
ClientBroker.setServerUrl(conn.serverUrl)
|
|
1054
|
+
lastReadyInstanceId = PluginSession.getInstanceId()
|
|
1348
1055
|
UI.updateUIState()
|
|
1056
|
+
StudioEventStream.start({
|
|
1057
|
+
serverUrl = conn.serverUrl,
|
|
1058
|
+
dispatchRequest = dispatchStreamRequest,
|
|
1059
|
+
onStatus = handleStatus,
|
|
1060
|
+
onHeartbeat = handleHeartbeat,
|
|
1061
|
+
onReady = handleReady,
|
|
1062
|
+
onTransportUpdate = handleTransportUpdate,
|
|
1063
|
+
})
|
|
1349
1064
|
if not conn.heartbeatConnection then
|
|
1350
1065
|
conn.heartbeatConnection = RunService.Heartbeat:Connect(function()
|
|
1351
|
-
local now = tick()
|
|
1352
1066
|
if initialRole == "server" and not RunService:IsRunning() then
|
|
1353
1067
|
ClientBroker.disconnectAllProxies()
|
|
1354
1068
|
deactivatePlugin()
|
|
1355
1069
|
return nil
|
|
1356
1070
|
end
|
|
1357
|
-
local currentInstanceId =
|
|
1071
|
+
local currentInstanceId = PluginSession.getInstanceId()
|
|
1358
1072
|
if lastReadyInstanceId ~= nil and currentInstanceId ~= lastReadyInstanceId then
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
end
|
|
1363
|
-
local currentInterval = if conn.consecutiveFailures > 5 then conn.currentRetryDelay else conn.pollInterval
|
|
1364
|
-
if now - conn.lastPoll > currentInterval then
|
|
1365
|
-
conn.lastPoll = now
|
|
1366
|
-
pollForRequests()
|
|
1073
|
+
lastReadyInstanceId = currentInstanceId
|
|
1074
|
+
PluginSession.invalidatePlaceName()
|
|
1075
|
+
StudioEventStream.refresh()
|
|
1367
1076
|
end
|
|
1368
1077
|
end)
|
|
1369
1078
|
end
|
|
1370
|
-
-- Initial /ready; pollForRequests will also re-fire ready if the server
|
|
1371
|
-
-- later reports knownInstance=false (process restart, etc).
|
|
1372
|
-
sendReady(conn)
|
|
1373
|
-
-- Remove legacy edit-mode eval bridge scripts from older plugin builds.
|
|
1374
|
-
-- Current bridges are created only in running play DataModels.
|
|
1375
1079
|
if not RunService:IsRunning() then
|
|
1376
|
-
task.spawn(
|
|
1080
|
+
task.spawn(cleanupEditBridgeArtifacts)
|
|
1377
1081
|
end
|
|
1378
|
-
|
|
1379
|
-
ensureIdentityWatcher(conn)
|
|
1082
|
+
ensureIdentityWatchers()
|
|
1380
1083
|
end
|
|
1381
1084
|
function deactivatePlugin()
|
|
1382
1085
|
local conn = State.getActiveConnection()
|
|
1383
|
-
|
|
1384
|
-
|
|
1086
|
+
if not conn.isActive then
|
|
1087
|
+
return nil
|
|
1088
|
+
end
|
|
1385
1089
|
conn.isActive = false
|
|
1090
|
+
conn.lastHttpOk = false
|
|
1386
1091
|
conn.lastMcpOk = false
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
["Content-Type"] = "application/json",
|
|
1394
|
-
},
|
|
1395
|
-
Body = HttpService:JSONEncode({
|
|
1396
|
-
pluginSessionId = pluginSessionId,
|
|
1397
|
-
timestamp = tick(),
|
|
1398
|
-
}),
|
|
1399
|
-
})
|
|
1400
|
-
end)
|
|
1092
|
+
conn.mcpWaitStartTime = nil
|
|
1093
|
+
StudioEventStream.stop()
|
|
1094
|
+
disconnectIdentityWatchers()
|
|
1095
|
+
if initialRole == "server" then
|
|
1096
|
+
ClientBroker.disconnectAllProxies()
|
|
1097
|
+
end
|
|
1401
1098
|
if conn.heartbeatConnection then
|
|
1402
1099
|
conn.heartbeatConnection:Disconnect()
|
|
1403
1100
|
conn.heartbeatConnection = nil
|
|
1404
1101
|
end
|
|
1102
|
+
lastReadyInstanceId = nil
|
|
1103
|
+
assignedRole = nil
|
|
1405
1104
|
conn.consecutiveFailures = 0
|
|
1406
1105
|
conn.currentRetryDelay = 0.5
|
|
1106
|
+
UI.updateUIState()
|
|
1407
1107
|
end
|
|
1408
1108
|
local function deactivateAll()
|
|
1409
1109
|
local conn = State.getActiveConnection()
|
|
@@ -1437,9 +1137,7 @@ local function checkForUpdates()
|
|
|
1437
1137
|
if _condition ~= "" and _condition then
|
|
1438
1138
|
local latestVersion = data.version
|
|
1439
1139
|
if Utils.compareVersions(State.CURRENT_VERSION, latestVersion) < 0 then
|
|
1440
|
-
|
|
1441
|
-
UI.showBanner("update", `v{latestVersion} available - github.com/chrrxs/robloxstudio-mcp`)
|
|
1442
|
-
end
|
|
1140
|
+
UI.showBanner("update", `v{latestVersion} available - github.com/chrrxs/robloxstudio-mcp`)
|
|
1443
1141
|
end
|
|
1444
1142
|
end
|
|
1445
1143
|
end
|
|
@@ -1456,6 +1154,81 @@ return {
|
|
|
1456
1154
|
</Properties>
|
|
1457
1155
|
</Item>
|
|
1458
1156
|
<Item class="ModuleScript" referent="5">
|
|
1157
|
+
<Properties>
|
|
1158
|
+
<string name="Name">CooperativeJobRunner</string>
|
|
1159
|
+
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
1160
|
+
local DEADLINE_EXCEEDED = "__RSMCP_COOPERATIVE_JOB_DEADLINE_EXCEEDED__"
|
|
1161
|
+
local CANCELLED = "__RSMCP_COOPERATIVE_JOB_CANCELLED__"
|
|
1162
|
+
local MAX_SLICE_SECONDS = 0.008
|
|
1163
|
+
local CLOCK_CHECK_INTERVAL = 64
|
|
1164
|
+
local activeJobs = {}
|
|
1165
|
+
local function runExclusive(key, execution, work)
|
|
1166
|
+
local _key = key
|
|
1167
|
+
local activeRequestId = activeJobs[_key]
|
|
1168
|
+
if activeRequestId ~= nil then
|
|
1169
|
+
return {
|
|
1170
|
+
error = "plugin_busy",
|
|
1171
|
+
activeRequestId = activeRequestId,
|
|
1172
|
+
}
|
|
1173
|
+
end
|
|
1174
|
+
local _key_1 = key
|
|
1175
|
+
local _requestId = execution.requestId
|
|
1176
|
+
activeJobs[_key_1] = _requestId
|
|
1177
|
+
local sliceStartedAt = os.clock()
|
|
1178
|
+
local operationsUntilClockCheck = 0
|
|
1179
|
+
local control = {
|
|
1180
|
+
checkpoint = function(self)
|
|
1181
|
+
local _result = execution.isCancelled
|
|
1182
|
+
if _result ~= nil then
|
|
1183
|
+
_result = _result()
|
|
1184
|
+
end
|
|
1185
|
+
if _result then
|
|
1186
|
+
error(CANCELLED, 0)
|
|
1187
|
+
end
|
|
1188
|
+
if operationsUntilClockCheck > 0 then
|
|
1189
|
+
operationsUntilClockCheck -= 1
|
|
1190
|
+
return nil
|
|
1191
|
+
end
|
|
1192
|
+
operationsUntilClockCheck = CLOCK_CHECK_INTERVAL - 1
|
|
1193
|
+
local now = os.clock()
|
|
1194
|
+
if execution.deadlineAt ~= nil and now >= execution.deadlineAt then
|
|
1195
|
+
error(DEADLINE_EXCEEDED, 0)
|
|
1196
|
+
end
|
|
1197
|
+
if now - sliceStartedAt >= MAX_SLICE_SECONDS then
|
|
1198
|
+
task.wait()
|
|
1199
|
+
sliceStartedAt = os.clock()
|
|
1200
|
+
end
|
|
1201
|
+
end,
|
|
1202
|
+
}
|
|
1203
|
+
local ok, result = pcall(function()
|
|
1204
|
+
return work(control)
|
|
1205
|
+
end)
|
|
1206
|
+
local _key_2 = key
|
|
1207
|
+
activeJobs[_key_2] = nil
|
|
1208
|
+
if not ok then
|
|
1209
|
+
if result == DEADLINE_EXCEEDED then
|
|
1210
|
+
return {
|
|
1211
|
+
error = "deadline_exceeded",
|
|
1212
|
+
requestId = execution.requestId,
|
|
1213
|
+
}
|
|
1214
|
+
end
|
|
1215
|
+
if result == CANCELLED then
|
|
1216
|
+
return {
|
|
1217
|
+
error = "cancelled",
|
|
1218
|
+
requestId = execution.requestId,
|
|
1219
|
+
}
|
|
1220
|
+
end
|
|
1221
|
+
error(result, 0)
|
|
1222
|
+
end
|
|
1223
|
+
return result
|
|
1224
|
+
end
|
|
1225
|
+
return {
|
|
1226
|
+
runExclusive = runExclusive,
|
|
1227
|
+
}
|
|
1228
|
+
]]></string>
|
|
1229
|
+
</Properties>
|
|
1230
|
+
</Item>
|
|
1231
|
+
<Item class="ModuleScript" referent="6">
|
|
1459
1232
|
<Properties>
|
|
1460
1233
|
<string name="Name">EvalBridges</string>
|
|
1461
1234
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -1575,9 +1348,9 @@ local function computeBridgeStamp()
|
|
|
1575
1348
|
for i = 1, #combined do
|
|
1576
1349
|
h = (h * 33 + (string.byte(combined, i))) % 2147483647
|
|
1577
1350
|
end
|
|
1578
|
-
-- "3.0.
|
|
1351
|
+
-- "3.0.4" is replaced with the package version at package time
|
|
1579
1352
|
-- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
|
|
1580
|
-
return `{tostring(h)}-3.0.
|
|
1353
|
+
return `{tostring(h)}-3.0.4`
|
|
1581
1354
|
end
|
|
1582
1355
|
local BRIDGE_STAMP = computeBridgeStamp()
|
|
1583
1356
|
local function setSource(scriptInst, source)
|
|
@@ -1593,7 +1366,7 @@ local function setSource(scriptInst, source)
|
|
|
1593
1366
|
scriptInst.Source = source
|
|
1594
1367
|
end
|
|
1595
1368
|
end
|
|
1596
|
-
local function
|
|
1369
|
+
local function findEditBridgeArtifacts()
|
|
1597
1370
|
local sps = getStarterPlayerScripts()
|
|
1598
1371
|
return {
|
|
1599
1372
|
server = ServerScriptService:FindFirstChild(SERVER_SCRIPT_NAME),
|
|
@@ -1608,11 +1381,11 @@ local function destroyIfPresent(parent, name)
|
|
|
1608
1381
|
end)
|
|
1609
1382
|
end
|
|
1610
1383
|
end
|
|
1611
|
-
local function
|
|
1384
|
+
local function cleanupEditBridgeArtifacts()
|
|
1612
1385
|
if RunService:IsRunning() then
|
|
1613
1386
|
return nil
|
|
1614
1387
|
end
|
|
1615
|
-
local _binding =
|
|
1388
|
+
local _binding = findEditBridgeArtifacts()
|
|
1616
1389
|
local server = _binding.server
|
|
1617
1390
|
local client = _binding.client
|
|
1618
1391
|
if server then
|
|
@@ -1723,18 +1496,18 @@ local function ensureRuntimeBridgeInstalled()
|
|
|
1723
1496
|
return installClientRuntimeBridge()
|
|
1724
1497
|
end
|
|
1725
1498
|
return {
|
|
1726
|
-
|
|
1499
|
+
cleanupEditBridgeArtifacts = cleanupEditBridgeArtifacts,
|
|
1727
1500
|
ensureRuntimeBridgeInstalled = ensureRuntimeBridgeInstalled,
|
|
1728
1501
|
BRIDGE_NAMES = BRIDGE_NAMES,
|
|
1729
1502
|
}
|
|
1730
1503
|
]]></string>
|
|
1731
1504
|
</Properties>
|
|
1732
1505
|
</Item>
|
|
1733
|
-
<Item class="Folder" referent="
|
|
1506
|
+
<Item class="Folder" referent="7">
|
|
1734
1507
|
<Properties>
|
|
1735
1508
|
<string name="Name">handlers</string>
|
|
1736
1509
|
</Properties>
|
|
1737
|
-
<Item class="ModuleScript" referent="
|
|
1510
|
+
<Item class="ModuleScript" referent="8">
|
|
1738
1511
|
<Properties>
|
|
1739
1512
|
<string name="Name">AssetHandlers</string>
|
|
1740
1513
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -2175,7 +1948,7 @@ return {
|
|
|
2175
1948
|
]]></string>
|
|
2176
1949
|
</Properties>
|
|
2177
1950
|
</Item>
|
|
2178
|
-
<Item class="ModuleScript" referent="
|
|
1951
|
+
<Item class="ModuleScript" referent="9">
|
|
2179
1952
|
<Properties>
|
|
2180
1953
|
<string name="Name">BreakpointHandlers</string>
|
|
2181
1954
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -2693,7 +2466,7 @@ return {
|
|
|
2693
2466
|
]]></string>
|
|
2694
2467
|
</Properties>
|
|
2695
2468
|
</Item>
|
|
2696
|
-
<Item class="ModuleScript" referent="
|
|
2469
|
+
<Item class="ModuleScript" referent="10">
|
|
2697
2470
|
<Properties>
|
|
2698
2471
|
<string name="Name">CaptureHandlers</string>
|
|
2699
2472
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -2702,6 +2475,8 @@ local RenderMonitor = TS.import(script, script.Parent.Parent, "RenderMonitor")
|
|
|
2702
2475
|
local CaptureService = game:GetService("CaptureService")
|
|
2703
2476
|
local AssetService = game:GetService("AssetService")
|
|
2704
2477
|
local MAX_TILE_SIZE = 1024
|
|
2478
|
+
local MAX_RAW_PIXEL_BYTES = 36 * 1024 * 1024
|
|
2479
|
+
local MAX_CREATED_IMAGE_DIM = 2048
|
|
2705
2480
|
local BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
|
2706
2481
|
local PAD_BYTE = (string.byte("="))
|
|
2707
2482
|
local B64 = {}
|
|
@@ -2853,14 +2628,39 @@ local function readContentToBase64(contentId)
|
|
|
2853
2628
|
error = `Failed to create EditableImage from screenshot. Enable EditableImage API: Game Settings > Security > 'Allow Mesh / Image APIs'. ({tostring(editableResult)})`,
|
|
2854
2629
|
}
|
|
2855
2630
|
end
|
|
2856
|
-
local
|
|
2857
|
-
local imgSize =
|
|
2858
|
-
local
|
|
2859
|
-
local
|
|
2631
|
+
local sourceImage = editableResult
|
|
2632
|
+
local imgSize = sourceImage.Size
|
|
2633
|
+
local nativeW = math.floor(imgSize.X)
|
|
2634
|
+
local nativeH = math.floor(imgSize.Y)
|
|
2635
|
+
local w = nativeW
|
|
2636
|
+
local h = nativeH
|
|
2637
|
+
if nativeW * nativeH * 4 > MAX_RAW_PIXEL_BYTES then
|
|
2638
|
+
local scale = math.min(math.sqrt(MAX_RAW_PIXEL_BYTES / (nativeW * nativeH * 4)), MAX_CREATED_IMAGE_DIM / math.max(nativeW, nativeH))
|
|
2639
|
+
w = math.max(1, math.floor(nativeW * scale))
|
|
2640
|
+
h = math.max(1, math.floor(nativeH * scale))
|
|
2641
|
+
local scaleOk, scaledResult = pcall(function()
|
|
2642
|
+
local target = AssetService:CreateEditableImage({
|
|
2643
|
+
Size = Vector2.new(w, h),
|
|
2644
|
+
})
|
|
2645
|
+
target:DrawImageTransformed(Vector2.new(0, 0), Vector2.new(w / nativeW, h / nativeH), 0, sourceImage, {
|
|
2646
|
+
CombineType = Enum.ImageCombineType.AlphaBlend,
|
|
2647
|
+
SamplingMode = Enum.ResamplerMode.Default,
|
|
2648
|
+
PivotPoint = Vector2.new(0, 0),
|
|
2649
|
+
})
|
|
2650
|
+
return target
|
|
2651
|
+
end)
|
|
2652
|
+
sourceImage:Destroy()
|
|
2653
|
+
if not scaleOk then
|
|
2654
|
+
return {
|
|
2655
|
+
error = `Screenshot is {nativeW}x{nativeH} (too large to transfer raw) and downscaling failed: {tostring(scaledResult)}`,
|
|
2656
|
+
}
|
|
2657
|
+
end
|
|
2658
|
+
sourceImage = scaledResult
|
|
2659
|
+
end
|
|
2860
2660
|
local readOk, pixelBuffer = pcall(function()
|
|
2861
|
-
return readPixelsTiled(
|
|
2661
|
+
return readPixelsTiled(sourceImage, w, h)
|
|
2862
2662
|
end)
|
|
2863
|
-
|
|
2663
|
+
sourceImage:Destroy()
|
|
2864
2664
|
if not readOk then
|
|
2865
2665
|
return {
|
|
2866
2666
|
error = `Failed to read pixel data: {tostring(pixelBuffer)}`,
|
|
@@ -2872,6 +2672,8 @@ local function readContentToBase64(contentId)
|
|
|
2872
2672
|
width = w,
|
|
2873
2673
|
height = h,
|
|
2874
2674
|
data = base64Data,
|
|
2675
|
+
nativeWidth = nativeW,
|
|
2676
|
+
nativeHeight = nativeH,
|
|
2875
2677
|
}
|
|
2876
2678
|
end
|
|
2877
2679
|
-- Edit-mode single shot: capture and read back in the same (edit) context.
|
|
@@ -2908,7 +2710,7 @@ return {
|
|
|
2908
2710
|
]]></string>
|
|
2909
2711
|
</Properties>
|
|
2910
2712
|
</Item>
|
|
2911
|
-
<Item class="ModuleScript" referent="
|
|
2713
|
+
<Item class="ModuleScript" referent="11">
|
|
2912
2714
|
<Properties>
|
|
2913
2715
|
<string name="Name">EvalRuntimeHandlers</string>
|
|
2914
2716
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -3062,7 +2864,7 @@ return {
|
|
|
3062
2864
|
]]></string>
|
|
3063
2865
|
</Properties>
|
|
3064
2866
|
</Item>
|
|
3065
|
-
<Item class="ModuleScript" referent="
|
|
2867
|
+
<Item class="ModuleScript" referent="12">
|
|
3066
2868
|
<Properties>
|
|
3067
2869
|
<string name="Name">GenerateModelHandlers</string>
|
|
3068
2870
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -3267,7 +3069,7 @@ return {
|
|
|
3267
3069
|
]]></string>
|
|
3268
3070
|
</Properties>
|
|
3269
3071
|
</Item>
|
|
3270
|
-
<Item class="ModuleScript" referent="
|
|
3072
|
+
<Item class="ModuleScript" referent="13">
|
|
3271
3073
|
<Properties>
|
|
3272
3074
|
<string name="Name">InputHandlers</string>
|
|
3273
3075
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -3467,7 +3269,7 @@ return {
|
|
|
3467
3269
|
]]></string>
|
|
3468
3270
|
</Properties>
|
|
3469
3271
|
</Item>
|
|
3470
|
-
<Item class="ModuleScript" referent="
|
|
3272
|
+
<Item class="ModuleScript" referent="14">
|
|
3471
3273
|
<Properties>
|
|
3472
3274
|
<string name="Name">LogHandlers</string>
|
|
3473
3275
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -3493,7 +3295,7 @@ return {
|
|
|
3493
3295
|
]]></string>
|
|
3494
3296
|
</Properties>
|
|
3495
3297
|
</Item>
|
|
3496
|
-
<Item class="ModuleScript" referent="
|
|
3298
|
+
<Item class="ModuleScript" referent="15">
|
|
3497
3299
|
<Properties>
|
|
3498
3300
|
<string name="Name">MemoryHandlers</string>
|
|
3499
3301
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -3561,7 +3363,7 @@ return {
|
|
|
3561
3363
|
]]></string>
|
|
3562
3364
|
</Properties>
|
|
3563
3365
|
</Item>
|
|
3564
|
-
<Item class="ModuleScript" referent="
|
|
3366
|
+
<Item class="ModuleScript" referent="16">
|
|
3565
3367
|
<Properties>
|
|
3566
3368
|
<string name="Name">MetadataHandlers</string>
|
|
3567
3369
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -3892,7 +3694,7 @@ return {
|
|
|
3892
3694
|
]]></string>
|
|
3893
3695
|
</Properties>
|
|
3894
3696
|
</Item>
|
|
3895
|
-
<Item class="ModuleScript" referent="
|
|
3697
|
+
<Item class="ModuleScript" referent="17">
|
|
3896
3698
|
<Properties>
|
|
3897
3699
|
<string name="Name">MicroProfilerHandlers</string>
|
|
3898
3700
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -5645,7 +5447,7 @@ return {
|
|
|
5645
5447
|
]]></string>
|
|
5646
5448
|
</Properties>
|
|
5647
5449
|
</Item>
|
|
5648
|
-
<Item class="ModuleScript" referent="
|
|
5450
|
+
<Item class="ModuleScript" referent="18">
|
|
5649
5451
|
<Properties>
|
|
5650
5452
|
<string name="Name">PropertyHandlers</string>
|
|
5651
5453
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -5730,12 +5532,14 @@ return {
|
|
|
5730
5532
|
]]></string>
|
|
5731
5533
|
</Properties>
|
|
5732
5534
|
</Item>
|
|
5733
|
-
<Item class="ModuleScript" referent="
|
|
5535
|
+
<Item class="ModuleScript" referent="19">
|
|
5734
5536
|
<Properties>
|
|
5735
5537
|
<string name="Name">QueryHandlers</string>
|
|
5736
5538
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
5737
5539
|
local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
|
|
5738
5540
|
local Utils = TS.import(script, script.Parent.Parent, "Utils")
|
|
5541
|
+
local CooperativeJobRunner = TS.import(script, script.Parent.Parent, "CooperativeJobRunner")
|
|
5542
|
+
local ScriptSearch = TS.import(script, script.Parent.Parent, "ScriptSearch")
|
|
5739
5543
|
local _binding = Utils
|
|
5740
5544
|
local getInstancePath = _binding.getInstancePath
|
|
5741
5545
|
local getInstanceByPath = _binding.getInstanceByPath
|
|
@@ -6328,265 +6132,46 @@ local function getProjectStructure(requestData)
|
|
|
6328
6132
|
result.timestamp = tick()
|
|
6329
6133
|
return result
|
|
6330
6134
|
end
|
|
6331
|
-
|
|
6332
|
-
|
|
6333
|
-
|
|
6334
|
-
|
|
6335
|
-
|
|
6336
|
-
|
|
6337
|
-
|
|
6338
|
-
|
|
6339
|
-
|
|
6340
|
-
|
|
6341
|
-
while i <= n do
|
|
6342
|
-
local c = string.sub(pattern, i, i)
|
|
6343
|
-
if c == "%" then
|
|
6344
|
-
if string.sub(pattern, i + 1, i + 1) == "b" then
|
|
6345
|
-
current ..= string.sub(pattern, i, math.min(i + 3, n))
|
|
6346
|
-
i += 4
|
|
6347
|
-
continue
|
|
6348
|
-
end
|
|
6349
|
-
-- Preserve an escape pair (e.g. %|, %., %d) intact.
|
|
6350
|
-
current ..= string.sub(pattern, i, i + 1)
|
|
6351
|
-
i += 2
|
|
6352
|
-
elseif c == "[" then
|
|
6353
|
-
inCharClass = true
|
|
6354
|
-
current ..= c
|
|
6355
|
-
i += 1
|
|
6356
|
-
elseif c == "]" then
|
|
6357
|
-
inCharClass = false
|
|
6358
|
-
current ..= c
|
|
6359
|
-
i += 1
|
|
6360
|
-
elseif c == "|" and not inCharClass then
|
|
6361
|
-
local _current = current
|
|
6362
|
-
table.insert(parts, _current)
|
|
6363
|
-
current = ""
|
|
6364
|
-
i += 1
|
|
6365
|
-
else
|
|
6366
|
-
current ..= c
|
|
6367
|
-
i += 1
|
|
6368
|
-
end
|
|
6369
|
-
end
|
|
6370
|
-
local _current = current
|
|
6371
|
-
table.insert(parts, _current)
|
|
6372
|
-
return parts
|
|
6373
|
-
end
|
|
6374
|
-
-- Return the earliest match across alternatives (mirrors regex alternation).
|
|
6375
|
-
local function findFirstPattern(line, alternatives)
|
|
6376
|
-
local bestStart
|
|
6377
|
-
local bestEnd
|
|
6378
|
-
for _, alt in alternatives do
|
|
6379
|
-
if alt == "" then
|
|
6380
|
-
continue
|
|
6381
|
-
end
|
|
6382
|
-
local s, e = string.find(line, alt)
|
|
6383
|
-
if s ~= nil and (bestStart == nil or s < bestStart) then
|
|
6384
|
-
bestStart = s
|
|
6385
|
-
bestEnd = e
|
|
6135
|
+
local scriptSearch = ScriptSearch.createScriptSearch({
|
|
6136
|
+
resolveRoot = function(self, path)
|
|
6137
|
+
return getInstanceByPath(path)
|
|
6138
|
+
end,
|
|
6139
|
+
getChildren = function(self, instance)
|
|
6140
|
+
return instance:GetChildren()
|
|
6141
|
+
end,
|
|
6142
|
+
readScript = function(self, instance, classFilter)
|
|
6143
|
+
if not instance:IsA("LuaSourceContainer") or (classFilter ~= nil and instance.ClassName ~= classFilter) then
|
|
6144
|
+
return nil
|
|
6386
6145
|
end
|
|
6387
|
-
|
|
6388
|
-
|
|
6389
|
-
|
|
6390
|
-
|
|
6391
|
-
|
|
6392
|
-
if not (pattern ~= "" and pattern) then
|
|
6393
|
-
return {
|
|
6394
|
-
error = "pattern is required",
|
|
6395
|
-
}
|
|
6396
|
-
end
|
|
6397
|
-
local _condition = (requestData.usePattern)
|
|
6398
|
-
if _condition == nil then
|
|
6399
|
-
_condition = false
|
|
6400
|
-
end
|
|
6401
|
-
local usePattern = _condition
|
|
6402
|
-
if usePattern and requestData.caseSensitive == false then
|
|
6403
|
-
return {
|
|
6404
|
-
error = "Case-insensitive Lua pattern search is not supported. Omit caseSensitive or pass caseSensitive: true with usePattern: true, or use literal search.",
|
|
6146
|
+
local snapshot = {
|
|
6147
|
+
instancePath = getInstancePath(instance),
|
|
6148
|
+
name = instance.Name,
|
|
6149
|
+
className = instance.ClassName,
|
|
6150
|
+
source = readScriptSource(instance),
|
|
6405
6151
|
}
|
|
6406
|
-
|
|
6407
|
-
|
|
6408
|
-
if usePattern then
|
|
6409
|
-
_result = true
|
|
6410
|
-
else
|
|
6411
|
-
local _condition_1 = (requestData.caseSensitive)
|
|
6412
|
-
if _condition_1 == nil then
|
|
6413
|
-
_condition_1 = false
|
|
6152
|
+
if instance:IsA("BaseScript") then
|
|
6153
|
+
snapshot.enabled = instance.Enabled
|
|
6414
6154
|
end
|
|
6415
|
-
|
|
6416
|
-
end
|
|
6417
|
-
|
|
6418
|
-
|
|
6419
|
-
|
|
6420
|
-
|
|
6421
|
-
end
|
|
6422
|
-
|
|
6423
|
-
|
|
6424
|
-
|
|
6425
|
-
|
|
6426
|
-
|
|
6427
|
-
local maxResults = _condition_2
|
|
6428
|
-
local _condition_3 = (requestData.maxResultsPerScript)
|
|
6429
|
-
if _condition_3 == nil then
|
|
6430
|
-
_condition_3 = 0
|
|
6431
|
-
end
|
|
6432
|
-
local maxResultsPerScript = _condition_3
|
|
6433
|
-
local _condition_4 = (requestData.filesOnly)
|
|
6434
|
-
if _condition_4 == nil then
|
|
6435
|
-
_condition_4 = false
|
|
6436
|
-
end
|
|
6437
|
-
local filesOnly = _condition_4
|
|
6438
|
-
local _condition_5 = (requestData.path)
|
|
6439
|
-
if _condition_5 == nil then
|
|
6440
|
-
_condition_5 = ""
|
|
6441
|
-
end
|
|
6442
|
-
local searchPath = _condition_5
|
|
6443
|
-
local classFilter = requestData.classFilter
|
|
6444
|
-
local startInstance = if searchPath ~= "" then getInstanceByPath(searchPath) else game
|
|
6445
|
-
if not startInstance then
|
|
6446
|
-
return {
|
|
6447
|
-
error = `Path not found: {searchPath}`,
|
|
6448
|
-
}
|
|
6155
|
+
return snapshot
|
|
6156
|
+
end,
|
|
6157
|
+
})
|
|
6158
|
+
local function grepScripts(requestData, execution)
|
|
6159
|
+
local result = CooperativeJobRunner.runExclusive("script-source-search", execution, function(control)
|
|
6160
|
+
return scriptSearch.search(requestData, control)
|
|
6161
|
+
end)
|
|
6162
|
+
if result.error == "plugin_busy" then
|
|
6163
|
+
local _object = table.clone(result)
|
|
6164
|
+
setmetatable(_object, nil)
|
|
6165
|
+
_object.message = "Another grep_scripts request is already running in this Studio DataModel. Retry after it completes."
|
|
6166
|
+
return _object
|
|
6449
6167
|
end
|
|
6450
|
-
|
|
6451
|
-
|
|
6452
|
-
|
|
6453
|
-
|
|
6454
|
-
|
|
6455
|
-
local totalMatches = 0
|
|
6456
|
-
local scriptsSearched = 0
|
|
6457
|
-
local hitLimit = false
|
|
6458
|
-
local function searchInstance(instance)
|
|
6459
|
-
if hitLimit then
|
|
6460
|
-
return nil
|
|
6461
|
-
end
|
|
6462
|
-
if instance:IsA("LuaSourceContainer") then
|
|
6463
|
-
-- Apply class filter
|
|
6464
|
-
if classFilter ~= "" and classFilter then
|
|
6465
|
-
local _exp = string.lower(instance.ClassName)
|
|
6466
|
-
local _arg0 = string.lower(classFilter)
|
|
6467
|
-
local _value = (string.find(_exp, _arg0))
|
|
6468
|
-
if not (_value ~= 0 and _value == _value and _value) then
|
|
6469
|
-
return nil
|
|
6470
|
-
end
|
|
6471
|
-
end
|
|
6472
|
-
scriptsSearched += 1
|
|
6473
|
-
local source = readScriptSource(instance)
|
|
6474
|
-
local lines = Utils.splitLines(source)
|
|
6475
|
-
local scriptMatches = {}
|
|
6476
|
-
local scriptMatchCount = 0
|
|
6477
|
-
for i = 0, #lines - 1 do
|
|
6478
|
-
if hitLimit then
|
|
6479
|
-
break
|
|
6480
|
-
end
|
|
6481
|
-
if maxResultsPerScript > 0 and scriptMatchCount >= maxResultsPerScript then
|
|
6482
|
-
break
|
|
6483
|
-
end
|
|
6484
|
-
local line = lines[i + 1]
|
|
6485
|
-
local searchLine = if caseSensitive then line else string.lower(line)
|
|
6486
|
-
local matchStart
|
|
6487
|
-
local matchEnd
|
|
6488
|
-
if usePattern then
|
|
6489
|
-
local _binding_1 = findFirstPattern(searchLine, patternAlternatives)
|
|
6490
|
-
matchStart = _binding_1[1]
|
|
6491
|
-
matchEnd = _binding_1[2]
|
|
6492
|
-
else
|
|
6493
|
-
matchStart, matchEnd = string.find(searchLine, searchPattern, 1, true)
|
|
6494
|
-
end
|
|
6495
|
-
if matchStart ~= nil then
|
|
6496
|
-
scriptMatchCount += 1
|
|
6497
|
-
totalMatches += 1
|
|
6498
|
-
if totalMatches > maxResults then
|
|
6499
|
-
hitLimit = true
|
|
6500
|
-
break
|
|
6501
|
-
end
|
|
6502
|
-
if not filesOnly then
|
|
6503
|
-
-- Gather context lines
|
|
6504
|
-
local before = {}
|
|
6505
|
-
local after = {}
|
|
6506
|
-
if contextLines > 0 then
|
|
6507
|
-
local beforeStart = math.max(0, i - contextLines)
|
|
6508
|
-
do
|
|
6509
|
-
local j = beforeStart
|
|
6510
|
-
local _shouldIncrement = false
|
|
6511
|
-
while true do
|
|
6512
|
-
if _shouldIncrement then
|
|
6513
|
-
j += 1
|
|
6514
|
-
else
|
|
6515
|
-
_shouldIncrement = true
|
|
6516
|
-
end
|
|
6517
|
-
if not (j < i) then
|
|
6518
|
-
break
|
|
6519
|
-
end
|
|
6520
|
-
local _arg0 = lines[j + 1]
|
|
6521
|
-
table.insert(before, _arg0)
|
|
6522
|
-
end
|
|
6523
|
-
end
|
|
6524
|
-
local afterEnd = math.min(#lines - 1, i + contextLines)
|
|
6525
|
-
do
|
|
6526
|
-
local j = i + 1
|
|
6527
|
-
local _shouldIncrement = false
|
|
6528
|
-
while true do
|
|
6529
|
-
if _shouldIncrement then
|
|
6530
|
-
j += 1
|
|
6531
|
-
else
|
|
6532
|
-
_shouldIncrement = true
|
|
6533
|
-
end
|
|
6534
|
-
if not (j <= afterEnd) then
|
|
6535
|
-
break
|
|
6536
|
-
end
|
|
6537
|
-
local _arg0 = lines[j + 1]
|
|
6538
|
-
table.insert(after, _arg0)
|
|
6539
|
-
end
|
|
6540
|
-
end
|
|
6541
|
-
end
|
|
6542
|
-
local _arg0 = {
|
|
6543
|
-
line = i + 1,
|
|
6544
|
-
column = matchStart,
|
|
6545
|
-
text = line,
|
|
6546
|
-
before = before,
|
|
6547
|
-
after = after,
|
|
6548
|
-
}
|
|
6549
|
-
table.insert(scriptMatches, _arg0)
|
|
6550
|
-
end
|
|
6551
|
-
end
|
|
6552
|
-
end
|
|
6553
|
-
if scriptMatchCount > 0 then
|
|
6554
|
-
local scriptResult = {
|
|
6555
|
-
instancePath = getInstancePath(instance),
|
|
6556
|
-
name = instance.Name,
|
|
6557
|
-
className = instance.ClassName,
|
|
6558
|
-
matches = scriptMatches,
|
|
6559
|
-
}
|
|
6560
|
-
if instance:IsA("BaseScript") then
|
|
6561
|
-
scriptResult.enabled = instance.Enabled
|
|
6562
|
-
end
|
|
6563
|
-
table.insert(results, scriptResult)
|
|
6564
|
-
end
|
|
6565
|
-
end
|
|
6566
|
-
for _, child in instance:GetChildren() do
|
|
6567
|
-
if hitLimit then
|
|
6568
|
-
return nil
|
|
6569
|
-
end
|
|
6570
|
-
searchInstance(child)
|
|
6571
|
-
end
|
|
6168
|
+
if result.error == "deadline_exceeded" then
|
|
6169
|
+
local _object = table.clone(result)
|
|
6170
|
+
setmetatable(_object, nil)
|
|
6171
|
+
_object.message = "grep_scripts exceeded its bridge deadline before the scan completed."
|
|
6172
|
+
return _object
|
|
6572
6173
|
end
|
|
6573
|
-
|
|
6574
|
-
return {
|
|
6575
|
-
results = results,
|
|
6576
|
-
pattern = pattern,
|
|
6577
|
-
totalMatches = if hitLimit then `>{maxResults}` else totalMatches,
|
|
6578
|
-
scriptsSearched = scriptsSearched,
|
|
6579
|
-
scriptsMatched = #results,
|
|
6580
|
-
truncated = hitLimit,
|
|
6581
|
-
options = {
|
|
6582
|
-
caseSensitive = caseSensitive,
|
|
6583
|
-
contextLines = contextLines,
|
|
6584
|
-
usePattern = usePattern,
|
|
6585
|
-
filesOnly = filesOnly,
|
|
6586
|
-
maxResults = maxResults,
|
|
6587
|
-
maxResultsPerScript = maxResultsPerScript,
|
|
6588
|
-
},
|
|
6589
|
-
}
|
|
6174
|
+
return result
|
|
6590
6175
|
end
|
|
6591
6176
|
return {
|
|
6592
6177
|
getFileTree = getFileTree,
|
|
@@ -6602,7 +6187,7 @@ return {
|
|
|
6602
6187
|
]]></string>
|
|
6603
6188
|
</Properties>
|
|
6604
6189
|
</Item>
|
|
6605
|
-
<Item class="ModuleScript" referent="
|
|
6190
|
+
<Item class="ModuleScript" referent="20">
|
|
6606
6191
|
<Properties>
|
|
6607
6192
|
<string name="Name">SceneAnalysisHandlers</string>
|
|
6608
6193
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -6851,7 +6436,7 @@ return {
|
|
|
6851
6436
|
]]></string>
|
|
6852
6437
|
</Properties>
|
|
6853
6438
|
</Item>
|
|
6854
|
-
<Item class="ModuleScript" referent="
|
|
6439
|
+
<Item class="ModuleScript" referent="21">
|
|
6855
6440
|
<Properties>
|
|
6856
6441
|
<string name="Name">ScriptHandlers</string>
|
|
6857
6442
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -7014,7 +6599,7 @@ local function setScriptSource(requestData)
|
|
|
7014
6599
|
error = `Instance is not a script-like object: {instance.ClassName}`,
|
|
7015
6600
|
}
|
|
7016
6601
|
end
|
|
7017
|
-
-- Communication has already JSON-decoded the
|
|
6602
|
+
-- Communication has already JSON-decoded the transport payload; source text is exact at this boundary.
|
|
7018
6603
|
local sourceToSet = newSource
|
|
7019
6604
|
local recordingId = beginRecording(`Set script source: {instance.Name}`)
|
|
7020
6605
|
local readSuccess, readResult = pcall(function()
|
|
@@ -7523,7 +7108,7 @@ return {
|
|
|
7523
7108
|
]]></string>
|
|
7524
7109
|
</Properties>
|
|
7525
7110
|
</Item>
|
|
7526
|
-
<Item class="ModuleScript" referent="
|
|
7111
|
+
<Item class="ModuleScript" referent="22">
|
|
7527
7112
|
<Properties>
|
|
7528
7113
|
<string name="Name">ScriptProfilerHandlers</string>
|
|
7529
7114
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -7986,7 +7571,7 @@ return {
|
|
|
7986
7571
|
]]></string>
|
|
7987
7572
|
</Properties>
|
|
7988
7573
|
</Item>
|
|
7989
|
-
<Item class="ModuleScript" referent="
|
|
7574
|
+
<Item class="ModuleScript" referent="23">
|
|
7990
7575
|
<Properties>
|
|
7991
7576
|
<string name="Name">SerializationHandlers</string>
|
|
7992
7577
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -8172,7 +7757,7 @@ return {
|
|
|
8172
7757
|
]]></string>
|
|
8173
7758
|
</Properties>
|
|
8174
7759
|
</Item>
|
|
8175
|
-
<Item class="ModuleScript" referent="
|
|
7760
|
+
<Item class="ModuleScript" referent="24">
|
|
8176
7761
|
<Properties>
|
|
8177
7762
|
<string name="Name">TestHandlers</string>
|
|
8178
7763
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -8551,7 +8136,7 @@ return {
|
|
|
8551
8136
|
</Properties>
|
|
8552
8137
|
</Item>
|
|
8553
8138
|
</Item>
|
|
8554
|
-
<Item class="ModuleScript" referent="
|
|
8139
|
+
<Item class="ModuleScript" referent="25">
|
|
8555
8140
|
<Properties>
|
|
8556
8141
|
<string name="Name">HttpDiagnostics</string>
|
|
8557
8142
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -8632,7 +8217,7 @@ return {
|
|
|
8632
8217
|
]]></string>
|
|
8633
8218
|
</Properties>
|
|
8634
8219
|
</Item>
|
|
8635
|
-
<Item class="ModuleScript" referent="
|
|
8220
|
+
<Item class="ModuleScript" referent="26">
|
|
8636
8221
|
<Properties>
|
|
8637
8222
|
<string name="Name">LuauExec</string>
|
|
8638
8223
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -9066,7 +8651,99 @@ return {
|
|
|
9066
8651
|
]]></string>
|
|
9067
8652
|
</Properties>
|
|
9068
8653
|
</Item>
|
|
9069
|
-
<Item class="ModuleScript" referent="
|
|
8654
|
+
<Item class="ModuleScript" referent="27">
|
|
8655
|
+
<Properties>
|
|
8656
|
+
<string name="Name">PluginSession</string>
|
|
8657
|
+
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
8658
|
+
local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
8659
|
+
local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
|
|
8660
|
+
local HttpService = _services.HttpService
|
|
8661
|
+
local RunService = _services.RunService
|
|
8662
|
+
local ServerStorage = _services.ServerStorage
|
|
8663
|
+
local State = TS.import(script, script.Parent, "State")
|
|
8664
|
+
local MCP_PLACE_ID_ATTRIBUTE = "__MCPPlaceId"
|
|
8665
|
+
local id = HttpService:GenerateGUID(false)
|
|
8666
|
+
local cachedPlaceName
|
|
8667
|
+
local cachedPlaceNamePlaceId
|
|
8668
|
+
local function getInstanceId()
|
|
8669
|
+
if game.PlaceId ~= 0 then
|
|
8670
|
+
return `place:{tostring(game.PlaceId)}`
|
|
8671
|
+
end
|
|
8672
|
+
local existing = ServerStorage:GetAttribute(MCP_PLACE_ID_ATTRIBUTE)
|
|
8673
|
+
if type(existing) == "string" and existing ~= "" then
|
|
8674
|
+
return `anon:{existing}`
|
|
8675
|
+
end
|
|
8676
|
+
local fresh = HttpService:GenerateGUID(false)
|
|
8677
|
+
pcall(function()
|
|
8678
|
+
return ServerStorage:SetAttribute(MCP_PLACE_ID_ATTRIBUTE, fresh)
|
|
8679
|
+
end)
|
|
8680
|
+
return `anon:{fresh}`
|
|
8681
|
+
end
|
|
8682
|
+
local function getRole()
|
|
8683
|
+
if not RunService:IsRunning() then
|
|
8684
|
+
return "edit"
|
|
8685
|
+
end
|
|
8686
|
+
if RunService:IsServer() then
|
|
8687
|
+
return "server"
|
|
8688
|
+
end
|
|
8689
|
+
return "client"
|
|
8690
|
+
end
|
|
8691
|
+
local function invalidatePlaceName()
|
|
8692
|
+
cachedPlaceName = nil
|
|
8693
|
+
cachedPlaceNamePlaceId = nil
|
|
8694
|
+
end
|
|
8695
|
+
local function getPlaceName()
|
|
8696
|
+
if cachedPlaceName ~= nil and cachedPlaceNamePlaceId == game.PlaceId then
|
|
8697
|
+
return cachedPlaceName
|
|
8698
|
+
end
|
|
8699
|
+
invalidatePlaceName()
|
|
8700
|
+
cachedPlaceNamePlaceId = game.PlaceId
|
|
8701
|
+
if game.PlaceId == 0 then
|
|
8702
|
+
cachedPlaceName = game.Name
|
|
8703
|
+
return cachedPlaceName
|
|
8704
|
+
end
|
|
8705
|
+
local MarketplaceService = game:GetService("MarketplaceService")
|
|
8706
|
+
local ok, info = pcall(function()
|
|
8707
|
+
return MarketplaceService:GetProductInfo(game.PlaceId)
|
|
8708
|
+
end)
|
|
8709
|
+
if ok and info ~= nil then
|
|
8710
|
+
-- GetProductInfo's generated type is broader than the place metadata returned here.
|
|
8711
|
+
local placeInfo = info
|
|
8712
|
+
local name = placeInfo.Name
|
|
8713
|
+
if type(name) == "string" and name ~= "" then
|
|
8714
|
+
cachedPlaceName = name
|
|
8715
|
+
return cachedPlaceName
|
|
8716
|
+
end
|
|
8717
|
+
end
|
|
8718
|
+
return game.Name
|
|
8719
|
+
end
|
|
8720
|
+
local function createReadyPayload(pluginSessionId, role)
|
|
8721
|
+
return {
|
|
8722
|
+
pluginSessionId = pluginSessionId,
|
|
8723
|
+
physicalSessionId = id,
|
|
8724
|
+
instanceId = getInstanceId(),
|
|
8725
|
+
role = role,
|
|
8726
|
+
placeId = game.PlaceId,
|
|
8727
|
+
placeName = getPlaceName(),
|
|
8728
|
+
dataModelName = game.Name,
|
|
8729
|
+
isRunning = RunService:IsRunning(),
|
|
8730
|
+
pluginVersion = State.CURRENT_VERSION,
|
|
8731
|
+
pluginVariant = State.PLUGIN_VARIANT,
|
|
8732
|
+
timestamp = tick(),
|
|
8733
|
+
}
|
|
8734
|
+
end
|
|
8735
|
+
return {
|
|
8736
|
+
id = id,
|
|
8737
|
+
getInstanceId = getInstanceId,
|
|
8738
|
+
getRole = getRole,
|
|
8739
|
+
getPlaceName = getPlaceName,
|
|
8740
|
+
invalidatePlaceName = invalidatePlaceName,
|
|
8741
|
+
createReadyPayload = createReadyPayload,
|
|
8742
|
+
}
|
|
8743
|
+
]]></string>
|
|
8744
|
+
</Properties>
|
|
8745
|
+
</Item>
|
|
8746
|
+
<Item class="ModuleScript" referent="28">
|
|
9070
8747
|
<Properties>
|
|
9071
8748
|
<string name="Name">Recording</string>
|
|
9072
8749
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -9096,7 +8773,7 @@ return {
|
|
|
9096
8773
|
]]></string>
|
|
9097
8774
|
</Properties>
|
|
9098
8775
|
</Item>
|
|
9099
|
-
<Item class="ModuleScript" referent="
|
|
8776
|
+
<Item class="ModuleScript" referent="29">
|
|
9100
8777
|
<Properties>
|
|
9101
8778
|
<string name="Name">RenderMonitor</string>
|
|
9102
8779
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -9164,7 +8841,7 @@ return {
|
|
|
9164
8841
|
]]></string>
|
|
9165
8842
|
</Properties>
|
|
9166
8843
|
</Item>
|
|
9167
|
-
<Item class="ModuleScript" referent="
|
|
8844
|
+
<Item class="ModuleScript" referent="30">
|
|
9168
8845
|
<Properties>
|
|
9169
8846
|
<string name="Name">RuntimeLogBuffer</string>
|
|
9170
8847
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -9419,7 +9096,334 @@ return {
|
|
|
9419
9096
|
]]></string>
|
|
9420
9097
|
</Properties>
|
|
9421
9098
|
</Item>
|
|
9422
|
-
<Item class="ModuleScript" referent="
|
|
9099
|
+
<Item class="ModuleScript" referent="31">
|
|
9100
|
+
<Properties>
|
|
9101
|
+
<string name="Name">ScriptSearch</string>
|
|
9102
|
+
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
9103
|
+
local function splitLuaAlternation(pattern)
|
|
9104
|
+
local parts = {}
|
|
9105
|
+
local current = ""
|
|
9106
|
+
local index = 1
|
|
9107
|
+
local inCharacterClass = false
|
|
9108
|
+
while index <= #pattern do
|
|
9109
|
+
local character = string.sub(pattern, index, index)
|
|
9110
|
+
if character == "%" then
|
|
9111
|
+
if string.sub(pattern, index + 1, index + 1) == "b" then
|
|
9112
|
+
current ..= string.sub(pattern, index, math.min(index + 3, #pattern))
|
|
9113
|
+
index += 4
|
|
9114
|
+
else
|
|
9115
|
+
current ..= string.sub(pattern, index, index + 1)
|
|
9116
|
+
index += 2
|
|
9117
|
+
end
|
|
9118
|
+
elseif character == "[" then
|
|
9119
|
+
inCharacterClass = true
|
|
9120
|
+
current ..= character
|
|
9121
|
+
index += 1
|
|
9122
|
+
elseif character == "]" then
|
|
9123
|
+
inCharacterClass = false
|
|
9124
|
+
current ..= character
|
|
9125
|
+
index += 1
|
|
9126
|
+
elseif character == "|" and not inCharacterClass then
|
|
9127
|
+
local _current = current
|
|
9128
|
+
table.insert(parts, _current)
|
|
9129
|
+
current = ""
|
|
9130
|
+
index += 1
|
|
9131
|
+
else
|
|
9132
|
+
current ..= character
|
|
9133
|
+
index += 1
|
|
9134
|
+
end
|
|
9135
|
+
end
|
|
9136
|
+
local _current = current
|
|
9137
|
+
table.insert(parts, _current)
|
|
9138
|
+
return parts
|
|
9139
|
+
end
|
|
9140
|
+
local function findFirstPattern(line, alternatives, control)
|
|
9141
|
+
local earliest
|
|
9142
|
+
for _, alternative in alternatives do
|
|
9143
|
+
control:checkpoint()
|
|
9144
|
+
if alternative == "" then
|
|
9145
|
+
continue
|
|
9146
|
+
end
|
|
9147
|
+
local start = string.find(line, alternative)
|
|
9148
|
+
if start ~= nil and (earliest == nil or start < earliest) then
|
|
9149
|
+
earliest = start
|
|
9150
|
+
end
|
|
9151
|
+
end
|
|
9152
|
+
return earliest
|
|
9153
|
+
end
|
|
9154
|
+
local function readLine(source, start)
|
|
9155
|
+
local boundary = string.find(source, "[\r\n]", start)
|
|
9156
|
+
if boundary == nil then
|
|
9157
|
+
return { string.sub(source, start), nil }
|
|
9158
|
+
end
|
|
9159
|
+
local nextStart = boundary + 1
|
|
9160
|
+
if string.sub(source, boundary, boundary) == "\r" and string.sub(source, nextStart, nextStart) == "\n" then
|
|
9161
|
+
nextStart += 1
|
|
9162
|
+
end
|
|
9163
|
+
return { string.sub(source, start, boundary - 1), if nextStart <= #source then nextStart else nil }
|
|
9164
|
+
end
|
|
9165
|
+
local MAX_RESULTS = 10_000
|
|
9166
|
+
local LITERAL_CHUNK_BYTES = 64 * 1024
|
|
9167
|
+
local MAX_PATTERN_BYTES = 4096
|
|
9168
|
+
local MAX_CONTEXT_LINES = 100
|
|
9169
|
+
local function sourceCanMatchLiteral(source, pattern, caseSensitive, control)
|
|
9170
|
+
if #source == 0 then
|
|
9171
|
+
return false
|
|
9172
|
+
end
|
|
9173
|
+
local overlapBytes = math.max(#pattern - 1, 0)
|
|
9174
|
+
local chunkStart = 1
|
|
9175
|
+
while chunkStart <= #source do
|
|
9176
|
+
control:checkpoint()
|
|
9177
|
+
local chunkEnd = math.min(#source, chunkStart + LITERAL_CHUNK_BYTES + overlapBytes - 1)
|
|
9178
|
+
local chunk = string.sub(source, chunkStart, chunkEnd)
|
|
9179
|
+
local candidate = if caseSensitive then chunk else string.lower(chunk)
|
|
9180
|
+
if (string.find(candidate, pattern, 1, true)) ~= nil then
|
|
9181
|
+
return true
|
|
9182
|
+
end
|
|
9183
|
+
if chunkEnd >= #source then
|
|
9184
|
+
return false
|
|
9185
|
+
end
|
|
9186
|
+
chunkStart += LITERAL_CHUNK_BYTES
|
|
9187
|
+
end
|
|
9188
|
+
return false
|
|
9189
|
+
end
|
|
9190
|
+
local function createScriptSearch(corpus)
|
|
9191
|
+
local function search(requestData, control)
|
|
9192
|
+
local requestedPattern = requestData.pattern
|
|
9193
|
+
if not (type(requestedPattern) == "string") or requestedPattern == "" then
|
|
9194
|
+
return {
|
|
9195
|
+
error = "pattern is required",
|
|
9196
|
+
}
|
|
9197
|
+
end
|
|
9198
|
+
if #requestedPattern > MAX_PATTERN_BYTES then
|
|
9199
|
+
return {
|
|
9200
|
+
error = "invalid_request",
|
|
9201
|
+
message = `pattern must contain between 1 and {MAX_PATTERN_BYTES} bytes`,
|
|
9202
|
+
}
|
|
9203
|
+
end
|
|
9204
|
+
local pattern = requestedPattern
|
|
9205
|
+
for _, optionName in { "usePattern", "caseSensitive", "filesOnly" } do
|
|
9206
|
+
local option = requestData[optionName]
|
|
9207
|
+
if option ~= nil and not (type(option) == "boolean") then
|
|
9208
|
+
return {
|
|
9209
|
+
error = "invalid_request",
|
|
9210
|
+
message = `{optionName} must be a boolean`,
|
|
9211
|
+
}
|
|
9212
|
+
end
|
|
9213
|
+
end
|
|
9214
|
+
local _condition = (requestData.usePattern)
|
|
9215
|
+
if _condition == nil then
|
|
9216
|
+
_condition = false
|
|
9217
|
+
end
|
|
9218
|
+
local usePattern = _condition
|
|
9219
|
+
if usePattern and requestData.caseSensitive == false then
|
|
9220
|
+
return {
|
|
9221
|
+
error = "Case-insensitive Lua pattern search is not supported.",
|
|
9222
|
+
}
|
|
9223
|
+
end
|
|
9224
|
+
local _result
|
|
9225
|
+
if usePattern then
|
|
9226
|
+
_result = true
|
|
9227
|
+
else
|
|
9228
|
+
local _condition_1 = (requestData.caseSensitive)
|
|
9229
|
+
if _condition_1 == nil then
|
|
9230
|
+
_condition_1 = false
|
|
9231
|
+
end
|
|
9232
|
+
_result = _condition_1
|
|
9233
|
+
end
|
|
9234
|
+
local caseSensitive = _result
|
|
9235
|
+
local _condition_1 = (requestData.filesOnly)
|
|
9236
|
+
if _condition_1 == nil then
|
|
9237
|
+
_condition_1 = false
|
|
9238
|
+
end
|
|
9239
|
+
local filesOnly = _condition_1
|
|
9240
|
+
local requestedContextLines = requestData.contextLines
|
|
9241
|
+
if requestedContextLines ~= nil and (not (type(requestedContextLines) == "number") or math.floor(requestedContextLines) ~= requestedContextLines or requestedContextLines < 0 or requestedContextLines > MAX_CONTEXT_LINES) then
|
|
9242
|
+
return {
|
|
9243
|
+
error = "invalid_request",
|
|
9244
|
+
message = `contextLines must be an integer between 0 and {MAX_CONTEXT_LINES}`,
|
|
9245
|
+
}
|
|
9246
|
+
end
|
|
9247
|
+
local _condition_2 = requestedContextLines
|
|
9248
|
+
if _condition_2 == nil then
|
|
9249
|
+
_condition_2 = 0
|
|
9250
|
+
end
|
|
9251
|
+
local contextLines = _condition_2
|
|
9252
|
+
local requestedMaxResults = requestData.maxResults
|
|
9253
|
+
if requestedMaxResults ~= nil and (not (type(requestedMaxResults) == "number") or math.floor(requestedMaxResults) ~= requestedMaxResults or requestedMaxResults < 1 or requestedMaxResults > MAX_RESULTS) then
|
|
9254
|
+
return {
|
|
9255
|
+
error = "invalid_request",
|
|
9256
|
+
message = `maxResults must be an integer between 1 and {MAX_RESULTS}`,
|
|
9257
|
+
}
|
|
9258
|
+
end
|
|
9259
|
+
local _condition_3 = requestedMaxResults
|
|
9260
|
+
if _condition_3 == nil then
|
|
9261
|
+
_condition_3 = 100
|
|
9262
|
+
end
|
|
9263
|
+
local maxResults = _condition_3
|
|
9264
|
+
local requestedMaxResultsPerScript = requestData.maxResultsPerScript
|
|
9265
|
+
if requestedMaxResultsPerScript ~= nil and (not (type(requestedMaxResultsPerScript) == "number") or math.floor(requestedMaxResultsPerScript) ~= requestedMaxResultsPerScript or requestedMaxResultsPerScript < 0 or requestedMaxResultsPerScript > MAX_RESULTS) then
|
|
9266
|
+
return {
|
|
9267
|
+
error = "invalid_request",
|
|
9268
|
+
message = `maxResultsPerScript must be an integer between 0 and {MAX_RESULTS}`,
|
|
9269
|
+
}
|
|
9270
|
+
end
|
|
9271
|
+
local _condition_4 = requestedMaxResultsPerScript
|
|
9272
|
+
if _condition_4 == nil then
|
|
9273
|
+
_condition_4 = 0
|
|
9274
|
+
end
|
|
9275
|
+
local maxResultsPerScript = _condition_4
|
|
9276
|
+
local classFilter = requestData.classFilter
|
|
9277
|
+
local _condition_5 = (requestData.path)
|
|
9278
|
+
if _condition_5 == nil then
|
|
9279
|
+
_condition_5 = ""
|
|
9280
|
+
end
|
|
9281
|
+
local searchPath = _condition_5
|
|
9282
|
+
local root = corpus:resolveRoot(searchPath)
|
|
9283
|
+
if root == nil then
|
|
9284
|
+
return {
|
|
9285
|
+
error = `Path not found: {searchPath}`,
|
|
9286
|
+
}
|
|
9287
|
+
end
|
|
9288
|
+
local searchPattern = if caseSensitive then pattern else string.lower(pattern)
|
|
9289
|
+
local patternAlternatives = if usePattern then splitLuaAlternation(searchPattern) else nil
|
|
9290
|
+
local results = {}
|
|
9291
|
+
local stack = { root }
|
|
9292
|
+
local totalMatches = 0
|
|
9293
|
+
local scriptsSearched = 0
|
|
9294
|
+
local hitLimit = false
|
|
9295
|
+
while #stack > 0 and not hitLimit do
|
|
9296
|
+
control:checkpoint()
|
|
9297
|
+
-- ▼ Array.pop ▼
|
|
9298
|
+
local _length = #stack
|
|
9299
|
+
local _result_1 = stack[_length]
|
|
9300
|
+
stack[_length] = nil
|
|
9301
|
+
-- ▲ Array.pop ▲
|
|
9302
|
+
local instance = _result_1
|
|
9303
|
+
local snapshot = corpus:readScript(instance, classFilter)
|
|
9304
|
+
if snapshot ~= nil and (classFilter == nil or snapshot.className == classFilter) then
|
|
9305
|
+
scriptsSearched += 1
|
|
9306
|
+
local scriptMatches = {}
|
|
9307
|
+
local before = {}
|
|
9308
|
+
local pendingAfter = {}
|
|
9309
|
+
local scriptMatchCount = 0
|
|
9310
|
+
local lineNumber = 1
|
|
9311
|
+
local lineStart = 1
|
|
9312
|
+
local canMatch = usePattern or sourceCanMatchLiteral(snapshot.source, searchPattern, caseSensitive, control)
|
|
9313
|
+
if canMatch then
|
|
9314
|
+
while lineStart ~= nil and not hitLimit do
|
|
9315
|
+
control:checkpoint()
|
|
9316
|
+
local _binding = readLine(snapshot.source, lineStart)
|
|
9317
|
+
local line = _binding[1]
|
|
9318
|
+
local nextLineStart = _binding[2]
|
|
9319
|
+
for _, pending in pendingAfter do
|
|
9320
|
+
if pending.remaining > 0 then
|
|
9321
|
+
local _exp = pending.match.after
|
|
9322
|
+
table.insert(_exp, line)
|
|
9323
|
+
pending.remaining -= 1
|
|
9324
|
+
end
|
|
9325
|
+
end
|
|
9326
|
+
while #pendingAfter > 0 and pendingAfter[1].remaining == 0 do
|
|
9327
|
+
table.remove(pendingAfter, 1)
|
|
9328
|
+
end
|
|
9329
|
+
local candidate = if caseSensitive then line else string.lower(line)
|
|
9330
|
+
local matchStart = if usePattern then findFirstPattern(candidate, patternAlternatives, control) else (string.find(candidate, searchPattern, 1, true))
|
|
9331
|
+
if matchStart ~= nil and (maxResultsPerScript == 0 or scriptMatchCount < maxResultsPerScript) then
|
|
9332
|
+
scriptMatchCount += 1
|
|
9333
|
+
totalMatches += 1
|
|
9334
|
+
if totalMatches > maxResults then
|
|
9335
|
+
hitLimit = true
|
|
9336
|
+
break
|
|
9337
|
+
end
|
|
9338
|
+
if not filesOnly then
|
|
9339
|
+
local _object = {
|
|
9340
|
+
line = lineNumber,
|
|
9341
|
+
column = matchStart,
|
|
9342
|
+
text = line,
|
|
9343
|
+
}
|
|
9344
|
+
local _left = "before"
|
|
9345
|
+
local _array = {}
|
|
9346
|
+
local _length_1 = #_array
|
|
9347
|
+
table.move(before, 1, #before, _length_1 + 1, _array)
|
|
9348
|
+
_object[_left] = _array
|
|
9349
|
+
_object.after = {}
|
|
9350
|
+
local lineMatch = _object
|
|
9351
|
+
table.insert(scriptMatches, lineMatch)
|
|
9352
|
+
if contextLines > 0 then
|
|
9353
|
+
local _arg0 = {
|
|
9354
|
+
match = lineMatch,
|
|
9355
|
+
remaining = contextLines,
|
|
9356
|
+
}
|
|
9357
|
+
table.insert(pendingAfter, _arg0)
|
|
9358
|
+
end
|
|
9359
|
+
end
|
|
9360
|
+
end
|
|
9361
|
+
if maxResultsPerScript > 0 and scriptMatchCount >= maxResultsPerScript and #pendingAfter == 0 then
|
|
9362
|
+
break
|
|
9363
|
+
end
|
|
9364
|
+
if contextLines > 0 then
|
|
9365
|
+
table.insert(before, line)
|
|
9366
|
+
while #before > contextLines do
|
|
9367
|
+
table.remove(before, 1)
|
|
9368
|
+
end
|
|
9369
|
+
end
|
|
9370
|
+
lineStart = nextLineStart
|
|
9371
|
+
if nextLineStart == nil then
|
|
9372
|
+
break
|
|
9373
|
+
end
|
|
9374
|
+
lineNumber += 1
|
|
9375
|
+
end
|
|
9376
|
+
end
|
|
9377
|
+
if scriptMatchCount > 0 then
|
|
9378
|
+
local scriptResult = {
|
|
9379
|
+
instancePath = snapshot.instancePath,
|
|
9380
|
+
name = snapshot.name,
|
|
9381
|
+
className = snapshot.className,
|
|
9382
|
+
matches = scriptMatches,
|
|
9383
|
+
}
|
|
9384
|
+
if snapshot.enabled ~= nil then
|
|
9385
|
+
scriptResult.enabled = snapshot.enabled
|
|
9386
|
+
end
|
|
9387
|
+
table.insert(results, scriptResult)
|
|
9388
|
+
end
|
|
9389
|
+
end
|
|
9390
|
+
local children = {}
|
|
9391
|
+
for _, child in corpus:getChildren(instance) do
|
|
9392
|
+
table.insert(children, child)
|
|
9393
|
+
end
|
|
9394
|
+
for index = #children - 1, 0, -1 do
|
|
9395
|
+
local _arg0 = children[index + 1]
|
|
9396
|
+
table.insert(stack, _arg0)
|
|
9397
|
+
end
|
|
9398
|
+
end
|
|
9399
|
+
return {
|
|
9400
|
+
results = results,
|
|
9401
|
+
pattern = pattern,
|
|
9402
|
+
totalMatches = if hitLimit then `>{maxResults}` else totalMatches,
|
|
9403
|
+
scriptsSearched = scriptsSearched,
|
|
9404
|
+
scriptsMatched = #results,
|
|
9405
|
+
truncated = hitLimit,
|
|
9406
|
+
options = {
|
|
9407
|
+
caseSensitive = caseSensitive,
|
|
9408
|
+
contextLines = contextLines,
|
|
9409
|
+
usePattern = usePattern,
|
|
9410
|
+
filesOnly = filesOnly,
|
|
9411
|
+
maxResults = maxResults,
|
|
9412
|
+
maxResultsPerScript = maxResultsPerScript,
|
|
9413
|
+
},
|
|
9414
|
+
}
|
|
9415
|
+
end
|
|
9416
|
+
return {
|
|
9417
|
+
search = search,
|
|
9418
|
+
}
|
|
9419
|
+
end
|
|
9420
|
+
return {
|
|
9421
|
+
createScriptSearch = createScriptSearch,
|
|
9422
|
+
}
|
|
9423
|
+
]]></string>
|
|
9424
|
+
</Properties>
|
|
9425
|
+
</Item>
|
|
9426
|
+
<Item class="ModuleScript" referent="32">
|
|
9423
9427
|
<Properties>
|
|
9424
9428
|
<string name="Name">ServerUrlSettings</string>
|
|
9425
9429
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -9427,7 +9431,6 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
|
9427
9431
|
local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
|
|
9428
9432
|
local HttpService = _services.HttpService
|
|
9429
9433
|
local ServerStorage = _services.ServerStorage
|
|
9430
|
-
local LEGACY_SETTING_KEY_PREFIX = "MCP_SERVER_URL_"
|
|
9431
9434
|
local SETTING_KEY_PREFIX = "MCP_LAST_SUCCESSFUL_SERVER_URL_"
|
|
9432
9435
|
local GLOBAL_SETTING_KEY = "MCP_LAST_SUCCESSFUL_SERVER_URL_GLOBAL_V1"
|
|
9433
9436
|
local pluginRef
|
|
@@ -9497,9 +9500,6 @@ end
|
|
|
9497
9500
|
local function settingKey(instanceId)
|
|
9498
9501
|
return SETTING_KEY_PREFIX .. instanceId
|
|
9499
9502
|
end
|
|
9500
|
-
local function legacySettingKey(instanceId)
|
|
9501
|
-
return LEGACY_SETTING_KEY_PREFIX .. instanceId
|
|
9502
|
-
end
|
|
9503
9503
|
local function readSettingString(key)
|
|
9504
9504
|
if not pluginRef then
|
|
9505
9505
|
return nil
|
|
@@ -9531,7 +9531,6 @@ local function rememberServerUrl(serverUrl)
|
|
|
9531
9531
|
createAnonymous = true,
|
|
9532
9532
|
}) do
|
|
9533
9533
|
writeSettingString(settingKey(instanceId), normalized)
|
|
9534
|
-
writeSettingString(legacySettingKey(instanceId), normalized)
|
|
9535
9534
|
end
|
|
9536
9535
|
end
|
|
9537
9536
|
local function readServerUrl()
|
|
@@ -9551,12 +9550,6 @@ local function readServerUrl()
|
|
|
9551
9550
|
if globalRemembered ~= nil then
|
|
9552
9551
|
return globalRemembered
|
|
9553
9552
|
end
|
|
9554
|
-
for _, instanceId in computeInstanceIds() do
|
|
9555
|
-
local legacyRemembered = readSettingString(legacySettingKey(instanceId))
|
|
9556
|
-
if legacyRemembered ~= nil then
|
|
9557
|
-
return legacyRemembered
|
|
9558
|
-
end
|
|
9559
|
-
end
|
|
9560
9553
|
return nil
|
|
9561
9554
|
end
|
|
9562
9555
|
return {
|
|
@@ -9569,31 +9562,24 @@ return {
|
|
|
9569
9562
|
]]></string>
|
|
9570
9563
|
</Properties>
|
|
9571
9564
|
</Item>
|
|
9572
|
-
<Item class="ModuleScript" referent="
|
|
9565
|
+
<Item class="ModuleScript" referent="33">
|
|
9573
9566
|
<Properties>
|
|
9574
9567
|
<string name="Name">State</string>
|
|
9575
9568
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
9576
|
-
local CURRENT_VERSION = "3.0.
|
|
9569
|
+
local CURRENT_VERSION = "3.0.4"
|
|
9577
9570
|
local PLUGIN_VARIANT = "main"
|
|
9578
9571
|
local BASE_PORT = 58741
|
|
9579
|
-
local POLL_REQUEST_TIMEOUT_SECONDS = 20
|
|
9580
9572
|
local function createConnection(port)
|
|
9581
9573
|
return {
|
|
9582
9574
|
port = port,
|
|
9583
9575
|
serverUrl = `http://localhost:{port}`,
|
|
9584
9576
|
isActive = false,
|
|
9585
|
-
pollInterval = 0.5,
|
|
9586
|
-
lastPoll = 0,
|
|
9587
9577
|
consecutiveFailures = 0,
|
|
9588
9578
|
maxFailuresBeforeError = 50,
|
|
9589
|
-
lastSuccessfulConnection = 0,
|
|
9590
9579
|
currentRetryDelay = 0.5,
|
|
9591
|
-
maxRetryDelay = 5,
|
|
9592
|
-
retryBackoffMultiplier = 1.2,
|
|
9593
9580
|
lastHttpOk = false,
|
|
9594
9581
|
lastMcpOk = false,
|
|
9595
9582
|
mcpWaitStartTime = nil,
|
|
9596
|
-
isPolling = false,
|
|
9597
9583
|
heartbeatConnection = nil,
|
|
9598
9584
|
}
|
|
9599
9585
|
end
|
|
@@ -9605,13 +9591,12 @@ return {
|
|
|
9605
9591
|
CURRENT_VERSION = CURRENT_VERSION,
|
|
9606
9592
|
PLUGIN_VARIANT = PLUGIN_VARIANT,
|
|
9607
9593
|
BASE_PORT = BASE_PORT,
|
|
9608
|
-
POLL_REQUEST_TIMEOUT_SECONDS = POLL_REQUEST_TIMEOUT_SECONDS,
|
|
9609
9594
|
getActiveConnection = getActiveConnection,
|
|
9610
9595
|
}
|
|
9611
9596
|
]]></string>
|
|
9612
9597
|
</Properties>
|
|
9613
9598
|
</Item>
|
|
9614
|
-
<Item class="ModuleScript" referent="
|
|
9599
|
+
<Item class="ModuleScript" referent="34">
|
|
9615
9600
|
<Properties>
|
|
9616
9601
|
<string name="Name">StopPlayMonitor</string>
|
|
9617
9602
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -9664,7 +9649,7 @@ local endTestIssued = false
|
|
|
9664
9649
|
local function init(p)
|
|
9665
9650
|
pluginRef = p
|
|
9666
9651
|
end
|
|
9667
|
-
-- Mirror of
|
|
9652
|
+
-- Mirror of PluginSession's place identity rules. Duplicated here because
|
|
9668
9653
|
-- StopPlayMonitor runs in both edit and play-server DMs, and both must
|
|
9669
9654
|
-- agree on the place identifier (published places: placeId; unpublished:
|
|
9670
9655
|
-- UUID on ServerStorage's __MCPPlaceId attribute, travels with the .rbxl
|
|
@@ -9820,16 +9805,9 @@ local function startMonitor()
|
|
|
9820
9805
|
task.spawn(function()
|
|
9821
9806
|
while true do
|
|
9822
9807
|
for _, myKey in settingKeys() do
|
|
9823
|
-
local
|
|
9824
|
-
if
|
|
9825
|
-
|
|
9826
|
-
-- a prior crashed session. New stop requests use token payloads.
|
|
9827
|
-
writeSetting(myKey, false)
|
|
9828
|
-
else
|
|
9829
|
-
local payload = decodePayload(value)
|
|
9830
|
-
if payload then
|
|
9831
|
-
handleStopRequest(myKey, payload)
|
|
9832
|
-
end
|
|
9808
|
+
local payload = decodePayload(readSetting(myKey))
|
|
9809
|
+
if payload then
|
|
9810
|
+
handleStopRequest(myKey, payload)
|
|
9833
9811
|
end
|
|
9834
9812
|
end
|
|
9835
9813
|
task.wait(POLL_INTERVAL_SEC)
|
|
@@ -9909,7 +9887,701 @@ return {
|
|
|
9909
9887
|
]]></string>
|
|
9910
9888
|
</Properties>
|
|
9911
9889
|
</Item>
|
|
9912
|
-
<Item class="ModuleScript" referent="
|
|
9890
|
+
<Item class="ModuleScript" referent="35">
|
|
9891
|
+
<Properties>
|
|
9892
|
+
<string name="Name">StudioEventStream</string>
|
|
9893
|
+
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
9894
|
+
local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
9895
|
+
local HttpService = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services").HttpService
|
|
9896
|
+
local HttpDiagnostics = TS.import(script, script.Parent, "HttpDiagnostics")
|
|
9897
|
+
local PluginSession = TS.import(script, script.Parent, "PluginSession")
|
|
9898
|
+
local INITIAL_RECONNECT_DELAY_SECONDS = 0.5
|
|
9899
|
+
local MAX_RECONNECT_DELAY_SECONDS = 5
|
|
9900
|
+
local INITIAL_RESPONSE_RETRY_DELAY_SECONDS = 0.5
|
|
9901
|
+
local MAX_RESPONSE_RETRY_DELAY_SECONDS = 5
|
|
9902
|
+
local MAX_TERMINAL_RESPONSES = 256
|
|
9903
|
+
local STREAM_SILENCE_TIMEOUT_SECONDS = 20
|
|
9904
|
+
local options
|
|
9905
|
+
local active = false
|
|
9906
|
+
local generation = 0
|
|
9907
|
+
local reconnectAttempt = 0
|
|
9908
|
+
local streamClient
|
|
9909
|
+
local streamConnections = {}
|
|
9910
|
+
local lastValidEventAt = 0
|
|
9911
|
+
local inFlightRequests = {}
|
|
9912
|
+
local pendingResponses = {}
|
|
9913
|
+
local terminalResponseIds = {}
|
|
9914
|
+
local terminalResponseOrder = {}
|
|
9915
|
+
local readyFailureLogKeys = {}
|
|
9916
|
+
local function decodeMessage(message)
|
|
9917
|
+
-- Studio versions in the supported channel have surfaced either the SSE
|
|
9918
|
+
-- data payload or the complete single-line `data:` frame. The bridge emits
|
|
9919
|
+
-- one JSON data line per event, so normalize both forms before decoding.
|
|
9920
|
+
local payload = message
|
|
9921
|
+
local normalized = (string.gsub((string.gsub(message, "\r\n", "\n")), "\r", "\n"))
|
|
9922
|
+
if string.sub(normalized, 1, 5) == "data:" then
|
|
9923
|
+
payload = (string.gsub((string.gsub(string.sub(normalized, 6), "^%s+", "")), "%s+$", ""))
|
|
9924
|
+
end
|
|
9925
|
+
local decodeOk, decoded = pcall(function()
|
|
9926
|
+
return HttpService:JSONDecode(payload)
|
|
9927
|
+
end)
|
|
9928
|
+
if not decodeOk or not (type(decoded) == "table") then
|
|
9929
|
+
return nil
|
|
9930
|
+
end
|
|
9931
|
+
local envelope = decoded
|
|
9932
|
+
if envelope.kind == "heartbeat" then
|
|
9933
|
+
local _timestamp = envelope.timestamp
|
|
9934
|
+
if not (type(_timestamp) == "number") then
|
|
9935
|
+
return nil
|
|
9936
|
+
end
|
|
9937
|
+
return {
|
|
9938
|
+
kind = "heartbeat",
|
|
9939
|
+
timestamp = envelope.timestamp,
|
|
9940
|
+
}
|
|
9941
|
+
end
|
|
9942
|
+
if envelope.kind == "status" then
|
|
9943
|
+
local _knownInstance = envelope.knownInstance
|
|
9944
|
+
local _condition = not (type(_knownInstance) == "boolean")
|
|
9945
|
+
if not _condition then
|
|
9946
|
+
local _mcpConnected = envelope.mcpConnected
|
|
9947
|
+
_condition = not (type(_mcpConnected) == "boolean")
|
|
9948
|
+
end
|
|
9949
|
+
if _condition then
|
|
9950
|
+
return nil
|
|
9951
|
+
end
|
|
9952
|
+
local _object = {
|
|
9953
|
+
kind = "status",
|
|
9954
|
+
knownInstance = envelope.knownInstance,
|
|
9955
|
+
mcpConnected = envelope.mcpConnected,
|
|
9956
|
+
}
|
|
9957
|
+
local _left = "serverVersion"
|
|
9958
|
+
local _serverVersion = envelope.serverVersion
|
|
9959
|
+
_object[_left] = if type(_serverVersion) == "string" then envelope.serverVersion else nil
|
|
9960
|
+
local _left_1 = "pluginVersion"
|
|
9961
|
+
local _pluginVersion = envelope.pluginVersion
|
|
9962
|
+
_object[_left_1] = if type(_pluginVersion) == "string" then envelope.pluginVersion else nil
|
|
9963
|
+
local _left_2 = "pluginVariant"
|
|
9964
|
+
local _pluginVariant = envelope.pluginVariant
|
|
9965
|
+
_object[_left_2] = if type(_pluginVariant) == "string" then envelope.pluginVariant else nil
|
|
9966
|
+
return _object
|
|
9967
|
+
end
|
|
9968
|
+
if envelope.kind == "cancel" then
|
|
9969
|
+
local _requestId = envelope.requestId
|
|
9970
|
+
local _condition = not (type(_requestId) == "string")
|
|
9971
|
+
if not _condition then
|
|
9972
|
+
_condition = (envelope.reason ~= "timeout" and envelope.reason ~= "aborted" and envelope.reason ~= "connection_closed")
|
|
9973
|
+
end
|
|
9974
|
+
if _condition then
|
|
9975
|
+
return nil
|
|
9976
|
+
end
|
|
9977
|
+
return {
|
|
9978
|
+
kind = "cancel",
|
|
9979
|
+
requestId = envelope.requestId,
|
|
9980
|
+
reason = envelope.reason,
|
|
9981
|
+
}
|
|
9982
|
+
end
|
|
9983
|
+
if envelope.kind == "request" then
|
|
9984
|
+
local _requestId = envelope.requestId
|
|
9985
|
+
local _condition = not (type(_requestId) == "string")
|
|
9986
|
+
if not _condition then
|
|
9987
|
+
local _logicalSessionId = envelope.logicalSessionId
|
|
9988
|
+
_condition = not (type(_logicalSessionId) == "string")
|
|
9989
|
+
if not _condition then
|
|
9990
|
+
local _target = envelope.target
|
|
9991
|
+
_condition = not (type(_target) == "string")
|
|
9992
|
+
if not _condition then
|
|
9993
|
+
local _endpoint = envelope.endpoint
|
|
9994
|
+
_condition = not (type(_endpoint) == "string")
|
|
9995
|
+
if not _condition then
|
|
9996
|
+
local _remainingMs = envelope.remainingMs
|
|
9997
|
+
_condition = not (type(_remainingMs) == "number")
|
|
9998
|
+
if not _condition then
|
|
9999
|
+
_condition = envelope.remainingMs < 0
|
|
10000
|
+
end
|
|
10001
|
+
end
|
|
10002
|
+
end
|
|
10003
|
+
end
|
|
10004
|
+
end
|
|
10005
|
+
if _condition then
|
|
10006
|
+
return nil
|
|
10007
|
+
end
|
|
10008
|
+
local data
|
|
10009
|
+
local _data = envelope.data
|
|
10010
|
+
if type(_data) == "table" then
|
|
10011
|
+
data = envelope.data
|
|
10012
|
+
end
|
|
10013
|
+
return {
|
|
10014
|
+
kind = "request",
|
|
10015
|
+
requestId = envelope.requestId,
|
|
10016
|
+
logicalSessionId = envelope.logicalSessionId,
|
|
10017
|
+
target = envelope.target,
|
|
10018
|
+
endpoint = envelope.endpoint,
|
|
10019
|
+
data = data,
|
|
10020
|
+
remainingMs = envelope.remainingMs,
|
|
10021
|
+
}
|
|
10022
|
+
end
|
|
10023
|
+
return nil
|
|
10024
|
+
end
|
|
10025
|
+
local function closeCurrentStream()
|
|
10026
|
+
local current = streamClient
|
|
10027
|
+
streamClient = nil
|
|
10028
|
+
for _, connection in streamConnections do
|
|
10029
|
+
connection:Disconnect()
|
|
10030
|
+
end
|
|
10031
|
+
streamConnections = {}
|
|
10032
|
+
if current ~= nil then
|
|
10033
|
+
pcall(function()
|
|
10034
|
+
return current:Close()
|
|
10035
|
+
end)
|
|
10036
|
+
end
|
|
10037
|
+
end
|
|
10038
|
+
local function responseRetryDelay(attempt)
|
|
10039
|
+
return math.min(INITIAL_RESPONSE_RETRY_DELAY_SECONDS * math.pow(2, math.max(attempt - 1, 0)), MAX_RESPONSE_RETRY_DELAY_SECONDS)
|
|
10040
|
+
end
|
|
10041
|
+
local function parseResponseDisposition(success, body)
|
|
10042
|
+
local decodeOk, decoded = pcall(function()
|
|
10043
|
+
return HttpService:JSONDecode(body)
|
|
10044
|
+
end)
|
|
10045
|
+
if not decodeOk or not (type(decoded) == "table") then
|
|
10046
|
+
return nil
|
|
10047
|
+
end
|
|
10048
|
+
local acknowledgement = decoded
|
|
10049
|
+
local disposition = acknowledgement.disposition
|
|
10050
|
+
if disposition == "accepted" or disposition == "already_settled" or disposition == "unknown" then
|
|
10051
|
+
return disposition
|
|
10052
|
+
end
|
|
10053
|
+
if success and acknowledgement.success == true and disposition == nil then
|
|
10054
|
+
return "accepted"
|
|
10055
|
+
end
|
|
10056
|
+
return nil
|
|
10057
|
+
end
|
|
10058
|
+
local function rememberTerminalResponse(requestId)
|
|
10059
|
+
local _requestId = requestId
|
|
10060
|
+
if terminalResponseIds[_requestId] ~= nil then
|
|
10061
|
+
return nil
|
|
10062
|
+
end
|
|
10063
|
+
local _requestId_1 = requestId
|
|
10064
|
+
terminalResponseIds[_requestId_1] = true
|
|
10065
|
+
local _requestId_2 = requestId
|
|
10066
|
+
table.insert(terminalResponseOrder, _requestId_2)
|
|
10067
|
+
while #terminalResponseOrder > MAX_TERMINAL_RESPONSES do
|
|
10068
|
+
local oldest = table.remove(terminalResponseOrder, 1)
|
|
10069
|
+
if oldest ~= nil then
|
|
10070
|
+
terminalResponseIds[oldest] = nil
|
|
10071
|
+
end
|
|
10072
|
+
end
|
|
10073
|
+
end
|
|
10074
|
+
local function settleResponse(requestId, entry, disposition)
|
|
10075
|
+
local _requestId = requestId
|
|
10076
|
+
if pendingResponses[_requestId] ~= entry then
|
|
10077
|
+
return nil
|
|
10078
|
+
end
|
|
10079
|
+
local _requestId_1 = requestId
|
|
10080
|
+
pendingResponses[_requestId_1] = nil
|
|
10081
|
+
rememberTerminalResponse(requestId)
|
|
10082
|
+
if disposition == "unknown" then
|
|
10083
|
+
warn(`[robloxstudio-mcp] Server no longer recognizes response {requestId}; dropping stored result`)
|
|
10084
|
+
end
|
|
10085
|
+
end
|
|
10086
|
+
local function postPendingResponse(requestId, entry)
|
|
10087
|
+
local currentOptions = options
|
|
10088
|
+
local _condition = not active or currentOptions == nil
|
|
10089
|
+
if not _condition then
|
|
10090
|
+
local _requestId = requestId
|
|
10091
|
+
_condition = pendingResponses[_requestId] ~= entry
|
|
10092
|
+
if not _condition then
|
|
10093
|
+
_condition = entry.posting
|
|
10094
|
+
end
|
|
10095
|
+
end
|
|
10096
|
+
if _condition then
|
|
10097
|
+
return nil
|
|
10098
|
+
end
|
|
10099
|
+
entry.posting = true
|
|
10100
|
+
entry.retryToken += 1
|
|
10101
|
+
task.spawn(function()
|
|
10102
|
+
local _condition_1 = not active or options ~= currentOptions
|
|
10103
|
+
if not _condition_1 then
|
|
10104
|
+
local _requestId = requestId
|
|
10105
|
+
_condition_1 = pendingResponses[_requestId] ~= entry
|
|
10106
|
+
end
|
|
10107
|
+
if _condition_1 then
|
|
10108
|
+
entry.posting = false
|
|
10109
|
+
return nil
|
|
10110
|
+
end
|
|
10111
|
+
local responseUrl = `{currentOptions.serverUrl}/response`
|
|
10112
|
+
local requestOk, requestResult = pcall(function()
|
|
10113
|
+
return HttpService:RequestAsync({
|
|
10114
|
+
Url = responseUrl,
|
|
10115
|
+
Method = "POST",
|
|
10116
|
+
Headers = {
|
|
10117
|
+
["Content-Type"] = "application/json",
|
|
10118
|
+
},
|
|
10119
|
+
Body = entry.body,
|
|
10120
|
+
})
|
|
10121
|
+
end)
|
|
10122
|
+
local _requestId = requestId
|
|
10123
|
+
if pendingResponses[_requestId] ~= entry then
|
|
10124
|
+
return nil
|
|
10125
|
+
end
|
|
10126
|
+
entry.posting = false
|
|
10127
|
+
local failure
|
|
10128
|
+
if not requestOk then
|
|
10129
|
+
failure = HttpDiagnostics.formatRequestFailure(responseUrl, false, requestResult)
|
|
10130
|
+
else
|
|
10131
|
+
local disposition = parseResponseDisposition(requestResult.Success, requestResult.Body)
|
|
10132
|
+
if disposition ~= nil then
|
|
10133
|
+
settleResponse(requestId, entry, disposition)
|
|
10134
|
+
return nil
|
|
10135
|
+
end
|
|
10136
|
+
failure = if requestResult.Success then "Invalid /response acknowledgement" else HttpDiagnostics.formatRequestFailure(responseUrl, true, requestResult)
|
|
10137
|
+
end
|
|
10138
|
+
warn(`[robloxstudio-mcp] Failed to deliver response {requestId}: {failure}`)
|
|
10139
|
+
entry.retryAttempt += 1
|
|
10140
|
+
local _condition_2 = not active
|
|
10141
|
+
if not _condition_2 then
|
|
10142
|
+
local _requestId_1 = requestId
|
|
10143
|
+
_condition_2 = pendingResponses[_requestId_1] ~= entry
|
|
10144
|
+
end
|
|
10145
|
+
if _condition_2 then
|
|
10146
|
+
return nil
|
|
10147
|
+
end
|
|
10148
|
+
entry.retryToken += 1
|
|
10149
|
+
local retryToken = entry.retryToken
|
|
10150
|
+
local delay = responseRetryDelay(entry.retryAttempt)
|
|
10151
|
+
task.delay(delay, function()
|
|
10152
|
+
local _condition_3 = not active
|
|
10153
|
+
if not _condition_3 then
|
|
10154
|
+
local _requestId_1 = requestId
|
|
10155
|
+
_condition_3 = pendingResponses[_requestId_1] ~= entry
|
|
10156
|
+
if not _condition_3 then
|
|
10157
|
+
_condition_3 = entry.retryToken ~= retryToken
|
|
10158
|
+
end
|
|
10159
|
+
end
|
|
10160
|
+
if _condition_3 then
|
|
10161
|
+
return nil
|
|
10162
|
+
end
|
|
10163
|
+
postPendingResponse(requestId, entry)
|
|
10164
|
+
end)
|
|
10165
|
+
end)
|
|
10166
|
+
end
|
|
10167
|
+
local function resumePendingResponses()
|
|
10168
|
+
for requestId, entry in pendingResponses do
|
|
10169
|
+
postPendingResponse(requestId, entry)
|
|
10170
|
+
end
|
|
10171
|
+
end
|
|
10172
|
+
local function encodeResponse(requestId, response)
|
|
10173
|
+
local encodeOk, encoded = pcall(function()
|
|
10174
|
+
return HttpService:JSONEncode({
|
|
10175
|
+
requestId = requestId,
|
|
10176
|
+
response = response,
|
|
10177
|
+
})
|
|
10178
|
+
end)
|
|
10179
|
+
if encodeOk then
|
|
10180
|
+
return encoded
|
|
10181
|
+
end
|
|
10182
|
+
warn(`[robloxstudio-mcp] Failed to serialize response {requestId}: {tostring(encoded)}`)
|
|
10183
|
+
return HttpService:JSONEncode({
|
|
10184
|
+
requestId = requestId,
|
|
10185
|
+
error = `Plugin response serialization failed: {tostring(encoded)}`,
|
|
10186
|
+
})
|
|
10187
|
+
end
|
|
10188
|
+
local function cancelRequest(event)
|
|
10189
|
+
local _requestId = event.requestId
|
|
10190
|
+
local inFlight = inFlightRequests[_requestId]
|
|
10191
|
+
if inFlight ~= nil then
|
|
10192
|
+
inFlight.cancelled = true
|
|
10193
|
+
end
|
|
10194
|
+
local _requestId_1 = event.requestId
|
|
10195
|
+
local pending = pendingResponses[_requestId_1]
|
|
10196
|
+
if pending ~= nil then
|
|
10197
|
+
pending.retryToken += 1
|
|
10198
|
+
local _requestId_2 = event.requestId
|
|
10199
|
+
pendingResponses[_requestId_2] = nil
|
|
10200
|
+
end
|
|
10201
|
+
rememberTerminalResponse(event.requestId)
|
|
10202
|
+
end
|
|
10203
|
+
local function dispatchRequest(request)
|
|
10204
|
+
local _requestId = request.requestId
|
|
10205
|
+
local _condition = terminalResponseIds[_requestId] ~= nil
|
|
10206
|
+
if not _condition then
|
|
10207
|
+
local _requestId_1 = request.requestId
|
|
10208
|
+
_condition = pendingResponses[_requestId_1] ~= nil
|
|
10209
|
+
if not _condition then
|
|
10210
|
+
local _requestId_2 = request.requestId
|
|
10211
|
+
_condition = inFlightRequests[_requestId_2] ~= nil
|
|
10212
|
+
end
|
|
10213
|
+
end
|
|
10214
|
+
if _condition then
|
|
10215
|
+
return nil
|
|
10216
|
+
end
|
|
10217
|
+
local dispatchOptions = options
|
|
10218
|
+
if not active or dispatchOptions == nil then
|
|
10219
|
+
return nil
|
|
10220
|
+
end
|
|
10221
|
+
local inFlight = {
|
|
10222
|
+
cancelled = false,
|
|
10223
|
+
}
|
|
10224
|
+
local context = {
|
|
10225
|
+
requestId = request.requestId,
|
|
10226
|
+
deadlineAt = os.clock() + request.remainingMs / 1000,
|
|
10227
|
+
isCancelled = function()
|
|
10228
|
+
return inFlight.cancelled
|
|
10229
|
+
end,
|
|
10230
|
+
}
|
|
10231
|
+
local _requestId_1 = request.requestId
|
|
10232
|
+
inFlightRequests[_requestId_1] = inFlight
|
|
10233
|
+
task.spawn(function()
|
|
10234
|
+
local _condition_1 = inFlight.cancelled
|
|
10235
|
+
if not _condition_1 then
|
|
10236
|
+
local _requestId_2 = request.requestId
|
|
10237
|
+
_condition_1 = terminalResponseIds[_requestId_2] ~= nil
|
|
10238
|
+
end
|
|
10239
|
+
if _condition_1 then
|
|
10240
|
+
local _requestId_2 = request.requestId
|
|
10241
|
+
if inFlightRequests[_requestId_2] == inFlight then
|
|
10242
|
+
local _requestId_3 = request.requestId
|
|
10243
|
+
inFlightRequests[_requestId_3] = nil
|
|
10244
|
+
end
|
|
10245
|
+
return nil
|
|
10246
|
+
end
|
|
10247
|
+
local dispatchOk, response = pcall(function()
|
|
10248
|
+
return dispatchOptions.dispatchRequest(request, context)
|
|
10249
|
+
end)
|
|
10250
|
+
local _condition_2 = inFlight.cancelled
|
|
10251
|
+
if not _condition_2 then
|
|
10252
|
+
local _requestId_2 = request.requestId
|
|
10253
|
+
_condition_2 = terminalResponseIds[_requestId_2] ~= nil
|
|
10254
|
+
end
|
|
10255
|
+
if _condition_2 then
|
|
10256
|
+
local _requestId_2 = request.requestId
|
|
10257
|
+
if inFlightRequests[_requestId_2] == inFlight then
|
|
10258
|
+
local _requestId_3 = request.requestId
|
|
10259
|
+
inFlightRequests[_requestId_3] = nil
|
|
10260
|
+
end
|
|
10261
|
+
return nil
|
|
10262
|
+
end
|
|
10263
|
+
local responseData = if dispatchOk then response else {
|
|
10264
|
+
error = tostring(response),
|
|
10265
|
+
}
|
|
10266
|
+
local entry = {
|
|
10267
|
+
body = encodeResponse(request.requestId, responseData),
|
|
10268
|
+
retryAttempt = 0,
|
|
10269
|
+
posting = false,
|
|
10270
|
+
retryToken = 0,
|
|
10271
|
+
}
|
|
10272
|
+
local _requestId_2 = request.requestId
|
|
10273
|
+
pendingResponses[_requestId_2] = entry
|
|
10274
|
+
local _requestId_3 = request.requestId
|
|
10275
|
+
inFlightRequests[_requestId_3] = nil
|
|
10276
|
+
postPendingResponse(request.requestId, entry)
|
|
10277
|
+
end)
|
|
10278
|
+
end
|
|
10279
|
+
local function invokeCallback(name, callback)
|
|
10280
|
+
local callbackOk, callbackError = pcall(callback)
|
|
10281
|
+
if not callbackOk then
|
|
10282
|
+
warn(`[robloxstudio-mcp] {name} callback failed: {tostring(callbackError)}`)
|
|
10283
|
+
end
|
|
10284
|
+
end
|
|
10285
|
+
local function reportTransport(update)
|
|
10286
|
+
local currentOptions = options
|
|
10287
|
+
if active and currentOptions ~= nil then
|
|
10288
|
+
invokeCallback("event stream transport", function()
|
|
10289
|
+
return currentOptions.onTransportUpdate(update)
|
|
10290
|
+
end)
|
|
10291
|
+
end
|
|
10292
|
+
end
|
|
10293
|
+
local function reconnectDelay(attempt)
|
|
10294
|
+
return math.min(INITIAL_RECONNECT_DELAY_SECONDS * math.pow(2, math.max(attempt - 1, 0)), MAX_RECONNECT_DELAY_SECONDS)
|
|
10295
|
+
end
|
|
10296
|
+
local connect
|
|
10297
|
+
local function connectAfter(delaySeconds, expectedGeneration)
|
|
10298
|
+
task.delay(delaySeconds, function()
|
|
10299
|
+
if not active or generation ~= expectedGeneration then
|
|
10300
|
+
return nil
|
|
10301
|
+
end
|
|
10302
|
+
connect(expectedGeneration)
|
|
10303
|
+
end)
|
|
10304
|
+
end
|
|
10305
|
+
local function scheduleReconnect(expectedGeneration, detail, duplicate)
|
|
10306
|
+
if duplicate == nil then
|
|
10307
|
+
duplicate = false
|
|
10308
|
+
end
|
|
10309
|
+
if not active or generation ~= expectedGeneration then
|
|
10310
|
+
return nil
|
|
10311
|
+
end
|
|
10312
|
+
generation += 1
|
|
10313
|
+
closeCurrentStream()
|
|
10314
|
+
reconnectAttempt += 1
|
|
10315
|
+
local delay = if duplicate then 1 else reconnectDelay(reconnectAttempt)
|
|
10316
|
+
reportTransport({
|
|
10317
|
+
state = if duplicate then "waiting-duplicate" else "retrying",
|
|
10318
|
+
attempt = reconnectAttempt,
|
|
10319
|
+
retryDelay = delay,
|
|
10320
|
+
detail = detail,
|
|
10321
|
+
})
|
|
10322
|
+
connectAfter(delay, generation)
|
|
10323
|
+
end
|
|
10324
|
+
local function watchForSilence(expectedGeneration, expectedClient)
|
|
10325
|
+
local elapsed = tick() - lastValidEventAt
|
|
10326
|
+
local delay = math.max(STREAM_SILENCE_TIMEOUT_SECONDS - elapsed, 0.1)
|
|
10327
|
+
task.delay(delay, function()
|
|
10328
|
+
if not active or generation ~= expectedGeneration or streamClient ~= expectedClient then
|
|
10329
|
+
return nil
|
|
10330
|
+
end
|
|
10331
|
+
local silentFor = tick() - lastValidEventAt
|
|
10332
|
+
if silentFor >= STREAM_SILENCE_TIMEOUT_SECONDS then
|
|
10333
|
+
scheduleReconnect(expectedGeneration, `Event stream silent for {math.floor(silentFor)} seconds`)
|
|
10334
|
+
return nil
|
|
10335
|
+
end
|
|
10336
|
+
watchForSilence(expectedGeneration, expectedClient)
|
|
10337
|
+
end)
|
|
10338
|
+
end
|
|
10339
|
+
local function parseReadyResponse(body)
|
|
10340
|
+
local decodeOk, decoded = pcall(function()
|
|
10341
|
+
return HttpService:JSONDecode(body)
|
|
10342
|
+
end)
|
|
10343
|
+
if not decodeOk or not (type(decoded) == "table") then
|
|
10344
|
+
return nil
|
|
10345
|
+
end
|
|
10346
|
+
local value = decoded
|
|
10347
|
+
local _condition = value.success ~= true
|
|
10348
|
+
if not _condition then
|
|
10349
|
+
local _assignedRole = value.assignedRole
|
|
10350
|
+
_condition = not (type(_assignedRole) == "string")
|
|
10351
|
+
if not _condition then
|
|
10352
|
+
_condition = value.assignedRole == ""
|
|
10353
|
+
if not _condition then
|
|
10354
|
+
local _instanceId = value.instanceId
|
|
10355
|
+
_condition = not (type(_instanceId) == "string")
|
|
10356
|
+
if not _condition then
|
|
10357
|
+
_condition = value.instanceId == ""
|
|
10358
|
+
if not _condition then
|
|
10359
|
+
local _serverVersion = value.serverVersion
|
|
10360
|
+
_condition = not (type(_serverVersion) == "string")
|
|
10361
|
+
if not _condition then
|
|
10362
|
+
_condition = value.serverVersion == ""
|
|
10363
|
+
end
|
|
10364
|
+
end
|
|
10365
|
+
end
|
|
10366
|
+
end
|
|
10367
|
+
end
|
|
10368
|
+
end
|
|
10369
|
+
if _condition then
|
|
10370
|
+
return nil
|
|
10371
|
+
end
|
|
10372
|
+
return {
|
|
10373
|
+
success = true,
|
|
10374
|
+
assignedRole = value.assignedRole,
|
|
10375
|
+
instanceId = value.instanceId,
|
|
10376
|
+
serverVersion = value.serverVersion,
|
|
10377
|
+
}
|
|
10378
|
+
end
|
|
10379
|
+
local refresh
|
|
10380
|
+
function connect(expectedGeneration)
|
|
10381
|
+
local currentOptions = options
|
|
10382
|
+
if not active or generation ~= expectedGeneration or currentOptions == nil then
|
|
10383
|
+
return nil
|
|
10384
|
+
end
|
|
10385
|
+
reportTransport({
|
|
10386
|
+
state = "connecting",
|
|
10387
|
+
attempt = reconnectAttempt,
|
|
10388
|
+
retryDelay = 0,
|
|
10389
|
+
})
|
|
10390
|
+
task.spawn(function()
|
|
10391
|
+
local instanceId = PluginSession.getInstanceId()
|
|
10392
|
+
local readyUrl = `{currentOptions.serverUrl}/ready`
|
|
10393
|
+
local physicalRole = PluginSession.getRole()
|
|
10394
|
+
local readyPayload = PluginSession.createReadyPayload(PluginSession.id, physicalRole)
|
|
10395
|
+
readyPayload.pluginReady = true
|
|
10396
|
+
if not active or generation ~= expectedGeneration or options ~= currentOptions then
|
|
10397
|
+
return nil
|
|
10398
|
+
end
|
|
10399
|
+
local readyOk, readyResult = pcall(function()
|
|
10400
|
+
return HttpService:RequestAsync({
|
|
10401
|
+
Url = readyUrl,
|
|
10402
|
+
Method = "POST",
|
|
10403
|
+
Headers = {
|
|
10404
|
+
["Content-Type"] = "application/json",
|
|
10405
|
+
},
|
|
10406
|
+
Body = HttpService:JSONEncode(readyPayload),
|
|
10407
|
+
})
|
|
10408
|
+
end)
|
|
10409
|
+
if not active or generation ~= expectedGeneration or options ~= currentOptions then
|
|
10410
|
+
return nil
|
|
10411
|
+
end
|
|
10412
|
+
local readyLogKey = `{currentOptions.serverUrl}|{instanceId}|{physicalRole}`
|
|
10413
|
+
if not readyOk then
|
|
10414
|
+
local detail = HttpDiagnostics.formatRequestFailure(readyUrl, false, readyResult)
|
|
10415
|
+
if not (readyFailureLogKeys[readyLogKey] ~= nil) then
|
|
10416
|
+
readyFailureLogKeys[readyLogKey] = true
|
|
10417
|
+
warn(`[robloxstudio-mcp] /ready failed for {instanceId}/{physicalRole}: {detail}`)
|
|
10418
|
+
end
|
|
10419
|
+
scheduleReconnect(expectedGeneration, detail)
|
|
10420
|
+
return nil
|
|
10421
|
+
end
|
|
10422
|
+
if not readyResult.Success then
|
|
10423
|
+
local detail = HttpDiagnostics.formatRequestFailure(readyUrl, true, readyResult)
|
|
10424
|
+
if not (readyFailureLogKeys[readyLogKey] ~= nil) then
|
|
10425
|
+
readyFailureLogKeys[readyLogKey] = true
|
|
10426
|
+
warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{physicalRole}: {detail}`)
|
|
10427
|
+
end
|
|
10428
|
+
scheduleReconnect(expectedGeneration, detail, readyResult.StatusCode == 409)
|
|
10429
|
+
return nil
|
|
10430
|
+
end
|
|
10431
|
+
local readyData = parseReadyResponse(readyResult.Body)
|
|
10432
|
+
if readyData == nil then
|
|
10433
|
+
scheduleReconnect(expectedGeneration, "Invalid /ready response: expected the bundled server protocol")
|
|
10434
|
+
return nil
|
|
10435
|
+
end
|
|
10436
|
+
if readyFailureLogKeys[readyLogKey] ~= nil then
|
|
10437
|
+
readyFailureLogKeys[readyLogKey] = nil
|
|
10438
|
+
print(`[robloxstudio-mcp] /ready connected for {instanceId}/{readyData.assignedRole} via {currentOptions.serverUrl}`)
|
|
10439
|
+
end
|
|
10440
|
+
invokeCallback("event stream ready", function()
|
|
10441
|
+
return currentOptions.onReady(readyData)
|
|
10442
|
+
end)
|
|
10443
|
+
local createOk, createdClient = pcall(function()
|
|
10444
|
+
return HttpService:CreateWebStreamClient(Enum.WebStreamClientType.SSE, {
|
|
10445
|
+
Url = `{currentOptions.serverUrl}/events?pluginSessionId={PluginSession.id}`,
|
|
10446
|
+
Method = "GET",
|
|
10447
|
+
Headers = {
|
|
10448
|
+
Accept = "text/event-stream",
|
|
10449
|
+
},
|
|
10450
|
+
})
|
|
10451
|
+
end)
|
|
10452
|
+
if not createOk then
|
|
10453
|
+
scheduleReconnect(expectedGeneration, `Failed to create event stream: {tostring(createdClient)}`)
|
|
10454
|
+
return nil
|
|
10455
|
+
end
|
|
10456
|
+
if not active or generation ~= expectedGeneration or options ~= currentOptions then
|
|
10457
|
+
pcall(function()
|
|
10458
|
+
return createdClient:Close()
|
|
10459
|
+
end)
|
|
10460
|
+
return nil
|
|
10461
|
+
end
|
|
10462
|
+
streamClient = createdClient
|
|
10463
|
+
streamConnections = { createdClient.Opened:Connect(function(statusCode, _headers)
|
|
10464
|
+
if not active or generation ~= expectedGeneration or streamClient ~= createdClient then
|
|
10465
|
+
return nil
|
|
10466
|
+
end
|
|
10467
|
+
lastValidEventAt = tick()
|
|
10468
|
+
if statusCode < 200 or statusCode >= 300 then
|
|
10469
|
+
scheduleReconnect(expectedGeneration, `Event stream opened with HTTP {statusCode}`)
|
|
10470
|
+
return nil
|
|
10471
|
+
end
|
|
10472
|
+
reconnectAttempt = 0
|
|
10473
|
+
reportTransport({
|
|
10474
|
+
state = "open",
|
|
10475
|
+
attempt = 0,
|
|
10476
|
+
retryDelay = 0,
|
|
10477
|
+
})
|
|
10478
|
+
resumePendingResponses()
|
|
10479
|
+
end), createdClient.MessageReceived:Connect(function(message)
|
|
10480
|
+
if not active or generation ~= expectedGeneration or streamClient ~= createdClient then
|
|
10481
|
+
return nil
|
|
10482
|
+
end
|
|
10483
|
+
local event = decodeMessage(message)
|
|
10484
|
+
if event == nil then
|
|
10485
|
+
return nil
|
|
10486
|
+
end
|
|
10487
|
+
lastValidEventAt = tick()
|
|
10488
|
+
if event.kind == "heartbeat" then
|
|
10489
|
+
invokeCallback("event stream heartbeat", function()
|
|
10490
|
+
return currentOptions.onHeartbeat(event.timestamp)
|
|
10491
|
+
end)
|
|
10492
|
+
return nil
|
|
10493
|
+
end
|
|
10494
|
+
if event.kind == "cancel" then
|
|
10495
|
+
cancelRequest(event)
|
|
10496
|
+
return nil
|
|
10497
|
+
end
|
|
10498
|
+
if event.kind == "request" then
|
|
10499
|
+
dispatchRequest(event)
|
|
10500
|
+
return nil
|
|
10501
|
+
end
|
|
10502
|
+
invokeCallback("event stream status", function()
|
|
10503
|
+
return currentOptions.onStatus(event)
|
|
10504
|
+
end)
|
|
10505
|
+
if not event.knownInstance then
|
|
10506
|
+
refresh()
|
|
10507
|
+
end
|
|
10508
|
+
end), createdClient.Error:Connect(function(statusCode, message)
|
|
10509
|
+
if not active or generation ~= expectedGeneration or streamClient ~= createdClient then
|
|
10510
|
+
return nil
|
|
10511
|
+
end
|
|
10512
|
+
local detail = if statusCode == 404 then `Event stream session is not registered: {message}` else `Event stream error {statusCode}: {message}`
|
|
10513
|
+
scheduleReconnect(expectedGeneration, detail)
|
|
10514
|
+
end), createdClient.Closed:Connect(function()
|
|
10515
|
+
if not active or generation ~= expectedGeneration or streamClient ~= createdClient then
|
|
10516
|
+
return nil
|
|
10517
|
+
end
|
|
10518
|
+
scheduleReconnect(expectedGeneration, "Event stream closed")
|
|
10519
|
+
end) }
|
|
10520
|
+
lastValidEventAt = tick()
|
|
10521
|
+
watchForSilence(expectedGeneration, createdClient)
|
|
10522
|
+
end)
|
|
10523
|
+
end
|
|
10524
|
+
local stop
|
|
10525
|
+
local function start(newOptions)
|
|
10526
|
+
if active then
|
|
10527
|
+
stop()
|
|
10528
|
+
end
|
|
10529
|
+
options = newOptions
|
|
10530
|
+
active = true
|
|
10531
|
+
reconnectAttempt = 0
|
|
10532
|
+
generation += 1
|
|
10533
|
+
connect(generation)
|
|
10534
|
+
end
|
|
10535
|
+
function refresh()
|
|
10536
|
+
if not active or options == nil then
|
|
10537
|
+
return nil
|
|
10538
|
+
end
|
|
10539
|
+
generation += 1
|
|
10540
|
+
closeCurrentStream()
|
|
10541
|
+
reconnectAttempt = 0
|
|
10542
|
+
connect(generation)
|
|
10543
|
+
end
|
|
10544
|
+
function stop()
|
|
10545
|
+
if not active then
|
|
10546
|
+
return nil
|
|
10547
|
+
end
|
|
10548
|
+
local currentOptions = options
|
|
10549
|
+
active = false
|
|
10550
|
+
generation += 1
|
|
10551
|
+
for _, inFlight in inFlightRequests do
|
|
10552
|
+
inFlight.cancelled = true
|
|
10553
|
+
end
|
|
10554
|
+
table.clear(inFlightRequests)
|
|
10555
|
+
table.clear(pendingResponses)
|
|
10556
|
+
closeCurrentStream()
|
|
10557
|
+
table.clear(readyFailureLogKeys)
|
|
10558
|
+
options = nil
|
|
10559
|
+
reconnectAttempt = 0
|
|
10560
|
+
if currentOptions ~= nil then
|
|
10561
|
+
pcall(function()
|
|
10562
|
+
return HttpService:RequestAsync({
|
|
10563
|
+
Url = `{currentOptions.serverUrl}/disconnect`,
|
|
10564
|
+
Method = "POST",
|
|
10565
|
+
Headers = {
|
|
10566
|
+
["Content-Type"] = "application/json",
|
|
10567
|
+
},
|
|
10568
|
+
Body = HttpService:JSONEncode({
|
|
10569
|
+
pluginSessionId = PluginSession.id,
|
|
10570
|
+
timestamp = tick(),
|
|
10571
|
+
}),
|
|
10572
|
+
})
|
|
10573
|
+
end)
|
|
10574
|
+
end
|
|
10575
|
+
end
|
|
10576
|
+
return {
|
|
10577
|
+
start = start,
|
|
10578
|
+
refresh = refresh,
|
|
10579
|
+
stop = stop,
|
|
10580
|
+
}
|
|
10581
|
+
]]></string>
|
|
10582
|
+
</Properties>
|
|
10583
|
+
</Item>
|
|
10584
|
+
<Item class="ModuleScript" referent="36">
|
|
9913
10585
|
<Properties>
|
|
9914
10586
|
<string name="Name">UI</string>
|
|
9915
10587
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -10461,7 +11133,7 @@ return {
|
|
|
10461
11133
|
]]></string>
|
|
10462
11134
|
</Properties>
|
|
10463
11135
|
</Item>
|
|
10464
|
-
<Item class="ModuleScript" referent="
|
|
11136
|
+
<Item class="ModuleScript" referent="37">
|
|
10465
11137
|
<Properties>
|
|
10466
11138
|
<string name="Name">Utils</string>
|
|
10467
11139
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -10745,21 +11417,11 @@ local function splitLines(source)
|
|
|
10745
11417
|
end
|
|
10746
11418
|
local normalized = (string.gsub((string.gsub(_condition, "\r\n", "\n")), "\r", "\n"))
|
|
10747
11419
|
local endsWithNewline = string.sub(normalized, -1) == "\n"
|
|
10748
|
-
local lines =
|
|
10749
|
-
|
|
10750
|
-
|
|
10751
|
-
|
|
10752
|
-
|
|
10753
|
-
local _arg0 = string.sub(normalized, start, newlinePos - 1)
|
|
10754
|
-
table.insert(lines, _arg0)
|
|
10755
|
-
start = newlinePos + 1
|
|
10756
|
-
else
|
|
10757
|
-
local remainder = string.sub(normalized, start)
|
|
10758
|
-
if remainder ~= "" or not endsWithNewline then
|
|
10759
|
-
table.insert(lines, remainder)
|
|
10760
|
-
end
|
|
10761
|
-
break
|
|
10762
|
-
end
|
|
11420
|
+
local lines = string.split(normalized, "\n")
|
|
11421
|
+
-- split() includes a final empty field for a trailing newline. Keep the
|
|
11422
|
+
-- existing splitLines contract, which tracks that newline separately.
|
|
11423
|
+
if endsWithNewline and lines[#lines] == "" then
|
|
11424
|
+
lines[#lines] = nil
|
|
10763
11425
|
end
|
|
10764
11426
|
if #lines == 0 then
|
|
10765
11427
|
table.insert(lines, "")
|
|
@@ -10774,15 +11436,21 @@ local function joinLines(lines, hadTrailingNewline)
|
|
|
10774
11436
|
return source
|
|
10775
11437
|
end
|
|
10776
11438
|
local function readScriptSource(instance)
|
|
10777
|
-
local
|
|
11439
|
+
local editorOk, editorSource = pcall(function()
|
|
11440
|
+
return ScriptEditorService:GetEditorSource(instance)
|
|
11441
|
+
end)
|
|
11442
|
+
if editorOk then
|
|
11443
|
+
return editorSource
|
|
11444
|
+
end
|
|
11445
|
+
local documentOk, documentSource = pcall(function()
|
|
10778
11446
|
local doc = ScriptEditorService:FindScriptDocument(instance)
|
|
10779
11447
|
if doc then
|
|
10780
11448
|
return doc:GetText()
|
|
10781
11449
|
end
|
|
10782
11450
|
return nil
|
|
10783
11451
|
end)
|
|
10784
|
-
if
|
|
10785
|
-
return
|
|
11452
|
+
if documentOk and documentSource ~= nil then
|
|
11453
|
+
return documentSource
|
|
10786
11454
|
end
|
|
10787
11455
|
-- @rbxts/types does not expose PluginSecurity Source reads.
|
|
10788
11456
|
local readableScript = instance
|
|
@@ -11306,11 +11974,11 @@ return {
|
|
|
11306
11974
|
</Properties>
|
|
11307
11975
|
</Item>
|
|
11308
11976
|
</Item>
|
|
11309
|
-
<Item class="Folder" referent="
|
|
11977
|
+
<Item class="Folder" referent="42">
|
|
11310
11978
|
<Properties>
|
|
11311
11979
|
<string name="Name">include</string>
|
|
11312
11980
|
</Properties>
|
|
11313
|
-
<Item class="ModuleScript" referent="
|
|
11981
|
+
<Item class="ModuleScript" referent="38">
|
|
11314
11982
|
<Properties>
|
|
11315
11983
|
<string name="Name">LibMP</string>
|
|
11316
11984
|
<string name="Source"><![CDATA[-- =============================================================================
|
|
@@ -167694,7 +168362,7 @@ return LibMP
|
|
|
167694
168362
|
]]></string>
|
|
167695
168363
|
</Properties>
|
|
167696
168364
|
</Item>
|
|
167697
|
-
<Item class="ModuleScript" referent="
|
|
168365
|
+
<Item class="ModuleScript" referent="39">
|
|
167698
168366
|
<Properties>
|
|
167699
168367
|
<string name="Name">Promise</string>
|
|
167700
168368
|
<string name="Source"><![CDATA[--[[
|
|
@@ -169768,7 +170436,7 @@ return Promise
|
|
|
169768
170436
|
]]></string>
|
|
169769
170437
|
</Properties>
|
|
169770
170438
|
</Item>
|
|
169771
|
-
<Item class="ModuleScript" referent="
|
|
170439
|
+
<Item class="ModuleScript" referent="40">
|
|
169772
170440
|
<Properties>
|
|
169773
170441
|
<string name="Name">RuntimeLib</string>
|
|
169774
170442
|
<string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
|
|
@@ -170035,15 +170703,15 @@ return TS
|
|
|
170035
170703
|
</Properties>
|
|
170036
170704
|
</Item>
|
|
170037
170705
|
</Item>
|
|
170038
|
-
<Item class="Folder" referent="
|
|
170706
|
+
<Item class="Folder" referent="43">
|
|
170039
170707
|
<Properties>
|
|
170040
170708
|
<string name="Name">node_modules</string>
|
|
170041
170709
|
</Properties>
|
|
170042
|
-
<Item class="Folder" referent="
|
|
170710
|
+
<Item class="Folder" referent="44">
|
|
170043
170711
|
<Properties>
|
|
170044
170712
|
<string name="Name">@rbxts</string>
|
|
170045
170713
|
</Properties>
|
|
170046
|
-
<Item class="ModuleScript" referent="
|
|
170714
|
+
<Item class="ModuleScript" referent="41">
|
|
170047
170715
|
<Properties>
|
|
170048
170716
|
<string name="Name">services</string>
|
|
170049
170717
|
<string name="Source"><![CDATA[return setmetatable({}, {
|