@chrrxs/robloxstudio-mcp-inspector 3.1.3 → 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.
@@ -278,6 +278,326 @@ return {
278
278
  </Properties>
279
279
  </Item>
280
280
  <Item class="ModuleScript" referent="3">
281
+ <Properties>
282
+ <string name="Name">CaptureTransfer</string>
283
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
284
+ -- Private ClientBroker transport. Only metadata and <=1 MiB data chunks cross
285
+ -- InvokeClient; callers still receive the original capture-studio response.
286
+ local HttpService = game:GetService("HttpService")
287
+ local READ_ENDPOINT = "/__mcp/capture-studio/read"
288
+ local RELEASE_ENDPOINT = "/__mcp/capture-studio/release"
289
+ local CHUNK_BYTES = 1024 * 1024
290
+ local MAX_TRANSFER_BYTES = 64 * 1024 * 1024
291
+ local MAX_TRANSFERS = 2
292
+ local TRANSFER_TTL_SECONDS = 60
293
+ local transfers = {}
294
+ local function integer(value, min, max)
295
+ local _value = value
296
+ local _condition = type(_value) == "number"
297
+ if _condition then
298
+ _condition = value >= min and value <= max and value == math.floor(value)
299
+ end
300
+ return _condition
301
+ end
302
+ local function validId(value)
303
+ local _value = value
304
+ local _condition = type(_value) == "string"
305
+ if _condition then
306
+ _condition = #value > 0 and #value <= 64
307
+ end
308
+ return _condition
309
+ end
310
+ local function metadata(value)
311
+ local _value = value
312
+ if not (type(_value) == "table") then
313
+ return nil
314
+ end
315
+ local fields = value
316
+ if fields.success ~= true or (fields.encoding ~= "png" and fields.encoding ~= "rgba8") or fields.source ~= "StudioCaptureService" or not integer(fields.width, 1, 65536) or not integer(fields.height, 1, 65536) or not integer(fields.nativeWidth, 1, 65536) or not integer(fields.nativeHeight, 1, 65536) then
317
+ return nil
318
+ end
319
+ return {
320
+ success = true,
321
+ encoding = fields.encoding,
322
+ source = fields.source,
323
+ width = fields.width,
324
+ height = fields.height,
325
+ nativeWidth = fields.nativeWidth,
326
+ nativeHeight = fields.nativeHeight,
327
+ }
328
+ end
329
+ local function fail(message)
330
+ return {
331
+ success = false,
332
+ error = `Capture transfer: {message}`,
333
+ }
334
+ end
335
+ local function discard(id)
336
+ local _id = id
337
+ local transfer = transfers[_id]
338
+ if transfer == nil then
339
+ return nil
340
+ end
341
+ local _id_1 = id
342
+ transfers[_id_1] = nil
343
+ if transfer.expiryThread ~= nil then
344
+ task.cancel(transfer.expiryThread)
345
+ end
346
+ end
347
+ local function prune()
348
+ local now = os.clock()
349
+ for id, transfer in transfers do
350
+ if now >= transfer.expiresAt then
351
+ discard(id)
352
+ end
353
+ end
354
+ end
355
+ local function release(data)
356
+ if not validId(data.transferId) then
357
+ return fail("invalid transfer ID")
358
+ end
359
+ discard(data.transferId)
360
+ -- Idempotent release also negotiates chunk support before capture starts.
361
+ return {
362
+ success = true,
363
+ chunkBytes = CHUNK_BYTES,
364
+ }
365
+ end
366
+ local validSize
367
+ local function begin(data, capture)
368
+ prune()
369
+ local id = data.transferId
370
+ -- An older server cannot consume chunk descriptors. Let it use legacy
371
+ -- capture rather than returning an unsafe inline image or a hard error.
372
+ if id == nil then
373
+ return {
374
+ unavailable = "Client capture requires bounded transfer support",
375
+ }
376
+ end
377
+ if not validId(id) then
378
+ return fail("invalid transfer ID")
379
+ end
380
+ if transfers[id] ~= nil then
381
+ return fail("duplicate transfer ID")
382
+ end
383
+ -- Reserve before capture yields: concurrent captures cannot over-admit or
384
+ -- evict each other's data. Retained strings are capped at 2 * 64 MiB.
385
+ -- ▼ ReadonlyMap.size ▼
386
+ local _size = 0
387
+ for _ in transfers do
388
+ _size += 1
389
+ end
390
+ -- ▲ ReadonlyMap.size ▲
391
+ if _size >= MAX_TRANSFERS then
392
+ return fail("two captures are already in flight; release or await them first")
393
+ end
394
+ local transfer = {
395
+ offset = 0,
396
+ expiresAt = os.clock() + TRANSFER_TTL_SECONDS,
397
+ }
398
+ transfers[id] = transfer
399
+ -- Capture only the ID, not the large payload, in the expiry closure.
400
+ transfer.expiryThread = task.delay(TRANSFER_TTL_SECONDS, function()
401
+ local current = transfers[id]
402
+ if current ~= nil and os.clock() >= current.expiresAt then
403
+ current.expiryThread = nil
404
+ discard(id)
405
+ end
406
+ end)
407
+ local ok, result = pcall(capture)
408
+ if not ok then
409
+ if transfers[id] == transfer then
410
+ discard(id)
411
+ end
412
+ return fail(`capture failed: {tostring(result)}`)
413
+ end
414
+ if transfers[id] ~= transfer or os.clock() >= transfer.expiresAt then
415
+ if transfers[id] == transfer then
416
+ discard(id)
417
+ end
418
+ return fail("capture expired before it completed")
419
+ end
420
+ local info = metadata(result)
421
+ if info == nil then
422
+ discard(id)
423
+ -- Unavailability must reach core unchanged to preserve legacy fallback.
424
+ if type(result) == "table" then
425
+ local fields = result
426
+ local _unavailable = fields.unavailable
427
+ if type(_unavailable) == "string" then
428
+ return {
429
+ unavailable = string.sub(fields.unavailable, 1, 4096),
430
+ }
431
+ end
432
+ local _error = fields.error
433
+ if type(_error) == "string" then
434
+ return fail(string.sub(fields.error, 1, 4096))
435
+ end
436
+ end
437
+ return fail("invalid capture response")
438
+ end
439
+ local fields = result
440
+ local pixels = fields.data
441
+ if not (type(pixels) == "string") or not validSize(#pixels, info) then
442
+ discard(id)
443
+ return fail("invalid or oversized capture data")
444
+ end
445
+ transfer.data = pixels
446
+ return {
447
+ transferId = id,
448
+ totalBytes = #pixels,
449
+ chunkBytes = CHUNK_BYTES,
450
+ metadata = info,
451
+ }
452
+ end
453
+ function validSize(size, info)
454
+ if not integer(size, 4, MAX_TRANSFER_BYTES) or size % 4 ~= 0 then
455
+ return false
456
+ end
457
+ return info.encoding ~= "rgba8" or size == math.ceil(info.width * info.height * 4 / 3) * 4
458
+ end
459
+ local function read(data)
460
+ prune()
461
+ local id = data.transferId
462
+ if not validId(id) then
463
+ return fail("invalid transfer ID")
464
+ end
465
+ local transfer = transfers[id]
466
+ if transfer == nil then
467
+ return fail("unknown or expired transfer")
468
+ end
469
+ local pixels = transfer.data
470
+ local offset = data.offset
471
+ local length = data.length
472
+ if pixels == nil or not integer(offset, 0, #pixels - 1) or not integer(length, 1, CHUNK_BYTES) or offset ~= transfer.offset or length ~= math.min(CHUNK_BYTES, #pixels - offset) then
473
+ discard(id)
474
+ return fail("invalid chunk offset or length")
475
+ end
476
+ local _arg0 = offset + 1
477
+ local _arg1 = offset + length
478
+ local chunk = string.sub(pixels, _arg0, _arg1)
479
+ transfer.offset += length
480
+ -- Last read releases immediately, even if the receiver disappears before
481
+ -- its explicit release. Explicit release is idempotent for error paths.
482
+ if transfer.offset == #pixels then
483
+ discard(id)
484
+ end
485
+ return {
486
+ transferId = id,
487
+ offset = offset,
488
+ data = chunk,
489
+ }
490
+ end
491
+ local function receive(invoke, data)
492
+ local id = HttpService:GenerateGUID(false)
493
+ local ok, result = pcall(function()
494
+ -- Older clients return inline data and may disconnect on a large capture.
495
+ -- Probe a harmless release of our fresh ID before requesting any pixels.
496
+ local probe = invoke(RELEASE_ENDPOINT, {
497
+ transferId = id,
498
+ })
499
+ if not (type(probe) == "table") then
500
+ return {
501
+ unavailable = "Client capture lacks bounded transfer support",
502
+ }
503
+ end
504
+ local capability = probe
505
+ if capability.success ~= true or capability.chunkBytes ~= CHUNK_BYTES then
506
+ return {
507
+ unavailable = "Client capture lacks bounded transfer support",
508
+ }
509
+ end
510
+ local response = invoke("/api/capture-studio", {
511
+ encoding = data.encoding,
512
+ transferId = id,
513
+ })
514
+ if not (type(response) == "table") then
515
+ error("invalid transfer response")
516
+ end
517
+ local fields = response
518
+ local _unavailable = fields.unavailable
519
+ if type(_unavailable) == "string" then
520
+ return {
521
+ unavailable = string.sub(fields.unavailable, 1, 4096),
522
+ }
523
+ end
524
+ local _error = fields.error
525
+ if type(_error) == "string" then
526
+ error(string.sub(fields.error, 1, 4096))
527
+ end
528
+ local info = metadata(fields.metadata)
529
+ if info == nil or fields.transferId ~= id or fields.chunkBytes ~= CHUNK_BYTES or not validSize(fields.totalBytes, info) then
530
+ error("invalid transfer metadata or size")
531
+ end
532
+ local size = fields.totalBytes
533
+ local chunks = {}
534
+ local offset = 0
535
+ while offset < size do
536
+ local length = math.min(CHUNK_BYTES, size - offset)
537
+ local response = invoke(READ_ENDPOINT, {
538
+ transferId = id,
539
+ offset = offset,
540
+ length = length,
541
+ })
542
+ if not (type(response) == "table") then
543
+ error("invalid chunk response")
544
+ end
545
+ local chunk = response
546
+ local _error_1 = chunk.error
547
+ if type(_error_1) == "string" then
548
+ error(string.sub(chunk.error, 1, 4096))
549
+ end
550
+ local _condition = chunk.transferId ~= id or chunk.offset ~= offset
551
+ if not _condition then
552
+ local _data = chunk.data
553
+ _condition = not (type(_data) == "string")
554
+ if not _condition then
555
+ _condition = #chunk.data ~= length
556
+ end
557
+ end
558
+ if _condition then
559
+ error("chunk ID, offset or length mismatch")
560
+ end
561
+ local _data = chunk.data
562
+ table.insert(chunks, _data)
563
+ offset += length
564
+ end
565
+ return {
566
+ success = true,
567
+ encoding = info.encoding,
568
+ source = info.source,
569
+ width = info.width,
570
+ height = info.height,
571
+ nativeWidth = info.nativeWidth,
572
+ nativeHeight = info.nativeHeight,
573
+ data = table.concat(chunks, ""),
574
+ }
575
+ end)
576
+ -- The final read already frees successful transfers. Cleanup is best-effort:
577
+ -- it must not mask an unavailable signal from an older client or a completed
578
+ -- image. Failed partial transfers also have the bounded expiry above.
579
+ pcall(function()
580
+ return invoke(RELEASE_ENDPOINT, {
581
+ transferId = id,
582
+ })
583
+ end)
584
+ if not ok then
585
+ return fail(tostring(result))
586
+ end
587
+ return result
588
+ end
589
+ return {
590
+ READ_ENDPOINT = READ_ENDPOINT,
591
+ RELEASE_ENDPOINT = RELEASE_ENDPOINT,
592
+ begin = begin,
593
+ read = read,
594
+ release = release,
595
+ receive = receive,
596
+ }
597
+ ]]></string>
598
+ </Properties>
599
+ </Item>
600
+ <Item class="ModuleScript" referent="4">
281
601
  <Properties>
282
602
  <string name="Name">ClientBroker</string>
283
603
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -287,10 +607,12 @@ local HttpService = _services.HttpService
287
607
  local Players = _services.Players
288
608
  local ReplicatedStorage = _services.ReplicatedStorage
289
609
  local RunService = _services.RunService
610
+ local Workspace = _services.Workspace
290
611
  local RuntimeLogBuffer = TS.import(script, script.Parent, "RuntimeLogBuffer")
291
612
  local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandlers")
292
613
  local SceneAnalysisHandlers = TS.import(script, script.Parent, "handlers", "SceneAnalysisHandlers")
293
614
  local CaptureHandlers = TS.import(script, script.Parent, "handlers", "CaptureHandlers")
615
+ local CaptureTransfer = TS.import(script, script.Parent, "CaptureTransfer")
294
616
  local InputHandlers = TS.import(script, script.Parent, "handlers", "InputHandlers")
295
617
  local MetadataHandlers = TS.import(script, script.Parent, "handlers", "MetadataHandlers")
296
618
  local EvalRuntimeHandlers = TS.import(script, script.Parent, "handlers", "EvalRuntimeHandlers")
@@ -312,6 +634,11 @@ local mcpUrl = DEFAULT_MCP_URL
312
634
  local BROKER_NAME = "__MCPClientBroker"
313
635
  local BROKER_OWNER_ATTRIBUTE = "__MCPBrokerOwner"
314
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
315
642
  -- Endpoints the server-peer broker is allowed to forward to the client peer.
316
643
  -- Each requires the client peer's plugin VM (because the buffer / require
317
644
  -- cache / etc. lives there) so the server peer alone can't satisfy them.
