@chrrxs/robloxstudio-mcp-inspector 3.1.4 → 3.1.5

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.
@@ -607,6 +607,7 @@ local HttpService = _services.HttpService
607
607
  local Players = _services.Players
608
608
  local ReplicatedStorage = _services.ReplicatedStorage
609
609
  local RunService = _services.RunService
610
+ local Workspace = _services.Workspace
610
611
  local RuntimeLogBuffer = TS.import(script, script.Parent, "RuntimeLogBuffer")
611
612
  local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandlers")
612
613
  local SceneAnalysisHandlers = TS.import(script, script.Parent, "handlers", "SceneAnalysisHandlers")
@@ -633,6 +634,11 @@ local mcpUrl = DEFAULT_MCP_URL
633
634
  local BROKER_NAME = "__MCPClientBroker"
634
635
  local BROKER_OWNER_ATTRIBUTE = "__MCPBrokerOwner"
635
636
  local CLIENT_IDENTITY_KIND = "identity"
637
+ -- A normal capture needs at most 15s sampling + 5s waiting for profiler data.
638
+ -- Leave room for processing, then return a diagnostic before the outer 30s wait.
639
+ local CLIENT_PROFILER_MAX_WAIT_SECONDS = 25
640
+ local CLIENT_PROFILER_RESPONSE_RESERVE_SECONDS = 5
641
+ local CLIENT_PROFILER_POLL_SECONDS = 0.05
636
642
  -- Endpoints the server-peer broker is allowed to forward to the client peer.
637
643
  -- Each requires the client peer's plugin VM (because the buffer / require
638
644
  -- cache / etc. lives there) so the server peer alone can't satisfy them.
@@ -649,6 +655,7 @@ local CLIENT_BROKER_ALLOWED_ENDPOINTS = {
649
655
  ["/api/multiplayer-test-leave-client"] = true,
650
656
  ["/api/capture-begin"] = true,
651
657
  ["/api/capture-studio"] = true,
658
+ ["/api/capture-markers"] = true,
652
659
  ["/api/simulate-mouse-input"] = true,
653
660
  ["/api/simulate-keyboard-input"] = true,
654
661
  ["/api/focus-viewport"] = true,
@@ -836,6 +843,32 @@ local function setupClientBroker(attempt)
836
843
  return BreakpointHandlers.breakpoints(payload.data or {})
837
844
  end
838
845
  if payload and payload.endpoint == "/api/capture-script-profiler" then
846
+ -- os.clock is VM-local. Only the engine-synchronized server clock can
847
+ -- reject an envelope that expired while travelling to this client.
848
+ local _remainingMs = payload.remainingMs
849
+ local _condition_1 = type(_remainingMs) == "number"
850
+ if _condition_1 then
851
+ _condition_1 = payload.remainingMs <= 0
852
+ end
853
+ local _condition_2 = _condition_1
854
+ if not _condition_2 then
855
+ local _expiresAtServerTime = payload.expiresAtServerTime
856
+ local _condition_3 = type(_expiresAtServerTime) == "number"
857
+ if _condition_3 then
858
+ _condition_3 = Workspace:GetServerTimeNow() >= payload.expiresAtServerTime
859
+ end
860
+ _condition_2 = _condition_3
861
+ end
862
+ if _condition_2 then
863
+ return {
864
+ success = false,
865
+ error = "client_broker_expired",
866
+ stage = "client_broker_pre_start",
867
+ requestId = payload.requestId,
868
+ executionOutcome = "not_executed",
869
+ message = "Client profiler request expired before capture started.",
870
+ }
871
+ end
839
872
  return ScriptProfilerHandlers.captureScriptProfiler(payload.data or {})
840
873
  end
841
874
  if payload and payload.endpoint == "/api/capture-micro-profiler" then
@@ -855,6 +888,9 @@ local function setupClientBroker(attempt)
855
888
  return CaptureHandlers.captureStudio(payload.data or {})
856
889
  end)
857
890
  end
891
+ if payload and payload.endpoint == "/api/capture-markers" then
892
+ return CaptureHandlers.captureMarkers(payload.data or {})
893
+ end
858
894
  if payload and payload.endpoint == CaptureTransfer.READ_ENDPOINT then
859
895
  return CaptureTransfer.read(payload.data or {})
860
896
  end
@@ -1133,7 +1169,112 @@ local function refreshAllProxyRegistrations()
1133
1169
  task.spawn(registerProxyEntry, entry)
1134
1170
  end
1135
1171
  end
1136
- local function dispatchClientRequest(peerId, target, endpoint, data)
1172
+ local function invokeClientProfiler(entry, target, data, context)
1173
+ local startedAt = os.clock()
1174
+ local deadlineAt = math.min(context.deadlineAt - CLIENT_PROFILER_RESPONSE_RESERVE_SECONDS, startedAt + CLIENT_PROFILER_MAX_WAIT_SECONDS)
1175
+ local failure = function(code, stage, outcome, detail)
1176
+ context.executionOutcome = outcome
1177
+ return {
1178
+ success = false,
1179
+ error = code,
1180
+ stage = stage,
1181
+ requestId = context.requestId,
1182
+ peerId = entry.peerId,
1183
+ target = target,
1184
+ elapsedMs = math.floor(math.max(0, os.clock() - startedAt) * 1000),
1185
+ executionOutcome = outcome,
1186
+ message = if outcome == "unknown" then `{detail} Only the broker's local wait ended; remote execution may still be running. No retry was started.` else `{detail} Client profiler execution was not started.`,
1187
+ }
1188
+ end
1189
+ if context.isCancelled() then
1190
+ return failure("client_broker_cancelled", "client_broker_admission", "not_executed", "Request cancelled before client invocation.")
1191
+ end
1192
+ if startedAt >= deadlineAt then
1193
+ return failure("client_broker_timeout", "client_broker_admission", "not_executed", "Insufficient request budget for client invocation and response delivery.")
1194
+ end
1195
+ local interrupted = function()
1196
+ if context.isCancelled() then
1197
+ return failure("client_broker_cancelled", "client_broker_wait", "unknown", "Request cancelled while awaiting the client profiler response.")
1198
+ end
1199
+ local _peerId = entry.peerId
1200
+ local _condition = proxyByPeerId[_peerId] ~= entry
1201
+ if not _condition then
1202
+ local _player = entry.player
1203
+ _condition = proxyByPlayer[_player] ~= entry
1204
+ if not _condition then
1205
+ _condition = entry.player.Parent == nil or entry.remote.Parent == nil or not RunService:IsRunning()
1206
+ end
1207
+ end
1208
+ if _condition then
1209
+ return failure("client_broker_disconnected", "client_broker_wait", "unknown", "Client proxy disconnected while awaiting the profiler response.")
1210
+ end
1211
+ if os.clock() >= deadlineAt then
1212
+ return failure("client_broker_timeout", "client_broker_wait", "unknown", "Client profiler round-trip exceeded its response deadline.")
1213
+ end
1214
+ return nil
1215
+ end
1216
+ local remainingMs = math.max(0, math.floor((deadlineAt - os.clock()) * 1000))
1217
+ local envelope = {
1218
+ endpoint = "/api/capture-script-profiler",
1219
+ data = data,
1220
+ requestId = context.requestId,
1221
+ remainingMs = remainingMs,
1222
+ expiresAtServerTime = Workspace:GetServerTimeNow() + remainingMs / 1000,
1223
+ }
1224
+ local settled = false
1225
+ local response
1226
+ context.executionOutcome = "unknown"
1227
+ local invocation = task.spawn(function()
1228
+ local ok, result = pcall(function()
1229
+ return entry.remote:InvokeClient(entry.player, envelope)
1230
+ end)
1231
+ -- Cancelling a suspended engine wait is best effort. A late return must
1232
+ -- never replace the chosen outcome, even if task.cancel was unavailable.
1233
+ if settled then
1234
+ return nil
1235
+ end
1236
+ local interruption = interrupted()
1237
+ if interruption ~= nil then
1238
+ response = interruption
1239
+ elseif not ok then
1240
+ response = failure("client_broker_invoke_failed", "client_broker_wait", "unknown", `InvokeClient failed: {tostring(result)}`)
1241
+ elseif result == nil then
1242
+ response = failure("client_broker_nil_response", "client_broker_wait", "unknown", "Client profiler returned no response.")
1243
+ else
1244
+ response = result
1245
+ -- Only this endpoint's broker pre-start refusal establishes that no
1246
+ -- remote capture ran. Other responses retain normal handler outcome
1247
+ -- classification; arbitrary payload fields cannot override transport.
1248
+ context.executionOutcome = nil
1249
+ if type(result) == "table" then
1250
+ local reply = result
1251
+ if reply.error == "client_broker_expired" and reply.stage == "client_broker_pre_start" then
1252
+ context.executionOutcome = "not_executed"
1253
+ end
1254
+ end
1255
+ end
1256
+ settled = true
1257
+ end)
1258
+ while not settled do
1259
+ local interruption = interrupted()
1260
+ if interruption ~= nil then
1261
+ response = interruption
1262
+ settled = true
1263
+ -- This only releases the local coroutine; it cannot stop the remote
1264
+ -- OnClientInvoke callback or roll back an already-started capture.
1265
+ pcall(function()
1266
+ return task.cancel(invocation)
1267
+ end)
1268
+ break
1269
+ end
1270
+ task.wait(math.min(CLIENT_PROFILER_POLL_SECONDS, deadlineAt - os.clock()))
1271
+ end
1272
+ return response
1273
+ end
1274
+ local function dispatchClientRequest(peerId, target, endpoint, data, context)
1275
+ if endpoint == "/api/capture-script-profiler" then
1276
+ context.executionOutcome = "not_executed"
1277
+ end
1137
1278
  local _peerId = peerId
1138
1279
  local entry = proxyByPeerId[_peerId]
1139
1280
  local _condition = not entry
@@ -1173,6 +1314,9 @@ local function dispatchClientRequest(peerId, target, endpoint, data)
1173
1314
  error = `Client-proxy does not forward {endpoint}. Allowed: {table.concat(allowed, ", ")}.`,
1174
1315
  }
1175
1316
  end
