@chrrxs/robloxstudio-mcp 3.1.1 → 3.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1370 -347
- package/package.json +1 -1
- package/studio-plugin/MCPPlugin.rbxmx +667 -485
|
@@ -16,7 +16,7 @@ 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")
|
|
19
|
-
local
|
|
19
|
+
local StudioWebSocket = TS.import(script, script, "modules", "StudioWebSocket")
|
|
20
20
|
local BreakpointHandlers = TS.import(script, script, "modules", "handlers", "BreakpointHandlers")
|
|
21
21
|
local RenderMonitor = TS.import(script, script, "modules", "RenderMonitor")
|
|
22
22
|
-- Track render-loop liveness so input/screenshot tools can report "window
|
|
@@ -54,11 +54,11 @@ applyRememberedServerUrl()
|
|
|
54
54
|
-- Play DataModels do not reliably fire Plugin.Unloading before Studio tears
|
|
55
55
|
-- down their VM. Close the persistent WebStreamClient while BindToClose still
|
|
56
56
|
-- gives this server peer a live execution context; otherwise each play cycle
|
|
57
|
-
-- can retain one native
|
|
58
|
-
-- transport releases that native
|
|
57
|
+
-- can retain one native WebSocket until Studio itself is restarted. The
|
|
58
|
+
-- transport releases that native socket before yielding to unregister the
|
|
59
59
|
-- logical server peer.
|
|
60
60
|
if startupRole == "server" then
|
|
61
|
-
game:BindToClose(
|
|
61
|
+
game:BindToClose(StudioWebSocket.suspendForShutdown)
|
|
62
62
|
end
|
|
63
63
|
UI.init(plugin)
|
|
64
64
|
local elements = UI.getElements()
|
|
@@ -148,8 +148,8 @@ local function autoActivatePeer()
|
|
|
148
148
|
-- legal, so the stop-play monitor lives here. It consumes tokenized
|
|
149
149
|
-- stop requests from plugin settings and acknowledges EndTest results.
|
|
150
150
|
StopPlayMonitor.startMonitor({
|
|
151
|
-
beforeEndTest =
|
|
152
|
-
afterEndTestFailure =
|
|
151
|
+
beforeEndTest = StudioWebSocket.suspendForShutdown,
|
|
152
|
+
afterEndTestFailure = StudioWebSocket.resumeAfterShutdownFailure,
|
|
153
153
|
})
|
|
154
154
|
elseif role == "client" then
|
|
155
155
|
ClientBroker.setupClientBroker()
|
|
@@ -952,7 +952,7 @@ local EvalRuntimeHandlers = TS.import(script, script.Parent, "handlers", "EvalRu
|
|
|
952
952
|
local ClientBroker = TS.import(script, script.Parent, "ClientBroker")
|
|
953
953
|
local ServerUrlSettings = TS.import(script, script.Parent, "ServerUrlSettings")
|
|
954
954
|
local PluginSession = TS.import(script, script.Parent, "PluginSession")
|
|
955
|
-
local
|
|
955
|
+
local StudioWebSocket = TS.import(script, script.Parent, "StudioWebSocket")
|
|
956
956
|
local assignedRole
|
|
957
957
|
local lastReadyPlaceKey
|
|
958
958
|
local initialRole = PluginSession.getRole()
|
|
@@ -1028,7 +1028,7 @@ local function getConnectionStatus()
|
|
|
1028
1028
|
end
|
|
1029
1029
|
return "connecting"
|
|
1030
1030
|
end
|
|
1031
|
-
local function
|
|
1031
|
+
local function dispatchSocketRequest(request, context)
|
|
1032
1032
|
if request.peerId ~= PluginSession.peerId then
|
|
1033
1033
|
return ClientBroker.dispatchClientRequest(request.peerId, request.target, request.endpoint, request.data)
|
|
1034
1034
|
end
|
|
@@ -1123,7 +1123,7 @@ local function ensureIdentityWatchers()
|
|
|
1123
1123
|
end)
|
|
1124
1124
|
if signalOk and signal then
|
|
1125
1125
|
nameChangeConn = signal:Connect(function()
|
|
1126
|
-
return
|
|
1126
|
+
return StudioWebSocket.refresh()
|
|
1127
1127
|
end)
|
|
1128
1128
|
end
|
|
1129
1129
|
end
|
|
@@ -1135,7 +1135,7 @@ local function ensureIdentityWatchers()
|
|
|
1135
1135
|
placeIdChangeConn = signal:Connect(function()
|
|
1136
1136
|
PluginSession.invalidatePlaceName()
|
|
1137
1137
|
lastReadyPlaceKey = PluginSession.getPlaceKey()
|
|
1138
|
-
|
|
1138
|
+
StudioWebSocket.refresh()
|
|
1139
1139
|
end)
|
|
1140
1140
|
end
|
|
1141
1141
|
end
|
|
@@ -1176,9 +1176,9 @@ local function activatePlugin()
|
|
|
1176
1176
|
ClientBroker.setServerUrl(conn.serverUrl)
|
|
1177
1177
|
lastReadyPlaceKey = PluginSession.getPlaceKey()
|
|
1178
1178
|
UI.updateUIState()
|
|
1179
|
-
|
|
1179
|
+
StudioWebSocket.start({
|
|
1180
1180
|
serverUrl = conn.serverUrl,
|
|
1181
|
-
dispatchRequest =
|
|
1181
|
+
dispatchRequest = dispatchSocketRequest,
|
|
1182
1182
|
onStatus = handleStatus,
|
|
1183
1183
|
onHeartbeat = handleHeartbeat,
|
|
1184
1184
|
onReady = handleReady,
|
|
@@ -1195,7 +1195,7 @@ local function activatePlugin()
|
|
|
1195
1195
|
if lastReadyPlaceKey ~= nil and currentPlaceKey ~= lastReadyPlaceKey then
|
|
1196
1196
|
lastReadyPlaceKey = currentPlaceKey
|
|
1197
1197
|
PluginSession.invalidatePlaceName()
|
|
1198
|
-
|
|
1198
|
+
StudioWebSocket.refresh()
|
|
1199
1199
|
end
|
|
1200
1200
|
end)
|
|
1201
1201
|
end
|
|
@@ -1213,7 +1213,7 @@ function deactivatePlugin()
|
|
|
1213
1213
|
conn.lastHttpOk = false
|
|
1214
1214
|
conn.lastMcpOk = false
|
|
1215
1215
|
conn.mcpWaitStartTime = nil
|
|
1216
|
-
|
|
1216
|
+
StudioWebSocket.stop()
|
|
1217
1217
|
disconnectIdentityWatchers()
|
|
1218
1218
|
if initialRole == "server" then
|
|
1219
1219
|
ClientBroker.disconnectAllProxies()
|
|
@@ -1472,9 +1472,9 @@ local function computeBridgeStamp()
|
|
|
1472
1472
|
for i = 1, #combined do
|
|
1473
1473
|
h = (h * 33 + (string.byte(combined, i))) % 2147483647
|
|
1474
1474
|
end
|
|
1475
|
-
-- "3.1.
|
|
1475
|
+
-- "3.1.3" is replaced with the package version at package time
|
|
1476
1476
|
-- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
|
|
1477
|
-
return `{tostring(h)}-3.1.
|
|
1477
|
+
return `{tostring(h)}-3.1.3`
|
|
1478
1478
|
end
|
|
1479
1479
|
local BRIDGE_STAMP = computeBridgeStamp()
|
|
1480
1480
|
local function setSource(scriptInst, source)
|
|
@@ -3682,16 +3682,33 @@ end
|
|
|
3682
3682
|
-- camera's own field of view so any subject fills a similar share of frame.
|
|
3683
3683
|
local function focusViewport(requestData)
|
|
3684
3684
|
local instancePath = requestData.path
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3685
|
+
local instance
|
|
3686
|
+
if instancePath == nil then
|
|
3687
|
+
local selection = Selection:Get()
|
|
3688
|
+
if #selection == 0 then
|
|
3689
|
+
return {
|
|
3690
|
+
error = "No objects selected. Select a BasePart or Model, or provide path.",
|
|
3691
|
+
}
|
|
3692
|
+
end
|
|
3693
|
+
if #selection ~= 1 then
|
|
3694
|
+
return {
|
|
3695
|
+
error = "Select exactly one BasePart or Model to frame, or provide path.",
|
|
3696
|
+
}
|
|
3697
|
+
end
|
|
3698
|
+
instance = selection[1]
|
|
3699
|
+
else
|
|
3700
|
+
if not (type(instancePath) == "string") or instancePath == "" then
|
|
3701
|
+
return {
|
|
3702
|
+
error = "path must be a non-empty instance path when provided",
|
|
3703
|
+
}
|
|
3704
|
+
end
|
|
3705
|
+
local resolved = getInstanceByPath(instancePath)
|
|
3706
|
+
if not resolved then
|
|
3707
|
+
return {
|
|
3708
|
+
error = `Instance not found: {instancePath}`,
|
|
3709
|
+
}
|
|
3710
|
+
end
|
|
3711
|
+
instance = resolved
|
|
3695
3712
|
end
|
|
3696
3713
|
local workspace = game:GetService("Workspace")
|
|
3697
3714
|
local camera = workspace.CurrentCamera
|
|
@@ -3722,6 +3739,7 @@ local function focusViewport(requestData)
|
|
|
3722
3739
|
error = "angleY must be between -89 and 89",
|
|
3723
3740
|
}
|
|
3724
3741
|
end
|
|
3742
|
+
local originalCameraType = camera.CameraType
|
|
3725
3743
|
local ok, err = pcall(function()
|
|
3726
3744
|
local boundsCF
|
|
3727
3745
|
local boundsSize
|
|
@@ -3765,6 +3783,14 @@ local function focusViewport(requestData)
|
|
|
3765
3783
|
end
|
|
3766
3784
|
camera.Focus = CFrame.new(center)
|
|
3767
3785
|
end)
|
|
3786
|
+
local restored, restoreError = pcall(function()
|
|
3787
|
+
camera.CameraType = originalCameraType
|
|
3788
|
+
end)
|
|
3789
|
+
if not restored then
|
|
3790
|
+
return {
|
|
3791
|
+
error = `{if ok then "focus completed" else `focus failed: {tostring(err)}`}; failed to restore camera type: {tostring(restoreError)}`,
|
|
3792
|
+
}
|
|
3793
|
+
end
|
|
3768
3794
|
if not ok then
|
|
3769
3795
|
return {
|
|
3770
3796
|
error = `focus failed: {tostring(err)}`,
|
|
@@ -5569,6 +5595,8 @@ local convertPropertyValue = _binding.convertPropertyValue
|
|
|
5569
5595
|
local _binding_1 = Recording
|
|
5570
5596
|
local beginRecording = _binding_1.beginRecording
|
|
5571
5597
|
local finishRecording = _binding_1.finishRecording
|
|
5598
|
+
-- Native StringValue rejects UTF-8 strings of 200000 bytes or more.
|
|
5599
|
+
local STRING_VALUE_MAX_BYTES = 199999
|
|
5572
5600
|
local function setProperties(requestData)
|
|
5573
5601
|
local instancePath = requestData.instancePath
|
|
5574
5602
|
local properties = requestData.properties
|
|
@@ -5616,15 +5644,28 @@ local function setProperties(requestData)
|
|
|
5616
5644
|
table.insert(results, _arg0)
|
|
5617
5645
|
else
|
|
5618
5646
|
failureCount += 1
|
|
5619
|
-
local
|
|
5647
|
+
local failure = {
|
|
5620
5648
|
property = propName,
|
|
5621
5649
|
success = false,
|
|
5622
5650
|
error = tostring(err),
|
|
5623
5651
|
}
|
|
5624
|
-
|
|
5652
|
+
if instance:IsA("StringValue") and propName == "Value" and type(propValue) == "string" then
|
|
5653
|
+
local _object = table.clone(failure)
|
|
5654
|
+
setmetatable(_object, nil)
|
|
5655
|
+
_object.details = {
|
|
5656
|
+
stage = "property_write",
|
|
5657
|
+
bytes = #propValue,
|
|
5658
|
+
limitBytes = STRING_VALUE_MAX_BYTES,
|
|
5659
|
+
}
|
|
5660
|
+
table.insert(results, _object)
|
|
5661
|
+
else
|
|
5662
|
+
table.insert(results, failure)
|
|
5663
|
+
end
|
|
5625
5664
|
end
|
|
5626
5665
|
end
|
|
5627
|
-
|
|
5666
|
+
-- This tool is not a rollback transaction. Cancel can undo earlier unrecorded
|
|
5667
|
+
-- execute_luau edits as well, even when every assignment in this call failed.
|
|
5668
|
+
finishRecording(recordingId, true)
|
|
5628
5669
|
return {
|
|
5629
5670
|
instancePath = instancePath,
|
|
5630
5671
|
summary = {
|
|
@@ -7944,6 +7985,16 @@ local function startPlaytest(requestData)
|
|
|
7944
7985
|
error = "start_playtest is single-player only. Use multiplayer_test_start for multi-client StudioTestService sessions.",
|
|
7945
7986
|
}
|
|
7946
7987
|
end
|
|
7988
|
+
-- Peer teardown and ExecutePlayModeAsync completion can precede native edit
|
|
7989
|
+
-- readiness. Do not acknowledge a new start that the engine cannot execute.
|
|
7990
|
+
if not StudioTestService.EditModeActive then
|
|
7991
|
+
return {
|
|
7992
|
+
success = false,
|
|
7993
|
+
error = "Studio is not ready to start a playtest.",
|
|
7994
|
+
message = "Wait for Studio to finish its current playtest transition before starting another.",
|
|
7995
|
+
editModeReady = false,
|
|
7996
|
+
}
|
|
7997
|
+
end
|
|
7947
7998
|
-- Self-heal: if testRunning is stuck true but Studio reports no active
|
|
7948
7999
|
-- playtest, the previous start_playtest's task.spawn was orphaned
|
|
7949
8000
|
-- (plugin reload mid-test, Studio entered some inconsistent state, etc).
|
|
@@ -8023,22 +8074,24 @@ local function stopPlaytest(_requestData)
|
|
|
8023
8074
|
}
|
|
8024
8075
|
end
|
|
8025
8076
|
StopPlayMonitor.clearPending(stopRequest.requestId)
|
|
8026
|
-
--
|
|
8027
|
-
--
|
|
8028
|
-
--
|
|
8029
|
-
--
|
|
8030
|
-
-- teardown and get "A test is already running". 10s covers play-DM
|
|
8031
|
-
-- teardown on heavier places; if it still hasn't cleared we return
|
|
8032
|
-
-- anyway so users aren't stuck — but note that in the response so the
|
|
8033
|
-
-- caller knows a subsequent start may need a moment.
|
|
8077
|
+
-- EndTest consumption, task completion, and native edit readiness are distinct
|
|
8078
|
+
-- milestones. In particular, testRunning can clear before Studio accepts another
|
|
8079
|
+
-- ExecutePlayModeAsync call (and is never set for manually started playtests).
|
|
8080
|
+
-- Only report completion after both tracked execution and native teardown settle.
|
|
8034
8081
|
local deadline = tick() + 10
|
|
8035
|
-
while testRunning and tick() < deadline do
|
|
8082
|
+
while (testRunning or not StudioTestService.EditModeActive) and tick() < deadline do
|
|
8036
8083
|
task.wait(0.1)
|
|
8037
8084
|
end
|
|
8038
|
-
|
|
8085
|
+
local editModeReady = StudioTestService.EditModeActive
|
|
8086
|
+
if testRunning or not editModeReady then
|
|
8039
8087
|
return {
|
|
8040
|
-
success =
|
|
8041
|
-
|
|
8088
|
+
success = false,
|
|
8089
|
+
error = "Playtest teardown did not complete.",
|
|
8090
|
+
message = "Stop signal was accepted, but Studio teardown did not settle before timeout.",
|
|
8091
|
+
stopSignalAccepted = true,
|
|
8092
|
+
editModeReady = editModeReady,
|
|
8093
|
+
playtestTaskPending = testRunning,
|
|
8094
|
+
timedOut = true,
|
|
8042
8095
|
}
|
|
8043
8096
|
end
|
|
8044
8097
|
return {
|
|
@@ -8794,13 +8847,34 @@ local ServerStorage = _services.ServerStorage
|
|
|
8794
8847
|
local State = TS.import(script, script.Parent, "State")
|
|
8795
8848
|
local PeerRole = TS.import(script, script.Parent, "PeerRole")
|
|
8796
8849
|
local TopologyId = TS.import(script, script.Parent, "TopologyId")
|
|
8850
|
+
local CoreGui = game:GetService("CoreGui")
|
|
8797
8851
|
local MCP_PLACE_ID_ATTRIBUTE = "__MCPPlaceId"
|
|
8798
8852
|
local TOPOLOGY_MODE_ATTRIBUTE = "__MCPTopologyMode"
|
|
8799
8853
|
local TOPOLOGY_INSTANCE_ID_ATTRIBUTE = "__MCPTopologyInstanceId"
|
|
8800
8854
|
local TOPOLOGY_GROUP_ID_ATTRIBUTE = "__MCPTopologyGroupId"
|
|
8801
8855
|
local TOPOLOGY_TOKEN_ATTRIBUTE = "__MCPTopologyToken"
|
|
8856
|
+
local SESSION_IDENTITY_NAME = "__MCPSessionIdentity"
|
|
8802
8857
|
local peerId = TopologyId.createPeerId()
|
|
8803
|
-
local
|
|
8858
|
+
local isEditSession = PeerRole.detect() == "edit"
|
|
8859
|
+
local existingSessionIdentity = CoreGui:FindFirstChild(SESSION_IDENTITY_NAME)
|
|
8860
|
+
local sessionInstanceId
|
|
8861
|
+
local createdSessionIdentity = false
|
|
8862
|
+
if existingSessionIdentity ~= nil and existingSessionIdentity:IsA("StringValue") and not existingSessionIdentity.Archivable and (string.match(existingSessionIdentity.Value, "^instance:[0-9a-z][0-9a-z][0-9a-z]%-[0-9a-z][0-9a-z][0-9a-z]$")) ~= nil then
|
|
8863
|
+
sessionInstanceId = existingSessionIdentity.Value
|
|
8864
|
+
else
|
|
8865
|
+
local _result = existingSessionIdentity
|
|
8866
|
+
if _result ~= nil then
|
|
8867
|
+
_result:Destroy()
|
|
8868
|
+
end
|
|
8869
|
+
sessionInstanceId = TopologyId.createInstanceId()
|
|
8870
|
+
local sessionIdentity = Instance.new("StringValue")
|
|
8871
|
+
sessionIdentity.Name = SESSION_IDENTITY_NAME
|
|
8872
|
+
sessionIdentity.Value = sessionInstanceId
|
|
8873
|
+
sessionIdentity.Archivable = false
|
|
8874
|
+
sessionIdentity.Parent = CoreGui
|
|
8875
|
+
createdSessionIdentity = true
|
|
8876
|
+
end
|
|
8877
|
+
local inheritedInstanceId
|
|
8804
8878
|
local cachedPlaceName
|
|
8805
8879
|
local cachedPlaceNamePlaceId
|
|
8806
8880
|
local function getMarkerMode()
|
|
@@ -8808,13 +8882,17 @@ local function getMarkerMode()
|
|
|
8808
8882
|
return if mode == "shared" or mode == "multiplayer" then mode else nil
|
|
8809
8883
|
end
|
|
8810
8884
|
local function getInstanceId()
|
|
8811
|
-
if
|
|
8885
|
+
if inheritedInstanceId ~= nil then
|
|
8886
|
+
return inheritedInstanceId
|
|
8887
|
+
end
|
|
8888
|
+
if not isEditSession and getMarkerMode() == "shared" then
|
|
8812
8889
|
local sharedInstanceId = ReplicatedStorage:GetAttribute(TOPOLOGY_INSTANCE_ID_ATTRIBUTE)
|
|
8813
8890
|
if type(sharedInstanceId) == "string" and sharedInstanceId ~= "" then
|
|
8891
|
+
inheritedInstanceId = sharedInstanceId
|
|
8814
8892
|
return sharedInstanceId
|
|
8815
8893
|
end
|
|
8816
8894
|
end
|
|
8817
|
-
return
|
|
8895
|
+
return sessionInstanceId
|
|
8818
8896
|
end
|
|
8819
8897
|
local function getMultiplayerGroupId()
|
|
8820
8898
|
if getMarkerMode() ~= "multiplayer" then
|
|
@@ -8847,7 +8925,7 @@ local function setTopologyMarker(mode, instanceId, groupId)
|
|
|
8847
8925
|
return token
|
|
8848
8926
|
end
|
|
8849
8927
|
local function prepareSharedTopology()
|
|
8850
|
-
return setTopologyMarker("shared",
|
|
8928
|
+
return setTopologyMarker("shared", sessionInstanceId, nil)
|
|
8851
8929
|
end
|
|
8852
8930
|
local function prepareMultiplayerTopology(groupId)
|
|
8853
8931
|
return setTopologyMarker("multiplayer", nil, groupId)
|
|
@@ -8856,6 +8934,10 @@ local function clearTopologyMarker(token)
|
|
|
8856
8934
|
if ReplicatedStorage:GetAttribute(TOPOLOGY_TOKEN_ATTRIBUTE) ~= token then
|
|
8857
8935
|
return nil
|
|
8858
8936
|
end
|
|
8937
|
+
if isEditSession then
|
|
8938
|
+
prepareSharedTopology()
|
|
8939
|
+
return nil
|
|
8940
|
+
end
|
|
8859
8941
|
ReplicatedStorage:SetAttribute(TOPOLOGY_MODE_ATTRIBUTE, nil)
|
|
8860
8942
|
ReplicatedStorage:SetAttribute(TOPOLOGY_INSTANCE_ID_ATTRIBUTE, nil)
|
|
8861
8943
|
ReplicatedStorage:SetAttribute(TOPOLOGY_GROUP_ID_ATTRIBUTE, nil)
|
|
@@ -8916,6 +8998,15 @@ local function createReadyPayload(readyPeerId, role, instanceId, multiplayerGrou
|
|
|
8916
8998
|
timestamp = tick(),
|
|
8917
8999
|
}
|
|
8918
9000
|
end
|
|
9001
|
+
-- CoreGui survives plugin reloads without saving or replicating this identity.
|
|
9002
|
+
-- Preserve active test markers on reload; fresh edits replace saved place markers.
|
|
9003
|
+
if isEditSession then
|
|
9004
|
+
if createdSessionIdentity or getMarkerMode() == nil then
|
|
9005
|
+
prepareSharedTopology()
|
|
9006
|
+
end
|
|
9007
|
+
else
|
|
9008
|
+
getInstanceId()
|
|
9009
|
+
end
|
|
8919
9010
|
return {
|
|
8920
9011
|
peerId = peerId,
|
|
8921
9012
|
getInstanceId = getInstanceId,
|
|
@@ -9736,7 +9827,7 @@ return {
|
|
|
9736
9827
|
<Properties>
|
|
9737
9828
|
<string name="Name">State</string>
|
|
9738
9829
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
9739
|
-
local CURRENT_VERSION = "3.1.
|
|
9830
|
+
local CURRENT_VERSION = "3.1.3"
|
|
9740
9831
|
local PLUGIN_VARIANT = "main"
|
|
9741
9832
|
local BASE_PORT = 58741
|
|
9742
9833
|
local function createConnection(port)
|
|
@@ -10001,39 +10092,55 @@ return {
|
|
|
10001
10092
|
</Item>
|
|
10002
10093
|
<Item class="ModuleScript" referent="36">
|
|
10003
10094
|
<Properties>
|
|
10004
|
-
<string name="Name">
|
|
10095
|
+
<string name="Name">StudioWebSocket</string>
|
|
10005
10096
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
10006
10097
|
local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
10007
10098
|
local HttpService = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services").HttpService
|
|
10008
10099
|
local HttpDiagnostics = TS.import(script, script.Parent, "HttpDiagnostics")
|
|
10009
10100
|
local PluginSession = TS.import(script, script.Parent, "PluginSession")
|
|
10010
|
-
local
|
|
10011
|
-
local
|
|
10012
|
-
local
|
|
10013
|
-
local
|
|
10014
|
-
local
|
|
10015
|
-
local
|
|
10016
|
-
|
|
10017
|
-
local
|
|
10101
|
+
local PROTOCOL_VERSION = 1
|
|
10102
|
+
local INITIAL_RETRY_DELAY_SECONDS = 0.5
|
|
10103
|
+
local MAX_RETRY_DELAY_SECONDS = 5
|
|
10104
|
+
local SOCKET_SILENCE_TIMEOUT_SECONDS = 20
|
|
10105
|
+
local RESPONSE_RETENTION_SECONDS = 5 * 60
|
|
10106
|
+
local RESPONSE_ACK_TIMEOUT_SECONDS = 120
|
|
10107
|
+
local MAX_TERMINAL_RESPONSES = 32768
|
|
10108
|
+
local MAX_ACTIVE_RESPONSES = 128
|
|
10109
|
+
local MAX_ADMISSION_REJECTIONS = 128
|
|
10110
|
+
local MAX_FRAME_BYTES = 64 * 1024 * 1024
|
|
10111
|
+
local MAX_PENDING_RESPONSE_BYTES = 64 * 1024 * 1024
|
|
10112
|
+
local RESERVED_ERROR_BYTES = 4096
|
|
10113
|
+
-- The bridge admits 128 UTF-16 code units; each can require three UTF-8 bytes.
|
|
10114
|
+
local MAX_REQUEST_ID_BYTES = 128 * 3
|
|
10018
10115
|
local options
|
|
10019
10116
|
local active = false
|
|
10020
10117
|
local shutdownSuspended = false
|
|
10021
10118
|
local generation = 0
|
|
10022
10119
|
local reconnectAttempt = 0
|
|
10023
|
-
local
|
|
10024
|
-
local
|
|
10120
|
+
local socketClient
|
|
10121
|
+
local socketOpen = false
|
|
10122
|
+
local socketConnections = {}
|
|
10025
10123
|
local lastValidEventAt = 0
|
|
10124
|
+
local cachedReady
|
|
10125
|
+
local readyRefreshPending = false
|
|
10126
|
+
local pendingResponseBytes = 0
|
|
10127
|
+
local inFlightRequestBytes = 0
|
|
10128
|
+
local pendingRejectionCount = 0
|
|
10026
10129
|
local inFlightRequests = {}
|
|
10027
10130
|
local pendingResponses = {}
|
|
10028
10131
|
local terminalResponseIds = {}
|
|
10029
10132
|
local terminalResponseOrder = {}
|
|
10133
|
+
local terminalHead = 0
|
|
10134
|
+
local terminalCount = 0
|
|
10030
10135
|
local readyFailureLogKeys = {}
|
|
10031
|
-
local
|
|
10032
|
-
local
|
|
10033
|
-
local
|
|
10034
|
-
|
|
10035
|
-
|
|
10036
|
-
|
|
10136
|
+
local function validRequestId(value)
|
|
10137
|
+
local _value = value
|
|
10138
|
+
local _condition = type(_value) == "string"
|
|
10139
|
+
if _condition then
|
|
10140
|
+
_condition = #value > 0 and #value <= MAX_REQUEST_ID_BYTES
|
|
10141
|
+
end
|
|
10142
|
+
return _condition
|
|
10143
|
+
end
|
|
10037
10144
|
local function decodeMessage(payload)
|
|
10038
10145
|
local decodeOk, decoded = pcall(function()
|
|
10039
10146
|
return HttpService:JSONDecode(payload)
|
|
@@ -10078,13 +10185,21 @@ local function decodeMessage(payload)
|
|
|
10078
10185
|
_object[_left_2] = if type(_pluginVariant) == "string" then envelope.pluginVariant else nil
|
|
10079
10186
|
return _object
|
|
10080
10187
|
end
|
|
10081
|
-
if envelope.
|
|
10082
|
-
|
|
10083
|
-
|
|
10084
|
-
|
|
10085
|
-
|
|
10188
|
+
if not validRequestId(envelope.requestId) then
|
|
10189
|
+
return nil
|
|
10190
|
+
end
|
|
10191
|
+
if envelope.kind == "ack" then
|
|
10192
|
+
if envelope.disposition ~= "accepted" and envelope.disposition ~= "already_settled" and envelope.disposition ~= "unknown" then
|
|
10193
|
+
return nil
|
|
10086
10194
|
end
|
|
10087
|
-
|
|
10195
|
+
return {
|
|
10196
|
+
kind = "ack",
|
|
10197
|
+
requestId = envelope.requestId,
|
|
10198
|
+
disposition = envelope.disposition,
|
|
10199
|
+
}
|
|
10200
|
+
end
|
|
10201
|
+
if envelope.kind == "cancel" then
|
|
10202
|
+
if envelope.reason ~= "timeout" and envelope.reason ~= "aborted" and envelope.reason ~= "connection_closed" then
|
|
10088
10203
|
return nil
|
|
10089
10204
|
end
|
|
10090
10205
|
return {
|
|
@@ -10094,23 +10209,19 @@ local function decodeMessage(payload)
|
|
|
10094
10209
|
}
|
|
10095
10210
|
end
|
|
10096
10211
|
if envelope.kind == "request" then
|
|
10097
|
-
local
|
|
10098
|
-
local _condition = not (type(
|
|
10212
|
+
local _peerId = envelope.peerId
|
|
10213
|
+
local _condition = not (type(_peerId) == "string")
|
|
10099
10214
|
if not _condition then
|
|
10100
|
-
local
|
|
10101
|
-
_condition = not (type(
|
|
10215
|
+
local _target = envelope.target
|
|
10216
|
+
_condition = not (type(_target) == "string")
|
|
10102
10217
|
if not _condition then
|
|
10103
|
-
local
|
|
10104
|
-
_condition = not (type(
|
|
10218
|
+
local _endpoint = envelope.endpoint
|
|
10219
|
+
_condition = not (type(_endpoint) == "string")
|
|
10105
10220
|
if not _condition then
|
|
10106
|
-
local
|
|
10107
|
-
_condition = not (type(
|
|
10221
|
+
local _remainingMs = envelope.remainingMs
|
|
10222
|
+
_condition = not (type(_remainingMs) == "number")
|
|
10108
10223
|
if not _condition then
|
|
10109
|
-
|
|
10110
|
-
_condition = not (type(_remainingMs) == "number")
|
|
10111
|
-
if not _condition then
|
|
10112
|
-
_condition = envelope.remainingMs < 0
|
|
10113
|
-
end
|
|
10224
|
+
_condition = envelope.remainingMs < 0 or envelope.remainingMs ~= envelope.remainingMs or envelope.remainingMs == math.huge
|
|
10114
10225
|
end
|
|
10115
10226
|
end
|
|
10116
10227
|
end
|
|
@@ -10118,144 +10229,35 @@ local function decodeMessage(payload)
|
|
|
10118
10229
|
if _condition then
|
|
10119
10230
|
return nil
|
|
10120
10231
|
end
|
|
10121
|
-
local
|
|
10122
|
-
local _data = envelope.data
|
|
10123
|
-
if type(_data) == "table" then
|
|
10124
|
-
data = envelope.data
|
|
10125
|
-
end
|
|
10126
|
-
return {
|
|
10232
|
+
local _object = {
|
|
10127
10233
|
kind = "request",
|
|
10128
10234
|
requestId = envelope.requestId,
|
|
10129
10235
|
peerId = envelope.peerId,
|
|
10130
10236
|
target = envelope.target,
|
|
10131
10237
|
endpoint = envelope.endpoint,
|
|
10132
|
-
data = data,
|
|
10133
10238
|
remainingMs = envelope.remainingMs,
|
|
10134
10239
|
}
|
|
10240
|
+
local _left = "data"
|
|
10241
|
+
local _data = envelope.data
|
|
10242
|
+
_object[_left] = if type(_data) == "table" then envelope.data else nil
|
|
10243
|
+
return _object
|
|
10135
10244
|
end
|
|
10136
10245
|
return nil
|
|
10137
10246
|
end
|
|
10138
|
-
local function
|
|
10139
|
-
|
|
10140
|
-
|
|
10141
|
-
|
|
10142
|
-
|
|
10143
|
-
|
|
10144
|
-
end
|
|
10145
|
-
local function finishStreamLine(events)
|
|
10146
|
-
if not streamLineHasContent then
|
|
10147
|
-
if not streamDiscardingFrame and #streamDataLines > 0 then
|
|
10148
|
-
local event = decodeMessage(table.concat(streamDataLines, "\n"))
|
|
10149
|
-
if event ~= nil then
|
|
10150
|
-
table.insert(events, event)
|
|
10151
|
-
end
|
|
10152
|
-
end
|
|
10153
|
-
resetStreamFrame()
|
|
10154
|
-
return nil
|
|
10155
|
-
end
|
|
10156
|
-
if not streamDiscardingFrame then
|
|
10157
|
-
local line = table.concat(streamLineParts, "")
|
|
10158
|
-
if string.sub(line, 1, 5) == "data:" then
|
|
10159
|
-
local start = if string.sub(line, 6, 6) == " " then 7 else 6
|
|
10160
|
-
local _streamDataLines = streamDataLines
|
|
10161
|
-
local _arg0 = string.sub(line, start)
|
|
10162
|
-
table.insert(_streamDataLines, _arg0)
|
|
10163
|
-
elseif line == "data" then
|
|
10164
|
-
table.insert(streamDataLines, "")
|
|
10165
|
-
end
|
|
10166
|
-
end
|
|
10167
|
-
streamLineParts = {}
|
|
10168
|
-
streamLineHasContent = false
|
|
10169
|
-
end
|
|
10170
|
-
-- MessageReceived may coalesce frames (notably server/client fanout) or split
|
|
10171
|
-
-- them across callbacks. Callback boundaries are not SSE event boundaries.
|
|
10172
|
-
local function decodeMessages(message)
|
|
10173
|
-
local events = {}
|
|
10174
|
-
local messageBytes = #message
|
|
10175
|
-
if messageBytes == 0 then
|
|
10176
|
-
return events
|
|
10177
|
-
end
|
|
10178
|
-
-- Some Studio versions deliver the decoded SSE data or a complete data line
|
|
10179
|
-
-- without delimiters. Only recognize those forms with no unfinished frame;
|
|
10180
|
-
-- once framing starts, callback boundaries never terminate partial data.
|
|
10181
|
-
if streamFrameBytes == 0 and not streamDiscardingFrame and messageBytes <= MAX_STREAM_FRAME_BYTES then
|
|
10182
|
-
local first = string.sub(message, 1, 1)
|
|
10183
|
-
if first == "{" or first == " " or first == "\t" or first == "\r" or first == "\n" then
|
|
10184
|
-
local event = decodeMessage(message)
|
|
10185
|
-
if event ~= nil then
|
|
10186
|
-
streamSkipLf = false
|
|
10187
|
-
table.insert(events, event)
|
|
10188
|
-
return events
|
|
10189
|
-
end
|
|
10190
|
-
if first == "{" then
|
|
10191
|
-
return events
|
|
10192
|
-
end
|
|
10193
|
-
end
|
|
10194
|
-
if string.sub(message, 1, 5) == "data:" and (string.find(message, "[\r\n]")) == nil then
|
|
10195
|
-
local event = decodeMessage(string.sub(message, 6))
|
|
10196
|
-
if event ~= nil then
|
|
10197
|
-
streamSkipLf = false
|
|
10198
|
-
table.insert(events, event)
|
|
10199
|
-
return events
|
|
10200
|
-
end
|
|
10201
|
-
end
|
|
10247
|
+
local function closeCurrentSocket()
|
|
10248
|
+
local current = socketClient
|
|
10249
|
+
socketClient = nil
|
|
10250
|
+
socketOpen = false
|
|
10251
|
+
for _, connection in socketConnections do
|
|
10252
|
+
connection:Disconnect()
|
|
10202
10253
|
end
|
|
10203
|
-
|
|
10204
|
-
|
|
10205
|
-
if
|
|
10206
|
-
|
|
10207
|
-
local _message = message
|
|
10208
|
-
local _offset = offset
|
|
10209
|
-
local _offset_1 = offset
|
|
10210
|
-
if string.sub(_message, _offset, _offset_1) == "\n" then
|
|
10211
|
-
offset += 1
|
|
10212
|
-
continue
|
|
10213
|
-
end
|
|
10214
|
-
end
|
|
10215
|
-
local _message = message
|
|
10216
|
-
local _offset = offset
|
|
10217
|
-
local newline = string.find(_message, "[\r\n]", _offset)
|
|
10218
|
-
local lineEnd = if newline == nil then messageBytes + 1 else newline
|
|
10219
|
-
local partBytes = lineEnd - offset
|
|
10220
|
-
if partBytes > 0 then
|
|
10221
|
-
streamLineHasContent = true
|
|
10222
|
-
end
|
|
10223
|
-
if not streamDiscardingFrame then
|
|
10224
|
-
streamFrameBytes += partBytes + (if newline == nil then 0 else 1)
|
|
10225
|
-
if streamFrameBytes > MAX_STREAM_FRAME_BYTES then
|
|
10226
|
-
-- Keep scanning delimiters but retain no oversized frame bytes.
|
|
10227
|
-
streamDiscardingFrame = true
|
|
10228
|
-
streamLineParts = {}
|
|
10229
|
-
streamDataLines = {}
|
|
10230
|
-
elseif partBytes > 0 then
|
|
10231
|
-
-- Join only on a line boundary, not every callback (large scripts
|
|
10232
|
-
-- can arrive in many fragments).
|
|
10233
|
-
local _streamLineParts = streamLineParts
|
|
10234
|
-
local _message_1 = message
|
|
10235
|
-
local _offset_1 = offset
|
|
10236
|
-
local _arg1 = lineEnd - 1
|
|
10237
|
-
local _arg0 = string.sub(_message_1, _offset_1, _arg1)
|
|
10238
|
-
table.insert(_streamLineParts, _arg0)
|
|
10239
|
-
end
|
|
10240
|
-
end
|
|
10241
|
-
if newline == nil then
|
|
10242
|
-
break
|
|
10254
|
+
socketConnections = {}
|
|
10255
|
+
for _, entry in pendingResponses do
|
|
10256
|
+
if entry.ackTimer ~= nil then
|
|
10257
|
+
task.cancel(entry.ackTimer)
|
|
10243
10258
|
end
|
|
10244
|
-
|
|
10245
|
-
streamSkipLf = string.sub(message, newline, newline) == "\r"
|
|
10246
|
-
offset = newline + 1
|
|
10247
|
-
end
|
|
10248
|
-
return events
|
|
10249
|
-
end
|
|
10250
|
-
local function closeCurrentStream()
|
|
10251
|
-
local current = streamClient
|
|
10252
|
-
streamClient = nil
|
|
10253
|
-
resetStreamFrame()
|
|
10254
|
-
streamSkipLf = false
|
|
10255
|
-
for _, connection in streamConnections do
|
|
10256
|
-
connection:Disconnect()
|
|
10259
|
+
entry.ackTimer = nil
|
|
10257
10260
|
end
|
|
10258
|
-
streamConnections = {}
|
|
10259
10261
|
if current ~= nil then
|
|
10260
10262
|
pcall(function()
|
|
10261
10263
|
return current:Close()
|
|
@@ -10276,42 +10278,39 @@ local function disconnectSession(currentOptions)
|
|
|
10276
10278
|
})
|
|
10277
10279
|
end)
|
|
10278
10280
|
end
|
|
10279
|
-
local function
|
|
10280
|
-
return math.min(
|
|
10281
|
+
local function retryDelay(attempt)
|
|
10282
|
+
return math.min(INITIAL_RETRY_DELAY_SECONDS * math.pow(2, math.max(attempt - 1, 0)), MAX_RETRY_DELAY_SECONDS)
|
|
10281
10283
|
end
|
|
10282
|
-
local function
|
|
10283
|
-
local
|
|
10284
|
-
|
|
10285
|
-
|
|
10286
|
-
|
|
10287
|
-
|
|
10288
|
-
|
|
10289
|
-
|
|
10290
|
-
|
|
10291
|
-
|
|
10292
|
-
|
|
10293
|
-
|
|
10294
|
-
if success and acknowledgement.success == true and disposition == nil then
|
|
10295
|
-
return "accepted"
|
|
10284
|
+
local function pruneTerminalResponses()
|
|
10285
|
+
local now = os.clock()
|
|
10286
|
+
while terminalCount > 0 do
|
|
10287
|
+
local oldest = terminalResponseOrder[terminalHead + 1]
|
|
10288
|
+
if oldest == nil or oldest.expiresAt > now then
|
|
10289
|
+
break
|
|
10290
|
+
end
|
|
10291
|
+
local _requestId = oldest.requestId
|
|
10292
|
+
terminalResponseIds[_requestId] = nil
|
|
10293
|
+
terminalResponseOrder[terminalHead + 1] = nil
|
|
10294
|
+
terminalHead = (terminalHead + 1) % MAX_TERMINAL_RESPONSES
|
|
10295
|
+
terminalCount -= 1
|
|
10296
10296
|
end
|
|
10297
|
-
return nil
|
|
10298
10297
|
end
|
|
10299
10298
|
local function rememberTerminalResponse(requestId)
|
|
10299
|
+
pruneTerminalResponses()
|
|
10300
10300
|
local _requestId = requestId
|
|
10301
10301
|
if terminalResponseIds[_requestId] ~= nil then
|
|
10302
10302
|
return nil
|
|
10303
10303
|
end
|
|
10304
|
+
-- Admission reserves this tombstone before any side effects can execute.
|
|
10305
|
+
terminalResponseOrder[(terminalHead + terminalCount) % MAX_TERMINAL_RESPONSES + 1] = {
|
|
10306
|
+
requestId = requestId,
|
|
10307
|
+
expiresAt = os.clock() + RESPONSE_RETENTION_SECONDS,
|
|
10308
|
+
}
|
|
10309
|
+
terminalCount += 1
|
|
10304
10310
|
local _requestId_1 = requestId
|
|
10305
10311
|
terminalResponseIds[_requestId_1] = true
|
|
10306
|
-
local _requestId_2 = requestId
|
|
10307
|
-
table.insert(terminalResponseOrder, _requestId_2)
|
|
10308
|
-
while #terminalResponseOrder > MAX_TERMINAL_RESPONSES do
|
|
10309
|
-
local oldest = table.remove(terminalResponseOrder, 1)
|
|
10310
|
-
if oldest ~= nil then
|
|
10311
|
-
terminalResponseIds[oldest] = nil
|
|
10312
|
-
end
|
|
10313
|
-
end
|
|
10314
10312
|
end
|
|
10313
|
+
local scheduleReconnect
|
|
10315
10314
|
local function settleResponse(requestId, entry, disposition)
|
|
10316
10315
|
local _requestId = requestId
|
|
10317
10316
|
if pendingResponses[_requestId] ~= entry then
|
|
@@ -10319,66 +10318,145 @@ local function settleResponse(requestId, entry, disposition)
|
|
|
10319
10318
|
end
|
|
10320
10319
|
local _requestId_1 = requestId
|
|
10321
10320
|
pendingResponses[_requestId_1] = nil
|
|
10321
|
+
if entry.admissionRejection then
|
|
10322
|
+
pendingRejectionCount -= 1
|
|
10323
|
+
else
|
|
10324
|
+
pendingResponseBytes -= #entry.body
|
|
10325
|
+
end
|
|
10326
|
+
if entry.ackTimer ~= nil then
|
|
10327
|
+
task.cancel(entry.ackTimer)
|
|
10328
|
+
end
|
|
10329
|
+
if entry.expiryTimer ~= nil then
|
|
10330
|
+
task.cancel(entry.expiryTimer)
|
|
10331
|
+
end
|
|
10332
|
+
entry.ackTimer = nil
|
|
10333
|
+
entry.expiryTimer = nil
|
|
10334
|
+
-- Release a queued native frame before making its capacity available again.
|
|
10335
|
+
if disposition == "expired" and socketOpen and entry.sentGeneration == generation then
|
|
10336
|
+
scheduleReconnect(generation, `WebSocket response {requestId} expired without acknowledgement`)
|
|
10337
|
+
end
|
|
10338
|
+
entry.body = ""
|
|
10322
10339
|
rememberTerminalResponse(requestId)
|
|
10323
|
-
if disposition == "unknown" then
|
|
10324
|
-
warn(`[robloxstudio-mcp]
|
|
10340
|
+
if disposition == "unknown" or disposition == "expired" then
|
|
10341
|
+
warn(`[robloxstudio-mcp] Response {requestId} outcome unknown ({disposition}); stored result released, mutation must not be replayed`)
|
|
10325
10342
|
end
|
|
10326
10343
|
end
|
|
10327
|
-
local function
|
|
10328
|
-
local
|
|
10329
|
-
local _condition = not active or
|
|
10344
|
+
local function sendProgress(requestId, progress)
|
|
10345
|
+
local client = socketClient
|
|
10346
|
+
local _condition = not active or not socketOpen or client == nil
|
|
10330
10347
|
if not _condition then
|
|
10331
|
-
local
|
|
10332
|
-
|
|
10348
|
+
local _result = options
|
|
10349
|
+
if _result ~= nil then
|
|
10350
|
+
_result = _result.serverUrl
|
|
10351
|
+
end
|
|
10352
|
+
_condition = _result ~= progress.serverUrl
|
|
10333
10353
|
if not _condition then
|
|
10334
|
-
_condition =
|
|
10354
|
+
_condition = progress.phase == nil
|
|
10335
10355
|
end
|
|
10336
10356
|
end
|
|
10337
10357
|
if _condition then
|
|
10338
10358
|
return nil
|
|
10339
10359
|
end
|
|
10340
|
-
|
|
10341
|
-
|
|
10342
|
-
|
|
10343
|
-
|
|
10344
|
-
|
|
10345
|
-
|
|
10346
|
-
|
|
10360
|
+
if progress.sentGeneration == generation and progress.sentPhase == progress.phase then
|
|
10361
|
+
return nil
|
|
10362
|
+
end
|
|
10363
|
+
local expectedGeneration = generation
|
|
10364
|
+
progress.sentGeneration = expectedGeneration
|
|
10365
|
+
progress.sentPhase = progress.phase
|
|
10366
|
+
local sent = pcall(function()
|
|
10367
|
+
return client:Send(HttpService:JSONEncode({
|
|
10368
|
+
kind = "progress",
|
|
10369
|
+
requestId = requestId,
|
|
10370
|
+
phase = progress.phase,
|
|
10371
|
+
outcome = progress.outcome,
|
|
10372
|
+
}))
|
|
10373
|
+
end)
|
|
10374
|
+
if not sent then
|
|
10375
|
+
scheduleReconnect(expectedGeneration, "WebSocket progress send failed; current observation retained for reconnect")
|
|
10376
|
+
end
|
|
10377
|
+
end
|
|
10378
|
+
local function completeProgress(requestId, progress, outcome)
|
|
10379
|
+
progress.phase = "response_delivery"
|
|
10380
|
+
progress.outcome = outcome
|
|
10381
|
+
sendProgress(requestId, progress)
|
|
10382
|
+
end
|
|
10383
|
+
local function handlerOutcome(response)
|
|
10384
|
+
local _response = response
|
|
10385
|
+
if not (type(_response) == "table") then
|
|
10386
|
+
return "success"
|
|
10387
|
+
end
|
|
10388
|
+
local result = response
|
|
10389
|
+
if result.error ~= nil or result.success == false or result.ok == false then
|
|
10390
|
+
return "error"
|
|
10391
|
+
end
|
|
10392
|
+
local _summary = result.summary
|
|
10393
|
+
if type(_summary) == "table" then
|
|
10394
|
+
local summary = result.summary
|
|
10395
|
+
local _failed = summary.failed
|
|
10396
|
+
local _condition = type(_failed) == "number"
|
|
10397
|
+
if _condition then
|
|
10398
|
+
_condition = summary.failed > 0
|
|
10347
10399
|
end
|
|
10348
|
-
if
|
|
10349
|
-
|
|
10350
|
-
return nil
|
|
10400
|
+
if _condition then
|
|
10401
|
+
return "error"
|
|
10351
10402
|
end
|
|
10352
|
-
|
|
10353
|
-
|
|
10354
|
-
|
|
10355
|
-
|
|
10356
|
-
|
|
10357
|
-
|
|
10358
|
-
|
|
10359
|
-
|
|
10360
|
-
|
|
10361
|
-
|
|
10362
|
-
|
|
10363
|
-
local _requestId = requestId
|
|
10364
|
-
if pendingResponses[_requestId] ~= entry then
|
|
10365
|
-
return nil
|
|
10403
|
+
end
|
|
10404
|
+
return "success"
|
|
10405
|
+
end
|
|
10406
|
+
local function sendPendingResponse(requestId, entry)
|
|
10407
|
+
sendProgress(requestId, entry.progress)
|
|
10408
|
+
local client = socketClient
|
|
10409
|
+
local _condition = not active or not socketOpen or client == nil
|
|
10410
|
+
if not _condition then
|
|
10411
|
+
local _result = options
|
|
10412
|
+
if _result ~= nil then
|
|
10413
|
+
_result = _result.serverUrl
|
|
10366
10414
|
end
|
|
10367
|
-
|
|
10368
|
-
|
|
10369
|
-
|
|
10370
|
-
|
|
10371
|
-
else
|
|
10372
|
-
local disposition = parseResponseDisposition(requestResult.Success, requestResult.Body)
|
|
10373
|
-
if disposition ~= nil then
|
|
10374
|
-
settleResponse(requestId, entry, disposition)
|
|
10375
|
-
return nil
|
|
10376
|
-
end
|
|
10377
|
-
failure = if requestResult.Success then "Invalid /response acknowledgement" else HttpDiagnostics.formatRequestFailure(responseUrl, true, requestResult)
|
|
10415
|
+
_condition = _result ~= entry.serverUrl
|
|
10416
|
+
if not _condition then
|
|
10417
|
+
local _requestId = requestId
|
|
10418
|
+
_condition = pendingResponses[_requestId] ~= entry
|
|
10378
10419
|
end
|
|
10379
|
-
|
|
10380
|
-
|
|
10381
|
-
|
|
10420
|
+
end
|
|
10421
|
+
if _condition then
|
|
10422
|
+
return nil
|
|
10423
|
+
end
|
|
10424
|
+
if os.clock() >= entry.expiresAt then
|
|
10425
|
+
settleResponse(requestId, entry, "expired")
|
|
10426
|
+
return nil
|
|
10427
|
+
end
|
|
10428
|
+
if entry.sentGeneration == generation then
|
|
10429
|
+
return nil
|
|
10430
|
+
end
|
|
10431
|
+
local expectedGeneration = generation
|
|
10432
|
+
entry.sentGeneration = expectedGeneration
|
|
10433
|
+
local sendOk, sendError = pcall(function()
|
|
10434
|
+
return client:Send(entry.body)
|
|
10435
|
+
end)
|
|
10436
|
+
if not sendOk then
|
|
10437
|
+
warn(`[robloxstudio-mcp] WebSocket response send failed for {requestId} ({#entry.body} bytes, stage=socket_send): {tostring(sendError)}`)
|
|
10438
|
+
scheduleReconnect(expectedGeneration, "WebSocket response send failed; retained for reconnect")
|
|
10439
|
+
return nil
|
|
10440
|
+
end
|
|
10441
|
+
local _requestId = requestId
|
|
10442
|
+
local _condition_1 = pendingResponses[_requestId] ~= entry
|
|
10443
|
+
if not _condition_1 then
|
|
10444
|
+
_condition_1 = generation ~= expectedGeneration or socketClient ~= client
|
|
10445
|
+
end
|
|
10446
|
+
if _condition_1 then
|
|
10447
|
+
return nil
|
|
10448
|
+
end
|
|
10449
|
+
-- Send only queues bytes. A heartbeat does not prove that this upload drained.
|
|
10450
|
+
-- Give large transfers a generous window, growing after reconnect; never put
|
|
10451
|
+
-- a second copy on the same socket, even when an acknowledgement is lost.
|
|
10452
|
+
entry.sendAttempt += 1
|
|
10453
|
+
local ackDelay = RESPONSE_ACK_TIMEOUT_SECONDS * math.pow(2, math.min(entry.sendAttempt - 1, 2))
|
|
10454
|
+
if os.clock() + ackDelay >= entry.expiresAt then
|
|
10455
|
+
return nil
|
|
10456
|
+
end
|
|
10457
|
+
entry.ackTimer = task.delay(ackDelay, function()
|
|
10458
|
+
entry.ackTimer = nil
|
|
10459
|
+
local _condition_2 = generation ~= expectedGeneration
|
|
10382
10460
|
if not _condition_2 then
|
|
10383
10461
|
local _requestId_1 = requestId
|
|
10384
10462
|
_condition_2 = pendingResponses[_requestId_1] ~= entry
|
|
@@ -10386,62 +10464,145 @@ local function postPendingResponse(requestId, entry)
|
|
|
10386
10464
|
if _condition_2 then
|
|
10387
10465
|
return nil
|
|
10388
10466
|
end
|
|
10389
|
-
|
|
10390
|
-
local retryToken = entry.retryToken
|
|
10391
|
-
local delay = responseRetryDelay(entry.retryAttempt)
|
|
10392
|
-
task.delay(delay, function()
|
|
10393
|
-
local _condition_3 = not active
|
|
10394
|
-
if not _condition_3 then
|
|
10395
|
-
local _requestId_1 = requestId
|
|
10396
|
-
_condition_3 = pendingResponses[_requestId_1] ~= entry
|
|
10397
|
-
if not _condition_3 then
|
|
10398
|
-
_condition_3 = entry.retryToken ~= retryToken
|
|
10399
|
-
end
|
|
10400
|
-
end
|
|
10401
|
-
if _condition_3 then
|
|
10402
|
-
return nil
|
|
10403
|
-
end
|
|
10404
|
-
postPendingResponse(requestId, entry)
|
|
10405
|
-
end)
|
|
10467
|
+
scheduleReconnect(expectedGeneration, `WebSocket response {requestId} acknowledgement timed out; retained for reconnect`)
|
|
10406
10468
|
end)
|
|
10407
10469
|
end
|
|
10408
10470
|
local function resumePendingResponses()
|
|
10471
|
+
for requestId, entry in inFlightRequests do
|
|
10472
|
+
sendProgress(requestId, entry.progress)
|
|
10473
|
+
end
|
|
10409
10474
|
for requestId, entry in pendingResponses do
|
|
10410
|
-
|
|
10475
|
+
sendPendingResponse(requestId, entry)
|
|
10411
10476
|
end
|
|
10412
10477
|
end
|
|
10413
|
-
local function
|
|
10478
|
+
local function encodeError(requestId, detail, executionOutcome)
|
|
10414
10479
|
local encodeOk, encoded = pcall(function()
|
|
10415
10480
|
return HttpService:JSONEncode({
|
|
10481
|
+
kind = "response",
|
|
10416
10482
|
requestId = requestId,
|
|
10417
|
-
|
|
10483
|
+
executionOutcome = executionOutcome,
|
|
10484
|
+
error = `Request {requestId}: {detail}`,
|
|
10418
10485
|
})
|
|
10419
10486
|
end)
|
|
10420
|
-
if encodeOk then
|
|
10487
|
+
if encodeOk and #encoded <= RESERVED_ERROR_BYTES then
|
|
10421
10488
|
return encoded
|
|
10422
10489
|
end
|
|
10423
|
-
warn(`[robloxstudio-mcp] Failed to serialize response {requestId}: {tostring(encoded)}`)
|
|
10424
10490
|
return HttpService:JSONEncode({
|
|
10491
|
+
kind = "response",
|
|
10425
10492
|
requestId = requestId,
|
|
10426
|
-
|
|
10493
|
+
executionOutcome = executionOutcome,
|
|
10494
|
+
error = `Request {requestId}: Plugin error could not be encoded within its reserved response capacity (stage=error_encode); handler outcome={executionOutcome}`,
|
|
10427
10495
|
})
|
|
10428
10496
|
end
|
|
10497
|
+
local function encodeResponse(requestId, response, executionOutcome)
|
|
10498
|
+
local encodeOk, encoded = pcall(function()
|
|
10499
|
+
return HttpService:JSONEncode({
|
|
10500
|
+
kind = "response",
|
|
10501
|
+
requestId = requestId,
|
|
10502
|
+
response = response,
|
|
10503
|
+
executionOutcome = executionOutcome,
|
|
10504
|
+
})
|
|
10505
|
+
end)
|
|
10506
|
+
if not encodeOk then
|
|
10507
|
+
return encodeError(requestId, `Plugin response serialization failed (stage=response_encode): {string.sub(tostring(encoded), 1, 512)}`, executionOutcome)
|
|
10508
|
+
end
|
|
10509
|
+
if #encoded > MAX_FRAME_BYTES then
|
|
10510
|
+
return encodeError(requestId, `Plugin response exceeds WebSocket frame limit (stage=response_encode, bytes={#encoded}, maxBytes={MAX_FRAME_BYTES}); execution completed but result cannot be delivered`, executionOutcome)
|
|
10511
|
+
end
|
|
10512
|
+
return encoded
|
|
10513
|
+
end
|
|
10514
|
+
local function retainResponse(requestId, body, serverUrl, progress, admissionRejection)
|
|
10515
|
+
if admissionRejection == nil then
|
|
10516
|
+
admissionRejection = false
|
|
10517
|
+
end
|
|
10518
|
+
-- Admission errors have their own bounded 128 x 4KiB reserve so a full-size
|
|
10519
|
+
-- result never consumes the capacity required to retain a rejection.
|
|
10520
|
+
-- ▼ ReadonlyMap.size ▼
|
|
10521
|
+
local _size = 0
|
|
10522
|
+
for _ in inFlightRequests do
|
|
10523
|
+
_size += 1
|
|
10524
|
+
end
|
|
10525
|
+
-- ▲ ReadonlyMap.size ▲
|
|
10526
|
+
-- ▼ ReadonlyMap.size ▼
|
|
10527
|
+
local _size_1 = 0
|
|
10528
|
+
for _ in pendingResponses do
|
|
10529
|
+
_size_1 += 1
|
|
10530
|
+
end
|
|
10531
|
+
-- ▲ ReadonlyMap.size ▲
|
|
10532
|
+
local reservedBytes = (_size + _size_1 - pendingRejectionCount) * RESERVED_ERROR_BYTES
|
|
10533
|
+
if not admissionRejection and pendingResponseBytes + #body + reservedBytes > MAX_PENDING_RESPONSE_BYTES then
|
|
10534
|
+
body = encodeError(requestId, `Plugin response exceeds retained-result capacity (stage=response_retention, bytes={#body}, retainedBytes={pendingResponseBytes}, maxBytes={MAX_PENDING_RESPONSE_BYTES}); execution completed but result cannot be retained`, progress.outcome or "unknown")
|
|
10535
|
+
end
|
|
10536
|
+
local entry = {
|
|
10537
|
+
body = body,
|
|
10538
|
+
serverUrl = serverUrl,
|
|
10539
|
+
progress = progress,
|
|
10540
|
+
expiresAt = os.clock() + RESPONSE_RETENTION_SECONDS,
|
|
10541
|
+
admissionRejection = admissionRejection,
|
|
10542
|
+
sendAttempt = 0,
|
|
10543
|
+
}
|
|
10544
|
+
local _requestId = requestId
|
|
10545
|
+
pendingResponses[_requestId] = entry
|
|
10546
|
+
if admissionRejection then
|
|
10547
|
+
pendingRejectionCount += 1
|
|
10548
|
+
else
|
|
10549
|
+
pendingResponseBytes += #body
|
|
10550
|
+
end
|
|
10551
|
+
entry.expiryTimer = task.delay(RESPONSE_RETENTION_SECONDS, function()
|
|
10552
|
+
entry.expiryTimer = nil
|
|
10553
|
+
local _requestId_1 = requestId
|
|
10554
|
+
if pendingResponses[_requestId_1] == entry then
|
|
10555
|
+
settleResponse(requestId, entry, "expired")
|
|
10556
|
+
end
|
|
10557
|
+
end)
|
|
10558
|
+
sendPendingResponse(requestId, entry)
|
|
10559
|
+
end
|
|
10560
|
+
local function rejectAdmission(requestId, detail)
|
|
10561
|
+
local currentOptions = options
|
|
10562
|
+
if currentOptions == nil then
|
|
10563
|
+
return nil
|
|
10564
|
+
end
|
|
10565
|
+
local _condition = pendingRejectionCount >= MAX_ADMISSION_REJECTIONS
|
|
10566
|
+
if not _condition then
|
|
10567
|
+
local _exp = terminalCount
|
|
10568
|
+
-- ▼ ReadonlyMap.size ▼
|
|
10569
|
+
local _size = 0
|
|
10570
|
+
for _ in inFlightRequests do
|
|
10571
|
+
_size += 1
|
|
10572
|
+
end
|
|
10573
|
+
-- ▲ ReadonlyMap.size ▲
|
|
10574
|
+
local _exp_1 = _exp + _size
|
|
10575
|
+
-- ▼ ReadonlyMap.size ▼
|
|
10576
|
+
local _size_1 = 0
|
|
10577
|
+
for _ in pendingResponses do
|
|
10578
|
+
_size_1 += 1
|
|
10579
|
+
end
|
|
10580
|
+
-- ▲ ReadonlyMap.size ▲
|
|
10581
|
+
_condition = _exp_1 + _size_1 >= MAX_TERMINAL_RESPONSES
|
|
10582
|
+
end
|
|
10583
|
+
if _condition then
|
|
10584
|
+
-- No execution and no unretained "result". Reconnect drains existing
|
|
10585
|
+
-- outcomes so their acknowledgements can release rejection reservations.
|
|
10586
|
+
scheduleReconnect(generation, "Plugin admission rejection reserve exhausted; execution not started")
|
|
10587
|
+
return nil
|
|
10588
|
+
end
|
|
10589
|
+
local progress = {
|
|
10590
|
+
serverUrl = currentOptions.serverUrl,
|
|
10591
|
+
}
|
|
10592
|
+
completeProgress(requestId, progress, "not_executed")
|
|
10593
|
+
retainResponse(requestId, encodeError(requestId, detail, "not_executed"), currentOptions.serverUrl, progress, true)
|
|
10594
|
+
end
|
|
10429
10595
|
local function cancelRequest(event)
|
|
10430
10596
|
local _requestId = event.requestId
|
|
10431
10597
|
local inFlight = inFlightRequests[_requestId]
|
|
10432
10598
|
if inFlight ~= nil then
|
|
10433
10599
|
inFlight.cancelled = true
|
|
10434
10600
|
end
|
|
10435
|
-
|
|
10436
|
-
|
|
10437
|
-
if pending ~= nil then
|
|
10438
|
-
pending.retryToken += 1
|
|
10439
|
-
local _requestId_2 = event.requestId
|
|
10440
|
-
pendingResponses[_requestId_2] = nil
|
|
10441
|
-
end
|
|
10442
|
-
rememberTerminalResponse(event.requestId)
|
|
10601
|
+
-- Cancellation only changes execution context. Completed results still need
|
|
10602
|
+
-- recording, including results produced by already-running side effects.
|
|
10443
10603
|
end
|
|
10444
|
-
local function dispatchRequest(request)
|
|
10604
|
+
local function dispatchRequest(request, requestBytes)
|
|
10605
|
+
pruneTerminalResponses()
|
|
10445
10606
|
local _requestId = request.requestId
|
|
10446
10607
|
local _condition = terminalResponseIds[_requestId] ~= nil
|
|
10447
10608
|
if not _condition then
|
|
@@ -10459,8 +10620,28 @@ local function dispatchRequest(request)
|
|
|
10459
10620
|
if not active or dispatchOptions == nil then
|
|
10460
10621
|
return nil
|
|
10461
10622
|
end
|
|
10623
|
+
-- ▼ ReadonlyMap.size ▼
|
|
10624
|
+
local _size = 0
|
|
10625
|
+
for _ in inFlightRequests do
|
|
10626
|
+
_size += 1
|
|
10627
|
+
end
|
|
10628
|
+
-- ▲ ReadonlyMap.size ▲
|
|
10629
|
+
-- ▼ ReadonlyMap.size ▼
|
|
10630
|
+
local _size_1 = 0
|
|
10631
|
+
for _ in pendingResponses do
|
|
10632
|
+
_size_1 += 1
|
|
10633
|
+
end
|
|
10634
|
+
-- ▲ ReadonlyMap.size ▲
|
|
10635
|
+
local activeCount = _size + _size_1 - pendingRejectionCount
|
|
10636
|
+
if activeCount >= MAX_ACTIVE_RESPONSES or terminalCount + activeCount + MAX_ADMISSION_REJECTIONS >= MAX_TERMINAL_RESPONSES or pendingResponseBytes + (activeCount + 1) * RESERVED_ERROR_BYTES > MAX_PENDING_RESPONSE_BYTES or inFlightRequestBytes + requestBytes > MAX_FRAME_BYTES then
|
|
10637
|
+
rejectAdmission(request.requestId, `Plugin request admission capacity exceeded (stage=request_admission, bytes={requestBytes}, inFlightBytes={inFlightRequestBytes}, activeResponses={activeCount}, maxActiveResponses={MAX_ACTIVE_RESPONSES}, terminalIds={terminalCount}, maxTerminalIds={MAX_TERMINAL_RESPONSES}, retainedBytes={pendingResponseBytes}, maxBytes={MAX_PENDING_RESPONSE_BYTES}); execution not started`)
|
|
10638
|
+
return nil
|
|
10639
|
+
end
|
|
10462
10640
|
local inFlight = {
|
|
10463
10641
|
cancelled = false,
|
|
10642
|
+
progress = {
|
|
10643
|
+
serverUrl = dispatchOptions.serverUrl,
|
|
10644
|
+
},
|
|
10464
10645
|
}
|
|
10465
10646
|
local context = {
|
|
10466
10647
|
requestId = request.requestId,
|
|
@@ -10471,50 +10652,31 @@ local function dispatchRequest(request)
|
|
|
10471
10652
|
}
|
|
10472
10653
|
local _requestId_1 = request.requestId
|
|
10473
10654
|
inFlightRequests[_requestId_1] = inFlight
|
|
10655
|
+
inFlightRequestBytes += requestBytes
|
|
10474
10656
|
task.spawn(function()
|
|
10475
|
-
|
|
10476
|
-
|
|
10477
|
-
local _requestId_2 = request.requestId
|
|
10478
|
-
_condition_1 = terminalResponseIds[_requestId_2] ~= nil
|
|
10479
|
-
end
|
|
10480
|
-
if _condition_1 then
|
|
10657
|
+
if inFlight.cancelled or not active or os.clock() >= context.deadlineAt then
|
|
10658
|
+
completeProgress(request.requestId, inFlight.progress, "not_executed")
|
|
10481
10659
|
local _requestId_2 = request.requestId
|
|
10482
|
-
|
|
10483
|
-
|
|
10484
|
-
|
|
10485
|
-
end
|
|
10660
|
+
inFlightRequests[_requestId_2] = nil
|
|
10661
|
+
inFlightRequestBytes -= requestBytes
|
|
10662
|
+
retainResponse(request.requestId, encodeError(request.requestId, "Request cancelled or admission deadline expired before execution started", "not_executed"), dispatchOptions.serverUrl, inFlight.progress)
|
|
10486
10663
|
return nil
|
|
10487
10664
|
end
|
|
10665
|
+
-- This observes handler entry, including broker dispatch, not a user Luau
|
|
10666
|
+
-- instruction. The waiter's deadline/cancellation is not a rollback.
|
|
10667
|
+
inFlight.progress.phase = "executing"
|
|
10668
|
+
sendProgress(request.requestId, inFlight.progress)
|
|
10488
10669
|
local dispatchOk, response = pcall(function()
|
|
10489
10670
|
return dispatchOptions.dispatchRequest(request, context)
|
|
10490
10671
|
end)
|
|
10491
|
-
local
|
|
10492
|
-
|
|
10493
|
-
|
|
10494
|
-
|
|
10495
|
-
end
|
|
10496
|
-
if _condition_2 then
|
|
10497
|
-
local _requestId_2 = request.requestId
|
|
10498
|
-
if inFlightRequests[_requestId_2] == inFlight then
|
|
10499
|
-
local _requestId_3 = request.requestId
|
|
10500
|
-
inFlightRequests[_requestId_3] = nil
|
|
10501
|
-
end
|
|
10502
|
-
return nil
|
|
10503
|
-
end
|
|
10504
|
-
local responseData = if dispatchOk then response else {
|
|
10505
|
-
error = tostring(response),
|
|
10506
|
-
}
|
|
10507
|
-
local entry = {
|
|
10508
|
-
body = encodeResponse(request.requestId, responseData),
|
|
10509
|
-
retryAttempt = 0,
|
|
10510
|
-
posting = false,
|
|
10511
|
-
retryToken = 0,
|
|
10512
|
-
}
|
|
10672
|
+
local executionOutcome = if dispatchOk then handlerOutcome(response) else "error"
|
|
10673
|
+
-- Report handler return before JSON encoding or result retention can fail.
|
|
10674
|
+
completeProgress(request.requestId, inFlight.progress, executionOutcome)
|
|
10675
|
+
local body = if dispatchOk then encodeResponse(request.requestId, response, executionOutcome) else encodeError(request.requestId, `Plugin request execution failed: {string.sub(tostring(response), 1, 512)}`, executionOutcome)
|
|
10513
10676
|
local _requestId_2 = request.requestId
|
|
10514
|
-
|
|
10515
|
-
|
|
10516
|
-
|
|
10517
|
-
postPendingResponse(request.requestId, entry)
|
|
10677
|
+
inFlightRequests[_requestId_2] = nil
|
|
10678
|
+
inFlightRequestBytes -= requestBytes
|
|
10679
|
+
retainResponse(request.requestId, body, dispatchOptions.serverUrl, inFlight.progress)
|
|
10518
10680
|
end)
|
|
10519
10681
|
end
|
|
10520
10682
|
local function invokeCallback(name, callback)
|
|
@@ -10526,24 +10688,13 @@ end
|
|
|
10526
10688
|
local function reportTransport(update)
|
|
10527
10689
|
local currentOptions = options
|
|
10528
10690
|
if active and currentOptions ~= nil then
|
|
10529
|
-
invokeCallback("
|
|
10691
|
+
invokeCallback("WebSocket transport", function()
|
|
10530
10692
|
return currentOptions.onTransportUpdate(update)
|
|
10531
10693
|
end)
|
|
10532
10694
|
end
|
|
10533
10695
|
end
|
|
10534
|
-
local function reconnectDelay(attempt)
|
|
10535
|
-
return math.min(INITIAL_RECONNECT_DELAY_SECONDS * math.pow(2, math.max(attempt - 1, 0)), MAX_RECONNECT_DELAY_SECONDS)
|
|
10536
|
-
end
|
|
10537
10696
|
local connect
|
|
10538
|
-
|
|
10539
|
-
task.delay(delaySeconds, function()
|
|
10540
|
-
if not active or generation ~= expectedGeneration then
|
|
10541
|
-
return nil
|
|
10542
|
-
end
|
|
10543
|
-
connect(expectedGeneration)
|
|
10544
|
-
end)
|
|
10545
|
-
end
|
|
10546
|
-
local function scheduleReconnect(expectedGeneration, detail, duplicate)
|
|
10697
|
+
function scheduleReconnect(expectedGeneration, detail, duplicate)
|
|
10547
10698
|
if duplicate == nil then
|
|
10548
10699
|
duplicate = false
|
|
10549
10700
|
end
|
|
@@ -10551,27 +10702,31 @@ local function scheduleReconnect(expectedGeneration, detail, duplicate)
|
|
|
10551
10702
|
return nil
|
|
10552
10703
|
end
|
|
10553
10704
|
generation += 1
|
|
10554
|
-
|
|
10705
|
+
closeCurrentSocket()
|
|
10555
10706
|
reconnectAttempt += 1
|
|
10556
|
-
local delay = if duplicate then 1 else
|
|
10707
|
+
local delay = if duplicate then 1 else retryDelay(reconnectAttempt)
|
|
10557
10708
|
reportTransport({
|
|
10558
10709
|
state = if duplicate then "waiting-duplicate" else "retrying",
|
|
10559
10710
|
attempt = reconnectAttempt,
|
|
10560
10711
|
retryDelay = delay,
|
|
10561
10712
|
detail = detail,
|
|
10562
10713
|
})
|
|
10563
|
-
|
|
10714
|
+
local nextGeneration = generation
|
|
10715
|
+
task.delay(delay, function()
|
|
10716
|
+
if active and generation == nextGeneration then
|
|
10717
|
+
connect(nextGeneration)
|
|
10718
|
+
end
|
|
10719
|
+
end)
|
|
10564
10720
|
end
|
|
10565
10721
|
local function watchForSilence(expectedGeneration, expectedClient)
|
|
10566
10722
|
local elapsed = tick() - lastValidEventAt
|
|
10567
|
-
|
|
10568
|
-
|
|
10569
|
-
if not active or generation ~= expectedGeneration or streamClient ~= expectedClient then
|
|
10723
|
+
task.delay(math.max(SOCKET_SILENCE_TIMEOUT_SECONDS - elapsed, 0.1), function()
|
|
10724
|
+
if not active or generation ~= expectedGeneration or socketClient ~= expectedClient then
|
|
10570
10725
|
return nil
|
|
10571
10726
|
end
|
|
10572
10727
|
local silentFor = tick() - lastValidEventAt
|
|
10573
|
-
if silentFor >=
|
|
10574
|
-
scheduleReconnect(expectedGeneration, `
|
|
10728
|
+
if silentFor >= SOCKET_SILENCE_TIMEOUT_SECONDS then
|
|
10729
|
+
scheduleReconnect(expectedGeneration, `WebSocket silent for {math.floor(silentFor)} seconds`)
|
|
10575
10730
|
return nil
|
|
10576
10731
|
end
|
|
10577
10732
|
watchForSilence(expectedGeneration, expectedClient)
|
|
@@ -10600,14 +10755,21 @@ local function parseReadyResponse(body)
|
|
|
10600
10755
|
local _instanceId = value.instanceId
|
|
10601
10756
|
_condition = not (type(_instanceId) == "string")
|
|
10602
10757
|
if not _condition then
|
|
10603
|
-
_condition = value.instanceId == ""
|
|
10758
|
+
_condition = value.instanceId == "" or value.protocolVersion ~= PROTOCOL_VERSION
|
|
10604
10759
|
if not _condition then
|
|
10605
|
-
local
|
|
10606
|
-
|
|
10607
|
-
|
|
10608
|
-
|
|
10760
|
+
local _transportToken = value.transportToken
|
|
10761
|
+
_condition = not (type(_transportToken) == "string")
|
|
10762
|
+
if not _condition then
|
|
10763
|
+
_condition = value.transportToken == ""
|
|
10764
|
+
if not _condition then
|
|
10765
|
+
local _condition_1 = value.multiplayerGroupId ~= nil
|
|
10766
|
+
if _condition_1 then
|
|
10767
|
+
local _multiplayerGroupId = value.multiplayerGroupId
|
|
10768
|
+
_condition_1 = not (type(_multiplayerGroupId) == "string")
|
|
10769
|
+
end
|
|
10770
|
+
_condition = _condition_1
|
|
10771
|
+
end
|
|
10609
10772
|
end
|
|
10610
|
-
_condition = _condition_1
|
|
10611
10773
|
end
|
|
10612
10774
|
end
|
|
10613
10775
|
end
|
|
@@ -10624,9 +10786,64 @@ local function parseReadyResponse(body)
|
|
|
10624
10786
|
peerId = value.peerId,
|
|
10625
10787
|
instanceId = value.instanceId,
|
|
10626
10788
|
multiplayerGroupId = value.multiplayerGroupId,
|
|
10789
|
+
protocolVersion = PROTOCOL_VERSION,
|
|
10790
|
+
transportToken = value.transportToken,
|
|
10627
10791
|
}
|
|
10628
10792
|
end
|
|
10629
|
-
local
|
|
10793
|
+
local function registerReady(currentOptions, expectedGeneration, reconnectOnFailure)
|
|
10794
|
+
local instanceId = PluginSession.getInstanceId()
|
|
10795
|
+
local multiplayerGroupId = PluginSession.getMultiplayerGroupId()
|
|
10796
|
+
local transportRole = PluginSession.getRole()
|
|
10797
|
+
local readyUrl = `{currentOptions.serverUrl}/ready`
|
|
10798
|
+
local readyPayload = PluginSession.createReadyPayload(PluginSession.peerId, transportRole, instanceId, multiplayerGroupId)
|
|
10799
|
+
if not active or generation ~= expectedGeneration or options ~= currentOptions then
|
|
10800
|
+
return nil
|
|
10801
|
+
end
|
|
10802
|
+
local readyOk, readyResult = pcall(function()
|
|
10803
|
+
return HttpService:RequestAsync({
|
|
10804
|
+
Url = readyUrl,
|
|
10805
|
+
Method = "POST",
|
|
10806
|
+
Headers = {
|
|
10807
|
+
["Content-Type"] = "application/json",
|
|
10808
|
+
},
|
|
10809
|
+
Body = HttpService:JSONEncode(readyPayload),
|
|
10810
|
+
})
|
|
10811
|
+
end)
|
|
10812
|
+
if not active or generation ~= expectedGeneration or options ~= currentOptions then
|
|
10813
|
+
return nil
|
|
10814
|
+
end
|
|
10815
|
+
local readyLogKey = `{currentOptions.serverUrl}|{PluginSession.peerId}`
|
|
10816
|
+
if not readyOk or not readyResult.Success then
|
|
10817
|
+
local detail = if readyOk then HttpDiagnostics.formatRequestFailure(readyUrl, true, readyResult) else HttpDiagnostics.formatRequestFailure(readyUrl, false, readyResult)
|
|
10818
|
+
if not (readyFailureLogKeys[readyLogKey] ~= nil) then
|
|
10819
|
+
readyFailureLogKeys[readyLogKey] = true
|
|
10820
|
+
warn(`[robloxstudio-mcp] /ready failed for {instanceId}/{transportRole}: {detail}`)
|
|
10821
|
+
end
|
|
10822
|
+
if reconnectOnFailure then
|
|
10823
|
+
scheduleReconnect(expectedGeneration, detail, readyOk and readyResult.StatusCode == 409)
|
|
10824
|
+
end
|
|
10825
|
+
return nil
|
|
10826
|
+
end
|
|
10827
|
+
local readyData = parseReadyResponse(readyResult.Body)
|
|
10828
|
+
if readyData == nil or readyData.peerId ~= PluginSession.peerId or readyData.instanceId ~= instanceId or readyData.multiplayerGroupId ~= multiplayerGroupId then
|
|
10829
|
+
if reconnectOnFailure then
|
|
10830
|
+
scheduleReconnect(expectedGeneration, "Invalid /ready response: expected Peer topology and WebSocket protocol credentials")
|
|
10831
|
+
end
|
|
10832
|
+
return nil
|
|
10833
|
+
end
|
|
10834
|
+
if readyFailureLogKeys[readyLogKey] ~= nil then
|
|
10835
|
+
readyFailureLogKeys[readyLogKey] = nil
|
|
10836
|
+
print(`[robloxstudio-mcp] /ready connected for {instanceId}/{readyData.assignedRole} via {currentOptions.serverUrl}`)
|
|
10837
|
+
end
|
|
10838
|
+
cachedReady = readyData
|
|
10839
|
+
invokeCallback("WebSocket ready", function()
|
|
10840
|
+
return currentOptions.onReady(readyData)
|
|
10841
|
+
end)
|
|
10842
|
+
return readyData
|
|
10843
|
+
end
|
|
10844
|
+
local function credentialsRejected(statusCode)
|
|
10845
|
+
return statusCode == 401 or statusCode == 403 or statusCode == 404
|
|
10846
|
+
end
|
|
10630
10847
|
function connect(expectedGeneration)
|
|
10631
10848
|
local currentOptions = options
|
|
10632
10849
|
if not active or generation ~= expectedGeneration or currentOptions == nil then
|
|
@@ -10638,69 +10855,21 @@ function connect(expectedGeneration)
|
|
|
10638
10855
|
retryDelay = 0,
|
|
10639
10856
|
})
|
|
10640
10857
|
task.spawn(function()
|
|
10641
|
-
local
|
|
10642
|
-
|
|
10643
|
-
local readyUrl = `{currentOptions.serverUrl}/ready`
|
|
10644
|
-
local transportRole = PluginSession.getRole()
|
|
10645
|
-
local readyPayload = PluginSession.createReadyPayload(PluginSession.peerId, transportRole, instanceId, multiplayerGroupId)
|
|
10646
|
-
if not active or generation ~= expectedGeneration or options ~= currentOptions then
|
|
10647
|
-
return nil
|
|
10648
|
-
end
|
|
10649
|
-
local readyOk, readyResult = pcall(function()
|
|
10650
|
-
return HttpService:RequestAsync({
|
|
10651
|
-
Url = readyUrl,
|
|
10652
|
-
Method = "POST",
|
|
10653
|
-
Headers = {
|
|
10654
|
-
["Content-Type"] = "application/json",
|
|
10655
|
-
},
|
|
10656
|
-
Body = HttpService:JSONEncode(readyPayload),
|
|
10657
|
-
})
|
|
10658
|
-
end)
|
|
10659
|
-
if not active or generation ~= expectedGeneration or options ~= currentOptions then
|
|
10858
|
+
local readyData = cachedReady or registerReady(currentOptions, expectedGeneration, true)
|
|
10859
|
+
if readyData == nil or not active or generation ~= expectedGeneration or options ~= currentOptions then
|
|
10660
10860
|
return nil
|
|
10661
10861
|
end
|
|
10662
|
-
local
|
|
10663
|
-
if not readyOk then
|
|
10664
|
-
local detail = HttpDiagnostics.formatRequestFailure(readyUrl, false, readyResult)
|
|
10665
|
-
if not (readyFailureLogKeys[readyLogKey] ~= nil) then
|
|
10666
|
-
readyFailureLogKeys[readyLogKey] = true
|
|
10667
|
-
warn(`[robloxstudio-mcp] /ready failed for {instanceId}/{transportRole}: {detail}`)
|
|
10668
|
-
end
|
|
10669
|
-
scheduleReconnect(expectedGeneration, detail)
|
|
10670
|
-
return nil
|
|
10671
|
-
end
|
|
10672
|
-
if not readyResult.Success then
|
|
10673
|
-
local detail = HttpDiagnostics.formatRequestFailure(readyUrl, true, readyResult)
|
|
10674
|
-
if not (readyFailureLogKeys[readyLogKey] ~= nil) then
|
|
10675
|
-
readyFailureLogKeys[readyLogKey] = true
|
|
10676
|
-
warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{transportRole}: {detail}`)
|
|
10677
|
-
end
|
|
10678
|
-
scheduleReconnect(expectedGeneration, detail, readyResult.StatusCode == 409)
|
|
10679
|
-
return nil
|
|
10680
|
-
end
|
|
10681
|
-
local readyData = parseReadyResponse(readyResult.Body)
|
|
10682
|
-
if readyData == nil or readyData.peerId ~= PluginSession.peerId or readyData.instanceId ~= instanceId or readyData.multiplayerGroupId ~= multiplayerGroupId then
|
|
10683
|
-
scheduleReconnect(expectedGeneration, "Invalid /ready response: expected the registered Peer topology")
|
|
10684
|
-
return nil
|
|
10685
|
-
end
|
|
10686
|
-
if readyFailureLogKeys[readyLogKey] ~= nil then
|
|
10687
|
-
readyFailureLogKeys[readyLogKey] = nil
|
|
10688
|
-
print(`[robloxstudio-mcp] /ready connected for {instanceId}/{readyData.assignedRole} via {currentOptions.serverUrl}`)
|
|
10689
|
-
end
|
|
10690
|
-
invokeCallback("event stream ready", function()
|
|
10691
|
-
return currentOptions.onReady(readyData)
|
|
10692
|
-
end)
|
|
10862
|
+
local socketBaseUrl = string.gsub(currentOptions.serverUrl, "^http", "ws")
|
|
10693
10863
|
local createOk, createdClient = pcall(function()
|
|
10694
|
-
return HttpService:CreateWebStreamClient(Enum.WebStreamClientType.
|
|
10695
|
-
Url = `{
|
|
10696
|
-
Method = "GET",
|
|
10864
|
+
return HttpService:CreateWebStreamClient(Enum.WebStreamClientType.WebSocket, {
|
|
10865
|
+
Url = `{socketBaseUrl}/studio?peerId={HttpService:UrlEncode(PluginSession.peerId)}&protocolVersion={PROTOCOL_VERSION}`,
|
|
10697
10866
|
Headers = {
|
|
10698
|
-
|
|
10867
|
+
["X-Studio-Token"] = readyData.transportToken,
|
|
10699
10868
|
},
|
|
10700
10869
|
})
|
|
10701
10870
|
end)
|
|
10702
10871
|
if not createOk then
|
|
10703
|
-
scheduleReconnect(expectedGeneration, `Failed to create
|
|
10872
|
+
scheduleReconnect(expectedGeneration, `Failed to create WebSocket: {tostring(createdClient)}`)
|
|
10704
10873
|
return nil
|
|
10705
10874
|
end
|
|
10706
10875
|
if not active or generation ~= expectedGeneration or options ~= currentOptions then
|
|
@@ -10709,16 +10878,20 @@ function connect(expectedGeneration)
|
|
|
10709
10878
|
end)
|
|
10710
10879
|
return nil
|
|
10711
10880
|
end
|
|
10712
|
-
|
|
10713
|
-
|
|
10714
|
-
if not active or generation ~= expectedGeneration or
|
|
10881
|
+
socketClient = createdClient
|
|
10882
|
+
socketConnections = { createdClient.Opened:Connect(function(statusCode, _headers)
|
|
10883
|
+
if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
|
|
10715
10884
|
return nil
|
|
10716
10885
|
end
|
|
10717
|
-
|
|
10718
|
-
|
|
10719
|
-
|
|
10886
|
+
if statusCode ~= 101 and (statusCode < 200 or statusCode >= 300) then
|
|
10887
|
+
if credentialsRejected(statusCode) then
|
|
10888
|
+
cachedReady = nil
|
|
10889
|
+
end
|
|
10890
|
+
scheduleReconnect(expectedGeneration, `WebSocket opened with HTTP {statusCode}`)
|
|
10720
10891
|
return nil
|
|
10721
10892
|
end
|
|
10893
|
+
socketOpen = true
|
|
10894
|
+
lastValidEventAt = tick()
|
|
10722
10895
|
reconnectAttempt = 0
|
|
10723
10896
|
reportTransport({
|
|
10724
10897
|
state = "open",
|
|
@@ -10727,48 +10900,61 @@ function connect(expectedGeneration)
|
|
|
10727
10900
|
})
|
|
10728
10901
|
resumePendingResponses()
|
|
10729
10902
|
end), createdClient.MessageReceived:Connect(function(message)
|
|
10730
|
-
if not active or generation ~= expectedGeneration or
|
|
10903
|
+
if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
|
|
10731
10904
|
return nil
|
|
10732
10905
|
end
|
|
10733
|
-
|
|
10734
|
-
|
|
10735
|
-
|
|
10736
|
-
|
|
10737
|
-
|
|
10738
|
-
|
|
10739
|
-
|
|
10740
|
-
|
|
10741
|
-
|
|
10742
|
-
|
|
10743
|
-
|
|
10744
|
-
|
|
10745
|
-
|
|
10746
|
-
|
|
10747
|
-
|
|
10748
|
-
|
|
10749
|
-
|
|
10750
|
-
|
|
10751
|
-
|
|
10752
|
-
|
|
10753
|
-
|
|
10754
|
-
|
|
10906
|
+
local _message = message
|
|
10907
|
+
if not (type(_message) == "string") then
|
|
10908
|
+
scheduleReconnect(expectedGeneration, "Unsupported binary WebSocket frame (stage=request_receive); text JSON required")
|
|
10909
|
+
return nil
|
|
10910
|
+
end
|
|
10911
|
+
if #message > MAX_FRAME_BYTES then
|
|
10912
|
+
local detail = `WebSocket request frame exceeds limit (stage=request_receive, bytes={#message}, maxBytes={MAX_FRAME_BYTES}); execution not started`
|
|
10913
|
+
warn(`[robloxstudio-mcp] {detail}`)
|
|
10914
|
+
scheduleReconnect(expectedGeneration, detail)
|
|
10915
|
+
return nil
|
|
10916
|
+
end
|
|
10917
|
+
local event = decodeMessage(message)
|
|
10918
|
+
if event == nil then
|
|
10919
|
+
return nil
|
|
10920
|
+
end
|
|
10921
|
+
lastValidEventAt = tick()
|
|
10922
|
+
if event.kind == "ack" then
|
|
10923
|
+
local _requestId = event.requestId
|
|
10924
|
+
local pending = pendingResponses[_requestId]
|
|
10925
|
+
if pending ~= nil and pending.serverUrl == currentOptions.serverUrl then
|
|
10926
|
+
settleResponse(event.requestId, pending, event.disposition)
|
|
10927
|
+
end
|
|
10928
|
+
elseif event.kind == "heartbeat" then
|
|
10929
|
+
invokeCallback("WebSocket heartbeat", function()
|
|
10930
|
+
return currentOptions.onHeartbeat(event.timestamp)
|
|
10931
|
+
end)
|
|
10932
|
+
elseif event.kind == "cancel" then
|
|
10933
|
+
cancelRequest(event)
|
|
10934
|
+
elseif event.kind == "request" then
|
|
10935
|
+
dispatchRequest(event, #message)
|
|
10936
|
+
else
|
|
10937
|
+
invokeCallback("WebSocket status", function()
|
|
10755
10938
|
return currentOptions.onStatus(event)
|
|
10756
10939
|
end)
|
|
10757
10940
|
if not event.knownPeer then
|
|
10758
|
-
|
|
10941
|
+
cachedReady = nil
|
|
10942
|
+
scheduleReconnect(expectedGeneration, "WebSocket session is no longer registered")
|
|
10759
10943
|
end
|
|
10760
10944
|
end
|
|
10761
10945
|
end), createdClient.Error:Connect(function(statusCode, message)
|
|
10762
|
-
if not active or generation ~= expectedGeneration or
|
|
10946
|
+
if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
|
|
10763
10947
|
return nil
|
|
10764
10948
|
end
|
|
10765
|
-
|
|
10766
|
-
|
|
10949
|
+
if credentialsRejected(statusCode) then
|
|
10950
|
+
cachedReady = nil
|
|
10951
|
+
end
|
|
10952
|
+
scheduleReconnect(expectedGeneration, `WebSocket error {statusCode}: {message}`)
|
|
10767
10953
|
end), createdClient.Closed:Connect(function()
|
|
10768
|
-
if not active or generation ~= expectedGeneration or
|
|
10954
|
+
if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
|
|
10769
10955
|
return nil
|
|
10770
10956
|
end
|
|
10771
|
-
scheduleReconnect(expectedGeneration, "
|
|
10957
|
+
scheduleReconnect(expectedGeneration, "WebSocket closed")
|
|
10772
10958
|
end) }
|
|
10773
10959
|
lastValidEventAt = tick()
|
|
10774
10960
|
watchForSilence(expectedGeneration, createdClient)
|
|
@@ -10786,19 +10972,22 @@ local function start(newOptions)
|
|
|
10786
10972
|
generation += 1
|
|
10787
10973
|
connect(generation)
|
|
10788
10974
|
end
|
|
10789
|
-
function refresh()
|
|
10790
|
-
|
|
10975
|
+
local function refresh()
|
|
10976
|
+
local currentOptions = options
|
|
10977
|
+
if not active or currentOptions == nil or readyRefreshPending then
|
|
10791
10978
|
return nil
|
|
10792
10979
|
end
|
|
10793
|
-
|
|
10794
|
-
|
|
10795
|
-
|
|
10796
|
-
|
|
10980
|
+
-- Metadata changes must not close a healthy socket or make its reconnect
|
|
10981
|
+
-- depend on RequestAsync quota. Proxy registration remains independent.
|
|
10982
|
+
readyRefreshPending = true
|
|
10983
|
+
local expectedGeneration = generation
|
|
10984
|
+
task.spawn(function()
|
|
10985
|
+
registerReady(currentOptions, expectedGeneration, false)
|
|
10986
|
+
readyRefreshPending = false
|
|
10987
|
+
end)
|
|
10797
10988
|
end
|
|
10798
|
-
-- EndTest
|
|
10799
|
-
--
|
|
10800
|
-
-- yielding unregister request, but retain local request state and connection
|
|
10801
|
-
-- options so a failed EndTest can register again and reconnect.
|
|
10989
|
+
-- EndTest may tear down this VM without an Unloading callback. Release the
|
|
10990
|
+
-- native socket before yielding to unregister; a failed EndTest can resume.
|
|
10802
10991
|
local function suspendForShutdown()
|
|
10803
10992
|
local currentOptions = options
|
|
10804
10993
|
if not active or currentOptions == nil then
|
|
@@ -10808,7 +10997,8 @@ local function suspendForShutdown()
|
|
|
10808
10997
|
shutdownSuspended = true
|
|
10809
10998
|
generation += 1
|
|
10810
10999
|
reconnectAttempt = 0
|
|
10811
|
-
|
|
11000
|
+
closeCurrentSocket()
|
|
11001
|
+
cachedReady = nil
|
|
10812
11002
|
disconnectSession(currentOptions)
|
|
10813
11003
|
end
|
|
10814
11004
|
local function resumeAfterShutdownFailure()
|
|
@@ -10832,9 +11022,8 @@ function stop()
|
|
|
10832
11022
|
for _, inFlight in inFlightRequests do
|
|
10833
11023
|
inFlight.cancelled = true
|
|
10834
11024
|
end
|
|
10835
|
-
|
|
10836
|
-
|
|
10837
|
-
closeCurrentStream()
|
|
11025
|
+
closeCurrentSocket()
|
|
11026
|
+
cachedReady = nil
|
|
10838
11027
|
table.clear(readyFailureLogKeys)
|
|
10839
11028
|
options = nil
|
|
10840
11029
|
reconnectAttempt = 0
|
|
@@ -10890,16 +11079,9 @@ end
|
|
|
10890
11079
|
local function createInstanceId()
|
|
10891
11080
|
return formatId("instance", randomValue())
|
|
10892
11081
|
end
|
|
10893
|
-
local function currentProcessInstanceId()
|
|
10894
|
-
-- Roblox exposes process uptime through os.clock(). Quantizing the recovered
|
|
10895
|
-
-- launch wall-clock to 10 ms gives every VM in one Studio process the same ID.
|
|
10896
|
-
local launchTick = math.round((DateTime.now().UnixTimestampMillis - os.clock() * 1000) / 10)
|
|
10897
|
-
return formatId("instance", launchTick)
|
|
10898
|
-
end
|
|
10899
11082
|
return {
|
|
10900
11083
|
createPeerId = createPeerId,
|
|
10901
11084
|
createInstanceId = createInstanceId,
|
|
10902
|
-
currentProcessInstanceId = currentProcessInstanceId,
|
|
10903
11085
|
}
|
|
10904
11086
|
]]></string>
|
|
10905
11087
|
</Properties>
|