@@ -327,6 +654,8 @@ local CLIENT_BROKER_ALLOWED_ENDPOINTS = {
327
654
  ["/api/multiplayer-test-state"] = true,
328
655
  ["/api/multiplayer-test-leave-client"] = true,
329
656
  ["/api/capture-begin"] = true,
657
+ ["/api/capture-studio"] = true,
658
+ ["/api/capture-markers"] = true,
330
659
  ["/api/simulate-mouse-input"] = true,
331
660
  ["/api/simulate-keyboard-input"] = true,
332
661
  ["/api/focus-viewport"] = true,
@@ -514,6 +843,32 @@ local function setupClientBroker(attempt)
514
843
  return BreakpointHandlers.breakpoints(payload.data or {})
515
844
  end
516
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
517
872
  return ScriptProfilerHandlers.captureScriptProfiler(payload.data or {})
518
873
  end
519
874
  if payload and payload.endpoint == "/api/capture-micro-profiler" then
@@ -528,6 +883,20 @@ local function setupClientBroker(attempt)
528
883
  if payload and payload.endpoint == "/api/capture-begin" then
529
884
  return CaptureHandlers.captureBegin()
530
885
  end
886
+ if payload and payload.endpoint == "/api/capture-studio" then
887
+ return CaptureTransfer.begin(payload.data or {}, function()
888
+ return CaptureHandlers.captureStudio(payload.data or {})
889
+ end)
890
+ end
891
+ if payload and payload.endpoint == "/api/capture-markers" then
892
+ return CaptureHandlers.captureMarkers(payload.data or {})
893
+ end
894
+ if payload and payload.endpoint == CaptureTransfer.READ_ENDPOINT then
895
+ return CaptureTransfer.read(payload.data or {})
896
+ end
897
+ if payload and payload.endpoint == CaptureTransfer.RELEASE_ENDPOINT then
898
+ return CaptureTransfer.release(payload.data or {})
899
+ end
531
900
  if payload and payload.endpoint == "/api/simulate-mouse-input" then
532
901
  return InputHandlers.simulateMouseInput(payload.data or {})
533
902
  end
@@ -800,7 +1169,112 @@ local function refreshAllProxyRegistrations()
800
1169
  task.spawn(registerProxyEntry, entry)
801
1170
  end
802
1171
  end
803
- 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
804
1278
  local _peerId = peerId
805
1279
  local entry = proxyByPeerId[_peerId]
806
1280
  local _condition = not entry
@@ -840,6 +1314,17 @@ local function dispatchClientRequest(peerId, target, endpoint, data)
840
1314
  error = `Client-proxy does not forward {endpoint}. Allowed: {table.concat(allowed, ", ")}.`,
841
1315
  }
842
1316
  end
1317
+ if endpoint == "/api/capture-script-profiler" then
1318
+ return invokeClientProfiler(entry, target, data, context)
1319
+ end
1320
+ if endpoint == "/api/capture-studio" then
1321
+ return CaptureTransfer.receive(function(captureEndpoint, captureData)
1322
+ return entry.remote:InvokeClient(entry.player, {
1323
+ endpoint = captureEndpoint,
1324
+ data = captureData,
1325
+ })
1326
+ end, data or {})
1327
+ end
843
1328
  local envelope = {
844
1329
  endpoint = endpoint,
845
1330
  data = data,
@@ -920,7 +1405,7 @@ return {
920
1405
  ]]></string>
921
1406
  </Properties>
922
1407
  </Item>
923
- <Item class="ModuleScript" referent="4">
1408
+ <Item class="ModuleScript" referent="5">
924
1409
  <Properties>
925
1410
  <string name="Name">Communication</string>
