@chrrxs/robloxstudio-mcp 3.1.4 → 3.1.6

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.6" 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.6`
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,379 @@ 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
+ -- Camera.ViewportSize reports the device safe area, so markers must bound it too.
3704
+ gui.ScreenInsets = Enum.ScreenInsets.DeviceSafeInsets
3705
+ gui.SafeAreaCompatibility = Enum.SafeAreaCompatibility.None
3706
+ gui.DisplayOrder = 2147483647
3707
+ gui.ResetOnSpawn = false
3708
+ gui.ZIndexBehavior = Enum.ZIndexBehavior.Global
3709
+ local corners = { { 0, 0 }, { 1, 0 }, { 0, 1 }, { 1, 1 } }
3710
+ for _, _binding in corners do
3711
+ local x = _binding[1]
3712
+ local y = _binding[2]
3713
+ local frame = Instance.new("Frame")
3714
+ frame.Name = `Corner{x}{y}`
3715
+ frame.AnchorPoint = Vector2.new(x, y)
3716
+ frame.Position = UDim2.fromScale(x, y)
3717
+ frame.Size = UDim2.fromOffset(MARKER_SIZE, MARKER_SIZE)
3718
+ frame.BackgroundColor3 = MARKER_COLOR
3719
+ frame.BackgroundTransparency = 0
3720
+ frame.BorderSizePixel = 0
3721
+ frame.ZIndex = 2147483647
3722
+ frame.Parent = gui
3723
+ end
3724
+ gui.Parent = CoreGui
3725
+ task.delay(MARKER_AUTO_HIDE_SECONDS, function()
3726
+ if gui.Parent ~= nil then
3727
+ gui:Destroy()
3728
+ end
3729
+ end)
3730
+ local framesRendered = waitForRenderedFrames()
3731
+ local accessError = markerAccessError("show", captureId)
3732
+ if accessError ~= nil then
3733
+ gui:Destroy()
3734
+ return {
3735
+ error = accessError,
3736
+ }
3737
+ end
3738
+ local renderedSize = viewportSize()
3739
+ if renderedSize == nil then
3740
+ gui:Destroy()
3741
+ return {
3742
+ error = "No CurrentCamera after rendering viewport markers.",
3743
+ }
3744
+ end
3745
+ local _object = {
3746
+ success = true,
3747
+ }
3748
+ for _k, _v in renderedSize do
3749
+ _object[_k] = _v
3750
+ end
3751
+ _object.markerSize = MARKER_SIZE
3752
+ _object.framesRendered = framesRendered
3753
+ return _object
3754
+ end
3755
+ -- prepare temporarily fits a simulated viewport; finish restores it after the
3756
+ -- clean host grab. hide only removes markers: restoring here would clip that grab.
3757
+ -- show/query report the current camera dimensions, not prepare's original size.
3758
+ local function captureMarkers(requestData)
3759
+ local action = requestData.action
3760
+ if action == "prepare" then
3761
+ return prepareMarkerCapture()
3762
+ end
3763
+ if action == "finish" then
3764
+ local captureId = requestData.captureId
3765
+ if not (type(captureId) == "string") or captureId == "" then
3766
+ return {
3767
+ error = 'capture-markers action "finish" requires a non-empty captureId.',
3768
+ }
3769
+ end
3770
+ return finishMarkerCapture(captureId)
3771
+ end
3772
+ if action == "show" or action == "hide" or action == "query" then
3773
+ local accessError = markerAccessError(action, requestData.captureId)
3774
+ if accessError ~= nil then
3775
+ return {
3776
+ error = accessError,
3777
+ }
3778
+ end
3779
+ end
3780
+ if action == "show" then
3781
+ return showMarkers(requestData.captureId)
3782
+ end
3783
+ if action == "hide" then
3784
+ hideMarkers()
3785
+ return {
3786
+ success = true,
3787
+ }
3788
+ end
3789
+ if action == "query" then
3790
+ local size = viewportSize()
3791
+ if size == nil then
3792
+ return {
3793
+ error = "No CurrentCamera; cannot read viewport size.",
3794
+ }
3795
+ end
3796
+ local _object = {
3797
+ success = true,
3798
+ }
3799
+ for _k, _v in size do
3800
+ _object[_k] = _v
3801
+ end
3802
+ _object.markerSize = MARKER_SIZE
3803
+ return _object
3804
+ end
3805
+ return {
3806
+ error = `capture-markers action must be "prepare", "finish", "show", "hide" or "query" (got {tostring(action)})`,
3807
+ }
3808
+ end
3293
3809
  return {
3294
3810
  captureScreenshotData = captureScreenshotData,
3295
3811
  captureScreenshot = captureScreenshot,
3296
3812
  captureStudio = captureStudio,
3297
3813
  captureBegin = captureBegin,
3298
3814
  captureRead = captureRead,
3815
+ captureMarkers = captureMarkers,
3299
3816
  }
3300
3817
  ]]></string>
3301
3818
  </Properties>
@@ -4879,6 +5396,16 @@ local function captureMicroProfiler(requestData)
4879
5396
  backend = backend,
4880
5397
  }
4881
5398
  end
5399
+ local frameLimitOk, frameLimitResult = safeCall(function()
5400
+ return LibMP.Control:SetFrameLimit(math.min(frameWindow, 256))
5401
+ end)
5402
+ if not frameLimitOk then
5403
+ return {
5404
+ error = "micro_profiler_frame_limit_failed",
5405
+ message = tostring(frameLimitResult),
5406
+ backend = backend,
5407
+ }
5408
+ end
4882
5409
  local profilerOk, profilerResult = safeCall(function()
4883
5410
  return LibMP.Control:EnableProfiler(true)
4884
5411
  end)
@@ -4900,6 +5427,11 @@ local function captureMicroProfiler(requestData)
4900
5427
  }
4901
5428
  end
4902
5429
  task.wait(durationMs / 1000)
5430
+ -- Active reads synchronize the engine's data; paused reads can return an
5431
+ -- empty or stale cache. Export first, then pause, including on export failure.
5432
+ local bufferOk, snapshotOrErr = safeCall(function()
5433
+ return LibMP.Control:CaptureToBufferSync()
5434
+ end)
4903
5435
  local captureStopOk, captureStopResult = safeCall(function()
4904
5436
  return LibMP.Control:EnableCapture(false)
4905
5437
  end)