1317
+ if endpoint == "/api/capture-script-profiler" then
1318
+ return invokeClientProfiler(entry, target, data, context)
1319
+ end
1176
1320
  if endpoint == "/api/capture-studio" then
1177
1321
  return CaptureTransfer.receive(function(captureEndpoint, captureData)
1178
1322
  return entry.remote:InvokeClient(entry.player, {
@@ -1332,6 +1476,7 @@ local routeMap = {
1332
1476
  ["/api/capture-studio"] = CaptureHandlers.captureStudio,
1333
1477
  ["/api/capture-begin"] = CaptureHandlers.captureBegin,
1334
1478
  ["/api/capture-read"] = CaptureHandlers.captureRead,
1479
+ ["/api/capture-markers"] = CaptureHandlers.captureMarkers,
1335
1480
  ["/api/simulate-mouse-input"] = InputHandlers.simulateMouseInput,
1336
1481
  ["/api/simulate-keyboard-input"] = InputHandlers.simulateKeyboardInput,
1337
1482
  ["/api/find-and-replace-in-scripts"] = ScriptHandlers.findAndReplaceInScripts,
@@ -1372,7 +1517,7 @@ local function getConnectionStatus()
1372
1517
  end
1373
1518
  local function dispatchSocketRequest(request, context)
1374
1519
  if request.peerId ~= PluginSession.peerId then
1375
- return ClientBroker.dispatchClientRequest(request.peerId, request.target, request.endpoint, request.data)
1520
+ return ClientBroker.dispatchClientRequest(request.peerId, request.target, request.endpoint, request.data, context)
1376
1521
  end
1377
1522
  local _condition = assignedRole
1378
1523
  if _condition == nil then
@@ -1427,34 +1572,9 @@ local function handleTransportUpdate(update)
1427
1572
  if not conn.isActive then
1428
1573
  return nil
1429
1574
  end
1430
- if update.state == "open" then
1431
- conn.lastHttpOk = true
1432
- conn.lastMcpOk = false
1433
- conn.consecutiveFailures = 0
1434
- conn.currentRetryDelay = 0.5
1435
- conn.mcpWaitStartTime = tick()
1436
- else
1437
- conn.lastHttpOk = false
1438
- conn.lastMcpOk = false
1439
- conn.consecutiveFailures = update.attempt
1440
- if update.retryDelay > 0 then
1441
- conn.currentRetryDelay = update.retryDelay
1442
- end
1443
- conn.mcpWaitStartTime = nil
1444
- end
1575
+ State.applyTransportUpdate(update, tick())
1445
1576
  UI.updateUIState()
1446
1577
  UI.updateToolbarIcon()
1447
- if update.state == "waiting-duplicate" then
1448
- local ui = UI.getElements()
1449
- ui.statusLabel.Text = "Waiting for previous instance"
1450
- ui.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
1451
- local _condition = update.detail
1452
- if _condition == nil then
1453
- _condition = "The previous plugin instance is still active."
1454
- end
1455
- ui.detailStatusLabel.Text = _condition
1456
- ui.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
1457
- end
1458
1578
  end
1459
1579
  local nameChangeConn
1460
1580
  local placeIdChangeConn
@@ -1505,6 +1625,7 @@ local function activatePlugin()
1505
1625
  conn.lastHttpOk = false
1506
1626
  conn.lastMcpOk = false
1507
1627
  conn.mcpWaitStartTime = nil
1628
+ State.clearTransportDiagnostics()
1508
1629
  local normalizedUrl = ServerUrlSettings.normalizeServerUrl(ui.urlInput.Text)
1509
1630
  conn.serverUrl = if normalizedUrl ~= "" then normalizedUrl else conn.serverUrl
1510
1631
  if conn.serverUrl == "" then
@@ -1527,6 +1648,7 @@ local function activatePlugin()
1527
1648
  onTransportUpdate = handleTransportUpdate,
1528
1649
  })
1529
1650
  if not conn.heartbeatConnection then
1651
+ local lastDiagnosticUpdate = tick()
1530
1652
  conn.heartbeatConnection = RunService.Heartbeat:Connect(function()
1531
1653
  if initialRole == "server" and not RunService:IsRunning() then
1532
1654
  ClientBroker.disconnectAllProxies()
@@ -1539,6 +1661,11 @@ local function activatePlugin()
1539
1661
  PluginSession.invalidatePlaceName()
1540
1662
  StudioWebSocket.refresh()
1541
1663
  end
1664
+ local now = tick()
1665
+ if conn.nextRetryAt ~= nil and now - lastDiagnosticUpdate >= 0.25 then
1666
+ lastDiagnosticUpdate = now
1667
+ UI.updateUIState()
1668
+ end
1542
1669
  end)
1543
1670
  end
1544
1671
  if initialRole == "edit" then
@@ -1555,6 +1682,7 @@ function deactivatePlugin()
1555
1682
  conn.lastHttpOk = false
1556
1683
  conn.lastMcpOk = false
1557
1684
  conn.mcpWaitStartTime = nil
1685
+ State.clearTransportDiagnostics()
1558
1686
  StudioWebSocket.stop()
1559
1687
  disconnectIdentityWatchers()
1560
1688
  if initialRole == "server" then
@@ -1814,9 +1942,9 @@ local function computeBridgeStamp()
1814
1942
  for i = 1, #combined do
1815
1943
  h = (h * 33 + (string.byte(combined, i))) % 2147483647
1816
1944
  end
1817
- -- "3.1.4" is replaced with the package version at package time
1945
+ -- "3.1.5" is replaced with the package version at package time
1818
1946
  -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1819
- return `{tostring(h)}-3.1.4`
1947
+ return `{tostring(h)}-3.1.5`
1820
1948
  end
1821
1949
  local BRIDGE_STAMP = computeBridgeStamp()
1822
1950
  local function setSource(scriptInst, source)
@@ -2930,6 +3058,8 @@ local RenderMonitor = TS.import(script, script.Parent.Parent, "RenderMonitor")
2930
3058
  local CaptureService = game:GetService("CaptureService")
2931
3059
  local AssetService = game:GetService("AssetService")
2932
3060
  local Workspace = game:GetService("Workspace")
3061
+ local CoreGui = game:GetService("CoreGui")
3062
+ local RunService = game:GetService("RunService")
2933
3063
  local MAX_TILE_SIZE = 1024
2934
3064
  local MAX_RAW_PIXEL_BYTES = 36 * 1024 * 1024
2935
3065
  local MAX_CREATED_IMAGE_DIM = 2048
@@ -2941,10 +3071,16 @@ local PAD_BYTE = (string.byte("="))
2941
3071
  -- ViewportSize, which is exactly the coordinate space simulate_mouse_input
2942
3072
  -- expects. It is gated behind Studio FFlags and is missing from @rbxts/types,
2943
3073
  -- hence the local structural declarations below.
3074
+ -- Studio exposes this API at runtime, but the installed @rbxts/types predates it.
2944
3075
  -- Unchecked cast, single reason: these enums exist in Studio but are missing
2945
3076
  -- from @rbxts/types, so the global Enum table has to be read dynamically.
2946
3077
  local ENUM_TABLE = Enum
2947
3078
  local STUDIO_CAPTURE_FORMATS = ENUM_TABLE.StudioCaptureScreenshotFormat
3079
+ local _FIT_TO_WINDOW = ENUM_TABLE.DeviceSimulatorScalingMode
3080
+ if _FIT_TO_WINDOW ~= nil then
3081
+ _FIT_TO_WINDOW = _FIT_TO_WINDOW.FitToWindow
3082
+ end
3083
+ local FIT_TO_WINDOW = _FIT_TO_WINDOW
2948
3084
  -- Measured on a 1980x1032 viewport: RGBA8 completes in ~0.16s and PNG in ~1s.
2949
3085
  -- A capture that is still Pending well past that never completes (it happens
2950
3086
  -- while a playtest owns the renderer), so we stop waiting and let the caller
@@ -3059,7 +3195,11 @@ end
3059
3195
  -- pixels back is blocked, but capturing is not). The returned rbxtemp:// id is
3060
3196
  -- a process-scoped handle: it can be dereferenced from a DIFFERENT, more
3061
3197
  -- privileged DM (the edit DM) — see captureRead.
3198
+ local withViewportRead, captureScreenshotFrame
3062
3199
  local function doCaptureScreenshot()
3200
+ return withViewportRead(captureScreenshotFrame)
3201
+ end
3202
+ function captureScreenshotFrame()
3063
3203
  -- Fast-fail with a clear reason if the window isn't rendering — otherwise
3064
3204
  -- CaptureScreenshot's callback never fires and we'd block for the full 10s.
3065
3205
  local notRendering = RenderMonitor.notRenderingReason()
@@ -3095,8 +3235,12 @@ local function readContentToBase64(contentId)
3095
3235
  return AssetService:CreateEditableImageAsync(Content.fromUri(contentId))
3096
3236
  end)
3097
3237
  if not editableOk then
3238
+ -- Lead with Roblox's own message: the Game Settings toggle is only one of
3239
+ -- the reasons this can fail (temporary-texture privilege and multiplayer
3240
+ -- client handles are others), and telling someone who already enabled
3241
+ -- it to enable it again is a dead end.
3098
3242
  return {
3099
- error = `Failed to create EditableImage from screenshot. Enable EditableImage API: Game Settings > Security > 'Allow Mesh / Image APIs'. ({tostring(editableResult)})`,
3243
+ error = `Failed to create EditableImage from screenshot: {tostring(editableResult)}. ` .. "If that mentions permissions, check Game Settings > Security > 'Allow Mesh / Image APIs'.",
3100
3244
  }
3101
3245
  end
3102
3246
  local sourceImage = editableResult
@@ -3168,7 +3312,13 @@ end
3168
3312
  -- cannot capture right now (missing FFlag, permission not granted, or this
3169
3313
  -- DataModel is not the active one) so the caller can fall back to the
3170
3314
  -- CaptureService + EditableImage path.
3315
+ local captureStudioFrame
3171
3316
  local function doStudioCapture(wantPng)
3317
+ return withViewportRead(function()
3318
+ return captureStudioFrame(wantPng)
3319
+ end)
3320
+ end
3321
+ function captureStudioFrame(wantPng)
3172
3322
  if STUDIO_CAPTURE_FORMATS == nil then