926
1411
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -988,8 +1473,10 @@ local routeMap = {
988
1473
  ["/api/insert-asset"] = AssetHandlers.insertAsset,
989
1474
  ["/api/preview-asset"] = AssetHandlers.previewAsset,
990
1475
  ["/api/capture-screenshot"] = CaptureHandlers.captureScreenshot,
1476
+ ["/api/capture-studio"] = CaptureHandlers.captureStudio,
991
1477
  ["/api/capture-begin"] = CaptureHandlers.captureBegin,
992
1478
  ["/api/capture-read"] = CaptureHandlers.captureRead,
1479
+ ["/api/capture-markers"] = CaptureHandlers.captureMarkers,
993
1480
  ["/api/simulate-mouse-input"] = InputHandlers.simulateMouseInput,
994
1481
  ["/api/simulate-keyboard-input"] = InputHandlers.simulateKeyboardInput,
995
1482
  ["/api/find-and-replace-in-scripts"] = ScriptHandlers.findAndReplaceInScripts,
@@ -1030,7 +1517,7 @@ local function getConnectionStatus()
1030
1517
  end
1031
1518
  local function dispatchSocketRequest(request, context)
1032
1519
  if request.peerId ~= PluginSession.peerId then
1033
- return ClientBroker.dispatchClientRequest(request.peerId, request.target, request.endpoint, request.data)
1520
+ return ClientBroker.dispatchClientRequest(request.peerId, request.target, request.endpoint, request.data, context)
1034
1521
  end
1035
1522
  local _condition = assignedRole
1036
1523
  if _condition == nil then
@@ -1085,34 +1572,9 @@ local function handleTransportUpdate(update)
1085
1572
  if not conn.isActive then
1086
1573
  return nil
1087
1574
  end
1088
- if update.state == "open" then
1089
- conn.lastHttpOk = true
1090
- conn.lastMcpOk = false
1091
- conn.consecutiveFailures = 0
1092
- conn.currentRetryDelay = 0.5
1093
- conn.mcpWaitStartTime = tick()
1094
- else
1095
- conn.lastHttpOk = false
1096
- conn.lastMcpOk = false
1097
- conn.consecutiveFailures = update.attempt
1098
- if update.retryDelay > 0 then
1099
- conn.currentRetryDelay = update.retryDelay
1100
- end
1101
- conn.mcpWaitStartTime = nil
1102
- end
1575
+ State.applyTransportUpdate(update, tick())
1103
1576
  UI.updateUIState()
1104
1577
  UI.updateToolbarIcon()
1105
- if update.state == "waiting-duplicate" then
1106
- local ui = UI.getElements()
1107
- ui.statusLabel.Text = "Waiting for previous instance"
1108
- ui.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
1109
- local _condition = update.detail
1110
- if _condition == nil then
1111
- _condition = "The previous plugin instance is still active."
1112
- end
1113
- ui.detailStatusLabel.Text = _condition
1114
- ui.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
1115
- end
1116
1578
  end
1117
1579
  local nameChangeConn
1118
1580
  local placeIdChangeConn
@@ -1163,6 +1625,7 @@ local function activatePlugin()
1163
1625
  conn.lastHttpOk = false
1164
1626
  conn.lastMcpOk = false
1165
1627
  conn.mcpWaitStartTime = nil
1628
+ State.clearTransportDiagnostics()
1166
1629
  local normalizedUrl = ServerUrlSettings.normalizeServerUrl(ui.urlInput.Text)
1167
1630
  conn.serverUrl = if normalizedUrl ~= "" then normalizedUrl else conn.serverUrl
1168
1631
  if conn.serverUrl == "" then
@@ -1185,6 +1648,7 @@ local function activatePlugin()
1185
1648
  onTransportUpdate = handleTransportUpdate,
1186
1649
  })
1187
1650
  if not conn.heartbeatConnection then
1651
+ local lastDiagnosticUpdate = tick()
1188
1652
  conn.heartbeatConnection = RunService.Heartbeat:Connect(function()
1189
1653
  if initialRole == "server" and not RunService:IsRunning() then
1190
1654
  ClientBroker.disconnectAllProxies()
@@ -1197,6 +1661,11 @@ local function activatePlugin()
1197
1661
  PluginSession.invalidatePlaceName()
1198
1662
  StudioWebSocket.refresh()
1199
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
1200
1669
  end)
1201
1670
  end
1202
1671
  if initialRole == "edit" then
@@ -1213,6 +1682,7 @@ function deactivatePlugin()
1213
1682
  conn.lastHttpOk = false
1214
1683
  conn.lastMcpOk = false
1215
1684
  conn.mcpWaitStartTime = nil
1685
+ State.clearTransportDiagnostics()
1216
1686
  StudioWebSocket.stop()
1217
1687
  disconnectIdentityWatchers()
1218
1688
  if initialRole == "server" then
@@ -1276,7 +1746,7 @@ return {
1276
1746
  ]]></string>
1277
1747
  </Properties>
1278
1748
  </Item>
1279
- <Item class="ModuleScript" referent="5">
1749
+ <Item class="ModuleScript" referent="6">
1280
1750
  <Properties>
1281
1751
  <string name="Name">CooperativeJobRunner</string>
1282
1752
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -1351,7 +1821,7 @@ return {
1351
1821
  ]]></string>
1352
1822
  </Properties>
1353
1823
  </Item>
1354
- <Item class="ModuleScript" referent="6">
1824
+ <Item class="ModuleScript" referent="7">
1355
1825
  <Properties>
1356
1826
  <string name="Name">EvalBridges</string>
1357
1827
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -1472,9 +1942,9 @@ local function computeBridgeStamp()
1472
1942
  for i = 1, #combined do
1473
1943
  h = (h * 33 + (string.byte(combined, i))) % 2147483647
1474
1944
  end
1475
- -- "3.1.3" is replaced with the package version at package time
1945
+ -- "3.1.5" is replaced with the package version at package time
1476
1946
  -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1477
- return `{tostring(h)}-3.1.3`
1947
+ return `{tostring(h)}-3.1.5`
1478
1948
  end
1479
1949
  local BRIDGE_STAMP = computeBridgeStamp()
1480
1950
  local function setSource(scriptInst, source)
@@ -1627,11 +2097,11 @@ return {
1627
2097
  ]]></string>
1628
2098
  </Properties>
1629
2099
  </Item>
1630
- <Item class="Folder" referent="7">
2100
+ <Item class="Folder" referent="8">
1631
2101
  <Properties>
1632
2102
  <string name="Name">handlers</string>
1633
2103
  </Properties>
1634
- <Item class="ModuleScript" referent="8">
2104
+ <Item class="ModuleScript" referent="9">
1635
2105
  <Properties>
1636
2106
  <string name="Name">AssetHandlers</string>
1637
2107
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -2072,7 +2542,7 @@ return {
2072
2542
  ]]></string>
2073
2543
  </Properties>
2074
2544
  </Item>
2075
- <Item class="ModuleScript" referent="9">
2545
+ <Item class="ModuleScript" referent="10">
2076
2546
  <Properties>
2077
2547
  <string name="Name">BreakpointHandlers</string>
2078
2548
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -2579,7 +3049,7 @@ return {
2579
3049
  ]]></string>
2580
3050
  </Properties>
2581
3051
  </Item>
2582
- <Item class="ModuleScript" referent="10">
3052
+ <Item class="ModuleScript" referent="11">
2583
3053
  <Properties>
2584
3054
  <string name="Name">CaptureHandlers</string>
2585
3055
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -2587,11 +3057,35 @@ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
2587
3057
  local RenderMonitor = TS.import(script, script.Parent.Parent, "RenderMonitor")
2588
3058
  local CaptureService = game:GetService("CaptureService")
2589
3059
  local AssetService = game:GetService("AssetService")
3060
+ local Workspace = game:GetService("Workspace")
3061
+ local CoreGui = game:GetService("CoreGui")
3062
+ local RunService = game:GetService("RunService")
2590
3063
  local MAX_TILE_SIZE = 1024
2591
3064
  local MAX_RAW_PIXEL_BYTES = 36 * 1024 * 1024
2592
3065
  local MAX_CREATED_IMAGE_DIM = 2048
2593
3066
  local BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
2594
3067
  local PAD_BYTE = (string.byte("="))
3068
+ -- StudioCaptureService (Studio-only, PluginSecurity) is the fast path: it hands
3069
+ -- back the framebuffer directly, so it needs neither CaptureService's
3070
+ -- asynchronous callback nor an EditableImage round-trip — and it captures at
3071
+ -- ViewportSize, which is exactly the coordinate space simulate_mouse_input
3072
+ -- expects. It is gated behind Studio FFlags and is missing from @rbxts/types,
3073
+ -- hence the local structural declarations below.
3074
+ -- Studio exposes this API at runtime, but the installed @rbxts/types predates it.
3075
+ -- Unchecked cast, single reason: these enums exist in Studio but are missing
3076
+ -- from @rbxts/types, so the global Enum table has to be read dynamically.
3077
+ local ENUM_TABLE = Enum
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
3084
+ -- Measured on a 1980x1032 viewport: RGBA8 completes in ~0.16s and PNG in ~1s.
3085
+ -- A capture that is still Pending well past that never completes (it happens
3086
+ -- while a playtest owns the renderer), so we stop waiting and let the caller
3087
+ -- fall back instead of stalling the tool call.
3088
+ local STUDIO_CAPTURE_TIMEOUT = 3
2595
3089
  local B64 = {}
2596
3090
  for i = 0, 63 do
2597
3091
  B64[i + 1] = (string.byte(BASE64_CHARS, i + 1))
@@ -2701,7 +3195,11 @@ end
2701
3195
  -- pixels back is blocked, but capturing is not). The returned rbxtemp:// id is
2702
3196
  -- a process-scoped handle: it can be dereferenced from a DIFFERENT, more
2703
3197
  -- privileged DM (the edit DM) — see captureRead.
3198
+ local withViewportRead, captureScreenshotFrame
2704
3199
  local function doCaptureScreenshot()
3200
+ return withViewportRead(captureScreenshotFrame)
3201
+ end
3202
+ function captureScreenshotFrame()
2705
3203
  -- Fast-fail with a clear reason if the window isn't rendering — otherwise
2706
3204
  -- CaptureScreenshot's callback never fires and we'd block for the full 10s.
2707
3205
  local notRendering = RenderMonitor.notRenderingReason()