@@ -4910,9 +5442,6 @@ local function captureMicroProfiler(requestData)
4910
5442
  backend = backend,
4911
5443
  }
4912
5444
  end
4913
- local bufferOk, snapshotOrErr = safeCall(function()
4914
- return LibMP.Control:CaptureToBufferSync()
4915
- end)
4916
5445
  if not bufferOk then
4917
5446
  return {
4918
5447
  error = "micro_profiler_snapshot_failed",
@@ -6069,6 +6598,7 @@ local Recording = TS.import(script, script.Parent.Parent, "Recording")
6069
6598
  local _binding = Utils
6070
6599
  local getInstanceByPath = _binding.getInstanceByPath
6071
6600
  local convertPropertyValue = _binding.convertPropertyValue
6601
+ local applyScriptSource = _binding.applyScriptSource
6072
6602
  local _binding_1 = Recording
6073
6603
  local beginRecording = _binding_1.beginRecording
6074
6604
  local finishRecording = _binding_1.finishRecording
@@ -6096,7 +6626,12 @@ local function setProperties(requestData)
6096
6626
  for propName, propValue in pairs(properties) do
6097
6627
  local success, err = pcall(function()
6098
6628
  if propName == "Parent" or propName == "PrimaryPart" then
6099
- if type(propValue) == "string" then
6629
+ if not (type(propValue) == "string") then
6630
+ error(`{propName} must be an instance path string (or an empty string to clear)`)
6631
+ end
6632
+ if propValue == "" then
6633
+ inst[propName] = nil
6634
+ else
6100
6635
  local refInstance = getInstanceByPath(propValue)
6101
6636
  if not refInstance then
6102
6637
  error(`{propName} reference not found: {propValue}`)
@@ -6106,7 +6641,17 @@ local function setProperties(requestData)
6106
6641
  elseif propName == "Name" then
6107
6642
  instance.Name = tostring(propValue)
6108
6643
  elseif propName == "Source" and instance:IsA("LuaSourceContainer") then
6109
- instance.Source = tostring(propValue)
6644
+ if not (type(propValue) == "string") then
6645
+ error("Source must be a string")
6646
+ end
6647
+ local result = applyScriptSource(instance, propValue)
6648
+ if not result.success then
6649
+ local _condition = result.error
6650
+ if _condition == nil then
6651
+ _condition = "Script source update failed"
6652
+ end
6653
+ error(_condition)
6654
+ end
6110
6655
  else
6111
6656
  local converted = convertPropertyValue(instance, propName, propValue)
6112
6657
  inst[propName] = if converted ~= nil then converted else propValue
@@ -8512,6 +9057,7 @@ local function stopPlaytest(_requestData)
8512
9057
  -- Signal the play-server DM's StopPlayMonitor via plugin:SetSetting.
8513
9058
  -- The monitor acknowledges with the matching request id only after its
8514
9059
  -- StudioTestService:EndTest call returns from pcall.
9060
+ local hadActivePlaytest = testRunning or not StudioTestService.EditModeActive
8515
9061
  local stopRequest = StopPlayMonitor.requestStop()
8516
9062
  if not stopRequest.ok or stopRequest.requestId == nil then
8517
9063
  return {
@@ -8534,6 +9080,15 @@ local function stopPlaytest(_requestData)
8534
9080
  -- Either way clean up the pending request so a future playtest's monitor
8535
9081
  -- doesn't fire EndTest on startup against a stale signal.
8536
9082
  StopPlayMonitor.clearPending(stopRequest.requestId)
9083
+ -- Settings acknowledgement can be lost as the server VM exits. Reconcile
9084
+ -- only a playtest observed at entry, and only after both native teardown
9085
+ -- and the tracked execution have finished. Never mask an EndTest error.
9086
+ if not consumption.consumed and hadActivePlaytest and not testRunning and StudioTestService.EditModeActive then
9087
+ return {
9088
+ success = true,
9089
+ message = "Playtest stopped.",
9090
+ }
9091
+ end
8537
9092
  if testRunning then
8538
9093
  return {
8539
9094
  error = "Playtest stop signal failed or was not acknowledged. " .. "The playtest may have ended anyway; check get_connected_instances.",
@@ -10305,9 +10860,10 @@ return {
10305
10860
  <Properties>
10306
10861
  <string name="Name">State</string>
10307
10862
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
10308
- local CURRENT_VERSION = "3.1.4"
10863
+ local CURRENT_VERSION = "3.1.6"
10309
10864
  local PLUGIN_VARIANT = "main"
10310
10865
  local BASE_PORT = 58741
10866
+ local SENSITIVE_DIAGNOSTIC_TERMS = { "token", "authorization", "bearer", "password", "cookie", "secret" }
10311
10867
  local function createConnection(port)
10312
10868
  return {
10313
10869
  port = port,
@@ -10326,11 +10882,118 @@ local connection = createConnection(BASE_PORT)
10326
10882
  local function getActiveConnection()
10327
10883
  return connection
10328
10884
  end
10885
+ local function clearTransportDiagnostics()
10886
+ connection.transportState = nil
10887
+ connection.transportAttempt = nil
10888
+ connection.transportDetail = nil
10889
+ connection.lastTransportFailure = nil
10890
+ connection.nextRetryAt = nil
10891
+ end
10892
+ local function sanitizeTransportDetail(detail)
10893
+ -- Transport emits credential-free stage descriptions; also defend the UI
10894
+ -- against accidental raw errors, URLs, markup, and multiline responses.
10895
+ local lower = string.lower(detail)
10896
+ for _, sensitive in SENSITIVE_DIAGNOSTIC_TERMS do
10897
+ if (string.find(lower, sensitive, 1, true)) ~= nil then
10898
+ return "Connection failed (sensitive details hidden)."
10899
+ end
10900
+ end
10901
+ local plain = string.gsub(detail, "%w+://%S+", "[address hidden]")
10902
+ plain = string.gsub(plain, "%c", " ")
10903
+ plain = string.gsub(plain, "<[^>]*>", "")
10904
+ plain = string.gsub(plain, "%s+", " ")
10905
+ return if #plain > 180 then `{string.sub(plain, 1, 177)}...` else plain
10906
+ end
10907
+ local function applyTransportUpdate(update, now)
10908
+ if not connection.isActive then
10909
+ return nil
10910
+ end
10911
+ if update.state == "open" then
10912
+ clearTransportDiagnostics()
10913
+ connection.lastHttpOk = true
10914
+ connection.lastMcpOk = false
10915
+ connection.consecutiveFailures = 0
10916
+ connection.currentRetryDelay = 0.5
10917
+ connection.mcpWaitStartTime = now
10918
+ return nil
10919
+ end
10920
+ connection.lastHttpOk = false
10921
+ connection.lastMcpOk = false
10922
+ connection.consecutiveFailures = update.attempt
10923
+ connection.mcpWaitStartTime = nil
10924
+ local previousState = connection.transportState
10925
+ connection.transportState = update.state
10926
+ connection.transportAttempt = update.attempt
10927
+ if update.detail ~= nil then
10928
+ connection.transportDetail = sanitizeTransportDetail(update.detail)
10929
+ elseif update.state ~= "connecting" or previousState ~= "connecting" then
10930
+ connection.transportDetail = nil
10931
+ end
10932
+ if update.state == "retrying" or update.state == "waiting-duplicate" then
10933
+ local _condition = connection.transportDetail
10934
+ if _condition == nil then
10935
+ _condition = "Connection attempt failed."
10936
+ end
10937
+ connection.lastTransportFailure = _condition
10938
+ connection.currentRetryDelay = update.retryDelay
10939
+ connection.nextRetryAt = now + update.retryDelay
10940
+ else
10941
+ connection.nextRetryAt = nil
10942
+ end
10943
+ end
10944
+ local function getTransportDiagnostics(now)
10945
+ if not connection.isActive then
10946
+ return {
10947
+ status = "Disconnected",
10948
+ detail = "",
10949
+ }
10950
+ end
10951
+ if connection.lastHttpOk then
10952
+ return if connection.lastMcpOk then {
10953
+ status = "Connected",
10954
+ detail = "Listener: connected MCP client: connected",
10955
+ } else {
10956
+ status = "Waiting for MCP client",
10957
+ detail = "Listener: connected MCP client: not connected",
10958
+ }
10959
+ end
10960
+ local _condition = connection.transportAttempt
10961
+ if _condition == nil then
10962
+ _condition = 0
10963
+ end
10964
+ local attempt = _condition + 1
10965
+ local failure = connection.lastTransportFailure
10966
+ if connection.nextRetryAt ~= nil then
10967
+ local remaining = math.max(0, math.ceil((connection.nextRetryAt - now) * 10) / 10)
10968
+ local _object = {
10969
+ status = if connection.transportState == "waiting-duplicate" then "Waiting for previous instance" else "Listener disconnected",
10970
+ }
10971
+ local _left = "detail"
10972
+ local _condition_1 = failure
10973
+ if _condition_1 == nil then
10974
+ _condition_1 = "Connection attempt failed."
10975
+ end
10976
+ _object[_left] = `Retry attempt {attempt} in {remaining}s\n{_condition_1}`
10977
+ return _object
10978
+ end
10979
+ local _condition_1 = connection.transportDetail
10980
+ if _condition_1 == nil then
10981
+ _condition_1 = "Starting listener connection"
10982
+ end
10983
+ local stage = _condition_1
10984
+ return {
10985
+ status = `Connecting (attempt {attempt})`,
10986
+ detail = if failure ~= nil then `{stage}\nLast failure: {failure}` else stage,
10987
+ }
10988
+ end
10329
10989
  return {
10330
10990
  CURRENT_VERSION = CURRENT_VERSION,
10331
10991
  PLUGIN_VARIANT = PLUGIN_VARIANT,
10332
10992
  BASE_PORT = BASE_PORT,
10333
10993
  getActiveConnection = getActiveConnection,
10994
+ clearTransportDiagnostics = clearTransportDiagnostics,
10995
+ applyTransportUpdate = applyTransportUpdate,
10996
+ getTransportDiagnostics = getTransportDiagnostics,
10334
10997
  }
10335
10998
  ]]></string>
10336
10999
  </Properties>
@@ -10349,6 +11012,10 @@ local RunService = _services.RunService
10349
11012
  local PluginSession = TS.import(script, script.Parent, "PluginSession")
10350
11013
  local StudioTestService = game:GetService("StudioTestService")
10351
11014
  local SETTING_KEY_PREFIX = "MCP_STOP_PLAY_"
11015
+ local RESULT_KEY_SUFFIX = "_RESULT"
11016
+ -- Capture before registration yields. Requests from an earlier runtime must
11017
+ -- never stop a newly created play session, even if clearing settings was lost.
11018
+ local moduleLoadedAt = tick()
10352
11019
  -- Keep this conservative. plugin:GetSetting is backed by Studio's plugin
10353
11020
  -- settings store, and this monitor runs during every play session, including
10354
11021
  -- manually-started Play. The official reference implementation polls at 1s.
@@ -10361,9 +11028,14 @@ local POLL_INTERVAL_SEC = 1
10361
11028
  local WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 8.0
10362
11029
  local WAIT_POLL_SEC = 0.1
10363
11030
  local REQUEST_TTL_SEC = 12.0
11031
+ -- Plugin settings can silently lose writes when other Studio windows use the
11032
+ -- same plugin. Redeliver the same request, without extending its deadline/TTL.
11033
+ local RETRY_INTERVAL_SEC = 1
10364
11034
  local pluginRef
10365
11035
  local endTestIssued = false
10366
11036
  local transportLifecycle
11037
+ local pendingRequest
11038
+ local lastResult
10367
11039
  local function init(p)
10368
11040
  pluginRef = p
10369
11041
  end
@@ -10426,14 +11098,21 @@ local function writePayload(key, payload)
10426
11098
  return writeSetting(key, encoded)
10427
11099
  end
10428
11100
  local function writeResult(key, request, ok, errText)
10429
- writePayload(key, {
11101
+ local payload = {
10430
11102
  kind = "result",
10431
11103
  id = request.id,
10432
11104
  requestedAt = request.requestedAt,
10433
11105
  consumedAt = tick(),
10434
11106
  ok = ok,
10435
11107
  error = errText,
10436
- })
11108
+ }
11109
+ -- Cache before publishing: a lost acknowledgement must not issue EndTest a
11110
+ -- second time or turn a successful duplicate into an "already issued" error.
11111
+ lastResult = {
11112
+ key = key,
11113
+ payload = payload,
11114
+ }
11115
+ writePayload(key .. RESULT_KEY_SUFFIX, payload)
10437
11116
  end
10438
11117
  local function handleStopRequest(key, request)
10439
11118
  local _condition = request.kind ~= "request"
@@ -10450,10 +11129,14 @@ local function handleStopRequest(key, request)
10450
11129
  return nil
10451
11130
  end
10452
11131
  local age = tick() - request.requestedAt
10453
- if age < -5 or age > REQUEST_TTL_SEC then
11132
+ if request.requestedAt < moduleLoadedAt or age < -5 or age > REQUEST_TTL_SEC then
10454
11133
  writeSetting(key, false)
10455
11134
  return nil
10456
11135
  end
11136
+ if lastResult ~= nil and lastResult.key == key and lastResult.payload.id == request.id then
11137
+ writePayload(key .. RESULT_KEY_SUFFIX, lastResult.payload)
11138
+ return nil
11139
+ end
10457
11140
  if endTestIssued then
10458
11141
  writeResult(key, request, false, "StudioTestService:EndTest was already issued for this play session, but the runtime DataModel is still alive.")
10459
11142
  return nil
@@ -10514,6 +11197,11 @@ local function requestStop()
10514
11197
  requestedAt = tick(),
10515
11198
  }
10516
11199
  local ok = writePayload(settingKey(), payload)
11200
+ pendingRequest = if ok then {
11201
+ key = settingKey(),
11202
+ payload = payload,
11203
+ lastPublishedAt = tick(),
11204
+ } else nil
10517
11205
  return {
10518
11206
  ok = ok,
10519
11207
  requestId = if ok then requestId else nil,
@@ -10528,8 +11216,12 @@ local function waitForConsumption(requestId)
10528
11216
  }
10529
11217
  end
10530
11218
  local start = tick()
11219
+ local pending = pendingRequest
11220
+ local key = if pending ~= nil and pending.payload.id == requestId then pending.key else settingKey()
10531
11221
  while tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC do
10532
- local payload = decodePayload(readSetting(settingKey()))
11222
+ -- Separate slots prevent a retry from overwriting a durable acknowledgement
11223
+ -- during a transient nil read after the server VM has already exited.
11224
+ local payload = decodePayload(readSetting(key .. RESULT_KEY_SUFFIX))
10533
11225
  if payload and payload.kind == "result" and payload.id == requestId then
10534
11226
  return {
10535
11227
  ok = payload.ok == true,
@@ -10537,6 +11229,19 @@ local function waitForConsumption(requestId)
10537
11229
  error = payload.error,
10538
11230
  }
10539
11231
  end
11232
+ local _condition = pending ~= nil and pendingRequest == pending and pending.payload.id == requestId and tick() - pending.lastPublishedAt >= RETRY_INTERVAL_SEC
11233
+ if _condition then
11234
+ local _exp = tick()
11235
+ local _condition_1 = pending.payload.requestedAt
11236
+ if _condition_1 == nil then
11237
+ _condition_1 = 0
11238
+ end
11239
+ _condition = _exp - _condition_1 < REQUEST_TTL_SEC
11240
+ end
11241
+ if _condition then
11242
+ writePayload(key, pending.payload)
11243
+ pending.lastPublishedAt = tick()
11244
+ end
10540
11245
  task.wait(WAIT_POLL_SEC)
10541
11246
  end
10542
11247
  return {
@@ -10549,6 +11254,17 @@ local function clearPending(requestId)
10549
11254
  if not pluginRef then
10550
11255
  return nil
10551
11256
  end
11257
+ local _condition = requestId == nil
11258
+ if not _condition then
11259
+ local _result = pendingRequest
11260
+ if _result ~= nil then
11261
+ _result = _result.payload.id
11262
+ end
11263
+ _condition = _result == requestId
11264
+ end
11265
+ if _condition then
11266
+ pendingRequest = nil
11267
+ end
10552
11268
  local myKey = settingKey()
10553
11269
  if requestId ~= nil then
10554
11270
  local payload = decodePayload(readSetting(myKey))
@@ -10580,6 +11296,7 @@ local PROTOCOL_VERSION = 1
10580
11296
  local INITIAL_RETRY_DELAY_SECONDS = 0.5
10581
11297
  local MAX_RETRY_DELAY_SECONDS = 5
10582
11298
  local SOCKET_SILENCE_TIMEOUT_SECONDS = 20
11299
+ local CONNECTION_STAGE_TIMEOUT_SECONDS = 20
10583
11300
  local RESPONSE_RETENTION_SECONDS = 5 * 60
10584
11301
  local RESPONSE_ACK_TIMEOUT_SECONDS = 120
10585
11302
  local MAX_TERMINAL_RESPONSES = 32768
@@ -10600,7 +11317,11 @@ local socketOpen = false
10600
11317
  local socketConnections = {}
10601
11318
  local lastValidEventAt = 0
10602
11319
  local cachedReady
10603
- local readyRefreshPending = false
11320
+ local connectionWork
11321
+ local refreshWork
11322
+ local refreshRequested = false
11323
+ local reconnectTimer
11324
+ local silenceTimer
10604
11325
  local pendingResponseBytes = 0
10605
11326
  local inFlightRequestBytes = 0
10606
11327
  local pendingRejectionCount = 0
@@ -11147,7 +11868,7 @@ local function dispatchRequest(request, requestBytes)
11147
11868
  local dispatchOk, response = pcall(function()
11148
11869
  return dispatchOptions.dispatchRequest(request, context)
11149
11870
  end)
11150
- local executionOutcome = if dispatchOk then handlerOutcome(response) else "error"
11871
+ local executionOutcome = context.executionOutcome or (if dispatchOk then handlerOutcome(response) else "error")
11151
11872
  -- Report handler return before JSON encoding or result retention can fail.
11152
11873
  completeProgress(request.requestId, inFlight.progress, executionOutcome)
11153
11874
  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 +11892,90 @@ local function reportTransport(update)
11171
11892
  end)
11172
11893
  end
11173
11894
  end
11895
+ local function ownsWork(work)
11896
+ return active and generation == work.generation and options == work.options and (connectionWork == work or refreshWork == work)
11897
+ end
11898
+ local function cancelThread(worker)
11899
+ -- Cancellation is best-effort for native yielding calls. Identity checks
11900
+ -- remain necessary when an operation returns after its owner was retired.
11901
+ if worker ~= nil and worker ~= coroutine.running() then
11902
+ pcall(function()
11903
+ return task.cancel(worker)
11904
+ end)
11905
+ end
11906
+ end
11907
+ local function finishWork(work)
11908
+ if connectionWork == work then
11909
+ connectionWork = nil
11910
+ end
11911
+ if refreshWork == work then
11912
+ refreshWork = nil
11913
+ end
11914
+ cancelThread(work.deadline)
11915
+ cancelThread(work.worker)
11916
+ work.deadline = nil
11917
+ work.worker = nil
11918
+ end
11919
+ local function cancelTransportWork()
11920
+ if connectionWork ~= nil then
11921
+ finishWork(connectionWork)
11922
+ end
11923
+ if refreshWork ~= nil then
11924
+ refreshRequested = true
11925
+ finishWork(refreshWork)
11926
+ end
11927
+ cancelThread(reconnectTimer)
11928
+ cancelThread(silenceTimer)
11929
+ reconnectTimer = nil
11930
+ silenceTimer = nil
11931
+ end
11932
+ local function runWork(work, callback, failed)
11933
+ local finished = false
11934
+ local worker = task.spawn(function()
11935
+ if not ownsWork(work) then
11936
+ return nil
11937
+ end
11938
+ work.worker = coroutine.running()
11939
+ local ok, workError = pcall(callback)
11940
+ work.worker = nil
11941
+ finished = true
11942
+ if not ok and ownsWork(work) then
11943
+ -- Native upgrade errors can echo credential-bearing request data.
11944
+ local detail = if work.stage == "WebSocket upgrade" then "WebSocket upgrade failed" else `{work.stage} failed: {tostring(workError)}`
11945
+ failed(detail)
11946
+ end
11947
+ end)
11948
+ if not finished and ownsWork(work) then
11949
+ work.worker = worker
11950
+ end
11951
+ end
11952
+ local flushRefresh
11953
+ local function beginStage(work, stage)
11954
+ cancelThread(work.deadline)
11955
+ work.stage = stage
11956
+ work.deadline = task.delay(CONNECTION_STAGE_TIMEOUT_SECONDS, function()
11957
+ if not ownsWork(work) then
11958
+ return nil
11959
+ end
11960
+ work.deadline = nil
11961
+ local detail = `{stage} timed out after {CONNECTION_STAGE_TIMEOUT_SECONDS} seconds`
11962
+ if connectionWork == work then
11963
+ scheduleReconnect(work.generation, detail)
11964
+ else
11965
+ finishWork(work)
11966
+ warn(`[robloxstudio-mcp] {detail}`)
11967
+ flushRefresh()
11968
+ end
11969
+ end)
11970
+ if connectionWork == work then
11971
+ reportTransport({
11972
+ state = "connecting",
11973
+ attempt = reconnectAttempt,
11974
+ retryDelay = 0,
11975
+ detail = `{stage} (timeout {CONNECTION_STAGE_TIMEOUT_SECONDS} seconds)`,
11976
+ })
11977
+ end
11978
+ end
11174
11979
  local connect
11175
11980
  function scheduleReconnect(expectedGeneration, detail, duplicate)
11176
11981
  if duplicate == nil then
@@ -11179,7 +11984,14 @@ function scheduleReconnect(expectedGeneration, detail, duplicate)
11179
11984
  if not active or generation ~= expectedGeneration then
11180
11985
  return nil
11181
11986
  end
11987
+ -- A healthy registration gets one socket-only revalidation, preserving
11988
+ -- recovery under HTTP quota. Any failure before Opened exhausts that path.
11989
+ if not socketOpen then
11990
+ cachedReady = nil
11991
+ end
11182
11992
  generation += 1
11993
+ local nextGeneration = generation
11994
+ cancelTransportWork()
11183
11995
  closeCurrentSocket()
11184
11996
  reconnectAttempt += 1
11185
11997
  local delay = if duplicate then 1 else retryDelay(reconnectAttempt)
@@ -11189,8 +12001,11 @@ function scheduleReconnect(expectedGeneration, detail, duplicate)
11189
12001
  retryDelay = delay,
11190
12002
  detail = detail,
11191
12003
  })
11192
- local nextGeneration = generation
11193
- task.delay(delay, function()
12004
+ if not active or generation ~= nextGeneration then
12005
+ return nil
12006
+ end
12007
+ reconnectTimer = task.delay(delay, function()
12008
+ reconnectTimer = nil
11194
12009
  if active and generation == nextGeneration then
11195
12010
  connect(nextGeneration)
11196
12011
  end
@@ -11198,10 +12013,11 @@ function scheduleReconnect(expectedGeneration, detail, duplicate)
11198
12013
  end
11199
12014
  local function watchForSilence(expectedGeneration, expectedClient)
11200
12015
  local elapsed = tick() - lastValidEventAt
11201
- task.delay(math.max(SOCKET_SILENCE_TIMEOUT_SECONDS - elapsed, 0.1), function()
12016
+ silenceTimer = task.delay(math.max(SOCKET_SILENCE_TIMEOUT_SECONDS - elapsed, 0.1), function()
11202
12017
  if not active or generation ~= expectedGeneration or socketClient ~= expectedClient then
11203
12018
  return nil
11204
12019
  end
12020
+ silenceTimer = nil
11205
12021
  local silentFor = tick() - lastValidEventAt
11206
12022
  if silentFor >= SOCKET_SILENCE_TIMEOUT_SECONDS then
11207
12023
  scheduleReconnect(expectedGeneration, `WebSocket silent for {math.floor(silentFor)} seconds`)
@@ -11268,13 +12084,18 @@ local function parseReadyResponse(body)
11268
12084
  transportToken = value.transportToken,
11269
12085
  }
11270
12086
  end
11271
- local function registerReady(currentOptions, expectedGeneration, reconnectOnFailure)
12087
+ local function registerReady(work, reconnectOnFailure)
12088
+ if not ownsWork(work) then
12089
+ return nil
12090
+ end
12091
+ local currentOptions = work.options
12092
+ local expectedGeneration = work.generation
11272
12093
  local instanceId = PluginSession.getInstanceId()
11273
12094
  local multiplayerGroupId = PluginSession.getMultiplayerGroupId()
11274
12095
  local transportRole = PluginSession.getRole()
11275
12096
  local readyUrl = `{currentOptions.serverUrl}/ready`
11276
12097
  local readyPayload = PluginSession.createReadyPayload(PluginSession.peerId, transportRole, instanceId, multiplayerGroupId)
11277
- if not active or generation ~= expectedGeneration or options ~= currentOptions then
12098
+ if not ownsWork(work) then
11278
12099
  return nil
11279
12100
  end
11280
12101
  local readyOk, readyResult = pcall(function()
@@ -11287,7 +12108,7 @@ local function registerReady(currentOptions, expectedGeneration, reconnectOnFail
11287
12108
  Body = HttpService:JSONEncode(readyPayload),
11288
12109
  })
11289
12110
  end)
11290
- if not active or generation ~= expectedGeneration or options ~= currentOptions then
12111
+ if not ownsWork(work) then
11291
12112
  return nil
11292
12113
  end
11293
12114
  local readyLogKey = `{currentOptions.serverUrl}|{PluginSession.peerId}`
@@ -11317,7 +12138,7 @@ local function registerReady(currentOptions, expectedGeneration, reconnectOnFail
11317
12138
  invokeCallback("WebSocket ready", function()
11318
12139
  return currentOptions.onReady(readyData)
11319
12140
  end)
11320
- return readyData
12141
+ return if ownsWork(work) then readyData else nil
11321
12142
  end
11322
12143
  local function credentialsRejected(statusCode)
11323
12144
  return statusCode == 401 or statusCode == 403 or statusCode == 404
@@ -11327,14 +12148,28 @@ function connect(expectedGeneration)
11327
12148
  if not active or generation ~= expectedGeneration or currentOptions == nil then
11328
12149
  return nil
11329
12150
  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
12151
+ if refreshWork ~= nil then
12152
+ finishWork(refreshWork)
12153
+ end
12154
+ local work = {
12155
+ generation = expectedGeneration,
12156
+ options = currentOptions,
12157
+ stage = "",
12158
+ }
12159
+ connectionWork = work
12160
+ beginStage(work, if cachedReady == nil then "/ready registration" else "WebSocket upgrade")
12161
+ if not ownsWork(work) then
12162
+ return nil
12163
+ end
12164
+ runWork(work, function()
12165
+ local readyData = cachedReady or registerReady(work, true)
12166
+ if readyData == nil or not ownsWork(work) then
12167
+ return nil
12168
+ end
12169
+ if work.stage ~= "WebSocket upgrade" then
12170
+ beginStage(work, "WebSocket upgrade")
12171
+ end
12172
+ if not ownsWork(work) then
11338
12173
  return nil
11339
12174
  end
11340
12175
  local socketBaseUrl = string.gsub(currentOptions.serverUrl, "^http", "ws")
@@ -11347,28 +12182,32 @@ function connect(expectedGeneration)
11347
12182
  })
11348
12183
  end)
11349
12184
  if not createOk then
11350
- scheduleReconnect(expectedGeneration, `Failed to create WebSocket: {tostring(createdClient)}`)
12185
+ scheduleReconnect(expectedGeneration, "Failed to create WebSocket")
11351
12186
  return nil
11352
12187
  end
11353
- if not active or generation ~= expectedGeneration or options ~= currentOptions then
12188
+ if not ownsWork(work) then
11354
12189
  pcall(function()
11355
12190
  return createdClient:Close()
11356
12191
  end)
11357
12192
  return nil
11358
12193
  end
11359
12194
  socketClient = createdClient
11360
- socketConnections = { createdClient.Opened:Connect(function(statusCode, _headers)
12195
+ -- Attach incrementally so a failed subscription cannot leak earlier ones.
12196
+ local _socketConnections = socketConnections
12197
+ local _arg0 = createdClient.Opened:Connect(function(statusCode, _headers)
11361
12198
  if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
11362
12199
  return nil
11363
12200
  end
12201
+ if socketOpen then
12202
+ return nil
12203
+ end
11364
12204
  if statusCode ~= 101 and (statusCode < 200 or statusCode >= 300) then
11365
- if credentialsRejected(statusCode) then
11366
- cachedReady = nil
11367
- end
12205
+ cachedReady = nil
11368
12206
  scheduleReconnect(expectedGeneration, `WebSocket opened with HTTP {statusCode}`)
11369
12207
  return nil
11370
12208
  end
11371
12209
  socketOpen = true
12210
+ finishWork(work)
11372
12211
  lastValidEventAt = tick()
11373
12212
  reconnectAttempt = 0
11374
12213
  reportTransport({
@@ -11376,8 +12215,18 @@ function connect(expectedGeneration)
11376
12215
  attempt = 0,
11377
12216
  retryDelay = 0,
11378
12217
  })
12218
+ if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
12219
+ return nil
12220
+ end
12221
+ watchForSilence(expectedGeneration, createdClient)
11379
12222
  resumePendingResponses()
11380
- end), createdClient.MessageReceived:Connect(function(message)
12223
+ if active and generation == expectedGeneration and socketClient == createdClient then
12224
+ flushRefresh()
12225
+ end
12226
+ end)
12227
+ table.insert(_socketConnections, _arg0)
12228
+ local _socketConnections_1 = socketConnections
12229
+ local _arg0_1 = createdClient.MessageReceived:Connect(function(message)
11381
12230
  if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
11382
12231
  return nil
11383
12232
  end
@@ -11415,27 +12264,41 @@ function connect(expectedGeneration)
11415
12264
  invokeCallback("WebSocket status", function()
11416
12265
  return currentOptions.onStatus(event)
11417
12266
  end)
12267
+ if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
12268
+ return nil
12269
+ end
11418
12270
  if not event.knownPeer then
11419
12271
  cachedReady = nil
11420
12272
  scheduleReconnect(expectedGeneration, "WebSocket session is no longer registered")
11421
12273
  end
11422
12274
  end
11423
- end), createdClient.Error:Connect(function(statusCode, message)
12275
+ end)
12276
+ table.insert(_socketConnections_1, _arg0_1)
12277
+ local _socketConnections_2 = socketConnections
12278
+ local _arg0_2 = createdClient.Error:Connect(function(statusCode, _message)
11424
12279
  if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
11425
12280
  return nil
11426
12281
  end
11427
- if credentialsRejected(statusCode) then
12282
+ if not socketOpen or credentialsRejected(statusCode) then
11428
12283
  cachedReady = nil
11429
12284
  end
11430
- scheduleReconnect(expectedGeneration, `WebSocket error {statusCode}: {message}`)
11431
- end), createdClient.Closed:Connect(function()
12285
+ -- Native error messages may echo request credentials.
12286
+ scheduleReconnect(expectedGeneration, `WebSocket error {statusCode}`)
12287
+ end)
12288
+ table.insert(_socketConnections_2, _arg0_2)
12289
+ local _socketConnections_3 = socketConnections
12290
+ local _arg0_3 = createdClient.Closed:Connect(function()
11432
12291
  if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
11433
12292
  return nil
11434
12293
  end
12294
+ if not socketOpen then
12295
+ cachedReady = nil
12296
+ end
11435
12297
  scheduleReconnect(expectedGeneration, "WebSocket closed")
11436
- end) }
11437
- lastValidEventAt = tick()
11438
- watchForSilence(expectedGeneration, createdClient)
12298
+ end)
12299
+ table.insert(_socketConnections_3, _arg0_3)
12300
+ end, function(detail)
12301
+ return scheduleReconnect(expectedGeneration, detail)
11439
12302
  end)
11440
12303
  end
11441
12304
  local stop
@@ -11450,20 +12313,42 @@ local function start(newOptions)
11450
12313
  generation += 1
11451
12314
  connect(generation)
11452
12315
  end
11453
- local function refresh()
12316
+ function flushRefresh()
11454
12317
  local currentOptions = options
11455
- if not active or currentOptions == nil or readyRefreshPending then
12318
+ if not refreshRequested or not active or currentOptions == nil or refreshWork ~= nil or connectionWork ~= nil or not socketOpen then
11456
12319
  return nil
11457
12320
  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
12321
+ refreshRequested = false
12322
+ -- Only explicit metadata changes are queued. Failure alone never schedules
12323
+ -- more HTTP requests or replaces a healthy transport.
12324
+ local work = {
12325
+ generation = generation,
12326
+ options = currentOptions,
12327
+ stage = "",
12328
+ }
12329
+ refreshWork = work
12330
+ beginStage(work, "/ready metadata refresh")
12331
+ runWork(work, function()
12332
+ -- Consume at worker entry so changes coalesce even before task.spawn runs.
12333
+ refreshRequested = false
12334
+ registerReady(work, false)
12335
+ if ownsWork(work) then
12336
+ finishWork(work)
12337
+ flushRefresh()
12338
+ end
12339
+ end, function(detail)
12340
+ finishWork(work)
12341
+ warn(`[robloxstudio-mcp] {detail}`)
12342
+ flushRefresh()
11465
12343
  end)
11466
12344
  end
12345
+ local function refresh()
12346
+ if not active or options == nil then
12347
+ return nil
12348
+ end
12349
+ refreshRequested = true
12350
+ flushRefresh()
12351
+ end
11467
12352
  -- EndTest may tear down this VM without an Unloading callback. Release the
11468
12353
  -- native socket before yielding to unregister; a failed EndTest can resume.
11469
12354
  local function suspendForShutdown()
@@ -11474,6 +12359,8 @@ local function suspendForShutdown()
11474
12359
  active = false
11475
12360
  shutdownSuspended = true
11476
12361
  generation += 1
12362
+ cancelTransportWork()
12363
+ refreshRequested = false
11477
12364
  reconnectAttempt = 0
11478
12365
  closeCurrentSocket()
11479
12366
  cachedReady = nil
@@ -11497,6 +12384,8 @@ function stop()
11497
12384
  active = false
11498
12385
  shutdownSuspended = false
11499
12386
  generation += 1
12387
+ cancelTransportWork()
12388
+ refreshRequested = false
11500
12389
  for _, inFlight in inFlightRequests do
11501
12390
  inFlight.cancelled = true
11502
12391
  end
@@ -11827,12 +12716,13 @@ local function init(pluginRef)
11827
12716
  end
11828
12717
  end)