3173
3323
  return nil
3174
3324
  end
@@ -3290,12 +3440,377 @@ local function captureRead(requestData)
3290
3440
  end
3291
3441
  return readContentToBase64(contentId)
3292
3442
  end
3443
+ -- Viewport corner markers for the host-side window capture fallback.
3444
+ --
3445
+ -- Roblox's own capture APIs can be unavailable for the play viewport:
3446
+ -- StudioCaptureService reports CanCaptureScreenshot() == false in the play
3447
+ -- client (RequestScreenshotPermissionAsync raises "Feature not supported
3448
+ -- yet"), and CaptureService:CaptureScreenshot hands back a fully black frame
3449
+ -- there on some Studio builds (observed with the Vulkan renderer). When that
3450
+ -- happens the MCP server grabs the whole Studio window through the host OS
3451
+ -- instead and needs to know where the 3D viewport sits inside that window.
3452
+ -- Four small magenta squares pinned to the viewport corners give it an exact,
3453
+ -- DPI-independent answer; the server hides them again before the real capture.
3454
+ local MARKER_GUI_NAME = "__MCPCaptureMarkers"
3455
+ local MARKER_SIZE = 12
3456
+ local MARKER_COLOR = Color3.fromRGB(255, 0, 255)
3457
+ -- Safety net: the server always hides the markers itself, but if it dies
3458
+ -- mid-capture the viewport must not stay decorated.
3459
+ local MARKER_AUTO_HIDE_SECONDS = 15
3460
+ local MARKER_RENDER_FRAMES = 3
3461
+ local MARKER_RENDER_TIMEOUT = 2
3462
+ local CAPTURE_AUTO_FINISH_SECONDS = 60
3463
+ local VIEWPORT_ACQUIRE_TIMEOUT = 25
3464
+ local activeMarkerCapture
3465
+ local activeViewportReads = 0
3466
+ function withViewportRead(readFrame)
3467
+ local start = os.clock()
3468
+ while activeMarkerCapture ~= nil do
3469
+ if os.clock() - start >= VIEWPORT_ACQUIRE_TIMEOUT then
3470
+ return {
3471
+ error = "Timed out waiting for the active host capture to restore the viewport; retry capture.",
3472
+ }
3473
+ end
3474
+ task.wait(0.03)
3475
+ end
3476
+ -- No yield between observing the writer and acquiring this reader.
3477
+ activeViewportReads += 1
3478
+ local ok, result = pcall(readFrame)
3479
+ activeViewportReads -= 1
3480
+ if not ok then
3481
+ return {
3482
+ error = `Viewport capture failed: {tostring(result)}`,
3483
+ }
3484
+ end
3485
+ return result
3486
+ end
3487
+ local function markerAccessError(action, captureId)
3488
+ local capture = activeMarkerCapture
3489
+ if captureId ~= nil then
3490
+ local _captureId = captureId
3491
+ local _condition = not (type(_captureId) == "string")
3492
+ if not _condition then
3493
+ _condition = captureId == ""
3494
+ end
3495
+ if _condition then
3496
+ return "A non-empty captureId is required."
3497
+ end
3498
+ if capture == nil or capture.captureId ~= captureId or capture.expired then
3499
+ return "The host capture token is stale or expired; discard this capture and prepare a new transaction."
3500
+ end
3501
+ if capture.busy then
3502
+ return "The host capture is still preparing or restoring; retry after that operation completes."
3503
+ end
3504
+ elseif capture ~= nil and (action == "show" or action == "hide") then
3505
+ return "An active host capture owns the viewport markers; supply its captureId."
3506
+ end
3507
+ return nil
3508
+ end
3509
+ local function viewportSize()
3510
+ local camera = Workspace.CurrentCamera
3511
+ if camera == nil then
3512
+ return nil
3513
+ end
3514
+ local viewport = camera.ViewportSize
3515
+ return {
3516
+ viewportWidth = math.max(1, math.floor(viewport.X)),
3517
+ viewportHeight = math.max(1, math.floor(viewport.Y)),
3518
+ }
3519
+ end
3520
+ local function hideMarkers()
3521
+ local existing = CoreGui:FindFirstChild(MARKER_GUI_NAME)
3522
+ if existing ~= nil then
3523
+ existing:Destroy()
3524
+ end
3525
+ end
3526
+ -- Blocks until the markers have been composited into a few rendered frames
3527
+ -- (bounded, so a non-rendering window cannot hang the request).
3528
+ local function waitForRenderedFrames()
3529
+ local frames = 0
3530
+ local ok, connection = pcall(function()
3531
+ return RunService.RenderStepped:Connect(function()
3532
+ frames += 1
3533
+ end)
3534
+ end)
3535
+ if not ok then
3536
+ return 0
3537
+ end
3538
+ local start = os.clock()
3539
+ while frames < MARKER_RENDER_FRAMES and os.clock() - start < MARKER_RENDER_TIMEOUT do
3540
+ task.wait(0.03)
3541
+ end
3542
+ connection:Disconnect()
3543
+ return frames
3544
+ end
3545
+ local function finishMarkerCapture(captureId)
3546
+ local capture = activeMarkerCapture
3547
+ -- A delayed retry or safety callback must never touch another capture's GUI.
3548
+ if capture == nil or capture.captureId ~= captureId then
3549
+ return {
3550
+ success = true,
3551
+ captureId = captureId,
3552
+ stale = true,
3553
+ }
3554
+ end
3555
+ if capture.busy then
3556
+ return {
3557
+ error = "Host capture is still preparing or restoring; retry finish with the same captureId.",
3558
+ captureId = captureId,
3559
+ }
3560
+ end
3561
+ capture.busy = true
3562
+ local ok, failure = pcall(function()
3563
+ hideMarkers()
3564
+ local simulator = capture.simulator
3565
+ local originalMode = capture.scalingMode
3566
+ if capture.restorePending and simulator ~= nil and originalMode ~= nil then
3567
+ -- Never switch devices or overwrite a mode the user changed mid-capture.
3568
+ if simulator:GetDeviceAsync() == capture.deviceId and simulator:GetScalingModeAsync() == FIT_TO_WINDOW and simulator:GetDeviceAsync() == capture.deviceId then
3569
+ simulator:SetScalingModeAsync(originalMode)
3570
+ -- A minimized window may not render; the restored mode still releases
3571
+ -- the transaction so the next capture can proceed when it is visible.
3572
+ waitForRenderedFrames()
3573
+ end
3574
+ capture.restorePending = false
3575
+ end
3576
+ end)
3577
+ capture.busy = false
3578
+ if not ok then
3579
+ return {
3580
+ error = `Could not finish host capture or restore device scaling: {tostring(failure)} Retry finish with captureId {captureId}, or restore the simulator scaling mode manually.`,
3581
+ captureId = captureId,
3582
+ }
3583
+ end
3584
+ activeMarkerCapture = nil
3585
+ return {
3586
+ success = true,
3587
+ captureId = captureId,
3588
+ }
3589
+ end
3590
+ local function prepareMarkerCapture()
3591
+ local start = os.clock()
3592
+ while activeMarkerCapture ~= nil or activeViewportReads > 0 do
3593
+ if os.clock() - start >= VIEWPORT_ACQUIRE_TIMEOUT then
3594
+ return {
3595
+ error = "Timed out waiting for another viewport capture to finish; retry host capture.",
3596
+ }
3597
+ end
3598
+ task.wait(0.03)
3599
+ end
3600
+ local notRendering = RenderMonitor.notRenderingReason()
3601
+ if notRendering ~= nil then
3602
+ return {
3603
+ error = notRendering,
3604
+ }
3605
+ end
3606
+ local size = viewportSize()
3607
+ if size == nil then
3608
+ return {
3609
+ error = "No CurrentCamera; cannot prepare host capture.",
3610
+ }
3611
+ end
3612
+ local capture = {
3613
+ captureId = game:GetService("HttpService"):GenerateGUID(false),
3614
+ restorePending = false,
3615
+ busy = true,
3616
+ expired = false,
3617
+ }
3618
+ activeMarkerCapture = capture
3619
+ task.delay(CAPTURE_AUTO_FINISH_SECONDS, function()
3620
+ if activeMarkerCapture ~= capture then
3621
+ return nil
3622
+ end
3623
+ capture.expired = true
3624
+ -- A yielding prepare owns the state until it resumes and rolls back.
3625
+ if capture.busy then
3626
+ return nil
3627
+ end
3628
+ local result = finishMarkerCapture(capture.captureId)
3629
+ if result.error ~= nil then
3630
+ warn(result.error)
3631
+ end
3632
+ end)
3633
+ local ok, failure = pcall(function()
3634
+ -- Unchecked cast: the live simulator service is missing from this SDK's
3635
+ -- GetService overloads; the structural return type describes only APIs used here.
3636
+ local dynamicGame = game
3637
+ local simulator = dynamicGame:GetService("StudioDeviceSimulatorService")
3638
+ capture.simulator = simulator
3639
+ capture.deviceId = simulator:GetDeviceAsync()
3640
+ if capture.deviceId == "default" then
3641
+ return nil
3642
+ end
3643
+ if FIT_TO_WINDOW == nil then
3644
+ error("Studio does not expose DeviceSimulatorScalingMode.FitToWindow; update Studio before capturing a simulated viewport.")
3645
+ end
3646
+ local scalingMode = simulator:GetScalingModeAsync()
3647
+ capture.scalingMode = scalingMode
3648
+ if scalingMode == FIT_TO_WINDOW then
3649
+ return nil
3650
+ end
3651
+ if simulator:GetScalingModeAsync() ~= scalingMode or simulator:GetDeviceAsync() ~= capture.deviceId then
3652
+ error("The simulated device or scaling mode changed while preparing host capture; retry after the device settles.")
3653
+ end
3654
+ if capture.expired then
3655
+ error("Host capture preparation expired.")
3656
+ end
3657
+ -- Set before the yielding setter: a setter failure can still have changed Studio.
3658
+ capture.restorePending = true
3659
+ simulator:SetScalingModeAsync(FIT_TO_WINDOW)
3660
+ if waitForRenderedFrames() < MARKER_RENDER_FRAMES then
3661
+ local _condition = RenderMonitor.notRenderingReason()
3662
+ if _condition == nil then
3663
+ _condition = "The fitted viewport did not render in time; restore the Studio window and retry."
3664
+ end
3665
+ error(_condition)
3666
+ end
3667
+ if simulator:GetDeviceAsync() ~= capture.deviceId or simulator:GetScalingModeAsync() ~= FIT_TO_WINDOW then
3668
+ error("The simulated device or scaling mode changed while fitting the viewport; retry after the device settles.")
3669
+ end
3670
+ end)
3671
+ capture.busy = false
3672
+ if not ok or capture.expired then
3673
+ local restored = finishMarkerCapture(capture.captureId)
3674
+ local detail = if ok then "Host capture preparation expired." else tostring(failure)
3675
+ return {
3676
+ error = `Could not prepare host capture: {detail}{if restored.error ~= nil then ` {restored.error}` else ""}`,
3677
+ captureId = capture.captureId,
3678
+ }
3679
+ end
3680
+ -- Fitting can round 1200 logical pixels to 1199: keep the pre-fit dimensions
3681
+ -- for the server's final image, while show/query report the actual fitted camera.
3682
+ local _object = {
3683
+ success = true,
3684
+ captureId = capture.captureId,
3685
+ viewportChanged = capture.restorePending,
3686
+ }
3687
+ for _k, _v in size do
3688
+ _object[_k] = _v
3689
+ end
3690
+ return _object
3691
+ end
3692
+ local function showMarkers(captureId)
3693
+ hideMarkers()
3694
+ local size = viewportSize()
3695
+ if size == nil then
3696
+ return {
3697
+ error = "No CurrentCamera; cannot place viewport markers.",
3698
+ }
3699
+ end
3700
+ local gui = Instance.new("ScreenGui")
3701
+ gui.Name = MARKER_GUI_NAME
3702
+ gui.IgnoreGuiInset = true
3703
+ gui.ScreenInsets = Enum.ScreenInsets.None
3704
+ gui.DisplayOrder = 2147483647
3705
+ gui.ResetOnSpawn = false
3706
+ gui.ZIndexBehavior = Enum.ZIndexBehavior.Global
3707
+ local corners = { { 0, 0 }, { 1, 0 }, { 0, 1 }, { 1, 1 } }
3708
+ for _, _binding in corners do
3709
+ local x = _binding[1]
3710
+ local y = _binding[2]
3711
+ local frame = Instance.new("Frame")
3712
+ frame.Name = `Corner{x}{y}`
3713
+ frame.AnchorPoint = Vector2.new(x, y)
3714
+ frame.Position = UDim2.fromScale(x, y)
3715
+ frame.Size = UDim2.fromOffset(MARKER_SIZE, MARKER_SIZE)
3716
+ frame.BackgroundColor3 = MARKER_COLOR
3717
+ frame.BackgroundTransparency = 0
3718
+ frame.BorderSizePixel = 0
3719
+ frame.ZIndex = 2147483647
3720
+ frame.Parent = gui
3721
+ end
3722
+ gui.Parent = CoreGui
3723
+ task.delay(MARKER_AUTO_HIDE_SECONDS, function()
3724
+ if gui.Parent ~= nil then
3725
+ gui:Destroy()
3726
+ end
3727
+ end)
3728
+ local framesRendered = waitForRenderedFrames()
3729
+ local accessError = markerAccessError("show", captureId)
3730
+ if accessError ~= nil then
3731
+ gui:Destroy()
3732
+ return {
3733
+ error = accessError,
3734
+ }
3735
+ end
3736
+ local renderedSize = viewportSize()
3737
+ if renderedSize == nil then
3738
+ gui:Destroy()
3739
+ return {
3740
+ error = "No CurrentCamera after rendering viewport markers.",
3741
+ }
3742
+ end
3743
+ local _object = {
3744
+ success = true,
3745
+ }
3746
+ for _k, _v in renderedSize do
3747
+ _object[_k] = _v
3748
+ end
3749
+ _object.markerSize = MARKER_SIZE
3750
+ _object.framesRendered = framesRendered
3751
+ return _object
3752
+ end
3753
+ -- prepare temporarily fits a simulated viewport; finish restores it after the
3754
+ -- clean host grab. hide only removes markers: restoring here would clip that grab.
3755
+ -- show/query report the current camera dimensions, not prepare's original size.
3756
+ local function captureMarkers(requestData)
3757
+ local action = requestData.action
3758
+ if action == "prepare" then
3759
+ return prepareMarkerCapture()
3760
+ end
3761
+ if action == "finish" then
3762
+ local captureId = requestData.captureId
3763
+ if not (type(captureId) == "string") or captureId == "" then
3764
+ return {
3765
+ error = 'capture-markers action "finish" requires a non-empty captureId.',
3766
+ }
3767
+ end
3768
+ return finishMarkerCapture(captureId)
3769
+ end
3770
+ if action == "show" or action == "hide" or action == "query" then
3771
+ local accessError = markerAccessError(action, requestData.captureId)
3772
+ if accessError ~= nil then
3773
+ return {
3774
+ error = accessError,
3775
+ }
3776
+ end
3777
+ end
3778
+ if action == "show" then
3779
+ return showMarkers(requestData.captureId)
3780
+ end
3781
+ if action == "hide" then
3782
+ hideMarkers()
3783
+ return {
3784
+ success = true,
3785
+ }
3786
+ end
3787
+ if action == "query" then
3788
+ local size = viewportSize()
3789
+ if size == nil then
3790
+ return {
3791
+ error = "No CurrentCamera; cannot read viewport size.",
3792
+ }
3793
+ end
3794
+ local _object = {
3795
+ success = true,
3796
+ }
3797
+ for _k, _v in size do
3798
+ _object[_k] = _v
3799
+ end
3800
+ _object.markerSize = MARKER_SIZE
3801
+ return _object
3802
+ end
3803
+ return {
3804
+ error = `capture-markers action must be "prepare", "finish", "show", "hide" or "query" (got {tostring(action)})`,
3805
+ }
3806
+ end
3293
3807
  return {
3294
3808
  captureScreenshotData = captureScreenshotData,
3295
3809
  captureScreenshot = captureScreenshot,
3296
3810
  captureStudio = captureStudio,
3297
3811
  captureBegin = captureBegin,
3298
3812
  captureRead = captureRead,
3813
+ captureMarkers = captureMarkers,
3299
3814
  }
3300
3815
  ]]></string>