@@ -2737,8 +3235,12 @@ local function readContentToBase64(contentId)
2737
3235
  return AssetService:CreateEditableImageAsync(Content.fromUri(contentId))
2738
3236
  end)
2739
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.
2740
3242
  return {
2741
- 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'.",
2742
3244
  }
2743
3245
  end
2744
3246
  local sourceImage = editableResult
@@ -2789,6 +3291,130 @@ local function readContentToBase64(contentId)
2789
3291
  nativeHeight = nativeH,
2790
3292
  }
2791
3293
  end
3294
+ local cachedStudioService
3295
+ local function getStudioCaptureService()
3296
+ if cachedStudioService ~= nil then
3297
+ return cachedStudioService
3298
+ end
3299
+ -- Unchecked cast, single reason: StudioCaptureService is absent from
3300
+ -- @rbxts/types, so GetService cannot be called through the typed overload.
3301
+ local dynamicGame = game
3302
+ local ok, service = pcall(function()
3303
+ return dynamicGame:GetService("StudioCaptureService")
3304
+ end)
3305
+ if not ok or service == nil then
3306
+ return nil
3307
+ end
3308
+ cachedStudioService = service
3309
+ return cachedStudioService
3310
+ end
3311
+ -- Captures through StudioCaptureService. Returns undefined when the service
3312
+ -- cannot capture right now (missing FFlag, permission not granted, or this
3313
+ -- DataModel is not the active one) so the caller can fall back to the
3314
+ -- CaptureService + EditableImage path.
3315
+ local captureStudioFrame
3316
+ local function doStudioCapture(wantPng)
3317
+ return withViewportRead(function()
3318
+ return captureStudioFrame(wantPng)
3319
+ end)
3320
+ end
3321
+ function captureStudioFrame(wantPng)
3322
+ if STUDIO_CAPTURE_FORMATS == nil then
3323
+ return nil
3324
+ end
3325
+ local service = getStudioCaptureService()
3326
+ if service == nil then
3327
+ return nil
3328
+ end
3329
+ local canOk, can = pcall(function()
3330
+ return service:CanCaptureScreenshot()
3331
+ end)
3332
+ if not canOk or can ~= true then
3333
+ return nil
3334
+ end
3335
+ local camera = Workspace.CurrentCamera
3336
+ if camera == nil then
3337
+ return nil
3338
+ end
3339
+ local viewport = camera.ViewportSize
3340
+ local nativeW = math.max(1, math.floor(viewport.X))
3341
+ local nativeH = math.max(1, math.floor(viewport.Y))
3342
+ local w = nativeW
3343
+ local h = nativeH
3344
+ -- Raw RGBA rides back base64-encoded, so an oversized viewport is
3345
+ -- downscaled by the engine during the capture itself — no second pass.
3346
+ if not wantPng and nativeW * nativeH * 4 > MAX_RAW_PIXEL_BYTES then
3347
+ local scale = math.sqrt(MAX_RAW_PIXEL_BYTES / (nativeW * nativeH * 4))
3348
+ w = math.max(1, math.floor(nativeW * scale))
3349
+ h = math.max(1, math.floor(nativeH * scale))
3350
+ end
3351
+ -- CaptureSize is a framebuffer crop, not the logical viewport size. At
3352
+ -- fractional display scaling it cuts off the right/bottom of the frame.
3353
+ local options = {
3354
+ OutputSize = Vector2.new(w, h),
3355
+ ResampleMode = Enum.ResamplerMode.Default,
3356
+ Format = if wantPng then STUDIO_CAPTURE_FORMATS.PNG else STUDIO_CAPTURE_FORMATS.RGBA8,
3357
+ }
3358
+ local captureOk, captureResult = pcall(function()
3359
+ return service:CaptureScreenshot(options)
3360
+ end)
3361
+ if not captureOk then
3362
+ return {
3363
+ error = `StudioCaptureService:CaptureScreenshot failed: {tostring(captureResult)}`,
3364
+ }
3365
+ end
3366
+ if captureResult == nil then
3367
+ return nil
3368
+ end
3369
+ local capture = captureResult
3370
+ local startTime = tick()
3371
+ while capture.BufferStatus.Name == "Pending" or capture.BufferStatus.Name == "NotStarted" do
3372
+ if tick() - startTime > STUDIO_CAPTURE_TIMEOUT then
3373
+ return nil
3374
+ end
3375
+ task.wait(0.02)
3376
+ end
3377
+ if capture.BufferStatus.Name ~= "Ready" then
3378
+ local errorsOk, errors = pcall(function()
3379
+ return capture:GetErrors()
3380
+ end)
3381
+ local detail = if errorsOk then game:GetService("HttpService"):JSONEncode(errors) else "unavailable"
3382
+ return {
3383
+ error = `StudioCaptureService capture failed (status {capture.BufferStatus.Name}): {detail}`,
3384
+ }
3385
+ end
3386
+ local bufferOk, captureBuffer = pcall(function()
3387
+ return capture:GetBuffer()
3388
+ end)
3389
+ if not bufferOk then
3390
+ return {
3391
+ error = `StudioCaptureService:GetBuffer failed: {tostring(captureBuffer)}`,
3392
+ }
3393
+ end
3394
+ local resolution = capture.Resolution
3395
+ return {
3396
+ success = true,
3397
+ encoding = if wantPng then "png" else "rgba8",
3398
+ source = "StudioCaptureService",
3399
+ width = math.max(1, math.floor(resolution.X)),
3400
+ height = math.max(1, math.floor(resolution.Y)),
3401
+ nativeWidth = nativeW,
3402
+ nativeHeight = nativeH,
3403
+ data = encodeBase64(captureBuffer),
3404
+ }
3405
+ end
3406
+ -- Studio-only capture endpoint. Reports `unavailable` (instead of an error) so
3407
+ -- the server can fall back to the legacy CaptureService path.
3408
+ local function captureStudio(requestData)
3409
+ local wantPng = requestData.encoding == "png"
3410
+ local result = doStudioCapture(wantPng)
3411
+ if result == nil then
3412
+ return {
3413
+ unavailable = "StudioCaptureService cannot capture this DataModel right now",
3414
+ }
3415
+ end
3416
+ return result
3417
+ end
2792
3418
  -- Edit-mode single shot: capture and read back in the same (edit) context.
2793
3419
  local function captureScreenshotData()
2794
3420
  local cap = doCaptureScreenshot()
@@ -2814,16 +3440,382 @@ local function captureRead(requestData)
2814
3440
  end
2815
3441
  return readContentToBase64(contentId)
2816
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
2817
3807
  return {
2818
3808
  captureScreenshotData = captureScreenshotData,
2819
3809
  captureScreenshot = captureScreenshot,
3810
+ captureStudio = captureStudio,
2820
3811
  captureBegin = captureBegin,
2821
3812
  captureRead = captureRead,
3813
+ captureMarkers = captureMarkers,
2822
3814
  }
2823
3815
  ]]></string>
2824
3816
  </Properties>
2825
3817
  </Item>
2826
- <Item class="ModuleScript" referent="11">
3818
+ <Item class="ModuleScript" referent="12">
2827
3819
  <Properties>
2828
3820
  <string name="Name">EvalRuntimeHandlers</string>
2829
3821
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -2977,7 +3969,7 @@ return {
2977
3969
  ]]></string>
2978
3970
  </Properties>
2979
3971
  </Item>
2980
- <Item class="ModuleScript" referent="12">
3972
+ <Item class="ModuleScript" referent="13">
2981
3973
  <Properties>
2982
3974
  <string name="Name">GenerateModelHandlers</string>
2983
3975
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -3182,7 +4174,7 @@ return {
3182
4174
  ]]></string>
3183
4175
  </Properties>
3184
4176
  </Item>
3185
- <Item class="ModuleScript" referent="13">
4177
+ <Item class="ModuleScript" referent="14">
3186
4178
  <Properties>
3187
4179
  <string name="Name">InputHandlers</string>
3188
4180
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -3382,7 +4374,7 @@ return {
3382
4374
  ]]></string>
3383
4375
  </Properties>
3384
4376
  </Item>
3385
- <Item class="ModuleScript" referent="14">
4377
+ <Item class="ModuleScript" referent="15">
3386
4378
  <Properties>
3387
4379
  <string name="Name">LogHandlers</string>
3388
4380
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -3404,7 +4396,7 @@ return {
3404
4396
  ]]></string>
3405
4397
  </Properties>
3406
4398
  </Item>
3407
- <Item class="ModuleScript" referent="15">
4399
+ <Item class="ModuleScript" referent="16">
3408
4400
  <Properties>
3409
4401
  <string name="Name">MemoryHandlers</string>
3410
4402
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -3472,7 +4464,7 @@ return {
3472
4464
  ]]></string>
3473
4465
  </Properties>
3474
4466
  </Item>
3475
- <Item class="ModuleScript" referent="16">
4467
+ <Item class="ModuleScript" referent="17">
3476
4468
  <Properties>
3477
4469
  <string name="Name">MetadataHandlers</string>
3478
4470
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -3829,7 +4821,7 @@ return {
3829
4821
  ]]></string>
3830
4822
  </Properties>
3831
4823
  </Item>
3832
- <Item class="ModuleScript" referent="17">
4824
+ <Item class="ModuleScript" referent="18">
3833
4825
  <Properties>
3834
4826
  <string name="Name">MicroProfilerHandlers</string>