11829
12718
  local statusRow = Instance.new("Frame")
11830
- statusRow.Size = UDim2.new(1, 0, 0, 14)
12719
+ statusRow.Size = UDim2.new(1, 0, 0, 0)
12720
+ statusRow.AutomaticSize = Enum.AutomaticSize.Y
11831
12721
  statusRow.BackgroundTransparency = 1
11832
12722
  statusRow.LayoutOrder = 2
11833
12723
  statusRow.Parent = card
11834
12724
  local statusLabel = Instance.new("TextLabel")
11835
- statusLabel.Size = UDim2.new(1, 0, 1, 0)
12725
+ statusLabel.Size = UDim2.new(1, 0, 0, 18)
11836
12726
  statusLabel.BackgroundTransparency = 1
11837
12727
  statusLabel.Text = "Disconnected"
11838
12728
  statusLabel.TextColor3 = C.red
@@ -11840,17 +12730,20 @@ local function init(pluginRef)
11840
12730
  statusLabel.Font = Enum.Font.GothamBold
11841
12731
  statusLabel.TextXAlignment = Enum.TextXAlignment.Left
11842
12732
  statusLabel.TextWrapped = true
12733
+ statusLabel.RichText = false
11843
12734
  statusLabel.Parent = statusRow
11844
12735
  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)
