@chrrxs/robloxstudio-mcp 3.1.1 → 3.1.3

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