3835
4827
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -4402,6 +5394,16 @@ local function captureMicroProfiler(requestData)
4402
5394
  backend = backend,
4403
5395
  }
4404
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
4405
5407
  local profilerOk, profilerResult = safeCall(function()
4406
5408
  return LibMP.Control:EnableProfiler(true)
4407
5409
  end)
@@ -4423,6 +5425,11 @@ local function captureMicroProfiler(requestData)
4423
5425
  }
4424
5426
  end
4425
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)
4426
5433
  local captureStopOk, captureStopResult = safeCall(function()
4427
5434
  return LibMP.Control:EnableCapture(false)
4428
5435
  end)
@@ -4433,9 +5440,6 @@ local function captureMicroProfiler(requestData)
4433
5440
  backend = backend,
4434
5441
  }
4435
5442
  end
4436
- local bufferOk, snapshotOrErr = safeCall(function()
4437
- return LibMP.Control:CaptureToBufferSync()
4438
- end)
4439
5443
  if not bufferOk then
4440
5444
  return {
4441
5445
  error = "micro_profiler_snapshot_failed",
@@ -5582,7 +6586,7 @@ return {
5582
6586
  ]]></string>
5583
6587
  </Properties>
5584
6588
  </Item>
5585
- <Item class="ModuleScript" referent="18">
6589
+ <Item class="ModuleScript" referent="19">
5586
6590
  <Properties>
5587
6591
  <string name="Name">PropertyHandlers</string>
5588
6592
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -5667,6 +6671,7 @@ local function setProperties(requestData)
5667
6671
  -- execute_luau edits as well, even when every assignment in this call failed.
5668
6672
  finishRecording(recordingId, true)
5669
6673
  return {
6674
+ success = failureCount == 0,
5670
6675
  instancePath = instancePath,
5671
6676
  summary = {
5672
6677
  total = successCount + failureCount,
@@ -5682,7 +6687,7 @@ return {
5682
6687
  ]]></string>
5683
6688
  </Properties>
5684
6689
  </Item>
5685
- <Item class="ModuleScript" referent="19">
6690
+ <Item class="ModuleScript" referent="20">
5686
6691
  <Properties>
5687
6692
  <string name="Name">QueryHandlers</string>
5688
6693
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -6337,7 +7342,7 @@ return {
6337
7342
  ]]></string>
6338
7343
  </Properties>
6339
7344
  </Item>
6340
- <Item class="ModuleScript" referent="20">
7345
+ <Item class="ModuleScript" referent="21">
6341
7346
  <Properties>
6342
7347
  <string name="Name">SceneAnalysisHandlers</string>
6343
7348
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -6586,7 +7591,7 @@ return {
6586
7591
  ]]></string>
6587
7592
  </Properties>
6588
7593
  </Item>
6589
- <Item class="ModuleScript" referent="21">
7594
+ <Item class="ModuleScript" referent="22">
6590
7595
  <Properties>
6591
7596
  <string name="Name">ScriptHandlers</string>
6592
7597
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7258,7 +8263,7 @@ return {
7258
8263
  ]]></string>
7259
8264
  </Properties>
7260
8265
  </Item>
7261
- <Item class="ModuleScript" referent="22">
8266
+ <Item class="ModuleScript" referent="23">
7262
8267
  <Properties>
7263
8268
  <string name="Name">ScriptProfilerHandlers</string>
7264
8269
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7721,7 +8726,7 @@ return {
7721
8726
  ]]></string>
7722
8727
  </Properties>
7723
8728
  </Item>
7724
- <Item class="ModuleScript" referent="23">
8729
+ <Item class="ModuleScript" referent="24">
7725
8730
  <Properties>
7726
8731
  <string name="Name">SerializationHandlers</string>
7727
8732
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7907,7 +8912,7 @@ return {
7907
8912
  ]]></string>
7908
8913
  </Properties>
7909
8914
  </Item>
7910
- <Item class="ModuleScript" referent="24">
8915
+ <Item class="ModuleScript" referent="25">
7911
8916
  <Properties>
7912
8917
  <string name="Name">TestHandlers</string>
7913
8918
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -8034,6 +9039,7 @@ local function stopPlaytest(_requestData)
8034
9039
  -- Signal the play-server DM's StopPlayMonitor via plugin:SetSetting.
8035
9040
  -- The monitor acknowledges with the matching request id only after its
8036
9041
  -- StudioTestService:EndTest call returns from pcall.
9042
+ local hadActivePlaytest = testRunning or not StudioTestService.EditModeActive
8037
9043
  local stopRequest = StopPlayMonitor.requestStop()
8038
9044
  if not stopRequest.ok or stopRequest.requestId == nil then
8039
9045
  return {
@@ -8056,6 +9062,15 @@ local function stopPlaytest(_requestData)
8056
9062
  -- Either way clean up the pending request so a future playtest's monitor
8057
9063
  -- doesn't fire EndTest on startup against a stale signal.
8058
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
8059
9074
  if testRunning then
8060
9075
  return {
8061
9076
  error = "Playtest stop signal failed or was not acknowledged. " .. "The playtest may have ended anyway; check get_connected_instances.",
@@ -8298,7 +9313,7 @@ return {
8298
9313
  </Properties>
8299
9314
  </Item>
8300
9315
  </Item>
8301
- <Item class="ModuleScript" referent="25">
9316
+ <Item class="ModuleScript" referent="26">
8302
9317
  <Properties>
8303
9318
  <string name="Name">HttpDiagnostics</string>
8304
9319
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -8379,7 +9394,7 @@ return {
8379
9394
  ]]></string>
8380
9395
  </Properties>
8381
9396
  </Item>
8382
- <Item class="ModuleScript" referent="26">
9397
+ <Item class="ModuleScript" referent="27">
8383
9398
  <Properties>
8384
9399
  <string name="Name">LuauExec</string>
8385
9400
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -8813,7 +9828,7 @@ return {
8813
9828
  ]]></string>
8814
9829
  </Properties>
8815
9830
  </Item>
8816
- <Item class="ModuleScript" referent="27">
9831
+ <Item class="ModuleScript" referent="28">
8817
9832
  <Properties>
8818
9833
  <string name="Name">PeerRole</string>
8819
9834
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -8834,7 +9849,7 @@ return {
8834
9849
  ]]></string>
8835
9850
  </Properties>
8836
9851
  </Item>
8837
- <Item class="ModuleScript" referent="28">
9852
+ <Item class="ModuleScript" referent="29">
8838
9853
  <Properties>
8839
9854
  <string name="Name">PluginSession</string>
8840
9855
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9023,7 +10038,7 @@ return {
9023
10038
  ]]></string>
9024
10039
  </Properties>
9025
10040
  </Item>
9026
- <Item class="ModuleScript" referent="29">
10041
+ <Item class="ModuleScript" referent="30">
9027
10042
  <Properties>
9028
10043
  <string name="Name">Recording</string>
9029
10044
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9053,7 +10068,7 @@ return {
9053
10068
  ]]></string>
9054
10069
  </Properties>
9055
10070
  </Item>
9056
- <Item class="ModuleScript" referent="30">
10071
+ <Item class="ModuleScript" referent="31">
9057
10072
  <Properties>
9058
10073
  <string name="Name">RenderMonitor</string>
9059
10074
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9121,7 +10136,7 @@ return {
9121
10136
  ]]></string>
9122
10137
  </Properties>
9123
10138
  </Item>
9124
- <Item class="ModuleScript" referent="31">
10139
+ <Item class="ModuleScript" referent="32">
9125
10140
  <Properties>
9126
10141
  <string name="Name">RuntimeLogBuffer</string>
9127
10142
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9357,7 +10372,7 @@ return {
9357
10372
  ]]></string>
9358
10373
  </Properties>
9359
10374
  </Item>
9360
- <Item class="ModuleScript" referent="32">
10375
+ <Item class="ModuleScript" referent="33">
9361
10376
  <Properties>
9362
10377
  <string name="Name">ScriptSearch</string>
9363
10378
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9684,7 +10699,7 @@ return {
9684
10699
  ]]></string>
9685
10700
  </Properties>
9686
10701
  </Item>
9687
- <Item class="ModuleScript" referent="33">
10702
+ <Item class="ModuleScript" referent="34">
9688
10703
  <Properties>
9689
10704
  <string name="Name">ServerUrlSettings</string>
9690
10705
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9823,13 +10838,14 @@ return {
9823
10838
  ]]></string>
9824
10839
  </Properties>
9825
10840
  </Item>
9826
- <Item class="ModuleScript" referent="34">
10841
+ <Item class="ModuleScript" referent="35">
9827
10842
  <Properties>
9828
10843
  <string name="Name">State</string>
9829
10844
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
9830
- local CURRENT_VERSION = "3.1.3"
10845
+ local CURRENT_VERSION = "3.1.5"
9831
10846
  local PLUGIN_VARIANT = "inspector"
9832
10847
  local BASE_PORT = 58741
10848
+ local SENSITIVE_DIAGNOSTIC_TERMS = { "token", "authorization", "bearer", "password", "cookie", "secret" }
9833
10849
  local function createConnection(port)