12736
+ detailStatusLabel.Size = UDim2.new(1, 0, 0, 0)
12737
+ detailStatusLabel.Position = UDim2.new(0, 0, 0, 18)
12738
+ detailStatusLabel.AutomaticSize = Enum.AutomaticSize.Y
11847
12739
  detailStatusLabel.BackgroundTransparency = 1
11848
12740
  detailStatusLabel.Text = "HTTP: X MCP: X"
11849
12741
  detailStatusLabel.TextColor3 = C.muted
11850
12742
  detailStatusLabel.TextSize = 9
11851
12743
  detailStatusLabel.Font = Enum.Font.GothamMedium
11852
- detailStatusLabel.TextXAlignment = Enum.TextXAlignment.Right
12744
+ detailStatusLabel.TextXAlignment = Enum.TextXAlignment.Left
11853
12745
  detailStatusLabel.TextWrapped = true
12746
+ detailStatusLabel.RichText = false
11854
12747
  detailStatusLabel.Parent = statusRow
11855
12748
  local stepsFrame = Instance.new("Frame")
11856
12749
  stepsFrame.Size = UDim2.new(1, 0, 0, 0)
@@ -11987,13 +12880,14 @@ local function updateUIState()
11987
12880
  return nil
11988
12881
  end
11989
12882
  local el = elements