3301
3816
  </Properties>
@@ -4879,6 +5394,16 @@ local function captureMicroProfiler(requestData)
4879
5394
  backend = backend,
4880
5395
  }
4881
5396
  end
5397
+ local frameLimitOk, frameLimitResult = safeCall(function()
5398
+ return LibMP.Control:SetFrameLimit(math.min(frameWindow, 256))
5399
+ end)
5400
+ if not frameLimitOk then
5401
+ return {
5402
+ error = "micro_profiler_frame_limit_failed",
5403
+ message = tostring(frameLimitResult),
5404
+ backend = backend,
5405
+ }
5406
+ end
4882
5407
  local profilerOk, profilerResult = safeCall(function()
4883
5408
  return LibMP.Control:EnableProfiler(true)
4884
5409
  end)
@@ -4900,6 +5425,11 @@ local function captureMicroProfiler(requestData)
4900
5425
  }
4901
5426
  end
4902
5427
  task.wait(durationMs / 1000)
5428
+ -- Active reads synchronize the engine's data; paused reads can return an
5429
+ -- empty or stale cache. Export first, then pause, including on export failure.
5430
+ local bufferOk, snapshotOrErr = safeCall(function()
5431
+ return LibMP.Control:CaptureToBufferSync()
5432
+ end)
4903
5433
  local captureStopOk, captureStopResult = safeCall(function()
4904
5434
  return LibMP.Control:EnableCapture(false)
4905
5435
  end)
@@ -4910,9 +5440,6 @@ local function captureMicroProfiler(requestData)
4910
5440
  backend = backend,
4911
5441
  }
4912
5442
  end
4913
- local bufferOk, snapshotOrErr = safeCall(function()
4914
- return LibMP.Control:CaptureToBufferSync()
4915
- end)
4916
5443
  if not bufferOk then