9834
10850
  return {
9835
10851
  port = port,
@@ -9848,16 +10864,123 @@ local connection = createConnection(BASE_PORT)
9848
10864
  local function getActiveConnection()
9849
10865
  return connection
9850
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
9851
10971
  return {
9852
10972
  CURRENT_VERSION = CURRENT_VERSION,
9853
10973
  PLUGIN_VARIANT = PLUGIN_VARIANT,
9854
10974
  BASE_PORT = BASE_PORT,
9855
10975
  getActiveConnection = getActiveConnection,
10976
+ clearTransportDiagnostics = clearTransportDiagnostics,
10977
+ applyTransportUpdate = applyTransportUpdate,
10978
+ getTransportDiagnostics = getTransportDiagnostics,
9856
10979
  }
9857
10980
  ]]></string>
9858
10981
  </Properties>
9859
10982
  </Item>
9860
- <Item class="ModuleScript" referent="35">
10983
+ <Item class="ModuleScript" referent="36">
9861
10984
  <Properties>
9862
10985
  <string name="Name">StopPlayMonitor</string>
9863
10986
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9871,6 +10994,10 @@ local RunService = _services.RunService
9871
10994
  local PluginSession = TS.import(script, script.Parent, "PluginSession")
9872
10995
  local StudioTestService = game:GetService("StudioTestService")
9873
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()
9874
11001
  -- Keep this conservative. plugin:GetSetting is backed by Studio's plugin
9875
11002
  -- settings store, and this monitor runs during every play session, including
9876
11003
  -- manually-started Play. The official reference implementation polls at 1s.
@@ -9883,9 +11010,14 @@ local POLL_INTERVAL_SEC = 1
9883
11010
  local WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 8.0
9884
11011
  local WAIT_POLL_SEC = 0.1
9885
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
9886
11016
  local pluginRef
9887
11017
  local endTestIssued = false
9888
11018
  local transportLifecycle
11019
+ local pendingRequest
11020
+ local lastResult
9889
11021
  local function init(p)
9890
11022
  pluginRef = p
9891
11023
  end
@@ -9948,14 +11080,21 @@ local function writePayload(key, payload)
9948
11080
  return writeSetting(key, encoded)
9949
11081
  end
9950
11082
  local function writeResult(key, request, ok, errText)
9951
- writePayload(key, {
11083
+ local payload = {
9952
11084
  kind = "result",
9953
11085
  id = request.id,
9954
11086
  requestedAt = request.requestedAt,
9955
11087
  consumedAt = tick(),
9956
11088
  ok = ok,
9957
11089
  error = errText,
9958
- })
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)
9959
11098
  end
9960
11099
  local function handleStopRequest(key, request)
9961
11100
  local _condition = request.kind ~= "request"
@@ -9972,10 +11111,14 @@ local function handleStopRequest(key, request)
9972
11111
  return nil
9973
11112
  end
9974
11113
  local age = tick() - request.requestedAt
9975
- if age < -5 or age > REQUEST_TTL_SEC then
11114
+ if request.requestedAt < moduleLoadedAt or age < -5 or age > REQUEST_TTL_SEC then
9976
11115
  writeSetting(key, false)
9977
11116
  return nil
9978
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
9979
11122
  if endTestIssued then
9980
11123
  writeResult(key, request, false, "StudioTestService:EndTest was already issued for this play session, but the runtime DataModel is still alive.")
9981
11124
  return nil
@@ -10036,6 +11179,11 @@ local function requestStop()
10036
11179
  requestedAt = tick(),
10037
11180
  }
10038
11181
  local ok = writePayload(settingKey(), payload)
11182
+ pendingRequest = if ok then {
11183
+ key = settingKey(),
11184
+ payload = payload,
11185
+ lastPublishedAt = tick(),
11186
+ } else nil
10039
11187
  return {
10040
11188
  ok = ok,
10041
11189
  requestId = if ok then requestId else nil,
@@ -10050,8 +11198,12 @@ local function waitForConsumption(requestId)
10050
11198
  }
10051
11199
  end
10052
11200
  local start = tick()
11201
+ local pending = pendingRequest
11202
+ local key = if pending ~= nil and pending.payload.id == requestId then pending.key else settingKey()
10053
11203
  while tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC do
10054
- 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))
10055
11207
  if payload and payload.kind == "result" and payload.id == requestId then
10056
11208
  return {
10057
11209
  ok = payload.ok == true,
@@ -10059,6 +11211,19 @@ local function waitForConsumption(requestId)
10059
11211
  error = payload.error,
10060
11212
  }
10061
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
10062
11227
  task.wait(WAIT_POLL_SEC)
10063
11228
  end
10064
11229
  return {
@@ -10071,6 +11236,17 @@ local function clearPending(requestId)
10071
11236
  if not pluginRef then
10072
11237
  return nil
10073
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
10074
11250
  local myKey = settingKey()
10075
11251
  if requestId ~= nil then
10076
11252
  local payload = decodePayload(readSetting(myKey))
@@ -10090,7 +11266,7 @@ return {
10090
11266
  ]]></string>
10091
11267
  </Properties>
10092
11268
  </Item>
10093
- <Item class="ModuleScript" referent="36">
11269
+ <Item class="ModuleScript" referent="37">
10094
11270
  <Properties>
10095
11271
  <string name="Name">StudioWebSocket</string>
10096
11272
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -10102,6 +11278,7 @@ local PROTOCOL_VERSION = 1
10102
11278
  local INITIAL_RETRY_DELAY_SECONDS = 0.5
10103
11279
  local MAX_RETRY_DELAY_SECONDS = 5
10104
11280
  local SOCKET_SILENCE_TIMEOUT_SECONDS = 20
11281
+ local CONNECTION_STAGE_TIMEOUT_SECONDS = 20
10105
11282
  local RESPONSE_RETENTION_SECONDS = 5 * 60
10106
11283
  local RESPONSE_ACK_TIMEOUT_SECONDS = 120
10107
11284
  local MAX_TERMINAL_RESPONSES = 32768
@@ -10122,7 +11299,11 @@ local socketOpen = false
10122
11299
  local socketConnections = {}
10123
11300
  local lastValidEventAt = 0
10124
11301
  local cachedReady
10125
- local readyRefreshPending = false
11302
+ local connectionWork
11303
+ local refreshWork
11304
+ local refreshRequested = false
11305
+ local reconnectTimer
11306
+ local silenceTimer
10126
11307
  local pendingResponseBytes = 0
10127
11308
  local inFlightRequestBytes = 0
10128
11309
  local pendingRejectionCount = 0
@@ -10669,7 +11850,7 @@ local function dispatchRequest(request, requestBytes)
10669
11850
  local dispatchOk, response = pcall(function()
10670
11851
  return dispatchOptions.dispatchRequest(request, context)
10671
11852
  end)
10672
- local executionOutcome = if dispatchOk then handlerOutcome(response) else "error"
11853
+ local executionOutcome = context.executionOutcome or (if dispatchOk then handlerOutcome(response) else "error")
10673
11854
  -- Report handler return before JSON encoding or result retention can fail.
10674
11855
  completeProgress(request.requestId, inFlight.progress, executionOutcome)
10675
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)
@@ -10693,6 +11874,90 @@ local function reportTransport(update)
10693
11874
  end)
10694
11875
  end
10695
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
10696
11961
  local connect
10697
11962
  function scheduleReconnect(expectedGeneration, detail, duplicate)
10698
11963
  if duplicate == nil then
@@ -10701,7 +11966,14 @@ function scheduleReconnect(expectedGeneration, detail, duplicate)
10701
11966
  if not active or generation ~= expectedGeneration then
10702
11967
  return nil
10703
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
10704
11974
  generation += 1
11975
+ local nextGeneration = generation
11976
+ cancelTransportWork()
10705
11977
  closeCurrentSocket()
10706
11978
  reconnectAttempt += 1
10707
11979
  local delay = if duplicate then 1 else retryDelay(reconnectAttempt)
@@ -10711,8 +11983,11 @@ function scheduleReconnect(expectedGeneration, detail, duplicate)
10711
11983
  retryDelay = delay,
10712
11984
  detail = detail,
10713
11985
  })
10714
- local nextGeneration = generation
10715
- 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
10716
11991
  if active and generation == nextGeneration then
10717
11992
  connect(nextGeneration)
10718
11993
  end
@@ -10720,10 +11995,11 @@ function scheduleReconnect(expectedGeneration, detail, duplicate)
10720
11995
  end
10721
11996
  local function watchForSilence(expectedGeneration, expectedClient)
10722
11997
  local elapsed = tick() - lastValidEventAt
10723
- 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()
10724
11999
  if not active or generation ~= expectedGeneration or socketClient ~= expectedClient then
10725
12000
  return nil
10726
12001
  end
12002
+ silenceTimer = nil
10727
12003
  local silentFor = tick() - lastValidEventAt
10728
12004
  if silentFor >= SOCKET_SILENCE_TIMEOUT_SECONDS then
10729
12005
  scheduleReconnect(expectedGeneration, `WebSocket silent for {math.floor(silentFor)} seconds`)
@@ -10790,13 +12066,18 @@ local function parseReadyResponse(body)
10790
12066
  transportToken = value.transportToken,
10791
12067
  }
10792
12068
  end
10793
- 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
10794
12075
  local instanceId = PluginSession.getInstanceId()
10795
12076
  local multiplayerGroupId = PluginSession.getMultiplayerGroupId()
10796
12077
  local transportRole = PluginSession.getRole()
