@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.
@@ -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 StudioEventStream = TS.import(script, script, "modules", "StudioEventStream")
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 event stream until Studio itself is restarted. The
58
- -- transport releases that native stream before yielding to unregister the
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(StudioEventStream.suspendForShutdown)
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 = StudioEventStream.suspendForShutdown,
152
- afterEndTestFailure = StudioEventStream.resumeAfterShutdownFailure,
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 StudioEventStream = TS.import(script, script.Parent, "StudioEventStream")
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 dispatchStreamRequest(request, context)
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 StudioEventStream.refresh()
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
- StudioEventStream.refresh()
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
- StudioEventStream.start({
1179
+ StudioWebSocket.start({
1180
1180
  serverUrl = conn.serverUrl,
1181
- dispatchRequest = dispatchStreamRequest,
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
- StudioEventStream.refresh()
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
- StudioEventStream.stop()
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.0" is replaced with the package version at package time
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.0`
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
- if not (instancePath ~= "" and instancePath) then
3686
- return {
3687
- error = "path is required (the instance to frame)",
3688
- }
3689
- end
3690
- local instance = getInstanceByPath(instancePath)
3691
- if not instance then
3692
- return {
3693
- error = `Instance not found: {instancePath}`,
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 _arg0 = {
5647
+ local failure = {
5620
5648
  property = propName,
5621
5649
  success = false,
5622
5650
  error = tostring(err),
5623
5651
  }
5624
- table.insert(results, _arg0)
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
- finishRecording(recordingId, successCount > 0)
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 processInstanceId = TopologyId.currentProcessInstanceId()
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 getMarkerMode() == "shared" then
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 processInstanceId
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", processInstanceId, nil)
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.0"
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">StudioEventStream</string>
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 INITIAL_RECONNECT_DELAY_SECONDS = 0.5
10011
- local MAX_RECONNECT_DELAY_SECONDS = 5
10012
- local INITIAL_RESPONSE_RETRY_DELAY_SECONDS = 0.5
10013
- local MAX_RESPONSE_RETRY_DELAY_SECONDS = 5
10014
- local MAX_TERMINAL_RESPONSES = 256
10015
- local STREAM_SILENCE_TIMEOUT_SECONDS = 20
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 streamClient
10022
- local streamConnections = {}
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 decodeMessage(message)
10030
- -- Studio versions in the supported channel have surfaced either the SSE
10031
- -- data payload or the complete single-line `data:` frame. The bridge emits
10032
- -- one JSON data line per event, so normalize both forms before decoding.
10033
- local payload = message
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.kind == "cancel" then
10082
- local _requestId = envelope.requestId
10083
- local _condition = not (type(_requestId) == "string")
10084
- if not _condition then
10085
- _condition = (envelope.reason ~= "timeout" and envelope.reason ~= "aborted" and envelope.reason ~= "connection_closed")
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
- if _condition then
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 _requestId = envelope.requestId
10098
- local _condition = not (type(_requestId) == "string")
10200
+ local _peerId = envelope.peerId
10201
+ local _condition = not (type(_peerId) == "string")
10099
10202
  if not _condition then
10100
- local _peerId = envelope.peerId
10101
- _condition = not (type(_peerId) == "string")
10203
+ local _target = envelope.target
10204
+ _condition = not (type(_target) == "string")
10102
10205
  if not _condition then
10103
- local _target = envelope.target
10104
- _condition = not (type(_target) == "string")
10206
+ local _endpoint = envelope.endpoint
10207
+ _condition = not (type(_endpoint) == "string")
10105
10208
  if not _condition then
10106
- local _endpoint = envelope.endpoint
10107
- _condition = not (type(_endpoint) == "string")
10209
+ local _remainingMs = envelope.remainingMs
10210
+ _condition = not (type(_remainingMs) == "number")
10108
10211
  if not _condition then
10109
- local _remainingMs = envelope.remainingMs
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 data
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 closeCurrentStream()
10139
- local current = streamClient
10140
- streamClient = nil
10141
- for _, connection in streamConnections do
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
- streamConnections = {}
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 responseRetryDelay(attempt)
10166
- return math.min(INITIAL_RESPONSE_RETRY_DELAY_SECONDS * math.pow(2, math.max(attempt - 1, 0)), MAX_RESPONSE_RETRY_DELAY_SECONDS)
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 parseResponseDisposition(success, body)
10169
- local decodeOk, decoded = pcall(function()
10170
- return HttpService:JSONDecode(body)
10171
- end)
10172
- if not decodeOk or not (type(decoded) == "table") then
10173
- return nil
10174
- end
10175
- local acknowledgement = decoded
10176
- local disposition = acknowledgement.disposition
10177
- if disposition == "accepted" or disposition == "already_settled" or disposition == "unknown" then
10178
- return disposition
10179
- end
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] Server no longer recognizes response {requestId}; dropping stored result`)
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 postPendingResponse(requestId, entry)
10214
- local currentOptions = options
10215
- local _condition = not active or currentOptions == nil
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 _requestId = requestId
10218
- _condition = pendingResponses[_requestId] ~= entry
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 = entry.posting
10342
+ _condition = progress.phase == nil
10221
10343
  end
10222
10344
  end
10223
10345
  if _condition then
10224
10346
  return nil
10225
10347
  end
10226
- entry.posting = true
10227
- entry.retryToken += 1
10228
- task.spawn(function()
10229
- local _condition_1 = not active or options ~= currentOptions
10230
- if not _condition_1 then
10231
- local _requestId = requestId
10232
- _condition_1 = pendingResponses[_requestId] ~= entry
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 _condition_1 then
10235
- entry.posting = false
10236
- return nil
10388
+ if _condition then
10389
+ return "error"
10237
10390
  end
10238
- local responseUrl = `{currentOptions.serverUrl}/response`
10239
- local requestOk, requestResult = pcall(function()
10240
- return HttpService:RequestAsync({
10241
- Url = responseUrl,
10242
- Method = "POST",
10243
- Headers = {
10244
- ["Content-Type"] = "application/json",
10245
- },
10246
- Body = entry.body,
10247
- })
10248
- end)
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
- entry.posting = false
10254
- local failure
10255
- if not requestOk then
10256
- failure = HttpDiagnostics.formatRequestFailure(responseUrl, false, requestResult)
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
- warn(`[robloxstudio-mcp] Failed to deliver response {requestId}: {failure}`)
10266
- entry.retryAttempt += 1
10267
- local _condition_2 = not active
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
- entry.retryToken += 1
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
- postPendingResponse(requestId, entry)
10463
+ sendPendingResponse(requestId, entry)
10297
10464
  end
10298
10465
  end
10299
- local function encodeResponse(requestId, response)
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
- response = response,
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
- error = `Plugin response serialization failed: {tostring(encoded)}`,
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
- local _requestId_1 = event.requestId
10322
- local pending = pendingResponses[_requestId_1]
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
- local _condition_1 = inFlight.cancelled
10362
- if not _condition_1 then
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
- _condition_1 = terminalResponseIds[_requestId_2] ~= nil
10365
- end
10366
- if _condition_1 then
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 _condition_2 = inFlight.cancelled
10378
- if not _condition_2 then
10379
- local _requestId_2 = request.requestId
10380
- _condition_2 = terminalResponseIds[_requestId_2] ~= nil
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
- pendingResponses[_requestId_2] = entry
10401
- local _requestId_3 = request.requestId
10402
- inFlightRequests[_requestId_3] = nil
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("event stream transport", function()
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
- local function connectAfter(delaySeconds, expectedGeneration)
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
- closeCurrentStream()
10693
+ closeCurrentSocket()
10441
10694
  reconnectAttempt += 1
10442
- local delay = if duplicate then 1 else reconnectDelay(reconnectAttempt)
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
- connectAfter(delay, generation)
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
- local delay = math.max(STREAM_SILENCE_TIMEOUT_SECONDS - elapsed, 0.1)
10454
- task.delay(delay, function()
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 >= STREAM_SILENCE_TIMEOUT_SECONDS then
10460
- scheduleReconnect(expectedGeneration, `Event stream silent for {math.floor(silentFor)} seconds`)
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 _condition_1 = value.multiplayerGroupId ~= nil
10492
- if _condition_1 then
10493
- local _multiplayerGroupId = value.multiplayerGroupId
10494
- _condition_1 = not (type(_multiplayerGroupId) == "string")
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 refresh
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 instanceId = PluginSession.getInstanceId()
10528
- local multiplayerGroupId = PluginSession.getMultiplayerGroupId()
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 readyData = parseReadyResponse(readyResult.Body)
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.SSE, {
10581
- Url = `{currentOptions.serverUrl}/events?peerId={PluginSession.peerId}`,
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
- Accept = "text/event-stream",
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 event stream: {tostring(createdClient)}`)
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
- streamClient = createdClient
10599
- streamConnections = { createdClient.Opened:Connect(function(statusCode, _headers)
10600
- if not active or generation ~= expectedGeneration or streamClient ~= createdClient then
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
- lastValidEventAt = tick()
10604
- if statusCode < 200 or statusCode >= 300 then
10605
- scheduleReconnect(expectedGeneration, `Event stream opened with HTTP {statusCode}`)
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 streamClient ~= createdClient then
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 == "heartbeat" then
10625
- invokeCallback("event stream heartbeat", function()
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
- return nil
10629
- end
10630
- if event.kind == "cancel" then
10920
+ elseif event.kind == "cancel" then
10631
10921
  cancelRequest(event)
10632
- return nil
10633
- end
10634
- if event.kind == "request" then
10635
- dispatchRequest(event)
10636
- return nil
10637
- end
10638
- invokeCallback("event stream status", function()
10639
- return currentOptions.onStatus(event)
10640
- end)
10641
- if not event.knownPeer then
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 streamClient ~= createdClient then
10934
+ if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
10646
10935
  return nil
10647
10936
  end
10648
- local detail = if statusCode == 404 then `Event stream session is not registered: {message}` else `Event stream error {statusCode}: {message}`
10649
- scheduleReconnect(expectedGeneration, detail)
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 streamClient ~= createdClient then
10942
+ if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
10652
10943
  return nil
10653
10944
  end
10654
- scheduleReconnect(expectedGeneration, "Event stream closed")
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
- if not active or options == nil then
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
- generation += 1
10677
- closeCurrentStream()
10678
- reconnectAttempt = 0
10679
- connect(generation)
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 can tear down a play DataModel without giving Plugin.Unloading a
10682
- -- usable execution window. Release the native WebStreamClient before the
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
- closeCurrentStream()
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
- table.clear(inFlightRequests)
10719
- table.clear(pendingResponses)
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>