@chrrxs/robloxstudio-mcp 3.1.0 → 3.1.2
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 +1248 -337
- package/package.json +1 -1
- package/studio-plugin/MCPPlugin.rbxmx +640 -353
|
@@ -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.2" 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.2`
|
|
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 = {
|
|
@@ -8794,13 +8835,34 @@ local ServerStorage = _services.ServerStorage
|
|
|
8794
8835
|
local State = TS.import(script, script.Parent, "State")
|
|
8795
8836
|
local PeerRole = TS.import(script, script.Parent, "PeerRole")
|
|
8796
8837
|
local TopologyId = TS.import(script, script.Parent, "TopologyId")
|
|
8838
|
+
local CoreGui = game:GetService("CoreGui")
|
|
8797
8839
|
local MCP_PLACE_ID_ATTRIBUTE = "__MCPPlaceId"
|
|
8798
8840
|
local TOPOLOGY_MODE_ATTRIBUTE = "__MCPTopologyMode"
|
|
8799
8841
|
local TOPOLOGY_INSTANCE_ID_ATTRIBUTE = "__MCPTopologyInstanceId"
|
|
8800
8842
|
local TOPOLOGY_GROUP_ID_ATTRIBUTE = "__MCPTopologyGroupId"
|
|
8801
8843
|
local TOPOLOGY_TOKEN_ATTRIBUTE = "__MCPTopologyToken"
|
|
8844
|
+
local SESSION_IDENTITY_NAME = "__MCPSessionIdentity"
|
|
8802
8845
|
local peerId = TopologyId.createPeerId()
|
|
8803
|
-
local
|
|
8846
|
+
local isEditSession = PeerRole.detect() == "edit"
|
|
8847
|
+
local existingSessionIdentity = CoreGui:FindFirstChild(SESSION_IDENTITY_NAME)
|
|
8848
|
+
local sessionInstanceId
|
|
8849
|
+
local createdSessionIdentity = false
|
|
8850
|
+
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
|
|
8851
|
+
sessionInstanceId = existingSessionIdentity.Value
|
|
8852
|
+
else
|
|
8853
|
+
local _result = existingSessionIdentity
|
|
8854
|
+
if _result ~= nil then
|
|
8855
|
+
_result:Destroy()
|
|
8856
|
+
end
|
|
8857
|
+
sessionInstanceId = TopologyId.createInstanceId()
|
|
8858
|
+
local sessionIdentity = Instance.new("StringValue")
|
|
8859
|
+
sessionIdentity.Name = SESSION_IDENTITY_NAME
|
|
8860
|
+
sessionIdentity.Value = sessionInstanceId
|
|
8861
|
+
sessionIdentity.Archivable = false
|
|
8862
|
+
sessionIdentity.Parent = CoreGui
|
|
8863
|
+
createdSessionIdentity = true
|
|
8864
|
+
end
|
|
8865
|
+
local inheritedInstanceId
|
|
8804
8866
|
local cachedPlaceName
|
|
8805
8867
|
local cachedPlaceNamePlaceId
|
|
8806
8868
|
local function getMarkerMode()
|
|
@@ -8808,13 +8870,17 @@ local function getMarkerMode()
|
|
|
8808
8870
|
return if mode == "shared" or mode == "multiplayer" then mode else nil
|
|
8809
8871
|
end
|
|
8810
8872
|
local function getInstanceId()
|
|
8811
|
-
if
|
|
8873
|
+
if inheritedInstanceId ~= nil then
|
|
8874
|
+
return inheritedInstanceId
|
|
8875
|
+
end
|
|
8876
|
+
if not isEditSession and getMarkerMode() == "shared" then
|
|
8812
8877
|
local sharedInstanceId = ReplicatedStorage:GetAttribute(TOPOLOGY_INSTANCE_ID_ATTRIBUTE)
|
|
8813
8878
|
if type(sharedInstanceId) == "string" and sharedInstanceId ~= "" then
|
|
8879
|
+
inheritedInstanceId = sharedInstanceId
|
|
8814
8880
|
return sharedInstanceId
|
|
8815
8881
|
end
|
|
8816
8882
|
end
|
|
8817
|
-
return
|
|
8883
|
+
return sessionInstanceId
|
|
8818
8884
|
end
|
|
8819
8885
|
local function getMultiplayerGroupId()
|
|
8820
8886
|
if getMarkerMode() ~= "multiplayer" then
|
|
@@ -8847,7 +8913,7 @@ local function setTopologyMarker(mode, instanceId, groupId)
|
|
|
8847
8913
|
return token
|
|
8848
8914
|
end
|
|
8849
8915
|
local function prepareSharedTopology()
|
|
8850
|
-
return setTopologyMarker("shared",
|
|
8916
|
+
return setTopologyMarker("shared", sessionInstanceId, nil)
|
|
8851
8917
|
end
|
|
8852
8918
|
local function prepareMultiplayerTopology(groupId)
|
|
8853
8919
|
return setTopologyMarker("multiplayer", nil, groupId)
|
|
@@ -8856,6 +8922,10 @@ local function clearTopologyMarker(token)
|
|
|
8856
8922
|
if ReplicatedStorage:GetAttribute(TOPOLOGY_TOKEN_ATTRIBUTE) ~= token then
|
|
8857
8923
|
return nil
|
|
8858
8924
|
end
|
|
8925
|
+
if isEditSession then
|
|
8926
|
+
prepareSharedTopology()
|
|
8927
|
+
return nil
|
|
8928
|
+
end
|
|
8859
8929
|
ReplicatedStorage:SetAttribute(TOPOLOGY_MODE_ATTRIBUTE, nil)
|
|
8860
8930
|
ReplicatedStorage:SetAttribute(TOPOLOGY_INSTANCE_ID_ATTRIBUTE, nil)
|
|
8861
8931
|
ReplicatedStorage:SetAttribute(TOPOLOGY_GROUP_ID_ATTRIBUTE, nil)
|
|
@@ -8916,6 +8986,15 @@ local function createReadyPayload(readyPeerId, role, instanceId, multiplayerGrou
|
|
|
8916
8986
|
timestamp = tick(),
|
|
8917
8987
|
}
|
|
8918
8988
|
end
|
|
8989
|
+
-- CoreGui survives plugin reloads without saving or replicating this identity.
|
|
8990
|
+
-- Preserve active test markers on reload; fresh edits replace saved place markers.
|
|
8991
|
+
if isEditSession then
|
|
8992
|
+
if createdSessionIdentity or getMarkerMode() == nil then
|
|
8993
|
+
prepareSharedTopology()
|
|
8994
|
+
end
|
|
8995
|
+
else
|
|
8996
|
+
getInstanceId()
|
|
8997
|
+
end
|
|
8919
8998
|
return {
|
|
8920
8999
|
peerId = peerId,
|
|
8921
9000
|
getInstanceId = getInstanceId,
|
|
@@ -9736,7 +9815,7 @@ return {
|
|
|
9736
9815
|
<Properties>
|
|
9737
9816
|
<string name="Name">State</string>
|
|
9738
9817
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
9739
|
-
local CURRENT_VERSION = "3.1.
|
|
9818
|
+
local CURRENT_VERSION = "3.1.2"
|
|
9740
9819
|
local PLUGIN_VARIANT = "main"
|
|
9741
9820
|
local BASE_PORT = 58741
|
|
9742
9821
|
local function createConnection(port)
|
|
@@ -10001,40 +10080,56 @@ return {
|
|
|
10001
10080
|
</Item>
|
|
10002
10081
|
<Item class="ModuleScript" referent="36">
|
|
10003
10082
|
<Properties>
|
|
10004
|
-
<string name="Name">
|
|
10083
|
+
<string name="Name">StudioWebSocket</string>
|
|
10005
10084
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
10006
10085
|
local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
10007
10086
|
local HttpService = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services").HttpService
|
|
10008
10087
|
local HttpDiagnostics = TS.import(script, script.Parent, "HttpDiagnostics")
|
|
10009
10088
|
local PluginSession = TS.import(script, script.Parent, "PluginSession")
|
|
10010
|
-
local
|
|
10011
|
-
local
|
|
10012
|
-
local
|
|
10013
|
-
local
|
|
10014
|
-
local
|
|
10015
|
-
local
|
|
10089
|
+
local PROTOCOL_VERSION = 1
|
|
10090
|
+
local INITIAL_RETRY_DELAY_SECONDS = 0.5
|
|
10091
|
+
local MAX_RETRY_DELAY_SECONDS = 5
|
|
10092
|
+
local SOCKET_SILENCE_TIMEOUT_SECONDS = 20
|
|
10093
|
+
local RESPONSE_RETENTION_SECONDS = 5 * 60
|
|
10094
|
+
local RESPONSE_ACK_TIMEOUT_SECONDS = 120
|
|
10095
|
+
local MAX_TERMINAL_RESPONSES = 32768
|
|
10096
|
+
local MAX_ACTIVE_RESPONSES = 128
|
|
10097
|
+
local MAX_ADMISSION_REJECTIONS = 128
|
|
10098
|
+
local MAX_FRAME_BYTES = 64 * 1024 * 1024
|
|
10099
|
+
local MAX_PENDING_RESPONSE_BYTES = 64 * 1024 * 1024
|
|
10100
|
+
local RESERVED_ERROR_BYTES = 4096
|
|
10101
|
+
-- The bridge admits 128 UTF-16 code units; each can require three UTF-8 bytes.
|
|
10102
|
+
local MAX_REQUEST_ID_BYTES = 128 * 3
|
|
10016
10103
|
local options
|
|
10017
10104
|
local active = false
|
|
10018
10105
|
local shutdownSuspended = false
|
|
10019
10106
|
local generation = 0
|
|
10020
10107
|
local reconnectAttempt = 0
|
|
10021
|
-
local
|
|
10022
|
-
local
|
|
10108
|
+
local socketClient
|
|
10109
|
+
local socketOpen = false
|
|
10110
|
+
local socketConnections = {}
|
|
10023
10111
|
local lastValidEventAt = 0
|
|
10112
|
+
local cachedReady
|
|
10113
|
+
local readyRefreshPending = false
|
|
10114
|
+
local pendingResponseBytes = 0
|
|
10115
|
+
local inFlightRequestBytes = 0
|
|
10116
|
+
local pendingRejectionCount = 0
|
|
10024
10117
|
local inFlightRequests = {}
|
|
10025
10118
|
local pendingResponses = {}
|
|
10026
10119
|
local terminalResponseIds = {}
|
|
10027
10120
|
local terminalResponseOrder = {}
|
|
10121
|
+
local terminalHead = 0
|
|
10122
|
+
local terminalCount = 0
|
|
10028
10123
|
local readyFailureLogKeys = {}
|
|
10029
|
-
local function
|
|
10030
|
-
|
|
10031
|
-
|
|
10032
|
-
|
|
10033
|
-
|
|
10034
|
-
local normalized = (string.gsub((string.gsub(message, "\r\n", "\n")), "\r", "\n"))
|
|
10035
|
-
if string.sub(normalized, 1, 5) == "data:" then
|
|
10036
|
-
payload = (string.gsub((string.gsub(string.sub(normalized, 6), "^%s+", "")), "%s+$", ""))
|
|
10124
|
+
local function validRequestId(value)
|
|
10125
|
+
local _value = value
|
|
10126
|
+
local _condition = type(_value) == "string"
|
|
10127
|
+
if _condition then
|
|
10128
|
+
_condition = #value > 0 and #value <= MAX_REQUEST_ID_BYTES
|
|
10037
10129
|
end
|
|
10130
|
+
return _condition
|
|
10131
|
+
end
|
|
10132
|
+
local function decodeMessage(payload)
|
|
10038
10133
|
local decodeOk, decoded = pcall(function()
|
|
10039
10134
|
return HttpService:JSONDecode(payload)
|
|
10040
10135
|
end)
|
|
@@ -10078,13 +10173,21 @@ local function decodeMessage(message)
|
|
|
10078
10173
|
_object[_left_2] = if type(_pluginVariant) == "string" then envelope.pluginVariant else nil
|
|
10079
10174
|
return _object
|
|
10080
10175
|
end
|
|
10081
|
-
if envelope.
|
|
10082
|
-
|
|
10083
|
-
|
|
10084
|
-
|
|
10085
|
-
|
|
10176
|
+
if not validRequestId(envelope.requestId) then
|
|
10177
|
+
return nil
|
|
10178
|
+
end
|
|
10179
|
+
if envelope.kind == "ack" then
|
|
10180
|
+
if envelope.disposition ~= "accepted" and envelope.disposition ~= "already_settled" and envelope.disposition ~= "unknown" then
|
|
10181
|
+
return nil
|
|
10086
10182
|
end
|
|
10087
|
-
|
|
10183
|
+
return {
|
|
10184
|
+
kind = "ack",
|
|
10185
|
+
requestId = envelope.requestId,
|
|
10186
|
+
disposition = envelope.disposition,
|
|
10187
|
+
}
|
|
10188
|
+
end
|
|
10189
|
+
if envelope.kind == "cancel" then
|
|
10190
|
+
if envelope.reason ~= "timeout" and envelope.reason ~= "aborted" and envelope.reason ~= "connection_closed" then
|
|
10088
10191
|
return nil
|
|
10089
10192
|
end
|
|
10090
10193
|
return {
|
|
@@ -10094,23 +10197,19 @@ local function decodeMessage(message)
|
|
|
10094
10197
|
}
|
|
10095
10198
|
end
|
|
10096
10199
|
if envelope.kind == "request" then
|
|
10097
|
-
local
|
|
10098
|
-
local _condition = not (type(
|
|
10200
|
+
local _peerId = envelope.peerId
|
|
10201
|
+
local _condition = not (type(_peerId) == "string")
|
|
10099
10202
|
if not _condition then
|
|
10100
|
-
local
|
|
10101
|
-
_condition = not (type(
|
|
10203
|
+
local _target = envelope.target
|
|
10204
|
+
_condition = not (type(_target) == "string")
|
|
10102
10205
|
if not _condition then
|
|
10103
|
-
local
|
|
10104
|
-
_condition = not (type(
|
|
10206
|
+
local _endpoint = envelope.endpoint
|
|
10207
|
+
_condition = not (type(_endpoint) == "string")
|
|
10105
10208
|
if not _condition then
|
|
10106
|
-
local
|
|
10107
|
-
_condition = not (type(
|
|
10209
|
+
local _remainingMs = envelope.remainingMs
|
|
10210
|
+
_condition = not (type(_remainingMs) == "number")
|
|
10108
10211
|
if not _condition then
|
|
10109
|
-
|
|
10110
|
-
_condition = not (type(_remainingMs) == "number")
|
|
10111
|
-
if not _condition then
|
|
10112
|
-
_condition = envelope.remainingMs < 0
|
|
10113
|
-
end
|
|
10212
|
+
_condition = envelope.remainingMs < 0 or envelope.remainingMs ~= envelope.remainingMs or envelope.remainingMs == math.huge
|
|
10114
10213
|
end
|
|
10115
10214
|
end
|
|
10116
10215
|
end
|
|
@@ -10118,30 +10217,35 @@ local function decodeMessage(message)
|
|
|
10118
10217
|
if _condition then
|
|
10119
10218
|
return nil
|
|
10120
10219
|
end
|
|
10121
|
-
local
|
|
10122
|
-
local _data = envelope.data
|
|
10123
|
-
if type(_data) == "table" then
|
|
10124
|
-
data = envelope.data
|
|
10125
|
-
end
|
|
10126
|
-
return {
|
|
10220
|
+
local _object = {
|
|
10127
10221
|
kind = "request",
|
|
10128
10222
|
requestId = envelope.requestId,
|
|
10129
10223
|
peerId = envelope.peerId,
|
|
10130
10224
|
target = envelope.target,
|
|
10131
10225
|
endpoint = envelope.endpoint,
|
|
10132
|
-
data = data,
|
|
10133
10226
|
remainingMs = envelope.remainingMs,
|
|
10134
10227
|
}
|
|
10228
|
+
local _left = "data"
|
|
10229
|
+
local _data = envelope.data
|
|
10230
|
+
_object[_left] = if type(_data) == "table" then envelope.data else nil
|
|
10231
|
+
return _object
|
|
10135
10232
|
end
|
|
10136
10233
|
return nil
|
|
10137
10234
|
end
|
|
10138
|
-
local function
|
|
10139
|
-
local current =
|
|
10140
|
-
|
|
10141
|
-
|
|
10235
|
+
local function closeCurrentSocket()
|
|
10236
|
+
local current = socketClient
|
|
10237
|
+
socketClient = nil
|
|
10238
|
+
socketOpen = false
|
|
10239
|
+
for _, connection in socketConnections do
|
|
10142
10240
|
connection:Disconnect()
|
|
10143
10241
|
end
|
|
10144
|
-
|
|
10242
|
+
socketConnections = {}
|
|
10243
|
+
for _, entry in pendingResponses do
|
|
10244
|
+
if entry.ackTimer ~= nil then
|
|
10245
|
+
task.cancel(entry.ackTimer)
|
|
10246
|
+
end
|
|
10247
|
+
entry.ackTimer = nil
|
|
10248
|
+
end
|
|
10145
10249
|
if current ~= nil then
|
|
10146
10250
|
pcall(function()
|
|
10147
10251
|
return current:Close()
|
|
@@ -10162,42 +10266,39 @@ local function disconnectSession(currentOptions)
|
|
|
10162
10266
|
})
|
|
10163
10267
|
end)
|
|
10164
10268
|
end
|
|
10165
|
-
local function
|
|
10166
|
-
return math.min(
|
|
10269
|
+
local function retryDelay(attempt)
|
|
10270
|
+
return math.min(INITIAL_RETRY_DELAY_SECONDS * math.pow(2, math.max(attempt - 1, 0)), MAX_RETRY_DELAY_SECONDS)
|
|
10167
10271
|
end
|
|
10168
|
-
local function
|
|
10169
|
-
local
|
|
10170
|
-
|
|
10171
|
-
|
|
10172
|
-
|
|
10173
|
-
|
|
10174
|
-
|
|
10175
|
-
|
|
10176
|
-
|
|
10177
|
-
|
|
10178
|
-
|
|
10179
|
-
|
|
10180
|
-
if success and acknowledgement.success == true and disposition == nil then
|
|
10181
|
-
return "accepted"
|
|
10272
|
+
local function pruneTerminalResponses()
|
|
10273
|
+
local now = os.clock()
|
|
10274
|
+
while terminalCount > 0 do
|
|
10275
|
+
local oldest = terminalResponseOrder[terminalHead + 1]
|
|
10276
|
+
if oldest == nil or oldest.expiresAt > now then
|
|
10277
|
+
break
|
|
10278
|
+
end
|
|
10279
|
+
local _requestId = oldest.requestId
|
|
10280
|
+
terminalResponseIds[_requestId] = nil
|
|
10281
|
+
terminalResponseOrder[terminalHead + 1] = nil
|
|
10282
|
+
terminalHead = (terminalHead + 1) % MAX_TERMINAL_RESPONSES
|
|
10283
|
+
terminalCount -= 1
|
|
10182
10284
|
end
|
|
10183
|
-
return nil
|
|
10184
10285
|
end
|
|
10185
10286
|
local function rememberTerminalResponse(requestId)
|
|
10287
|
+
pruneTerminalResponses()
|
|
10186
10288
|
local _requestId = requestId
|
|
10187
10289
|
if terminalResponseIds[_requestId] ~= nil then
|
|
10188
10290
|
return nil
|
|
10189
10291
|
end
|
|
10292
|
+
-- Admission reserves this tombstone before any side effects can execute.
|
|
10293
|
+
terminalResponseOrder[(terminalHead + terminalCount) % MAX_TERMINAL_RESPONSES + 1] = {
|
|
10294
|
+
requestId = requestId,
|
|
10295
|
+
expiresAt = os.clock() + RESPONSE_RETENTION_SECONDS,
|
|
10296
|
+
}
|
|
10297
|
+
terminalCount += 1
|
|
10190
10298
|
local _requestId_1 = requestId
|
|
10191
10299
|
terminalResponseIds[_requestId_1] = true
|
|
10192
|
-
local _requestId_2 = requestId
|
|
10193
|
-
table.insert(terminalResponseOrder, _requestId_2)
|
|
10194
|
-
while #terminalResponseOrder > MAX_TERMINAL_RESPONSES do
|
|
10195
|
-
local oldest = table.remove(terminalResponseOrder, 1)
|
|
10196
|
-
if oldest ~= nil then
|
|
10197
|
-
terminalResponseIds[oldest] = nil
|
|
10198
|
-
end
|
|
10199
|
-
end
|
|
10200
10300
|
end
|
|
10301
|
+
local scheduleReconnect
|
|
10201
10302
|
local function settleResponse(requestId, entry, disposition)
|
|
10202
10303
|
local _requestId = requestId
|
|
10203
10304
|
if pendingResponses[_requestId] ~= entry then
|
|
@@ -10205,66 +10306,145 @@ local function settleResponse(requestId, entry, disposition)
|
|
|
10205
10306
|
end
|
|
10206
10307
|
local _requestId_1 = requestId
|
|
10207
10308
|
pendingResponses[_requestId_1] = nil
|
|
10309
|
+
if entry.admissionRejection then
|
|
10310
|
+
pendingRejectionCount -= 1
|
|
10311
|
+
else
|
|
10312
|
+
pendingResponseBytes -= #entry.body
|
|
10313
|
+
end
|
|
10314
|
+
if entry.ackTimer ~= nil then
|
|
10315
|
+
task.cancel(entry.ackTimer)
|
|
10316
|
+
end
|
|
10317
|
+
if entry.expiryTimer ~= nil then
|
|
10318
|
+
task.cancel(entry.expiryTimer)
|
|
10319
|
+
end
|
|
10320
|
+
entry.ackTimer = nil
|
|
10321
|
+
entry.expiryTimer = nil
|
|
10322
|
+
-- Release a queued native frame before making its capacity available again.
|
|
10323
|
+
if disposition == "expired" and socketOpen and entry.sentGeneration == generation then
|
|
10324
|
+
scheduleReconnect(generation, `WebSocket response {requestId} expired without acknowledgement`)
|
|
10325
|
+
end
|
|
10326
|
+
entry.body = ""
|
|
10208
10327
|
rememberTerminalResponse(requestId)
|
|
10209
|
-
if disposition == "unknown" then
|
|
10210
|
-
warn(`[robloxstudio-mcp]
|
|
10328
|
+
if disposition == "unknown" or disposition == "expired" then
|
|
10329
|
+
warn(`[robloxstudio-mcp] Response {requestId} outcome unknown ({disposition}); stored result released, mutation must not be replayed`)
|
|
10211
10330
|
end
|
|
10212
10331
|
end
|
|
10213
|
-
local function
|
|
10214
|
-
local
|
|
10215
|
-
local _condition = not active or
|
|
10332
|
+
local function sendProgress(requestId, progress)
|
|
10333
|
+
local client = socketClient
|
|
10334
|
+
local _condition = not active or not socketOpen or client == nil
|
|
10216
10335
|
if not _condition then
|
|
10217
|
-
local
|
|
10218
|
-
|
|
10336
|
+
local _result = options
|
|
10337
|
+
if _result ~= nil then
|
|
10338
|
+
_result = _result.serverUrl
|
|
10339
|
+
end
|
|
10340
|
+
_condition = _result ~= progress.serverUrl
|
|
10219
10341
|
if not _condition then
|
|
10220
|
-
_condition =
|
|
10342
|
+
_condition = progress.phase == nil
|
|
10221
10343
|
end
|
|
10222
10344
|
end
|
|
10223
10345
|
if _condition then
|
|
10224
10346
|
return nil
|
|
10225
10347
|
end
|
|
10226
|
-
|
|
10227
|
-
|
|
10228
|
-
|
|
10229
|
-
|
|
10230
|
-
|
|
10231
|
-
|
|
10232
|
-
|
|
10348
|
+
if progress.sentGeneration == generation and progress.sentPhase == progress.phase then
|
|
10349
|
+
return nil
|
|
10350
|
+
end
|
|
10351
|
+
local expectedGeneration = generation
|
|
10352
|
+
progress.sentGeneration = expectedGeneration
|
|
10353
|
+
progress.sentPhase = progress.phase
|
|
10354
|
+
local sent = pcall(function()
|
|
10355
|
+
return client:Send(HttpService:JSONEncode({
|
|
10356
|
+
kind = "progress",
|
|
10357
|
+
requestId = requestId,
|
|
10358
|
+
phase = progress.phase,
|
|
10359
|
+
outcome = progress.outcome,
|
|
10360
|
+
}))
|
|
10361
|
+
end)
|
|
10362
|
+
if not sent then
|
|
10363
|
+
scheduleReconnect(expectedGeneration, "WebSocket progress send failed; current observation retained for reconnect")
|
|
10364
|
+
end
|
|
10365
|
+
end
|
|
10366
|
+
local function completeProgress(requestId, progress, outcome)
|
|
10367
|
+
progress.phase = "response_delivery"
|
|
10368
|
+
progress.outcome = outcome
|
|
10369
|
+
sendProgress(requestId, progress)
|
|
10370
|
+
end
|
|
10371
|
+
local function handlerOutcome(response)
|
|
10372
|
+
local _response = response
|
|
10373
|
+
if not (type(_response) == "table") then
|
|
10374
|
+
return "success"
|
|
10375
|
+
end
|
|
10376
|
+
local result = response
|
|
10377
|
+
if result.error ~= nil or result.success == false or result.ok == false then
|
|
10378
|
+
return "error"
|
|
10379
|
+
end
|
|
10380
|
+
local _summary = result.summary
|
|
10381
|
+
if type(_summary) == "table" then
|
|
10382
|
+
local summary = result.summary
|
|
10383
|
+
local _failed = summary.failed
|
|
10384
|
+
local _condition = type(_failed) == "number"
|
|
10385
|
+
if _condition then
|
|
10386
|
+
_condition = summary.failed > 0
|
|
10233
10387
|
end
|
|
10234
|
-
if
|
|
10235
|
-
|
|
10236
|
-
return nil
|
|
10388
|
+
if _condition then
|
|
10389
|
+
return "error"
|
|
10237
10390
|
end
|
|
10238
|
-
|
|
10239
|
-
|
|
10240
|
-
|
|
10241
|
-
|
|
10242
|
-
|
|
10243
|
-
|
|
10244
|
-
|
|
10245
|
-
|
|
10246
|
-
|
|
10247
|
-
|
|
10248
|
-
|
|
10249
|
-
local _requestId = requestId
|
|
10250
|
-
if pendingResponses[_requestId] ~= entry then
|
|
10251
|
-
return nil
|
|
10391
|
+
end
|
|
10392
|
+
return "success"
|
|
10393
|
+
end
|
|
10394
|
+
local function sendPendingResponse(requestId, entry)
|
|
10395
|
+
sendProgress(requestId, entry.progress)
|
|
10396
|
+
local client = socketClient
|
|
10397
|
+
local _condition = not active or not socketOpen or client == nil
|
|
10398
|
+
if not _condition then
|
|
10399
|
+
local _result = options
|
|
10400
|
+
if _result ~= nil then
|
|
10401
|
+
_result = _result.serverUrl
|
|
10252
10402
|
end
|
|
10253
|
-
|
|
10254
|
-
|
|
10255
|
-
|
|
10256
|
-
|
|
10257
|
-
else
|
|
10258
|
-
local disposition = parseResponseDisposition(requestResult.Success, requestResult.Body)
|
|
10259
|
-
if disposition ~= nil then
|
|
10260
|
-
settleResponse(requestId, entry, disposition)
|
|
10261
|
-
return nil
|
|
10262
|
-
end
|
|
10263
|
-
failure = if requestResult.Success then "Invalid /response acknowledgement" else HttpDiagnostics.formatRequestFailure(responseUrl, true, requestResult)
|
|
10403
|
+
_condition = _result ~= entry.serverUrl
|
|
10404
|
+
if not _condition then
|
|
10405
|
+
local _requestId = requestId
|
|
10406
|
+
_condition = pendingResponses[_requestId] ~= entry
|
|
10264
10407
|
end
|
|
10265
|
-
|
|
10266
|
-
|
|
10267
|
-
|
|
10408
|
+
end
|
|
10409
|
+
if _condition then
|
|
10410
|
+
return nil
|
|
10411
|
+
end
|
|
10412
|
+
if os.clock() >= entry.expiresAt then
|
|
10413
|
+
settleResponse(requestId, entry, "expired")
|
|
10414
|
+
return nil
|
|
10415
|
+
end
|
|
10416
|
+
if entry.sentGeneration == generation then
|
|
10417
|
+
return nil
|
|
10418
|
+
end
|
|
10419
|
+
local expectedGeneration = generation
|
|
10420
|
+
entry.sentGeneration = expectedGeneration
|
|
10421
|
+
local sendOk, sendError = pcall(function()
|
|
10422
|
+
return client:Send(entry.body)
|
|
10423
|
+
end)
|
|
10424
|
+
if not sendOk then
|
|
10425
|
+
warn(`[robloxstudio-mcp] WebSocket response send failed for {requestId} ({#entry.body} bytes, stage=socket_send): {tostring(sendError)}`)
|
|
10426
|
+
scheduleReconnect(expectedGeneration, "WebSocket response send failed; retained for reconnect")
|
|
10427
|
+
return nil
|
|
10428
|
+
end
|
|
10429
|
+
local _requestId = requestId
|
|
10430
|
+
local _condition_1 = pendingResponses[_requestId] ~= entry
|
|
10431
|
+
if not _condition_1 then
|
|
10432
|
+
_condition_1 = generation ~= expectedGeneration or socketClient ~= client
|
|
10433
|
+
end
|
|
10434
|
+
if _condition_1 then
|
|
10435
|
+
return nil
|
|
10436
|
+
end
|
|
10437
|
+
-- Send only queues bytes. A heartbeat does not prove that this upload drained.
|
|
10438
|
+
-- Give large transfers a generous window, growing after reconnect; never put
|
|
10439
|
+
-- a second copy on the same socket, even when an acknowledgement is lost.
|
|
10440
|
+
entry.sendAttempt += 1
|
|
10441
|
+
local ackDelay = RESPONSE_ACK_TIMEOUT_SECONDS * math.pow(2, math.min(entry.sendAttempt - 1, 2))
|
|
10442
|
+
if os.clock() + ackDelay >= entry.expiresAt then
|
|
10443
|
+
return nil
|
|
10444
|
+
end
|
|
10445
|
+
entry.ackTimer = task.delay(ackDelay, function()
|
|
10446
|
+
entry.ackTimer = nil
|
|
10447
|
+
local _condition_2 = generation ~= expectedGeneration
|
|
10268
10448
|
if not _condition_2 then
|
|
10269
10449
|
local _requestId_1 = requestId
|
|
10270
10450
|
_condition_2 = pendingResponses[_requestId_1] ~= entry
|
|
@@ -10272,62 +10452,145 @@ local function postPendingResponse(requestId, entry)
|
|
|
10272
10452
|
if _condition_2 then
|
|
10273
10453
|
return nil
|
|
10274
10454
|
end
|
|
10275
|
-
|
|
10276
|
-
local retryToken = entry.retryToken
|
|
10277
|
-
local delay = responseRetryDelay(entry.retryAttempt)
|
|
10278
|
-
task.delay(delay, function()
|
|
10279
|
-
local _condition_3 = not active
|
|
10280
|
-
if not _condition_3 then
|
|
10281
|
-
local _requestId_1 = requestId
|
|
10282
|
-
_condition_3 = pendingResponses[_requestId_1] ~= entry
|
|
10283
|
-
if not _condition_3 then
|
|
10284
|
-
_condition_3 = entry.retryToken ~= retryToken
|
|
10285
|
-
end
|
|
10286
|
-
end
|
|
10287
|
-
if _condition_3 then
|
|
10288
|
-
return nil
|
|
10289
|
-
end
|
|
10290
|
-
postPendingResponse(requestId, entry)
|
|
10291
|
-
end)
|
|
10455
|
+
scheduleReconnect(expectedGeneration, `WebSocket response {requestId} acknowledgement timed out; retained for reconnect`)
|
|
10292
10456
|
end)
|
|
10293
10457
|
end
|
|
10294
10458
|
local function resumePendingResponses()
|
|
10459
|
+
for requestId, entry in inFlightRequests do
|
|
10460
|
+
sendProgress(requestId, entry.progress)
|
|
10461
|
+
end
|
|
10295
10462
|
for requestId, entry in pendingResponses do
|
|
10296
|
-
|
|
10463
|
+
sendPendingResponse(requestId, entry)
|
|
10297
10464
|
end
|
|
10298
10465
|
end
|
|
10299
|
-
local function
|
|
10466
|
+
local function encodeError(requestId, detail, executionOutcome)
|
|
10300
10467
|
local encodeOk, encoded = pcall(function()
|
|
10301
10468
|
return HttpService:JSONEncode({
|
|
10469
|
+
kind = "response",
|
|
10302
10470
|
requestId = requestId,
|
|
10303
|
-
|
|
10471
|
+
executionOutcome = executionOutcome,
|
|
10472
|
+
error = `Request {requestId}: {detail}`,
|
|
10304
10473
|
})
|
|
10305
10474
|
end)
|
|
10306
|
-
if encodeOk then
|
|
10475
|
+
if encodeOk and #encoded <= RESERVED_ERROR_BYTES then
|
|
10307
10476
|
return encoded
|
|
10308
10477
|
end
|
|
10309
|
-
warn(`[robloxstudio-mcp] Failed to serialize response {requestId}: {tostring(encoded)}`)
|
|
10310
10478
|
return HttpService:JSONEncode({
|
|
10479
|
+
kind = "response",
|
|
10311
10480
|
requestId = requestId,
|
|
10312
|
-
|
|
10481
|
+
executionOutcome = executionOutcome,
|
|
10482
|
+
error = `Request {requestId}: Plugin error could not be encoded within its reserved response capacity (stage=error_encode); handler outcome={executionOutcome}`,
|
|
10313
10483
|
})
|
|
10314
10484
|
end
|
|
10485
|
+
local function encodeResponse(requestId, response, executionOutcome)
|
|
10486
|
+
local encodeOk, encoded = pcall(function()
|
|
10487
|
+
return HttpService:JSONEncode({
|
|
10488
|
+
kind = "response",
|
|
10489
|
+
requestId = requestId,
|
|
10490
|
+
response = response,
|
|
10491
|
+
executionOutcome = executionOutcome,
|
|
10492
|
+
})
|
|
10493
|
+
end)
|
|
10494
|
+
if not encodeOk then
|
|
10495
|
+
return encodeError(requestId, `Plugin response serialization failed (stage=response_encode): {string.sub(tostring(encoded), 1, 512)}`, executionOutcome)
|
|
10496
|
+
end
|
|
10497
|
+
if #encoded > MAX_FRAME_BYTES then
|
|
10498
|
+
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)
|
|
10499
|
+
end
|
|
10500
|
+
return encoded
|
|
10501
|
+
end
|
|
10502
|
+
local function retainResponse(requestId, body, serverUrl, progress, admissionRejection)
|
|
10503
|
+
if admissionRejection == nil then
|
|
10504
|
+
admissionRejection = false
|
|
10505
|
+
end
|
|
10506
|
+
-- Admission errors have their own bounded 128 x 4KiB reserve so a full-size
|
|
10507
|
+
-- result never consumes the capacity required to retain a rejection.
|
|
10508
|
+
-- ▼ ReadonlyMap.size ▼
|
|
10509
|
+
local _size = 0
|
|
10510
|
+
for _ in inFlightRequests do
|
|
10511
|
+
_size += 1
|
|
10512
|
+
end
|
|
10513
|
+
-- ▲ ReadonlyMap.size ▲
|
|
10514
|
+
-- ▼ ReadonlyMap.size ▼
|
|
10515
|
+
local _size_1 = 0
|
|
10516
|
+
for _ in pendingResponses do
|
|
10517
|
+
_size_1 += 1
|
|
10518
|
+
end
|
|
10519
|
+
-- ▲ ReadonlyMap.size ▲
|
|
10520
|
+
local reservedBytes = (_size + _size_1 - pendingRejectionCount) * RESERVED_ERROR_BYTES
|
|
10521
|
+
if not admissionRejection and pendingResponseBytes + #body + reservedBytes > MAX_PENDING_RESPONSE_BYTES then
|
|
10522
|
+
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")
|
|
10523
|
+
end
|
|
10524
|
+
local entry = {
|
|
10525
|
+
body = body,
|
|
10526
|
+
serverUrl = serverUrl,
|
|
10527
|
+
progress = progress,
|
|
10528
|
+
expiresAt = os.clock() + RESPONSE_RETENTION_SECONDS,
|
|
10529
|
+
admissionRejection = admissionRejection,
|
|
10530
|
+
sendAttempt = 0,
|
|
10531
|
+
}
|
|
10532
|
+
local _requestId = requestId
|
|
10533
|
+
pendingResponses[_requestId] = entry
|
|
10534
|
+
if admissionRejection then
|
|
10535
|
+
pendingRejectionCount += 1
|
|
10536
|
+
else
|
|
10537
|
+
pendingResponseBytes += #body
|
|
10538
|
+
end
|
|
10539
|
+
entry.expiryTimer = task.delay(RESPONSE_RETENTION_SECONDS, function()
|
|
10540
|
+
entry.expiryTimer = nil
|
|
10541
|
+
local _requestId_1 = requestId
|
|
10542
|
+
if pendingResponses[_requestId_1] == entry then
|
|
10543
|
+
settleResponse(requestId, entry, "expired")
|
|
10544
|
+
end
|
|
10545
|
+
end)
|
|
10546
|
+
sendPendingResponse(requestId, entry)
|
|
10547
|
+
end
|
|
10548
|
+
local function rejectAdmission(requestId, detail)
|
|
10549
|
+
local currentOptions = options
|
|
10550
|
+
if currentOptions == nil then
|
|
10551
|
+
return nil
|
|
10552
|
+
end
|
|
10553
|
+
local _condition = pendingRejectionCount >= MAX_ADMISSION_REJECTIONS
|
|
10554
|
+
if not _condition then
|
|
10555
|
+
local _exp = terminalCount
|
|
10556
|
+
-- ▼ ReadonlyMap.size ▼
|
|
10557
|
+
local _size = 0
|
|
10558
|
+
for _ in inFlightRequests do
|
|
10559
|
+
_size += 1
|
|
10560
|
+
end
|
|
10561
|
+
-- ▲ ReadonlyMap.size ▲
|
|
10562
|
+
local _exp_1 = _exp + _size
|
|
10563
|
+
-- ▼ ReadonlyMap.size ▼
|
|
10564
|
+
local _size_1 = 0
|
|
10565
|
+
for _ in pendingResponses do
|
|
10566
|
+
_size_1 += 1
|
|
10567
|
+
end
|
|
10568
|
+
-- ▲ ReadonlyMap.size ▲
|
|
10569
|
+
_condition = _exp_1 + _size_1 >= MAX_TERMINAL_RESPONSES
|
|
10570
|
+
end
|
|
10571
|
+
if _condition then
|
|
10572
|
+
-- No execution and no unretained "result". Reconnect drains existing
|
|
10573
|
+
-- outcomes so their acknowledgements can release rejection reservations.
|
|
10574
|
+
scheduleReconnect(generation, "Plugin admission rejection reserve exhausted; execution not started")
|
|
10575
|
+
return nil
|
|
10576
|
+
end
|
|
10577
|
+
local progress = {
|
|
10578
|
+
serverUrl = currentOptions.serverUrl,
|
|
10579
|
+
}
|
|
10580
|
+
completeProgress(requestId, progress, "not_executed")
|
|
10581
|
+
retainResponse(requestId, encodeError(requestId, detail, "not_executed"), currentOptions.serverUrl, progress, true)
|
|
10582
|
+
end
|
|
10315
10583
|
local function cancelRequest(event)
|
|
10316
10584
|
local _requestId = event.requestId
|
|
10317
10585
|
local inFlight = inFlightRequests[_requestId]
|
|
10318
10586
|
if inFlight ~= nil then
|
|
10319
10587
|
inFlight.cancelled = true
|
|
10320
10588
|
end
|
|
10321
|
-
|
|
10322
|
-
|
|
10323
|
-
if pending ~= nil then
|
|
10324
|
-
pending.retryToken += 1
|
|
10325
|
-
local _requestId_2 = event.requestId
|
|
10326
|
-
pendingResponses[_requestId_2] = nil
|
|
10327
|
-
end
|
|
10328
|
-
rememberTerminalResponse(event.requestId)
|
|
10589
|
+
-- Cancellation only changes execution context. Completed results still need
|
|
10590
|
+
-- recording, including results produced by already-running side effects.
|
|
10329
10591
|
end
|
|
10330
|
-
local function dispatchRequest(request)
|
|
10592
|
+
local function dispatchRequest(request, requestBytes)
|
|
10593
|
+
pruneTerminalResponses()
|
|
10331
10594
|
local _requestId = request.requestId
|
|
10332
10595
|
local _condition = terminalResponseIds[_requestId] ~= nil
|
|
10333
10596
|
if not _condition then
|
|
@@ -10345,8 +10608,28 @@ local function dispatchRequest(request)
|
|
|
10345
10608
|
if not active or dispatchOptions == nil then
|
|
10346
10609
|
return nil
|
|
10347
10610
|
end
|
|
10611
|
+
-- ▼ ReadonlyMap.size ▼
|
|
10612
|
+
local _size = 0
|
|
10613
|
+
for _ in inFlightRequests do
|
|
10614
|
+
_size += 1
|
|
10615
|
+
end
|
|
10616
|
+
-- ▲ ReadonlyMap.size ▲
|
|
10617
|
+
-- ▼ ReadonlyMap.size ▼
|
|
10618
|
+
local _size_1 = 0
|
|
10619
|
+
for _ in pendingResponses do
|
|
10620
|
+
_size_1 += 1
|
|
10621
|
+
end
|
|
10622
|
+
-- ▲ ReadonlyMap.size ▲
|
|
10623
|
+
local activeCount = _size + _size_1 - pendingRejectionCount
|
|
10624
|
+
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
|
|
10625
|
+
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`)
|
|
10626
|
+
return nil
|
|
10627
|
+
end
|
|
10348
10628
|
local inFlight = {
|
|
10349
10629
|
cancelled = false,
|
|
10630
|
+
progress = {
|
|
10631
|
+
serverUrl = dispatchOptions.serverUrl,
|
|
10632
|
+
},
|
|
10350
10633
|
}
|
|
10351
10634
|
local context = {
|
|
10352
10635
|
requestId = request.requestId,
|
|
@@ -10357,50 +10640,31 @@ local function dispatchRequest(request)
|
|
|
10357
10640
|
}
|
|
10358
10641
|
local _requestId_1 = request.requestId
|
|
10359
10642
|
inFlightRequests[_requestId_1] = inFlight
|
|
10643
|
+
inFlightRequestBytes += requestBytes
|
|
10360
10644
|
task.spawn(function()
|
|
10361
|
-
|
|
10362
|
-
|
|
10645
|
+
if inFlight.cancelled or not active or os.clock() >= context.deadlineAt then
|
|
10646
|
+
completeProgress(request.requestId, inFlight.progress, "not_executed")
|
|
10363
10647
|
local _requestId_2 = request.requestId
|
|
10364
|
-
|
|
10365
|
-
|
|
10366
|
-
|
|
10367
|
-
local _requestId_2 = request.requestId
|
|
10368
|
-
if inFlightRequests[_requestId_2] == inFlight then
|
|
10369
|
-
local _requestId_3 = request.requestId
|
|
10370
|
-
inFlightRequests[_requestId_3] = nil
|
|
10371
|
-
end
|
|
10648
|
+
inFlightRequests[_requestId_2] = nil
|
|
10649
|
+
inFlightRequestBytes -= requestBytes
|
|
10650
|
+
retainResponse(request.requestId, encodeError(request.requestId, "Request cancelled or admission deadline expired before execution started", "not_executed"), dispatchOptions.serverUrl, inFlight.progress)
|
|
10372
10651
|
return nil
|
|
10373
10652
|
end
|
|
10653
|
+
-- This observes handler entry, including broker dispatch, not a user Luau
|
|
10654
|
+
-- instruction. The waiter's deadline/cancellation is not a rollback.
|
|
10655
|
+
inFlight.progress.phase = "executing"
|
|
10656
|
+
sendProgress(request.requestId, inFlight.progress)
|
|
10374
10657
|
local dispatchOk, response = pcall(function()
|
|
10375
10658
|
return dispatchOptions.dispatchRequest(request, context)
|
|
10376
10659
|
end)
|
|
10377
|
-
local
|
|
10378
|
-
|
|
10379
|
-
|
|
10380
|
-
|
|
10381
|
-
end
|
|
10382
|
-
if _condition_2 then
|
|
10383
|
-
local _requestId_2 = request.requestId
|
|
10384
|
-
if inFlightRequests[_requestId_2] == inFlight then
|
|
10385
|
-
local _requestId_3 = request.requestId
|
|
10386
|
-
inFlightRequests[_requestId_3] = nil
|
|
10387
|
-
end
|
|
10388
|
-
return nil
|
|
10389
|
-
end
|
|
10390
|
-
local responseData = if dispatchOk then response else {
|
|
10391
|
-
error = tostring(response),
|
|
10392
|
-
}
|
|
10393
|
-
local entry = {
|
|
10394
|
-
body = encodeResponse(request.requestId, responseData),
|
|
10395
|
-
retryAttempt = 0,
|
|
10396
|
-
posting = false,
|
|
10397
|
-
retryToken = 0,
|
|
10398
|
-
}
|
|
10660
|
+
local executionOutcome = if dispatchOk then handlerOutcome(response) else "error"
|
|
10661
|
+
-- Report handler return before JSON encoding or result retention can fail.
|
|
10662
|
+
completeProgress(request.requestId, inFlight.progress, executionOutcome)
|
|
10663
|
+
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)
|
|
10399
10664
|
local _requestId_2 = request.requestId
|
|
10400
|
-
|
|
10401
|
-
|
|
10402
|
-
|
|
10403
|
-
postPendingResponse(request.requestId, entry)
|
|
10665
|
+
inFlightRequests[_requestId_2] = nil
|
|
10666
|
+
inFlightRequestBytes -= requestBytes
|
|
10667
|
+
retainResponse(request.requestId, body, dispatchOptions.serverUrl, inFlight.progress)
|
|
10404
10668
|
end)
|
|
10405
10669
|
end
|
|
10406
10670
|
local function invokeCallback(name, callback)
|
|
@@ -10412,24 +10676,13 @@ end
|
|
|
10412
10676
|
local function reportTransport(update)
|
|
10413
10677
|
local currentOptions = options
|
|
10414
10678
|
if active and currentOptions ~= nil then
|
|
10415
|
-
invokeCallback("
|
|
10679
|
+
invokeCallback("WebSocket transport", function()
|
|
10416
10680
|
return currentOptions.onTransportUpdate(update)
|
|
10417
10681
|
end)
|
|
10418
10682
|
end
|
|
10419
10683
|
end
|
|
10420
|
-
local function reconnectDelay(attempt)
|
|
10421
|
-
return math.min(INITIAL_RECONNECT_DELAY_SECONDS * math.pow(2, math.max(attempt - 1, 0)), MAX_RECONNECT_DELAY_SECONDS)
|
|
10422
|
-
end
|
|
10423
10684
|
local connect
|
|
10424
|
-
|
|
10425
|
-
task.delay(delaySeconds, function()
|
|
10426
|
-
if not active or generation ~= expectedGeneration then
|
|
10427
|
-
return nil
|
|
10428
|
-
end
|
|
10429
|
-
connect(expectedGeneration)
|
|
10430
|
-
end)
|
|
10431
|
-
end
|
|
10432
|
-
local function scheduleReconnect(expectedGeneration, detail, duplicate)
|
|
10685
|
+
function scheduleReconnect(expectedGeneration, detail, duplicate)
|
|
10433
10686
|
if duplicate == nil then
|
|
10434
10687
|
duplicate = false
|
|
10435
10688
|
end
|
|
@@ -10437,27 +10690,31 @@ local function scheduleReconnect(expectedGeneration, detail, duplicate)
|
|
|
10437
10690
|
return nil
|
|
10438
10691
|
end
|
|
10439
10692
|
generation += 1
|
|
10440
|
-
|
|
10693
|
+
closeCurrentSocket()
|
|
10441
10694
|
reconnectAttempt += 1
|
|
10442
|
-
local delay = if duplicate then 1 else
|
|
10695
|
+
local delay = if duplicate then 1 else retryDelay(reconnectAttempt)
|
|
10443
10696
|
reportTransport({
|
|
10444
10697
|
state = if duplicate then "waiting-duplicate" else "retrying",
|
|
10445
10698
|
attempt = reconnectAttempt,
|
|
10446
10699
|
retryDelay = delay,
|
|
10447
10700
|
detail = detail,
|
|
10448
10701
|
})
|
|
10449
|
-
|
|
10702
|
+
local nextGeneration = generation
|
|
10703
|
+
task.delay(delay, function()
|
|
10704
|
+
if active and generation == nextGeneration then
|
|
10705
|
+
connect(nextGeneration)
|
|
10706
|
+
end
|
|
10707
|
+
end)
|
|
10450
10708
|
end
|
|
10451
10709
|
local function watchForSilence(expectedGeneration, expectedClient)
|
|
10452
10710
|
local elapsed = tick() - lastValidEventAt
|
|
10453
|
-
|
|
10454
|
-
|
|
10455
|
-
if not active or generation ~= expectedGeneration or streamClient ~= expectedClient then
|
|
10711
|
+
task.delay(math.max(SOCKET_SILENCE_TIMEOUT_SECONDS - elapsed, 0.1), function()
|
|
10712
|
+
if not active or generation ~= expectedGeneration or socketClient ~= expectedClient then
|
|
10456
10713
|
return nil
|
|
10457
10714
|
end
|
|
10458
10715
|
local silentFor = tick() - lastValidEventAt
|
|
10459
|
-
if silentFor >=
|
|
10460
|
-
scheduleReconnect(expectedGeneration, `
|
|
10716
|
+
if silentFor >= SOCKET_SILENCE_TIMEOUT_SECONDS then
|
|
10717
|
+
scheduleReconnect(expectedGeneration, `WebSocket silent for {math.floor(silentFor)} seconds`)
|
|
10461
10718
|
return nil
|
|
10462
10719
|
end
|
|
10463
10720
|
watchForSilence(expectedGeneration, expectedClient)
|
|
@@ -10486,14 +10743,21 @@ local function parseReadyResponse(body)
|
|
|
10486
10743
|
local _instanceId = value.instanceId
|
|
10487
10744
|
_condition = not (type(_instanceId) == "string")
|
|
10488
10745
|
if not _condition then
|
|
10489
|
-
_condition = value.instanceId == ""
|
|
10746
|
+
_condition = value.instanceId == "" or value.protocolVersion ~= PROTOCOL_VERSION
|
|
10490
10747
|
if not _condition then
|
|
10491
|
-
local
|
|
10492
|
-
|
|
10493
|
-
|
|
10494
|
-
|
|
10748
|
+
local _transportToken = value.transportToken
|
|
10749
|
+
_condition = not (type(_transportToken) == "string")
|
|
10750
|
+
if not _condition then
|
|
10751
|
+
_condition = value.transportToken == ""
|
|
10752
|
+
if not _condition then
|
|
10753
|
+
local _condition_1 = value.multiplayerGroupId ~= nil
|
|
10754
|
+
if _condition_1 then
|
|
10755
|
+
local _multiplayerGroupId = value.multiplayerGroupId
|
|
10756
|
+
_condition_1 = not (type(_multiplayerGroupId) == "string")
|
|
10757
|
+
end
|
|
10758
|
+
_condition = _condition_1
|
|
10759
|
+
end
|
|
10495
10760
|
end
|
|
10496
|
-
_condition = _condition_1
|
|
10497
10761
|
end
|
|
10498
10762
|
end
|
|
10499
10763
|
end
|
|
@@ -10510,9 +10774,64 @@ local function parseReadyResponse(body)
|
|
|
10510
10774
|
peerId = value.peerId,
|
|
10511
10775
|
instanceId = value.instanceId,
|
|
10512
10776
|
multiplayerGroupId = value.multiplayerGroupId,
|
|
10777
|
+
protocolVersion = PROTOCOL_VERSION,
|
|
10778
|
+
transportToken = value.transportToken,
|
|
10513
10779
|
}
|
|
10514
10780
|
end
|
|
10515
|
-
local
|
|
10781
|
+
local function registerReady(currentOptions, expectedGeneration, reconnectOnFailure)
|
|
10782
|
+
local instanceId = PluginSession.getInstanceId()
|
|
10783
|
+
local multiplayerGroupId = PluginSession.getMultiplayerGroupId()
|
|
10784
|
+
local transportRole = PluginSession.getRole()
|
|
10785
|
+
local readyUrl = `{currentOptions.serverUrl}/ready`
|
|
10786
|
+
local readyPayload = PluginSession.createReadyPayload(PluginSession.peerId, transportRole, instanceId, multiplayerGroupId)
|
|
10787
|
+
if not active or generation ~= expectedGeneration or options ~= currentOptions then
|
|
10788
|
+
return nil
|
|
10789
|
+
end
|
|
10790
|
+
local readyOk, readyResult = pcall(function()
|
|
10791
|
+
return HttpService:RequestAsync({
|
|
10792
|
+
Url = readyUrl,
|
|
10793
|
+
Method = "POST",
|
|
10794
|
+
Headers = {
|
|
10795
|
+
["Content-Type"] = "application/json",
|
|
10796
|
+
},
|
|
10797
|
+
Body = HttpService:JSONEncode(readyPayload),
|
|
10798
|
+
})
|
|
10799
|
+
end)
|
|
10800
|
+
if not active or generation ~= expectedGeneration or options ~= currentOptions then
|
|
10801
|
+
return nil
|
|
10802
|
+
end
|
|
10803
|
+
local readyLogKey = `{currentOptions.serverUrl}|{PluginSession.peerId}`
|
|
10804
|
+
if not readyOk or not readyResult.Success then
|
|
10805
|
+
local detail = if readyOk then HttpDiagnostics.formatRequestFailure(readyUrl, true, readyResult) else HttpDiagnostics.formatRequestFailure(readyUrl, false, readyResult)
|
|
10806
|
+
if not (readyFailureLogKeys[readyLogKey] ~= nil) then
|
|
10807
|
+
readyFailureLogKeys[readyLogKey] = true
|
|
10808
|
+
warn(`[robloxstudio-mcp] /ready failed for {instanceId}/{transportRole}: {detail}`)
|
|
10809
|
+
end
|
|
10810
|
+
if reconnectOnFailure then
|
|
10811
|
+
scheduleReconnect(expectedGeneration, detail, readyOk and readyResult.StatusCode == 409)
|
|
10812
|
+
end
|
|
10813
|
+
return nil
|
|
10814
|
+
end
|
|
10815
|
+
local readyData = parseReadyResponse(readyResult.Body)
|
|
10816
|
+
if readyData == nil or readyData.peerId ~= PluginSession.peerId or readyData.instanceId ~= instanceId or readyData.multiplayerGroupId ~= multiplayerGroupId then
|
|
10817
|
+
if reconnectOnFailure then
|
|
10818
|
+
scheduleReconnect(expectedGeneration, "Invalid /ready response: expected Peer topology and WebSocket protocol credentials")
|
|
10819
|
+
end
|
|
10820
|
+
return nil
|
|
10821
|
+
end
|
|
10822
|
+
if readyFailureLogKeys[readyLogKey] ~= nil then
|
|
10823
|
+
readyFailureLogKeys[readyLogKey] = nil
|
|
10824
|
+
print(`[robloxstudio-mcp] /ready connected for {instanceId}/{readyData.assignedRole} via {currentOptions.serverUrl}`)
|
|
10825
|
+
end
|
|
10826
|
+
cachedReady = readyData
|
|
10827
|
+
invokeCallback("WebSocket ready", function()
|
|
10828
|
+
return currentOptions.onReady(readyData)
|
|
10829
|
+
end)
|
|
10830
|
+
return readyData
|
|
10831
|
+
end
|
|
10832
|
+
local function credentialsRejected(statusCode)
|
|
10833
|
+
return statusCode == 401 or statusCode == 403 or statusCode == 404
|
|
10834
|
+
end
|
|
10516
10835
|
function connect(expectedGeneration)
|
|
10517
10836
|
local currentOptions = options
|
|
10518
10837
|
if not active or generation ~= expectedGeneration or currentOptions == nil then
|
|
@@ -10524,69 +10843,21 @@ function connect(expectedGeneration)
|
|
|
10524
10843
|
retryDelay = 0,
|
|
10525
10844
|
})
|
|
10526
10845
|
task.spawn(function()
|
|
10527
|
-
local
|
|
10528
|
-
|
|
10529
|
-
local readyUrl = `{currentOptions.serverUrl}/ready`
|
|
10530
|
-
local transportRole = PluginSession.getRole()
|
|
10531
|
-
local readyPayload = PluginSession.createReadyPayload(PluginSession.peerId, transportRole, instanceId, multiplayerGroupId)
|
|
10532
|
-
if not active or generation ~= expectedGeneration or options ~= currentOptions then
|
|
10533
|
-
return nil
|
|
10534
|
-
end
|
|
10535
|
-
local readyOk, readyResult = pcall(function()
|
|
10536
|
-
return HttpService:RequestAsync({
|
|
10537
|
-
Url = readyUrl,
|
|
10538
|
-
Method = "POST",
|
|
10539
|
-
Headers = {
|
|
10540
|
-
["Content-Type"] = "application/json",
|
|
10541
|
-
},
|
|
10542
|
-
Body = HttpService:JSONEncode(readyPayload),
|
|
10543
|
-
})
|
|
10544
|
-
end)
|
|
10545
|
-
if not active or generation ~= expectedGeneration or options ~= currentOptions then
|
|
10546
|
-
return nil
|
|
10547
|
-
end
|
|
10548
|
-
local readyLogKey = `{currentOptions.serverUrl}|{PluginSession.peerId}`
|
|
10549
|
-
if not readyOk then
|
|
10550
|
-
local detail = HttpDiagnostics.formatRequestFailure(readyUrl, false, readyResult)
|
|
10551
|
-
if not (readyFailureLogKeys[readyLogKey] ~= nil) then
|
|
10552
|
-
readyFailureLogKeys[readyLogKey] = true
|
|
10553
|
-
warn(`[robloxstudio-mcp] /ready failed for {instanceId}/{transportRole}: {detail}`)
|
|
10554
|
-
end
|
|
10555
|
-
scheduleReconnect(expectedGeneration, detail)
|
|
10556
|
-
return nil
|
|
10557
|
-
end
|
|
10558
|
-
if not readyResult.Success then
|
|
10559
|
-
local detail = HttpDiagnostics.formatRequestFailure(readyUrl, true, readyResult)
|
|
10560
|
-
if not (readyFailureLogKeys[readyLogKey] ~= nil) then
|
|
10561
|
-
readyFailureLogKeys[readyLogKey] = true
|
|
10562
|
-
warn(`[robloxstudio-mcp] /ready rejected for {instanceId}/{transportRole}: {detail}`)
|
|
10563
|
-
end
|
|
10564
|
-
scheduleReconnect(expectedGeneration, detail, readyResult.StatusCode == 409)
|
|
10846
|
+
local readyData = cachedReady or registerReady(currentOptions, expectedGeneration, true)
|
|
10847
|
+
if readyData == nil or not active or generation ~= expectedGeneration or options ~= currentOptions then
|
|
10565
10848
|
return nil
|
|
10566
10849
|
end
|
|
10567
|
-
local
|
|
10568
|
-
if readyData == nil or readyData.peerId ~= PluginSession.peerId or readyData.instanceId ~= instanceId or readyData.multiplayerGroupId ~= multiplayerGroupId then
|
|
10569
|
-
scheduleReconnect(expectedGeneration, "Invalid /ready response: expected the registered Peer topology")
|
|
10570
|
-
return nil
|
|
10571
|
-
end
|
|
10572
|
-
if readyFailureLogKeys[readyLogKey] ~= nil then
|
|
10573
|
-
readyFailureLogKeys[readyLogKey] = nil
|
|
10574
|
-
print(`[robloxstudio-mcp] /ready connected for {instanceId}/{readyData.assignedRole} via {currentOptions.serverUrl}`)
|
|
10575
|
-
end
|
|
10576
|
-
invokeCallback("event stream ready", function()
|
|
10577
|
-
return currentOptions.onReady(readyData)
|
|
10578
|
-
end)
|
|
10850
|
+
local socketBaseUrl = string.gsub(currentOptions.serverUrl, "^http", "ws")
|
|
10579
10851
|
local createOk, createdClient = pcall(function()
|
|
10580
|
-
return HttpService:CreateWebStreamClient(Enum.WebStreamClientType.
|
|
10581
|
-
Url = `{
|
|
10582
|
-
Method = "GET",
|
|
10852
|
+
return HttpService:CreateWebStreamClient(Enum.WebStreamClientType.WebSocket, {
|
|
10853
|
+
Url = `{socketBaseUrl}/studio?peerId={HttpService:UrlEncode(PluginSession.peerId)}&protocolVersion={PROTOCOL_VERSION}`,
|
|
10583
10854
|
Headers = {
|
|
10584
|
-
|
|
10855
|
+
["X-Studio-Token"] = readyData.transportToken,
|
|
10585
10856
|
},
|
|
10586
10857
|
})
|
|
10587
10858
|
end)
|
|
10588
10859
|
if not createOk then
|
|
10589
|
-
scheduleReconnect(expectedGeneration, `Failed to create
|
|
10860
|
+
scheduleReconnect(expectedGeneration, `Failed to create WebSocket: {tostring(createdClient)}`)
|
|
10590
10861
|
return nil
|
|
10591
10862
|
end
|
|
10592
10863
|
if not active or generation ~= expectedGeneration or options ~= currentOptions then
|
|
@@ -10595,16 +10866,20 @@ function connect(expectedGeneration)
|
|
|
10595
10866
|
end)
|
|
10596
10867
|
return nil
|
|
10597
10868
|
end
|
|
10598
|
-
|
|
10599
|
-
|
|
10600
|
-
if not active or generation ~= expectedGeneration or
|
|
10869
|
+
socketClient = createdClient
|
|
10870
|
+
socketConnections = { createdClient.Opened:Connect(function(statusCode, _headers)
|
|
10871
|
+
if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
|
|
10601
10872
|
return nil
|
|
10602
10873
|
end
|
|
10603
|
-
|
|
10604
|
-
|
|
10605
|
-
|
|
10874
|
+
if statusCode ~= 101 and (statusCode < 200 or statusCode >= 300) then
|
|
10875
|
+
if credentialsRejected(statusCode) then
|
|
10876
|
+
cachedReady = nil
|
|
10877
|
+
end
|
|
10878
|
+
scheduleReconnect(expectedGeneration, `WebSocket opened with HTTP {statusCode}`)
|
|
10606
10879
|
return nil
|
|
10607
10880
|
end
|
|
10881
|
+
socketOpen = true
|
|
10882
|
+
lastValidEventAt = tick()
|
|
10608
10883
|
reconnectAttempt = 0
|
|
10609
10884
|
reportTransport({
|
|
10610
10885
|
state = "open",
|
|
@@ -10613,7 +10888,18 @@ function connect(expectedGeneration)
|
|
|
10613
10888
|
})
|
|
10614
10889
|
resumePendingResponses()
|
|
10615
10890
|
end), createdClient.MessageReceived:Connect(function(message)
|
|
10616
|
-
if not active or generation ~= expectedGeneration or
|
|
10891
|
+
if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
|
|
10892
|
+
return nil
|
|
10893
|
+
end
|
|
10894
|
+
local _message = message
|
|
10895
|
+
if not (type(_message) == "string") then
|
|
10896
|
+
scheduleReconnect(expectedGeneration, "Unsupported binary WebSocket frame (stage=request_receive); text JSON required")
|
|
10897
|
+
return nil
|
|
10898
|
+
end
|
|
10899
|
+
if #message > MAX_FRAME_BYTES then
|
|
10900
|
+
local detail = `WebSocket request frame exceeds limit (stage=request_receive, bytes={#message}, maxBytes={MAX_FRAME_BYTES}); execution not started`
|
|
10901
|
+
warn(`[robloxstudio-mcp] {detail}`)
|
|
10902
|
+
scheduleReconnect(expectedGeneration, detail)
|
|
10617
10903
|
return nil
|
|
10618
10904
|
end
|
|
10619
10905
|
local event = decodeMessage(message)
|
|
@@ -10621,37 +10907,42 @@ function connect(expectedGeneration)
|
|
|
10621
10907
|
return nil
|
|
10622
10908
|
end
|
|
10623
10909
|
lastValidEventAt = tick()
|
|
10624
|
-
if event.kind == "
|
|
10625
|
-
|
|
10910
|
+
if event.kind == "ack" then
|
|
10911
|
+
local _requestId = event.requestId
|
|
10912
|
+
local pending = pendingResponses[_requestId]
|
|
10913
|
+
if pending ~= nil and pending.serverUrl == currentOptions.serverUrl then
|
|
10914
|
+
settleResponse(event.requestId, pending, event.disposition)
|
|
10915
|
+
end
|
|
10916
|
+
elseif event.kind == "heartbeat" then
|
|
10917
|
+
invokeCallback("WebSocket heartbeat", function()
|
|
10626
10918
|
return currentOptions.onHeartbeat(event.timestamp)
|
|
10627
10919
|
end)
|
|
10628
|
-
|
|
10629
|
-
end
|
|
10630
|
-
if event.kind == "cancel" then
|
|
10920
|
+
elseif event.kind == "cancel" then
|
|
10631
10921
|
cancelRequest(event)
|
|
10632
|
-
|
|
10633
|
-
|
|
10634
|
-
|
|
10635
|
-
|
|
10636
|
-
|
|
10637
|
-
|
|
10638
|
-
|
|
10639
|
-
|
|
10640
|
-
|
|
10641
|
-
|
|
10642
|
-
refresh()
|
|
10922
|
+
elseif event.kind == "request" then
|
|
10923
|
+
dispatchRequest(event, #message)
|
|
10924
|
+
else
|
|
10925
|
+
invokeCallback("WebSocket status", function()
|
|
10926
|
+
return currentOptions.onStatus(event)
|
|
10927
|
+
end)
|
|
10928
|
+
if not event.knownPeer then
|
|
10929
|
+
cachedReady = nil
|
|
10930
|
+
scheduleReconnect(expectedGeneration, "WebSocket session is no longer registered")
|
|
10931
|
+
end
|
|
10643
10932
|
end
|
|
10644
10933
|
end), createdClient.Error:Connect(function(statusCode, message)
|
|
10645
|
-
if not active or generation ~= expectedGeneration or
|
|
10934
|
+
if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
|
|
10646
10935
|
return nil
|
|
10647
10936
|
end
|
|
10648
|
-
|
|
10649
|
-
|
|
10937
|
+
if credentialsRejected(statusCode) then
|
|
10938
|
+
cachedReady = nil
|
|
10939
|
+
end
|
|
10940
|
+
scheduleReconnect(expectedGeneration, `WebSocket error {statusCode}: {message}`)
|
|
10650
10941
|
end), createdClient.Closed:Connect(function()
|
|
10651
|
-
if not active or generation ~= expectedGeneration or
|
|
10942
|
+
if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
|
|
10652
10943
|
return nil
|
|
10653
10944
|
end
|
|
10654
|
-
scheduleReconnect(expectedGeneration, "
|
|
10945
|
+
scheduleReconnect(expectedGeneration, "WebSocket closed")
|
|
10655
10946
|
end) }
|
|
10656
10947
|
lastValidEventAt = tick()
|
|
10657
10948
|
watchForSilence(expectedGeneration, createdClient)
|
|
@@ -10669,19 +10960,22 @@ local function start(newOptions)
|
|
|
10669
10960
|
generation += 1
|
|
10670
10961
|
connect(generation)
|
|
10671
10962
|
end
|
|
10672
|
-
function refresh()
|
|
10673
|
-
|
|
10963
|
+
local function refresh()
|
|
10964
|
+
local currentOptions = options
|
|
10965
|
+
if not active or currentOptions == nil or readyRefreshPending then
|
|
10674
10966
|
return nil
|
|
10675
10967
|
end
|
|
10676
|
-
|
|
10677
|
-
|
|
10678
|
-
|
|
10679
|
-
|
|
10968
|
+
-- Metadata changes must not close a healthy socket or make its reconnect
|
|
10969
|
+
-- depend on RequestAsync quota. Proxy registration remains independent.
|
|
10970
|
+
readyRefreshPending = true
|
|
10971
|
+
local expectedGeneration = generation
|
|
10972
|
+
task.spawn(function()
|
|
10973
|
+
registerReady(currentOptions, expectedGeneration, false)
|
|
10974
|
+
readyRefreshPending = false
|
|
10975
|
+
end)
|
|
10680
10976
|
end
|
|
10681
|
-
-- EndTest
|
|
10682
|
-
--
|
|
10683
|
-
-- yielding unregister request, but retain local request state and connection
|
|
10684
|
-
-- options so a failed EndTest can register again and reconnect.
|
|
10977
|
+
-- EndTest may tear down this VM without an Unloading callback. Release the
|
|
10978
|
+
-- native socket before yielding to unregister; a failed EndTest can resume.
|
|
10685
10979
|
local function suspendForShutdown()
|
|
10686
10980
|
local currentOptions = options
|
|
10687
10981
|
if not active or currentOptions == nil then
|
|
@@ -10691,7 +10985,8 @@ local function suspendForShutdown()
|
|
|
10691
10985
|
shutdownSuspended = true
|
|
10692
10986
|
generation += 1
|
|
10693
10987
|
reconnectAttempt = 0
|
|
10694
|
-
|
|
10988
|
+
closeCurrentSocket()
|
|
10989
|
+
cachedReady = nil
|
|
10695
10990
|
disconnectSession(currentOptions)
|
|
10696
10991
|
end
|
|
10697
10992
|
local function resumeAfterShutdownFailure()
|
|
@@ -10715,9 +11010,8 @@ function stop()
|
|
|
10715
11010
|
for _, inFlight in inFlightRequests do
|
|
10716
11011
|
inFlight.cancelled = true
|
|
10717
11012
|
end
|
|
10718
|
-
|
|
10719
|
-
|
|
10720
|
-
closeCurrentStream()
|
|
11013
|
+
closeCurrentSocket()
|
|
11014
|
+
cachedReady = nil
|
|
10721
11015
|
table.clear(readyFailureLogKeys)
|
|
10722
11016
|
options = nil
|
|
10723
11017
|
reconnectAttempt = 0
|
|
@@ -10773,16 +11067,9 @@ end
|
|
|
10773
11067
|
local function createInstanceId()
|
|
10774
11068
|
return formatId("instance", randomValue())
|
|
10775
11069
|
end
|
|
10776
|
-
local function currentProcessInstanceId()
|
|
10777
|
-
-- Roblox exposes process uptime through os.clock(). Quantizing the recovered
|
|
10778
|
-
-- launch wall-clock to 10 ms gives every VM in one Studio process the same ID.
|
|
10779
|
-
local launchTick = math.round((DateTime.now().UnixTimestampMillis - os.clock() * 1000) / 10)
|
|
10780
|
-
return formatId("instance", launchTick)
|
|
10781
|
-
end
|
|
10782
11070
|
return {
|
|
10783
11071
|
createPeerId = createPeerId,
|
|
10784
11072
|
createInstanceId = createInstanceId,
|
|
10785
|
-
currentProcessInstanceId = currentProcessInstanceId,
|
|
10786
11073
|
}
|
|
10787
11074
|
]]></string>
|
|
10788
11075
|
</Properties>
|