12883
+ local diagnostics = State.getTransportDiagnostics(tick())
11990
12884
  if not conn.isActive then
11991
- el.statusLabel.Text = "Disconnected"
12885
+ el.statusLabel.Text = diagnostics.status
11992
12886
  el.statusLabel.TextColor3 = C.muted
11993
12887
  el.statusIndicator.BackgroundColor3 = C.red
11994
12888
  el.statusPulse.BackgroundColor3 = C.red
11995
12889
  el.statusText.Text = "OFFLINE"
11996
- el.detailStatusLabel.Text = ""
12890
+ el.detailStatusLabel.Text = diagnostics.detail
11997
12891
  el.detailStatusLabel.TextColor3 = C.muted
11998
12892
  stopPulseAnimation()
11999
12893
  el.step1Dot.BackgroundColor3 = C.gray
@@ -12016,12 +12910,12 @@ local function updateUIState()
12016
12910
  el.urlInput.TextEditable = false
12017
12911
  el.urlInput.BackgroundColor3 = C.card
12018
12912
  if conn.lastHttpOk and conn.lastMcpOk then
12019
- el.statusLabel.Text = "Connected"
12913
+ el.statusLabel.Text = diagnostics.status
12020
12914
  el.statusLabel.TextColor3 = Color3.fromRGB(34, 197, 94)