10797
12078
  local readyUrl = `{currentOptions.serverUrl}/ready`
10798
12079
  local readyPayload = PluginSession.createReadyPayload(PluginSession.peerId, transportRole, instanceId, multiplayerGroupId)
10799
- if not active or generation ~= expectedGeneration or options ~= currentOptions then
12080
+ if not ownsWork(work) then
10800
12081
  return nil
10801
12082
  end
10802
12083
  local readyOk, readyResult = pcall(function()
@@ -10809,7 +12090,7 @@ local function registerReady(currentOptions, expectedGeneration, reconnectOnFail
10809
12090
  Body = HttpService:JSONEncode(readyPayload),
10810
12091
  })
10811
12092
  end)
10812
- if not active or generation ~= expectedGeneration or options ~= currentOptions then
12093
+ if not ownsWork(work) then
10813
12094
  return nil
10814
12095
  end
10815
12096
  local readyLogKey = `{currentOptions.serverUrl}|{PluginSession.peerId}`
@@ -10839,7 +12120,7 @@ local function registerReady(currentOptions, expectedGeneration, reconnectOnFail
10839
12120
  invokeCallback("WebSocket ready", function()
10840
12121
  return currentOptions.onReady(readyData)
10841
12122
  end)
10842
- return readyData
12123
+ return if ownsWork(work) then readyData else nil
10843
12124
  end
10844
12125
  local function credentialsRejected(statusCode)
10845
12126
  return statusCode == 401 or statusCode == 403 or statusCode == 404
@@ -10849,14 +12130,28 @@ function connect(expectedGeneration)
10849
12130
  if not active or generation ~= expectedGeneration or currentOptions == nil then
10850
12131
  return nil
10851
12132
  end
10852
- reportTransport({
10853
- state = "connecting",
10854
- attempt = reconnectAttempt,
10855
- retryDelay = 0,
10856
- })
10857
- task.spawn(function()
10858
- local readyData = cachedReady or registerReady(currentOptions, expectedGeneration, true)
10859
- 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
10860
12155
  return nil
10861
12156
  end
10862
12157
  local socketBaseUrl = string.gsub(currentOptions.serverUrl, "^http", "ws")
@@ -10869,28 +12164,32 @@ function connect(expectedGeneration)
10869
12164
  })
10870
12165
  end)
10871
12166
  if not createOk then
10872
- scheduleReconnect(expectedGeneration, `Failed to create WebSocket: {tostring(createdClient)}`)
12167
+ scheduleReconnect(expectedGeneration, "Failed to create WebSocket")
10873
12168
  return nil
10874
12169
  end
10875
- if not active or generation ~= expectedGeneration or options ~= currentOptions then
12170
+ if not ownsWork(work) then
10876
12171
  pcall(function()
10877
12172
  return createdClient:Close()
10878
12173
  end)
10879
12174
  return nil
10880
12175
  end
10881
12176
  socketClient = createdClient
10882
- 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)
10883
12180
  if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
10884
12181
  return nil
10885
12182
  end
12183
+ if socketOpen then
12184
+ return nil
12185
+ end
10886
12186
  if statusCode ~= 101 and (statusCode < 200 or statusCode >= 300) then
10887
- if credentialsRejected(statusCode) then
10888
- cachedReady = nil
10889
- end
12187
+ cachedReady = nil
10890
12188
  scheduleReconnect(expectedGeneration, `WebSocket opened with HTTP {statusCode}`)
10891
12189
  return nil
10892
12190
  end
10893
12191
  socketOpen = true
12192
+ finishWork(work)
10894
12193
  lastValidEventAt = tick()
10895
12194
  reconnectAttempt = 0
10896
12195
  reportTransport({
@@ -10898,8 +12197,18 @@ function connect(expectedGeneration)
10898
12197
  attempt = 0,
10899
12198
  retryDelay = 0,
10900
12199
  })
12200
+ if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
12201
+ return nil
12202
+ end
12203
+ watchForSilence(expectedGeneration, createdClient)
10901
12204
  resumePendingResponses()
10902
- 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)
10903
12212
  if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
10904
12213
  return nil
10905
12214
  end
@@ -10937,27 +12246,41 @@ function connect(expectedGeneration)
10937
12246
  invokeCallback("WebSocket status", function()
10938
12247
  return currentOptions.onStatus(event)
10939
12248
  end)
12249
+ if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
12250
+ return nil
12251
+ end
10940
12252
  if not event.knownPeer then
10941
12253
  cachedReady = nil
10942
12254
  scheduleReconnect(expectedGeneration, "WebSocket session is no longer registered")
10943
12255
  end
10944
12256
  end
10945
- 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)
10946
12261
  if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
10947
12262
  return nil
10948
12263
  end
10949
- if credentialsRejected(statusCode) then
12264
+ if not socketOpen or credentialsRejected(statusCode) then
10950
12265
  cachedReady = nil
10951
12266
  end
10952
- scheduleReconnect(expectedGeneration, `WebSocket error {statusCode}: {message}`)
10953
- 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()
10954
12273
  if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
10955
12274
  return nil
10956
12275
  end
12276
+ if not socketOpen then
12277
+ cachedReady = nil
12278
+ end
10957
12279
  scheduleReconnect(expectedGeneration, "WebSocket closed")
10958
- end) }
10959
- lastValidEventAt = tick()
10960
- watchForSilence(expectedGeneration, createdClient)
12280
+ end)
12281
+ table.insert(_socketConnections_3, _arg0_3)
12282
+ end, function(detail)
12283
+ return scheduleReconnect(expectedGeneration, detail)
10961
12284
  end)
10962
12285
  end
10963
12286
  local stop
@@ -10972,20 +12295,42 @@ local function start(newOptions)
10972
12295
  generation += 1
10973
12296
  connect(generation)
10974
12297
  end
10975
- local function refresh()
12298
+ function flushRefresh()
10976
12299
  local currentOptions = options
10977
- 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
10978
12301
  return nil
10979
12302
  end
10980
- -- Metadata changes must not close a healthy socket or make its reconnect
10981
- -- depend on RequestAsync quota. Proxy registration remains independent.
10982
- readyRefreshPending = true
10983
- local expectedGeneration = generation
10984
- task.spawn(function()
10985
- registerReady(currentOptions, expectedGeneration, false)
10986
- readyRefreshPending = false
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()
10987
12325
  end)
10988
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
10989
12334
  -- EndTest may tear down this VM without an Unloading callback. Release the
10990
12335
  -- native socket before yielding to unregister; a failed EndTest can resume.
10991
12336
  local function suspendForShutdown()
@@ -10996,6 +12341,8 @@ local function suspendForShutdown()
10996
12341
  active = false
10997
12342
  shutdownSuspended = true
10998
12343
  generation += 1
12344
+ cancelTransportWork()
12345
+ refreshRequested = false
10999
12346
  reconnectAttempt = 0
11000
12347
  closeCurrentSocket()
11001
12348
  cachedReady = nil
@@ -11019,6 +12366,8 @@ function stop()
11019
12366
  active = false
11020
12367
  shutdownSuspended = false
11021
12368
  generation += 1
12369
+ cancelTransportWork()
12370
+ refreshRequested = false
11022
12371
  for _, inFlight in inFlightRequests do
11023
12372
  inFlight.cancelled = true
11024
12373
  end
@@ -11041,7 +12390,7 @@ return {
11041
12390
  ]]></string>
11042
12391
  </Properties>
11043
12392
  </Item>
11044
- <Item class="ModuleScript" referent="37">
12393
+ <Item class="ModuleScript" referent="38">
11045
12394
  <Properties>
11046
12395
  <string name="Name">TopologyId</string>
11047
12396
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -11086,7 +12435,7 @@ return {
11086
12435
  ]]></string>
11087
12436
  </Properties>
11088
12437
  </Item>
11089
- <Item class="ModuleScript" referent="38">
12438
+ <Item class="ModuleScript" referent="39">
11090
12439
  <Properties>
11091
12440
  <string name="Name">UI</string>
11092
12441
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -11349,12 +12698,13 @@ local function init(pluginRef)
11349
12698
  end
11350
12699
  end)
11351
12700
  local statusRow = Instance.new("Frame")
11352
- statusRow.Size = UDim2.new(1, 0, 0, 14)
12701
+ statusRow.Size = UDim2.new(1, 0, 0, 0)
12702
+ statusRow.AutomaticSize = Enum.AutomaticSize.Y
11353
12703
  statusRow.BackgroundTransparency = 1
11354
12704
  statusRow.LayoutOrder = 2
11355
12705
  statusRow.Parent = card
11356
12706
  local statusLabel = Instance.new("TextLabel")
11357
- statusLabel.Size = UDim2.new(1, 0, 1, 0)
12707
+ statusLabel.Size = UDim2.new(1, 0, 0, 18)
11358
12708
  statusLabel.BackgroundTransparency = 1
11359
12709
  statusLabel.Text = "Disconnected"
11360
12710
  statusLabel.TextColor3 = C.red
@@ -11362,17 +12712,20 @@ local function init(pluginRef)
11362
12712
  statusLabel.Font = Enum.Font.GothamBold
11363
12713
  statusLabel.TextXAlignment = Enum.TextXAlignment.Left
11364
12714
  statusLabel.TextWrapped = true
12715
+ statusLabel.RichText = false
11365
12716
  statusLabel.Parent = statusRow