4917
5444
  return {
4918
5445
  error = "micro_profiler_snapshot_failed",
@@ -8512,6 +9039,7 @@ local function stopPlaytest(_requestData)
8512
9039
  -- Signal the play-server DM's StopPlayMonitor via plugin:SetSetting.
8513
9040
  -- The monitor acknowledges with the matching request id only after its
8514
9041
  -- StudioTestService:EndTest call returns from pcall.
9042
+ local hadActivePlaytest = testRunning or not StudioTestService.EditModeActive
8515
9043
  local stopRequest = StopPlayMonitor.requestStop()
8516
9044
  if not stopRequest.ok or stopRequest.requestId == nil then
8517
9045
  return {
@@ -8534,6 +9062,15 @@ local function stopPlaytest(_requestData)
8534
9062
  -- Either way clean up the pending request so a future playtest's monitor
8535
9063
  -- doesn't fire EndTest on startup against a stale signal.
8536
9064
  StopPlayMonitor.clearPending(stopRequest.requestId)
9065
+ -- Settings acknowledgement can be lost as the server VM exits. Reconcile
9066
+ -- only a playtest observed at entry, and only after both native teardown
9067
+ -- and the tracked execution have finished. Never mask an EndTest error.
9068
+ if not consumption.consumed and hadActivePlaytest and not testRunning and StudioTestService.EditModeActive then
9069
+ return {
9070
+ success = true,
9071
+ message = "Playtest stopped.",
9072
+ }
9073
+ end
8537
9074
  if testRunning then
8538
9075
  return {
8539
9076
  error = "Playtest stop signal failed or was not acknowledged. " .. "The playtest may have ended anyway; check get_connected_instances.",
@@ -10305,9 +10842,10 @@ return {
10305
10842
  <Properties>
10306
10843
  <string name="Name">State</string>
10307
10844
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
10308
- local CURRENT_VERSION = "3.1.4"
10845
+ local CURRENT_VERSION = "3.1.5"
10309
10846
  local PLUGIN_VARIANT = "inspector"
10310
10847
  local BASE_PORT = 58741
10848
+ local SENSITIVE_DIAGNOSTIC_TERMS = { "token", "authorization", "bearer", "password", "cookie", "secret" }
10311
10849
  local function createConnection(port)
10312
10850
  return {
10313
10851
  port = port,
@@ -10326,11 +10864,118 @@ local connection = createConnection(BASE_PORT)
10326
10864
  local function getActiveConnection()
10327
10865
  return connection
10328
10866
  end
10867
+ local function clearTransportDiagnostics()
10868
+ connection.transportState = nil
10869
+ connection.transportAttempt = nil
10870
+ connection.transportDetail = nil
10871
+ connection.lastTransportFailure = nil
10872
+ connection.nextRetryAt = nil
10873
+ end
10874
+ local function sanitizeTransportDetail(detail)
10875
+ -- Transport emits credential-free stage descriptions; also defend the UI
10876
+ -- against accidental raw errors, URLs, markup, and multiline responses.
10877
+ local lower = string.lower(detail)
10878
+ for _, sensitive in SENSITIVE_DIAGNOSTIC_TERMS do
10879
+ if (string.find(lower, sensitive, 1, true)) ~= nil then
10880
+ return "Connection failed (sensitive details hidden)."
10881
+ end
10882
+ end
10883
+ local plain = string.gsub(detail, "%w+://%S+", "[address hidden]")
10884
+ plain = string.gsub(plain, "%c", " ")
10885
+ plain = string.gsub(plain, "<[^>]*>", "")
10886
+ plain = string.gsub(plain, "%s+", " ")
10887
+ return if #plain > 180 then `{string.sub(plain, 1, 177)}...` else plain
10888
+ end
10889
+ local function applyTransportUpdate(update, now)
10890
+ if not connection.isActive then
10891
+ return nil
10892
+ end
10893
+ if update.state == "open" then
10894
+ clearTransportDiagnostics()
10895
+ connection.lastHttpOk = true
10896
+ connection.lastMcpOk = false
10897
+ connection.consecutiveFailures = 0
10898
+ connection.currentRetryDelay = 0.5
10899
+ connection.mcpWaitStartTime = now
10900
+ return nil
10901
+ end
10902
+ connection.lastHttpOk = false
10903
+ connection.lastMcpOk = false
10904
+ connection.consecutiveFailures = update.attempt
10905
+ connection.mcpWaitStartTime = nil
10906
+ local previousState = connection.transportState
10907
+ connection.transportState = update.state
10908
+ connection.transportAttempt = update.attempt
10909
+ if update.detail ~= nil then
10910
+ connection.transportDetail = sanitizeTransportDetail(update.detail)
10911
+ elseif update.state ~= "connecting" or previousState ~= "connecting" then
10912
+ connection.transportDetail = nil
10913
+ end
10914
+ if update.state == "retrying" or update.state == "waiting-duplicate" then
10915
+ local _condition = connection.transportDetail
10916
+ if _condition == nil then
10917
+ _condition = "Connection attempt failed."
10918
+ end
10919
+ connection.lastTransportFailure = _condition
10920
+ connection.currentRetryDelay = update.retryDelay
10921
+ connection.nextRetryAt = now + update.retryDelay
10922
+ else
10923
+ connection.nextRetryAt = nil
10924
+ end
10925
+ end
10926
+ local function getTransportDiagnostics(now)
10927
+ if not connection.isActive then
10928
+ return {
10929
+ status = "Disconnected",
10930
+ detail = "",
10931
+ }
10932
+ end
10933
+ if connection.lastHttpOk then
10934
+ return if connection.lastMcpOk then {
10935
+ status = "Connected",
10936
+ detail = "Listener: connected MCP client: connected",
10937
+ } else {
10938
+ status = "Waiting for MCP client",
10939
+ detail = "Listener: connected MCP client: not connected",
10940
+ }
10941
+ end
10942
+ local _condition = connection.transportAttempt
10943
+ if _condition == nil then
10944
+ _condition = 0
10945
+ end
10946
+ local attempt = _condition + 1
10947
+ local failure = connection.lastTransportFailure
10948
+ if connection.nextRetryAt ~= nil then
10949
+ local remaining = math.max(0, math.ceil((connection.nextRetryAt - now) * 10) / 10)
10950
+ local _object = {
10951
+ status = if connection.transportState == "waiting-duplicate" then "Waiting for previous instance" else "Listener disconnected",
10952
+ }
10953
+ local _left = "detail"
10954
+ local _condition_1 = failure
10955
+ if _condition_1 == nil then
10956
+ _condition_1 = "Connection attempt failed."
10957
+ end
10958
+ _object[_left] = `Retry attempt {attempt} in {remaining}s\n{_condition_1}`
10959
+ return _object
10960
+ end
10961
+ local _condition_1 = connection.transportDetail
10962
+ if _condition_1 == nil then
10963
+ _condition_1 = "Starting listener connection"
10964
+ end
10965
+ local stage = _condition_1
10966
+ return {
10967
+ status = `Connecting (attempt {attempt})`,
10968
+ detail = if failure ~= nil then `{stage}\nLast failure: {failure}` else stage,
10969
+ }
10970
+ end
10329
10971
  return {
10330
10972
  CURRENT_VERSION = CURRENT_VERSION,
10331
10973
  PLUGIN_VARIANT = PLUGIN_VARIANT,
10332
10974
  BASE_PORT = BASE_PORT,
10333
10975
  getActiveConnection = getActiveConnection,
10976
+ clearTransportDiagnostics = clearTransportDiagnostics,
10977
+ applyTransportUpdate = applyTransportUpdate,
10978
+ getTransportDiagnostics = getTransportDiagnostics,
10334
10979
  }
10335
10980
  ]]></string>
10336
10981
  </Properties>
@@ -10349,6 +10994,10 @@ local RunService = _services.RunService
10349
10994
  local PluginSession = TS.import(script, script.Parent, "PluginSession")
10350
10995
  local StudioTestService = game:GetService("StudioTestService")
10351
10996
  local SETTING_KEY_PREFIX = "MCP_STOP_PLAY_"
10997
+ local RESULT_KEY_SUFFIX = "_RESULT"
10998
+ -- Capture before registration yields. Requests from an earlier runtime must
10999
+ -- never stop a newly created play session, even if clearing settings was lost.
11000
+ local moduleLoadedAt = tick()
10352
11001
  -- Keep this conservative. plugin:GetSetting is backed by Studio's plugin
10353
11002
  -- settings store, and this monitor runs during every play session, including
10354
11003
  -- manually-started Play. The official reference implementation polls at 1s.
@@ -10361,9 +11010,14 @@ local POLL_INTERVAL_SEC = 1
10361
11010
  local WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 8.0
10362
11011
  local WAIT_POLL_SEC = 0.1
10363
11012
  local REQUEST_TTL_SEC = 12.0
11013
+ -- Plugin settings can silently lose writes when other Studio windows use the
11014
+ -- same plugin. Redeliver the same request, without extending its deadline/TTL.
11015
+ local RETRY_INTERVAL_SEC = 1
10364
11016
  local pluginRef
10365
11017
  local endTestIssued = false
10366
11018
  local transportLifecycle
11019
+ local pendingRequest
11020
+ local lastResult
10367
11021
  local function init(p)
10368
11022
  pluginRef = p
10369
11023
  end
@@ -10426,14 +11080,21 @@ local function writePayload(key, payload)
10426
11080
  return writeSetting(key, encoded)
10427
11081
  end
10428
11082
  local function writeResult(key, request, ok, errText)
10429
- writePayload(key, {
11083
+ local payload = {
10430
11084
  kind = "result",
10431
11085
  id = request.id,
10432
11086
  requestedAt = request.requestedAt,
10433
11087
  consumedAt = tick(),
10434
11088
  ok = ok,
10435
11089
  error = errText,
10436
- })
11090
+ }
11091
+ -- Cache before publishing: a lost acknowledgement must not issue EndTest a
11092
+ -- second time or turn a successful duplicate into an "already issued" error.
11093
+ lastResult = {
11094
+ key = key,
11095
+ payload = payload,
11096
+ }
11097
+ writePayload(key .. RESULT_KEY_SUFFIX, payload)
10437
11098
  end
10438
11099
  local function handleStopRequest(key, request)
10439
11100
  local _condition = request.kind ~= "request"
@@ -10450,10 +11111,14 @@ local function handleStopRequest(key, request)
10450
11111
  return nil
10451
11112
  end
10452
11113
  local age = tick() - request.requestedAt
10453
- if age < -5 or age > REQUEST_TTL_SEC then
11114
+ if request.requestedAt < moduleLoadedAt or age < -5 or age > REQUEST_TTL_SEC then
10454
11115
  writeSetting(key, false)
10455
11116
  return nil
10456
11117
  end
11118
+ if lastResult ~= nil and lastResult.key == key and lastResult.payload.id == request.id then
11119
+ writePayload(key .. RESULT_KEY_SUFFIX, lastResult.payload)
11120
+ return nil
11121
+ end
10457
11122
  if endTestIssued then
10458
11123
  writeResult(key, request, false, "StudioTestService:EndTest was already issued for this play session, but the runtime DataModel is still alive.")
10459
11124
  return nil
@@ -10514,6 +11179,11 @@ local function requestStop()
10514
11179
  requestedAt = tick(),
10515
11180
  }
10516
11181
  local ok = writePayload(settingKey(), payload)
11182
+ pendingRequest = if ok then {
11183
+ key = settingKey(),
11184
+ payload = payload,
11185
+ lastPublishedAt = tick(),
11186
+ } else nil
10517
11187
  return {
10518
11188
  ok = ok,
10519
11189
  requestId = if ok then requestId else nil,
@@ -10528,8 +11198,12 @@ local function waitForConsumption(requestId)
10528
11198
  }
10529
11199
  end
10530
11200
  local start = tick()
11201
+ local pending = pendingRequest
11202
+ local key = if pending ~= nil and pending.payload.id == requestId then pending.key else settingKey()
10531
11203
  while tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC do
10532
- local payload = decodePayload(readSetting(settingKey()))
11204
+ -- Separate slots prevent a retry from overwriting a durable acknowledgement
11205
+ -- during a transient nil read after the server VM has already exited.
11206
+ local payload = decodePayload(readSetting(key .. RESULT_KEY_SUFFIX))
10533
11207
  if payload and payload.kind == "result" and payload.id == requestId then
10534
11208
  return {
10535
11209
  ok = payload.ok == true,
@@ -10537,6 +11211,19 @@ local function waitForConsumption(requestId)
10537
11211
  error = payload.error,
10538
11212
  }
10539
11213
  end
11214
+ local _condition = pending ~= nil and pendingRequest == pending and pending.payload.id == requestId and tick() - pending.lastPublishedAt >= RETRY_INTERVAL_SEC
11215
+ if _condition then
11216
+ local _exp = tick()
11217
+ local _condition_1 = pending.payload.requestedAt
11218
+ if _condition_1 == nil then
11219
+ _condition_1 = 0
11220
+ end
11221
+ _condition = _exp - _condition_1 < REQUEST_TTL_SEC
11222
+ end
11223
+ if _condition then
11224
+ writePayload(key, pending.payload)
11225
+ pending.lastPublishedAt = tick()
11226
+ end
10540
11227
  task.wait(WAIT_POLL_SEC)
10541
11228
  end
10542
11229
  return {
@@ -10549,6 +11236,17 @@ local function clearPending(requestId)
10549
11236
  if not pluginRef then
10550
11237
  return nil
10551
11238
  end
11239
+ local _condition = requestId == nil
11240
+ if not _condition then
11241
+ local _result = pendingRequest
11242
+ if _result ~= nil then
11243
+ _result = _result.payload.id
11244
+ end
11245
+ _condition = _result == requestId
11246
+ end
11247
+ if _condition then
11248
+ pendingRequest = nil
11249
+ end
10552
11250
  local myKey = settingKey()
10553
11251
  if requestId ~= nil then
10554
11252
  local payload = decodePayload(readSetting(myKey))
@@ -10580,6 +11278,7 @@ local PROTOCOL_VERSION = 1
10580
11278
  local INITIAL_RETRY_DELAY_SECONDS = 0.5
10581
11279
  local MAX_RETRY_DELAY_SECONDS = 5
10582
11280
  local SOCKET_SILENCE_TIMEOUT_SECONDS = 20
11281
+ local CONNECTION_STAGE_TIMEOUT_SECONDS = 20
10583
11282
  local RESPONSE_RETENTION_SECONDS = 5 * 60
10584
11283
  local RESPONSE_ACK_TIMEOUT_SECONDS = 120
10585
11284
  local MAX_TERMINAL_RESPONSES = 32768
@@ -10600,7 +11299,11 @@ local socketOpen = false
10600
11299
  local socketConnections = {}
10601
11300
  local lastValidEventAt = 0
10602
11301
  local cachedReady
10603
- local readyRefreshPending = false
11302
+ local connectionWork
11303
+ local refreshWork
11304
+ local refreshRequested = false
11305
+ local reconnectTimer
11306
+ local silenceTimer
10604
11307
  local pendingResponseBytes = 0
10605
11308
  local inFlightRequestBytes = 0
10606
11309
  local pendingRejectionCount = 0
@@ -11147,7 +11850,7 @@ local function dispatchRequest(request, requestBytes)
11147
11850
  local dispatchOk, response = pcall(function()
11148
11851
  return dispatchOptions.dispatchRequest(request, context)
11149
11852
  end)
11150
- local executionOutcome = if dispatchOk then handlerOutcome(response) else "error"
11853
+ local executionOutcome = context.executionOutcome or (if dispatchOk then handlerOutcome(response) else "error")
11151
11854
  -- Report handler return before JSON encoding or result retention can fail.
11152
11855
  completeProgress(request.requestId, inFlight.progress, executionOutcome)
11153
11856
  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)
@@ -11171,6 +11874,90 @@ local function reportTransport(update)
11171
11874
  end)
11172
11875
  end
11173
11876
  end
11877
+ local function ownsWork(work)
11878
+ return active and generation == work.generation and options == work.options and (connectionWork == work or refreshWork == work)
11879
+ end
11880
+ local function cancelThread(worker)
11881
+ -- Cancellation is best-effort for native yielding calls. Identity checks
11882
+ -- remain necessary when an operation returns after its owner was retired.
11883
+ if worker ~= nil and worker ~= coroutine.running() then
11884
+ pcall(function()
11885
+ return task.cancel(worker)
11886
+ end)
11887
+ end
11888
+ end
11889
+ local function finishWork(work)
11890
+ if connectionWork == work then
11891
+ connectionWork = nil
11892
+ end
11893
+ if refreshWork == work then
11894
+ refreshWork = nil
11895
+ end
11896
+ cancelThread(work.deadline)
11897
+ cancelThread(work.worker)
11898
+ work.deadline = nil
11899
+ work.worker = nil
11900
+ end
11901
+ local function cancelTransportWork()
11902
+ if connectionWork ~= nil then
11903
+ finishWork(connectionWork)
11904
+ end
11905
+ if refreshWork ~= nil then
11906
+ refreshRequested = true
11907
+ finishWork(refreshWork)
11908
+ end
11909
+ cancelThread(reconnectTimer)
11910
+ cancelThread(silenceTimer)
11911
+ reconnectTimer = nil
11912
+ silenceTimer = nil
11913
+ end
11914
+ local function runWork(work, callback, failed)
11915
+ local finished = false
11916
+ local worker = task.spawn(function()
11917
+ if not ownsWork(work) then
11918
+ return nil
11919
+ end
11920
+ work.worker = coroutine.running()
11921
+ local ok, workError = pcall(callback)
11922
+ work.worker = nil
11923
+ finished = true
11924
+ if not ok and ownsWork(work) then
11925
+ -- Native upgrade errors can echo credential-bearing request data.
11926
+ local detail = if work.stage == "WebSocket upgrade" then "WebSocket upgrade failed" else `{work.stage} failed: {tostring(workError)}`
11927
+ failed(detail)
11928
+ end
11929
+ end)
11930
+ if not finished and ownsWork(work) then
11931
+ work.worker = worker
11932
+ end
11933
+ end
11934
+ local flushRefresh
11935
+ local function beginStage(work, stage)
11936
+ cancelThread(work.deadline)
11937
+ work.stage = stage
11938
+ work.deadline = task.delay(CONNECTION_STAGE_TIMEOUT_SECONDS, function()
11939
+ if not ownsWork(work) then
11940
+ return nil
11941
+ end
11942
+ work.deadline = nil
11943
+ local detail = `{stage} timed out after {CONNECTION_STAGE_TIMEOUT_SECONDS} seconds`
11944
+ if connectionWork == work then
11945
+ scheduleReconnect(work.generation, detail)
11946
+ else
11947
+ finishWork(work)
11948
+ warn(`[robloxstudio-mcp] {detail}`)
11949
+ flushRefresh()
11950
+ end
11951
+ end)
11952
+ if connectionWork == work then
11953
+ reportTransport({
11954
+ state = "connecting",
11955
+ attempt = reconnectAttempt,
11956
+ retryDelay = 0,
11957
+ detail = `{stage} (timeout {CONNECTION_STAGE_TIMEOUT_SECONDS} seconds)`,
11958
+ })
11959
+ end
11960
+ end
11174
11961
  local connect
11175
11962
  function scheduleReconnect(expectedGeneration, detail, duplicate)
11176
11963
  if duplicate == nil then
@@ -11179,7 +11966,14 @@ function scheduleReconnect(expectedGeneration, detail, duplicate)
11179
11966
  if not active or generation ~= expectedGeneration then
11180
11967
  return nil
11181
11968
  end
11969
+ -- A healthy registration gets one socket-only revalidation, preserving
11970
+ -- recovery under HTTP quota. Any failure before Opened exhausts that path.
11971
+ if not socketOpen then
11972
+ cachedReady = nil
11973
+ end
11182
11974
  generation += 1
11975
+ local nextGeneration = generation
11976
+ cancelTransportWork()
11183
11977
  closeCurrentSocket()
11184
11978
  reconnectAttempt += 1
11185
11979
  local delay = if duplicate then 1 else retryDelay(reconnectAttempt)
@@ -11189,8 +11983,11 @@ function scheduleReconnect(expectedGeneration, detail, duplicate)
11189
11983
  retryDelay = delay,
11190
11984
  detail = detail,
11191
11985
  })