12021
12915
  el.statusIndicator.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
12022
12916
  el.statusPulse.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
12023
12917
  el.statusText.Text = "ONLINE"
12024
- el.detailStatusLabel.Text = "HTTP: OK MCP: OK"
12918
+ el.detailStatusLabel.Text = diagnostics.detail
12025
12919
  el.detailStatusLabel.TextColor3 = Color3.fromRGB(34, 197, 94)
12026
12920
  el.step1Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
12027
12921
  el.step1Label.Text = "HTTP server (OK)"
@@ -12032,12 +12926,12 @@ local function updateUIState()
12032
12926
  el.troubleshootLabel.Visible = false
12033
12927
  stopPulseAnimation()
12034
12928
  elseif conn.lastHttpOk and not conn.lastMcpOk then
12035
- el.statusLabel.Text = "Waiting for MCP server"
12929
+ el.statusLabel.Text = diagnostics.status
12036
12930
  el.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
12037
12931
  el.statusIndicator.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
12038
12932
  el.statusPulse.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
12039
12933
  el.statusText.Text = "WAITING"
12040
- el.detailStatusLabel.Text = "HTTP: OK MCP: ..."
12934
+ el.detailStatusLabel.Text = diagnostics.detail
12041
12935
  el.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
12042
12936
  el.step1Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