11366
12717
  local detailStatusLabel = Instance.new("TextLabel")
11367
- detailStatusLabel.Size = UDim2.new(0.5, 0, 1, 0)
11368
- 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
11369
12721
  detailStatusLabel.BackgroundTransparency = 1
11370
12722
  detailStatusLabel.Text = "HTTP: X MCP: X"
11371
12723
  detailStatusLabel.TextColor3 = C.muted
11372
12724
  detailStatusLabel.TextSize = 9
11373
12725
  detailStatusLabel.Font = Enum.Font.GothamMedium
11374
- detailStatusLabel.TextXAlignment = Enum.TextXAlignment.Right
12726
+ detailStatusLabel.TextXAlignment = Enum.TextXAlignment.Left
11375
12727
  detailStatusLabel.TextWrapped = true
12728
+ detailStatusLabel.RichText = false
11376
12729
  detailStatusLabel.Parent = statusRow
11377
12730
  local stepsFrame = Instance.new("Frame")
11378
12731
  stepsFrame.Size = UDim2.new(1, 0, 0, 0)
@@ -11509,13 +12862,14 @@ local function updateUIState()
11509
12862
  return nil
11510
12863
  end
11511
12864
  local el = elements
12865
+ local diagnostics = State.getTransportDiagnostics(tick())
11512
12866
  if not conn.isActive then
11513
- el.statusLabel.Text = "Disconnected"
12867
+ el.statusLabel.Text = diagnostics.status
11514
12868
  el.statusLabel.TextColor3 = C.muted
11515
12869
  el.statusIndicator.BackgroundColor3 = C.red
11516
12870
  el.statusPulse.BackgroundColor3 = C.red
11517
12871
  el.statusText.Text = "OFFLINE"
11518
- el.detailStatusLabel.Text = ""
12872
+ el.detailStatusLabel.Text = diagnostics.detail
11519
12873
  el.detailStatusLabel.TextColor3 = C.muted
11520
12874
  stopPulseAnimation()
11521
12875
  el.step1Dot.BackgroundColor3 = C.gray
@@ -11538,12 +12892,12 @@ local function updateUIState()
11538
12892
  el.urlInput.TextEditable = false
11539
12893
  el.urlInput.BackgroundColor3 = C.card
11540
12894
  if conn.lastHttpOk and conn.lastMcpOk then
11541
- el.statusLabel.Text = "Connected"
12895
+ el.statusLabel.Text = diagnostics.status
11542
12896
  el.statusLabel.TextColor3 = Color3.fromRGB(34, 197, 94)
11543
12897
  el.statusIndicator.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
11544
12898
  el.statusPulse.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
11545
12899
  el.statusText.Text = "ONLINE"
11546
- el.detailStatusLabel.Text = "HTTP: OK MCP: OK"
12900
+ el.detailStatusLabel.Text = diagnostics.detail
11547
12901
  el.detailStatusLabel.TextColor3 = Color3.fromRGB(34, 197, 94)
11548
12902
  el.step1Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
11549
12903
  el.step1Label.Text = "HTTP server (OK)"
@@ -11554,12 +12908,12 @@ local function updateUIState()
11554
12908
  el.troubleshootLabel.Visible = false
11555
12909
  stopPulseAnimation()
11556
12910
  elseif conn.lastHttpOk and not conn.lastMcpOk then
11557
- el.statusLabel.Text = "Waiting for MCP server"
12911
+ el.statusLabel.Text = diagnostics.status
11558
12912
  el.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
11559
12913
  el.statusIndicator.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
11560
12914
  el.statusPulse.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
11561
12915
  el.statusText.Text = "WAITING"
11562
- el.detailStatusLabel.Text = "HTTP: OK MCP: ..."
12916
+ el.detailStatusLabel.Text = diagnostics.detail
11563
12917
  el.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
11564
12918
  el.step1Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
11565
12919
  el.step1Label.Text = "HTTP server (OK)"
@@ -11570,53 +12924,21 @@ local function updateUIState()
11570
12924
  local elapsed = if conn.mcpWaitStartTime ~= nil then tick() - conn.mcpWaitStartTime else 0
11571
12925
  el.troubleshootLabel.Visible = elapsed > 8
11572
12926
  startPulseAnimation()
11573
- elseif conn.consecutiveFailures >= conn.maxFailuresBeforeError then
11574
- el.statusLabel.Text = "Server unavailable"
11575
- el.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
11576
- el.statusIndicator.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
11577
- el.statusPulse.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
11578
- el.statusText.Text = "ERROR"
11579
- el.detailStatusLabel.Text = "HTTP: X MCP: X"
11580
- el.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
11581
- el.step1Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
11582
- el.step1Label.Text = "HTTP server (error)"
11583
- el.step2Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
11584
- el.step2Label.Text = "MCP bridge (error)"
11585
- el.step3Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
11586
- el.step3Label.Text = "Commands (error)"
11587
- el.troubleshootLabel.Visible = false
11588
- stopPulseAnimation()
11589
- elseif conn.consecutiveFailures > 5 then
11590
- local waitTime = math.ceil(conn.currentRetryDelay)
11591
- el.statusLabel.Text = `Retrying ({waitTime}s)`
11592
- el.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
11593
- el.statusIndicator.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
11594
- el.statusPulse.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
11595
- el.statusText.Text = "RETRY"
11596
- el.detailStatusLabel.Text = "HTTP: ... MCP: ..."
11597
- el.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
11598
- el.step1Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
11599
- el.step1Label.Text = "HTTP server (retrying...)"
11600
- el.step2Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
11601
- el.step2Label.Text = "MCP bridge (retrying...)"
11602
- el.step3Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
11603
- el.step3Label.Text = "Commands (retrying...)"
11604
- el.troubleshootLabel.Visible = false
11605
- startPulseAnimation()
11606
12927
  else
11607
- 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
11608
12930
  el.statusLabel.TextColor3 = C.yellow
11609
12931
  el.statusIndicator.BackgroundColor3 = C.yellow
11610
12932
  el.statusPulse.BackgroundColor3 = C.yellow
11611
- el.statusText.Text = "CONNECTING"
11612
- el.detailStatusLabel.Text = if conn.consecutiveFailures == 0 then "..." else "HTTP: ... MCP: ..."
11613
- 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
11614
12936
  el.step1Dot.BackgroundColor3 = C.yellow
11615
- el.step1Label.Text = "HTTP server (connecting...)"
11616
- el.step2Dot.BackgroundColor3 = C.yellow
11617
- el.step2Label.Text = "MCP bridge (connecting...)"
11618
- el.step3Dot.BackgroundColor3 = C.yellow
11619
- 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)"
11620
12942
  el.troubleshootLabel.Visible = false
11621
12943
  startPulseAnimation()
11622
12944
  end
@@ -11638,7 +12960,7 @@ return {
11638
12960
  ]]></string>
11639
12961
  </Properties>
11640
12962
  </Item>
11641
- <Item class="ModuleScript" referent="39">
12963
+ <Item class="ModuleScript" referent="40">
11642
12964
  <Properties>
11643
12965
  <string name="Name">Utils</string>
11644
12966
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -12479,11 +13801,11 @@ return {
12479
13801
  </Properties>
12480
13802
  </Item>
12481
13803
  </Item>
12482
- <Item class="Folder" referent="44">
13804
+ <Item class="Folder" referent="45">
12483
13805
  <Properties>
12484
13806
  <string name="Name">include</string>
12485
13807
  </Properties>
12486
- <Item class="ModuleScript" referent="40">
13808
+ <Item class="ModuleScript" referent="41">
12487
13809
  <Properties>
12488
13810
  <string name="Name">LibMP</string>
12489
13811
  <string name="Source"><![CDATA[-- =============================================================================
@@ -168867,7 +170189,7 @@ return LibMP
168867
170189
  ]]></string>
168868
170190
  </Properties>
168869
170191
  </Item>
168870
- <Item class="ModuleScript" referent="41">
170192
+ <Item class="ModuleScript" referent="42">
168871
170193
  <Properties>
168872
170194
  <string name="Name">Promise</string>
168873
170195
  <string name="Source"><![CDATA[--[[
@@ -170941,7 +172263,7 @@ return Promise
170941
172263
  ]]></string>
170942
172264
  </Properties>
170943
172265
  </Item>
170944
- <Item class="ModuleScript" referent="42">
172266
+ <Item class="ModuleScript" referent="43">
170945
172267
  <Properties>
170946
172268
  <string name="Name">RuntimeLib</string>
170947
172269
  <string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
@@ -171208,15 +172530,15 @@ return TS
171208
172530
  </Properties>
171209
172531
  </Item>
171210
172532
  </Item>
171211
- <Item class="Folder" referent="45">
172533
+ <Item class="Folder" referent="46">
171212
172534
  <Properties>
171213
172535
  <string name="Name">node_modules</string>
171214
172536
  </Properties>
171215
- <Item class="Folder" referent="46">
172537
+ <Item class="Folder" referent="47">
171216
172538
  <Properties>
171217
172539
  <string name="Name">@rbxts</string>
171218
172540
  </Properties>
171219
- <Item class="ModuleScript" referent="43">
172541
+ <Item class="ModuleScript" referent="44">
171220
172542
  <Properties>
171221
172543
  <string name="Name">services</string>
171222
172544
  <string name="Source"><![CDATA[return setmetatable({}, {