11192
- local nextGeneration = generation
11193
- task.delay(delay, function()
11986
+ if not active or generation ~= nextGeneration then
11987
+ return nil
11988
+ end
11989
+ reconnectTimer = task.delay(delay, function()
11990
+ reconnectTimer = nil
11194
11991
  if active and generation == nextGeneration then
11195
11992
  connect(nextGeneration)
11196
11993
  end
@@ -11198,10 +11995,11 @@ function scheduleReconnect(expectedGeneration, detail, duplicate)
11198
11995
  end
11199
11996
  local function watchForSilence(expectedGeneration, expectedClient)
11200
11997
  local elapsed = tick() - lastValidEventAt
11201
- task.delay(math.max(SOCKET_SILENCE_TIMEOUT_SECONDS - elapsed, 0.1), function()
11998
+ silenceTimer = task.delay(math.max(SOCKET_SILENCE_TIMEOUT_SECONDS - elapsed, 0.1), function()
11202
11999
  if not active or generation ~= expectedGeneration or socketClient ~= expectedClient then
11203
12000
  return nil
11204
12001
  end
12002
+ silenceTimer = nil
11205
12003
  local silentFor = tick() - lastValidEventAt
11206
12004
  if silentFor >= SOCKET_SILENCE_TIMEOUT_SECONDS then
11207
12005
  scheduleReconnect(expectedGeneration, `WebSocket silent for {math.floor(silentFor)} seconds`)
@@ -11268,13 +12066,18 @@ local function parseReadyResponse(body)
11268
12066
  transportToken = value.transportToken,
11269
12067
  }
11270
12068
  end
11271
- local function registerReady(currentOptions, expectedGeneration, reconnectOnFailure)
12069
+ local function registerReady(work, reconnectOnFailure)
12070
+ if not ownsWork(work) then
12071
+ return nil
12072
+ end
12073
+ local currentOptions = work.options
12074
+ local expectedGeneration = work.generation
11272
12075
  local instanceId = PluginSession.getInstanceId()
11273
12076
  local multiplayerGroupId = PluginSession.getMultiplayerGroupId()
11274
12077
  local transportRole = PluginSession.getRole()
11275
12078
  local readyUrl = `{currentOptions.serverUrl}/ready`
11276
12079
  local readyPayload = PluginSession.createReadyPayload(PluginSession.peerId, transportRole, instanceId, multiplayerGroupId)
11277
- if not active or generation ~= expectedGeneration or options ~= currentOptions then
12080
+ if not ownsWork(work) then
11278
12081
  return nil
11279
12082
  end
11280
12083
  local readyOk, readyResult = pcall(function()
@@ -11287,7 +12090,7 @@ local function registerReady(currentOptions, expectedGeneration, reconnectOnFail
11287
12090
  Body = HttpService:JSONEncode(readyPayload),
11288
12091
  })
11289
12092
  end)
11290
- if not active or generation ~= expectedGeneration or options ~= currentOptions then
12093
+ if not ownsWork(work) then
11291
12094
  return nil
11292
12095
  end
11293
12096
  local readyLogKey = `{currentOptions.serverUrl}|{PluginSession.peerId}`
@@ -11317,7 +12120,7 @@ local function registerReady(currentOptions, expectedGeneration, reconnectOnFail
11317
12120
  invokeCallback("WebSocket ready", function()
11318
12121
  return currentOptions.onReady(readyData)
11319
12122
  end)
11320
- return readyData
12123
+ return if ownsWork(work) then readyData else nil
11321
12124
  end
11322
12125
  local function credentialsRejected(statusCode)
11323
12126
  return statusCode == 401 or statusCode == 403 or statusCode == 404
@@ -11327,14 +12130,28 @@ function connect(expectedGeneration)
11327
12130
  if not active or generation ~= expectedGeneration or currentOptions == nil then
11328
12131
  return nil
11329
12132
  end
11330
- reportTransport({
11331
- state = "connecting",
11332
- attempt = reconnectAttempt,
11333
- retryDelay = 0,
11334
- })
11335
- task.spawn(function()
11336
- local readyData = cachedReady or registerReady(currentOptions, expectedGeneration, true)
11337
- if readyData == nil or not active or generation ~= expectedGeneration or options ~= currentOptions then
12133
+ if refreshWork ~= nil then
12134
+ finishWork(refreshWork)
12135
+ end
12136
+ local work = {
12137
+ generation = expectedGeneration,
12138
+ options = currentOptions,
12139
+ stage = "",
12140
+ }
12141
+ connectionWork = work
12142
+ beginStage(work, if cachedReady == nil then "/ready registration" else "WebSocket upgrade")
12143
+ if not ownsWork(work) then
12144
+ return nil
12145
+ end
12146
+ runWork(work, function()
12147
+ local readyData = cachedReady or registerReady(work, true)
12148
+ if readyData == nil or not ownsWork(work) then
12149
+ return nil
12150
+ end
12151
+ if work.stage ~= "WebSocket upgrade" then
12152
+ beginStage(work, "WebSocket upgrade")
12153
+ end
12154
+ if not ownsWork(work) then
11338
12155
  return nil
11339
12156
  end
11340
12157
  local socketBaseUrl = string.gsub(currentOptions.serverUrl, "^http", "ws")
@@ -11347,28 +12164,32 @@ function connect(expectedGeneration)
11347
12164
  })
11348
12165
  end)
11349
12166
  if not createOk then
11350
- scheduleReconnect(expectedGeneration, `Failed to create WebSocket: {tostring(createdClient)}`)
12167
+ scheduleReconnect(expectedGeneration, "Failed to create WebSocket")
11351
12168
  return nil
11352
12169
  end
11353
- if not active or generation ~= expectedGeneration or options ~= currentOptions then
12170
+ if not ownsWork(work) then
11354
12171
  pcall(function()
11355
12172
  return createdClient:Close()
11356
12173
  end)
11357
12174
  return nil
11358
12175
  end
11359
12176
  socketClient = createdClient
11360
- socketConnections = { createdClient.Opened:Connect(function(statusCode, _headers)
12177
+ -- Attach incrementally so a failed subscription cannot leak earlier ones.
12178
+ local _socketConnections = socketConnections
12179
+ local _arg0 = createdClient.Opened:Connect(function(statusCode, _headers)
11361
12180
  if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
11362
12181
  return nil
11363
12182
  end
12183
+ if socketOpen then
12184
+ return nil
12185
+ end
11364
12186
  if statusCode ~= 101 and (statusCode < 200 or statusCode >= 300) then
11365
- if credentialsRejected(statusCode) then
11366
- cachedReady = nil
11367
- end
12187
+ cachedReady = nil
11368
12188
  scheduleReconnect(expectedGeneration, `WebSocket opened with HTTP {statusCode}`)
11369
12189
  return nil
11370
12190
  end
11371
12191
  socketOpen = true
12192
+ finishWork(work)
11372
12193
  lastValidEventAt = tick()
11373
12194
  reconnectAttempt = 0
11374
12195
  reportTransport({
@@ -11376,8 +12197,18 @@ function connect(expectedGeneration)
11376
12197
  attempt = 0,
11377
12198
  retryDelay = 0,
11378
12199
  })
12200
+ if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
12201
+ return nil
12202
+ end
12203
+ watchForSilence(expectedGeneration, createdClient)
11379
12204
  resumePendingResponses()
11380
- end), createdClient.MessageReceived:Connect(function(message)
12205
+ if active and generation == expectedGeneration and socketClient == createdClient then
12206
+ flushRefresh()
12207
+ end
12208
+ end)
12209
+ table.insert(_socketConnections, _arg0)
12210
+ local _socketConnections_1 = socketConnections
12211
+ local _arg0_1 = createdClient.MessageReceived:Connect(function(message)
11381
12212
  if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
11382
12213
  return nil
11383
12214
  end
@@ -11415,27 +12246,41 @@ function connect(expectedGeneration)
11415
12246
  invokeCallback("WebSocket status", function()
11416
12247
  return currentOptions.onStatus(event)
11417
12248
  end)
12249
+ if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
12250
+ return nil
12251
+ end
11418
12252
  if not event.knownPeer then
11419
12253
  cachedReady = nil
11420
12254
  scheduleReconnect(expectedGeneration, "WebSocket session is no longer registered")
11421
12255
  end
11422
12256
  end
11423
- end), createdClient.Error:Connect(function(statusCode, message)
12257
+ end)
12258
+ table.insert(_socketConnections_1, _arg0_1)
12259
+ local _socketConnections_2 = socketConnections
12260
+ local _arg0_2 = createdClient.Error:Connect(function(statusCode, _message)
11424
12261
  if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
11425
12262
  return nil
11426
12263
  end
11427
- if credentialsRejected(statusCode) then
12264
+ if not socketOpen or credentialsRejected(statusCode) then
11428
12265
  cachedReady = nil
11429
12266
  end
11430
- scheduleReconnect(expectedGeneration, `WebSocket error {statusCode}: {message}`)
11431
- end), createdClient.Closed:Connect(function()
12267
+ -- Native error messages may echo request credentials.
12268
+ scheduleReconnect(expectedGeneration, `WebSocket error {statusCode}`)
12269
+ end)
12270
+ table.insert(_socketConnections_2, _arg0_2)
12271
+ local _socketConnections_3 = socketConnections
12272
+ local _arg0_3 = createdClient.Closed:Connect(function()
11432
12273
  if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
11433
12274
  return nil
11434
12275
  end
12276
+ if not socketOpen then
12277
+ cachedReady = nil
12278
+ end
11435
12279
  scheduleReconnect(expectedGeneration, "WebSocket closed")
11436
- end) }
11437
- lastValidEventAt = tick()
11438
- watchForSilence(expectedGeneration, createdClient)
12280
+ end)
12281
+ table.insert(_socketConnections_3, _arg0_3)
12282
+ end, function(detail)
12283
+ return scheduleReconnect(expectedGeneration, detail)
11439
12284
  end)
11440
12285
  end
11441
12286
  local stop
@@ -11450,20 +12295,42 @@ local function start(newOptions)
11450
12295
  generation += 1
11451
12296
  connect(generation)
11452
12297
  end
11453
- local function refresh()
12298
+ function flushRefresh()
11454
12299
  local currentOptions = options
11455
- if not active or currentOptions == nil or readyRefreshPending then
12300
+ if not refreshRequested or not active or currentOptions == nil or refreshWork ~= nil or connectionWork ~= nil or not socketOpen then
11456
12301
  return nil
11457
12302
  end
11458
- -- Metadata changes must not close a healthy socket or make its reconnect
11459
- -- depend on RequestAsync quota. Proxy registration remains independent.
11460
- readyRefreshPending = true
11461
- local expectedGeneration = generation
11462
- task.spawn(function()
11463
- registerReady(currentOptions, expectedGeneration, false)
11464
- readyRefreshPending = false
12303
+ refreshRequested = false
12304
+ -- Only explicit metadata changes are queued. Failure alone never schedules
12305
+ -- more HTTP requests or replaces a healthy transport.
12306
+ local work = {
12307
+ generation = generation,
12308
+ options = currentOptions,
12309
+ stage = "",
12310
+ }
12311
+ refreshWork = work
12312
+ beginStage(work, "/ready metadata refresh")
12313
+ runWork(work, function()
12314
+ -- Consume at worker entry so changes coalesce even before task.spawn runs.
12315
+ refreshRequested = false
12316
+ registerReady(work, false)
12317
+ if ownsWork(work) then
12318
+ finishWork(work)
12319
+ flushRefresh()
12320
+ end
12321
+ end, function(detail)
12322
+ finishWork(work)
12323
+ warn(`[robloxstudio-mcp] {detail}`)
12324
+ flushRefresh()
11465
12325
  end)
11466
12326
  end
12327
+ local function refresh()
12328
+ if not active or options == nil then
12329
+ return nil
12330
+ end
12331
+ refreshRequested = true
12332
+ flushRefresh()
12333
+ end
11467
12334
  -- EndTest may tear down this VM without an Unloading callback. Release the
11468
12335
  -- native socket before yielding to unregister; a failed EndTest can resume.
11469
12336
  local function suspendForShutdown()
@@ -11474,6 +12341,8 @@ local function suspendForShutdown()
11474
12341
  active = false
11475
12342
  shutdownSuspended = true
11476
12343
  generation += 1
12344
+ cancelTransportWork()
12345
+ refreshRequested = false
11477
12346
  reconnectAttempt = 0
11478
12347
  closeCurrentSocket()
11479
12348
  cachedReady = nil
@@ -11497,6 +12366,8 @@ function stop()
11497
12366
  active = false
11498
12367
  shutdownSuspended = false
11499
12368
  generation += 1
12369
+ cancelTransportWork()
12370
+ refreshRequested = false
11500
12371
  for _, inFlight in inFlightRequests do
11501
12372
  inFlight.cancelled = true
11502
12373
  end
@@ -11827,12 +12698,13 @@ local function init(pluginRef)
11827
12698
  end
11828
12699
  end)
11829
12700
  local statusRow = Instance.new("Frame")
11830
- statusRow.Size = UDim2.new(1, 0, 0, 14)
12701
+ statusRow.Size = UDim2.new(1, 0, 0, 0)
12702
+ statusRow.AutomaticSize = Enum.AutomaticSize.Y
11831
12703
  statusRow.BackgroundTransparency = 1
11832
12704
  statusRow.LayoutOrder = 2
11833
12705
  statusRow.Parent = card
11834
12706
  local statusLabel = Instance.new("TextLabel")
11835
- statusLabel.Size = UDim2.new(1, 0, 1, 0)
12707
+ statusLabel.Size = UDim2.new(1, 0, 0, 18)
11836
12708
  statusLabel.BackgroundTransparency = 1
11837
12709
  statusLabel.Text = "Disconnected"
11838
12710
  statusLabel.TextColor3 = C.red
@@ -11840,17 +12712,20 @@ local function init(pluginRef)
11840
12712
  statusLabel.Font = Enum.Font.GothamBold
11841
12713
  statusLabel.TextXAlignment = Enum.TextXAlignment.Left
11842
12714
  statusLabel.TextWrapped = true
12715
+ statusLabel.RichText = false
11843
12716
  statusLabel.Parent = statusRow
11844
12717
  local detailStatusLabel = Instance.new("TextLabel")
11845
- detailStatusLabel.Size = UDim2.new(0.5, 0, 1, 0)
11846
- detailStatusLabel.Position = UDim2.new(0.5, 0, 0, 0)
12718
+ detailStatusLabel.Size = UDim2.new(1, 0, 0, 0)
12719
+ detailStatusLabel.Position = UDim2.new(0, 0, 0, 18)
12720
+ detailStatusLabel.AutomaticSize = Enum.AutomaticSize.Y
11847
12721
  detailStatusLabel.BackgroundTransparency = 1
11848
12722
  detailStatusLabel.Text = "HTTP: X MCP: X"
11849
12723
  detailStatusLabel.TextColor3 = C.muted
11850
12724
  detailStatusLabel.TextSize = 9
11851
12725
  detailStatusLabel.Font = Enum.Font.GothamMedium
11852
- detailStatusLabel.TextXAlignment = Enum.TextXAlignment.Right
12726
+ detailStatusLabel.TextXAlignment = Enum.TextXAlignment.Left
11853
12727
  detailStatusLabel.TextWrapped = true
12728
+ detailStatusLabel.RichText = false
11854
12729
  detailStatusLabel.Parent = statusRow
11855
12730
  local stepsFrame = Instance.new("Frame")
11856
12731
  stepsFrame.Size = UDim2.new(1, 0, 0, 0)
@@ -11987,13 +12862,14 @@ local function updateUIState()
11987
12862
  return nil
11988
12863
  end
11989
12864
  local el = elements
12865
+ local diagnostics = State.getTransportDiagnostics(tick())
11990
12866
  if not conn.isActive then
11991
- el.statusLabel.Text = "Disconnected"
12867
+ el.statusLabel.Text = diagnostics.status
11992
12868
  el.statusLabel.TextColor3 = C.muted
11993
12869
  el.statusIndicator.BackgroundColor3 = C.red
11994
12870
  el.statusPulse.BackgroundColor3 = C.red
11995
12871
  el.statusText.Text = "OFFLINE"
11996
- el.detailStatusLabel.Text = ""
12872
+ el.detailStatusLabel.Text = diagnostics.detail
11997
12873
  el.detailStatusLabel.TextColor3 = C.muted
11998
12874
  stopPulseAnimation()
11999
12875
  el.step1Dot.BackgroundColor3 = C.gray
@@ -12016,12 +12892,12 @@ local function updateUIState()
12016
12892
  el.urlInput.TextEditable = false
12017
12893
  el.urlInput.BackgroundColor3 = C.card
12018
12894
  if conn.lastHttpOk and conn.lastMcpOk then
12019
- el.statusLabel.Text = "Connected"
12895
+ el.statusLabel.Text = diagnostics.status
12020
12896
  el.statusLabel.TextColor3 = Color3.fromRGB(34, 197, 94)
12021
12897
  el.statusIndicator.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
12022
12898
  el.statusPulse.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
12023
12899
  el.statusText.Text = "ONLINE"
12024
- el.detailStatusLabel.Text = "HTTP: OK MCP: OK"
12900
+ el.detailStatusLabel.Text = diagnostics.detail
12025
12901
  el.detailStatusLabel.TextColor3 = Color3.fromRGB(34, 197, 94)
12026
12902
  el.step1Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
12027
12903
  el.step1Label.Text = "HTTP server (OK)"
@@ -12032,12 +12908,12 @@ local function updateUIState()
12032
12908
  el.troubleshootLabel.Visible = false
12033
12909
  stopPulseAnimation()
12034
12910
  elseif conn.lastHttpOk and not conn.lastMcpOk then
12035
- el.statusLabel.Text = "Waiting for MCP server"
12911
+ el.statusLabel.Text = diagnostics.status
12036
12912
  el.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
12037
12913
  el.statusIndicator.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
12038
12914
  el.statusPulse.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
12039
12915
  el.statusText.Text = "WAITING"
12040
- el.detailStatusLabel.Text = "HTTP: OK MCP: ..."
12916
+ el.detailStatusLabel.Text = diagnostics.detail
12041
12917
  el.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
12042
12918
  el.step1Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
12043
12919
  el.step1Label.Text = "HTTP server (OK)"
@@ -12048,53 +12924,21 @@ local function updateUIState()
12048
12924
  local elapsed = if conn.mcpWaitStartTime ~= nil then tick() - conn.mcpWaitStartTime else 0
12049
12925
  el.troubleshootLabel.Visible = elapsed > 8
12050
12926
  startPulseAnimation()
12051
- elseif conn.consecutiveFailures >= conn.maxFailuresBeforeError then
12052
- el.statusLabel.Text = "Server unavailable"
12053
- el.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
12054
- el.statusIndicator.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
12055
- el.statusPulse.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
12056
- el.statusText.Text = "ERROR"
12057
- el.detailStatusLabel.Text = "HTTP: X MCP: X"
12058
- el.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
12059
- el.step1Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
12060
- el.step1Label.Text = "HTTP server (error)"
12061
- el.step2Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
12062
- el.step2Label.Text = "MCP bridge (error)"
12063
- el.step3Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
12064
- el.step3Label.Text = "Commands (error)"
12065
- el.troubleshootLabel.Visible = false
12066
- stopPulseAnimation()
12067
- elseif conn.consecutiveFailures > 5 then
12068
- local waitTime = math.ceil(conn.currentRetryDelay)
12069
- el.statusLabel.Text = `Retrying ({waitTime}s)`
12070
- el.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
12071
- el.statusIndicator.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
12072
- el.statusPulse.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
12073
- el.statusText.Text = "RETRY"
12074
- el.detailStatusLabel.Text = "HTTP: ... MCP: ..."
12075
- el.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
12076
- el.step1Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
12077
- el.step1Label.Text = "HTTP server (retrying...)"
12078
- el.step2Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
12079
- el.step2Label.Text = "MCP bridge (retrying...)"
12080
- el.step3Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
12081
- el.step3Label.Text = "Commands (retrying...)"
12082
- el.troubleshootLabel.Visible = false
12083
- startPulseAnimation()
12084
12927
  else
12085
- el.statusLabel.Text = if conn.consecutiveFailures > 1 then `Connecting (attempt {conn.consecutiveFailures})` else "Connecting..."
12928
+ local retrying = conn.nextRetryAt ~= nil
12929
+ el.statusLabel.Text = diagnostics.status
12086
12930
  el.statusLabel.TextColor3 = C.yellow
12087
12931
  el.statusIndicator.BackgroundColor3 = C.yellow
12088
12932
  el.statusPulse.BackgroundColor3 = C.yellow
12089
- el.statusText.Text = "CONNECTING"
12090
- el.detailStatusLabel.Text = if conn.consecutiveFailures == 0 then "..." else "HTTP: ... MCP: ..."
12091
- el.detailStatusLabel.TextColor3 = C.muted
12933
+ el.statusText.Text = if retrying then "RETRY" else "CONNECTING"
12934
+ el.detailStatusLabel.Text = diagnostics.detail
12935
+ el.detailStatusLabel.TextColor3 = C.yellow
12092
12936
  el.step1Dot.BackgroundColor3 = C.yellow
12093
- el.step1Label.Text = "HTTP server (connecting...)"
12094
- el.step2Dot.BackgroundColor3 = C.yellow
12095
- el.step2Label.Text = "MCP bridge (connecting...)"
12096
- el.step3Dot.BackgroundColor3 = C.yellow
12097
- el.step3Label.Text = "Commands (connecting...)"
12937
+ el.step1Label.Text = if retrying then "Listener (disconnected)" else "Listener (connecting...)"
12938
+ el.step2Dot.BackgroundColor3 = C.gray
12939
+ el.step2Label.Text = "MCP client (waiting for listener)"
12940
+ el.step3Dot.BackgroundColor3 = C.gray
12941
+ el.step3Label.Text = "Commands (waiting for listener)"
12098
12942
  el.troubleshootLabel.Visible = false
12099
12943
  startPulseAnimation()
12100
12944
  end