12043
12937
  el.step1Label.Text = "HTTP server (OK)"
@@ -12048,53 +12942,21 @@ local function updateUIState()
12048
12942
  local elapsed = if conn.mcpWaitStartTime ~= nil then tick() - conn.mcpWaitStartTime else 0
12049
12943
  el.troubleshootLabel.Visible = elapsed > 8
12050
12944
  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
12945
  else
12085
- el.statusLabel.Text = if conn.consecutiveFailures > 1 then `Connecting (attempt {conn.consecutiveFailures})` else "Connecting..."
12946
+ local retrying = conn.nextRetryAt ~= nil
12947
+ el.statusLabel.Text = diagnostics.status
12086
12948
  el.statusLabel.TextColor3 = C.yellow
12087
12949
  el.statusIndicator.BackgroundColor3 = C.yellow
12088
12950
  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
12951
+ el.statusText.Text = if retrying then "RETRY" else "CONNECTING"
12952
+ el.detailStatusLabel.Text = diagnostics.detail
12953
+ el.detailStatusLabel.TextColor3 = C.yellow
12092
12954
  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...)"
12955
+ el.step1Label.Text = if retrying then "Listener (disconnected)" else "Listener (connecting...)"
12956
+ el.step2Dot.BackgroundColor3 = C.gray
12957
+ el.step2Label.Text = "MCP client (waiting for listener)"
12958
+ el.step3Dot.BackgroundColor3 = C.gray
12959
+ el.step3Label.Text = "Commands (waiting for listener)"
12098
12960
  el.troubleshootLabel.Visible = false
12099
12961
  startPulseAnimation()
12100
12962
  end
@@ -12714,11 +13576,13 @@ local function convertPropertyValue(instance, propertyName, propertyValue)
12714
13576
  if propertyName == "BrickColor" then
12715
13577
  return BrickColor.new(propertyValue)
12716
13578
  end
12717
- if propertyValue == "true" then
12718
- return true
12719
- end
12720
- if propertyValue == "false" then
12721
- return false
13579
+ if success and typeof(currentVal) == "boolean" then
13580
+ if propertyValue == "true" then
13581
+ return true
13582
+ end
13583
+ if propertyValue == "false" then
13584
+ return false
13585
+ end
12722
13586
  end
12723
13587
  end
12724
13588
  return propertyValue