@princeofscale/bloxforge-inspector 3.0.0-rc.1 → 3.0.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@princeofscale/bloxforge-inspector",
3
- "version": "3.0.0-rc.1",
3
+ "version": "3.0.0",
4
4
  "description": "Read-only MCP server for AI agents to inspect and debug Roblox Studio — safe browsing with zero write risk.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -240,11 +240,41 @@ local CLIENT_BROKER_ALLOWED_ENDPOINTS = {
240
240
  -- Throttle re-ready calls per proxyId so a brief window of unknownInstance
241
241
  -- polls doesn't cause a re-register stampede.
242
242
  local lastReadyByProxy = {}
243
- local postJson
244
- local function reRegisterProxy(proxyId, role)
243
+ local completedProxyRequests = {}
244
+ local completedProxyRequestOrder = {}
245
+ local COMPLETED_PROXY_REQUEST_LIMIT = 500
246
+ local function rememberCompletedProxyRequest(requestId, response)
247
+ local _requestId = requestId
248
+ local _arg1 = {
249
+ response = response,
250
+ }
251
+ completedProxyRequests[_requestId] = _arg1
252
+ local _requestId_1 = requestId
253
+ table.insert(completedProxyRequestOrder, _requestId_1)
254
+ if #completedProxyRequestOrder > COMPLETED_PROXY_REQUEST_LIMIT then
255
+ local _arg0 = table.remove(completedProxyRequestOrder, 1)
256
+ completedProxyRequests[_arg0] = nil
257
+ end
258
+ end
259
+ local postJson, formatPostJsonFailure
260
+ local function sendProxyResponse(requestId, response, proxy, fence)
261
+ local _object = {
262
+ requestId = requestId,
263
+ pluginSessionId = proxy.pluginSessionId,
264
+ response = response,
265
+ }
266
+ for _k, _v in fence do
267
+ _object[_k] = _v
268
+ end
269
+ local ok, result = postJson("/response", _object, proxy.sessionToken)
270
+ if not ok or not result or not result.Success then
271
+ warn(`[BloxForge] proxy response delivery failed: {formatPostJsonFailure("/response", ok, result)}`)
272
+ end
273
+ end
274
+ local function reRegisterProxy(proxy, role)
275
+ local proxyId = proxy.pluginSessionId
245
276
  local now = tick()
246
- local _proxyId = proxyId
247
- local _condition = lastReadyByProxy[_proxyId]
277
+ local _condition = lastReadyByProxy[proxyId]
248
278
  if _condition == nil then
249
279
  _condition = 0
250
280
  end
@@ -252,10 +282,9 @@ local function reRegisterProxy(proxyId, role)
252
282
  if now - last < 2 then
253
283
  return nil
254
284
  end
255
- local _proxyId_1 = proxyId
256
- lastReadyByProxy[_proxyId_1] = now
285
+ lastReadyByProxy[proxyId] = now
257
286
  pcall(function()
258
- return postJson("/ready", {
287
+ local ok, result = postJson("/ready", {
259
288
  pluginSessionId = proxyId,
260
289
  instanceId = computeInstanceId(),
261
290
  role = role,
@@ -267,6 +296,12 @@ local function reRegisterProxy(proxyId, role)
267
296
  pluginVariant = State.PLUGIN_VARIANT,
268
297
  protocolVersion = State.PROTOCOL_VERSION,
269
298
  })
299
+ if ok and result and result.Success then
300
+ local body = HttpService:JSONDecode(result.Body)
301
+ if body.sessionToken ~= nil then
302
+ proxy.sessionToken = body.sessionToken
303
+ end
304
+ end
270
305
  end)
271
306
  end
272
307
  local function forkRole()
@@ -278,19 +313,23 @@ local function forkRole()
278
313
  end
279
314
  return "client"
280
315
  end
281
- function postJson(endpoint, body)
316
+ function postJson(endpoint, body, sessionToken)
317
+ local headers = {
318
+ ["Content-Type"] = "application/json",
319
+ }
320
+ if sessionToken ~= nil and sessionToken ~= "" then
321
+ headers.Authorization = `Bearer {sessionToken}`
322
+ end
282
323
  return pcall(function()
283
324
  return HttpService:RequestAsync({
284
325
  Url = `{mcpUrl}{endpoint}`,
285
326
  Method = "POST",
286
- Headers = {
287
- ["Content-Type"] = "application/json",
288
- },
327
+ Headers = headers,
289
328
  Body = HttpService:JSONEncode(body),
290
329
  })
291
330
  end)
292
331
  end
293
- local function formatPostJsonFailure(endpoint, ok, res)
332
+ function formatPostJsonFailure(endpoint, ok, res)
294
333
  return HttpDiagnostics.formatRequestFailure(`{mcpUrl}{endpoint}`, ok, res)
295
334
  end
296
335
  local function setServerUrl(serverUrl)
@@ -471,7 +510,7 @@ local function unregisterProxy(player, entry)
471
510
  proxyRegisterFailuresByPlayer[_player_1] = nil
472
511
  postJson("/disconnect", {
473
512
  pluginSessionId = proxy.pluginSessionId,
474
- })
513
+ }, proxy.sessionToken)
475
514
  end
476
515
  local function disconnectAllProxies()
477
516
  for player, entry in proxyByPlayer do
@@ -480,7 +519,8 @@ local function disconnectAllProxies()
480
519
  table.clear(proxyByPlayer)
481
520
  table.clear(proxyRegisterFailuresByPlayer)
482
521
  end
483
- local function pollProxy(proxyId, player, rf)
522
+ local function pollProxy(proxy, player, rf)
523
+ local proxyId = proxy.pluginSessionId
484
524
  while true do
485
525
  local _condition = player.Parent ~= nil
486
526
  if _condition then
@@ -498,11 +538,17 @@ local function pollProxy(proxyId, player, rf)
498
538
  return HttpService:RequestAsync({
499
539
  Url = `{mcpUrl}/poll?pluginSessionId={proxyId}`,
500
540
  Method = "GET",
501
- Headers = {
541
+ Headers = if proxy.sessionToken ~= "" then {
542
+ ["Content-Type"] = "application/json",
543
+ Authorization = `Bearer {proxy.sessionToken}`,
544
+ } else {
502
545
  ["Content-Type"] = "application/json",
503
546
  },
504
547
  })
505
548
  end)
549
+ if ok and res and res.StatusCode == 401 then
550
+ reRegisterProxy(proxy, "client")
551
+ end
506
552
  if ok and res and (res.Success or res.StatusCode == 503) then
507
553
  local okJson, body = pcall(function()
508
554
  return HttpService:JSONDecode(res.Body)
@@ -511,9 +557,41 @@ local function pollProxy(proxyId, player, rf)
511
557
  -- Server lost our proxy registration (process restart, etc.) -
512
558
  -- re-register so the next poll cycle starts routing again.
513
559
  if body.knownInstance == false then
514
- reRegisterProxy(proxyId, "client")
560
+ reRegisterProxy(proxy, "client")
515
561
  end
516
562
  if body.request and body.requestId ~= nil then
563
+ local _value = body.serverEpoch
564
+ local _condition_1 = not (_value ~= "" and _value)
565
+ if not _condition_1 then
566
+ _condition_1 = body.deliveryAttempt == nil
567
+ if not _condition_1 then
568
+ local _value_1 = body.leaseToken
569
+ _condition_1 = not (_value_1 ~= "" and _value_1)
570
+ end
571
+ end
572
+ if _condition_1 then
573
+ continue
574
+ end
575
+ local fence = {
576
+ serverEpoch = body.serverEpoch,
577
+ deliveryAttempt = body.deliveryAttempt,
578
+ leaseToken = body.leaseToken,
579
+ }
580
+ local _requestId = body.requestId
581
+ local cached = completedProxyRequests[_requestId]
582
+ if cached ~= nil then
583
+ sendProxyResponse(body.requestId, cached.response, proxy, fence)
584
+ task.wait(0.5)
585
+ continue
586
+ end
587
+ local _object = {
588
+ requestId = body.requestId,
589
+ pluginSessionId = proxyId,
590
+ }
591
+ for _k, _v in fence do
592
+ _object[_k] = _v
593
+ end
594
+ postJson("/ack", _object, proxy.sessionToken)
517
595
  local request = body.request
518
596
  local response
519
597
  local _endpoint = request.endpoint
@@ -547,10 +625,8 @@ local function pollProxy(proxyId, player, rf)
547
625
  error = `Client-proxy does not forward {tostring(request.endpoint)}. ` .. `Allowed: {table.concat(allowed, ", ")}.`,
548
626
  }
549
627
  end
550
- postJson("/response", {
551
- requestId = body.requestId,
552
- response = response,
553
- })
628
+ rememberCompletedProxyRequest(body.requestId, response)
629
+ sendProxyResponse(body.requestId, response, proxy, fence)
554
630
  end
555
631
  end
556
632
  end
@@ -587,19 +663,26 @@ local function registerProxy(player, rf)
587
663
  _condition = "client"
588
664
  end
589
665
  local assigned = _condition
590
- local _player_1 = player
591
- local _arg1 = {
666
+ local _object = {
592
667
  pluginSessionId = proxyId,
593
668
  role = assigned,
594
669
  }
595
- proxyByPlayer[_player_1] = _arg1
670
+ local _left = "sessionToken"
671
+ local _condition_1 = body.sessionToken
672
+ if _condition_1 == nil then
673
+ _condition_1 = ""
674
+ end
675
+ _object[_left] = _condition_1
676
+ local entry = _object
677
+ local _player_1 = player
678
+ proxyByPlayer[_player_1] = entry
596
679
  local _player_2 = player
597
680
  if proxyRegisterFailuresByPlayer[_player_2] ~= nil then
598
681
  local _player_3 = player
599
682
  proxyRegisterFailuresByPlayer[_player_3] = nil
600
683
  print(`[BloxForge] proxy registered for {player.Name} as {assigned} via {mcpUrl}`)
601
684
  end
602
- task.spawn(pollProxy, proxyId, player, rf)
685
+ task.spawn(pollProxy, entry, player, rf)
603
686
  end
604
687
  -- (Removed: startEditProxyLoop. The play-server DM no longer registers an
605
688
  -- "edit-proxy" peer with the MCP server. stop_playtest now uses a cross-DM
@@ -755,6 +838,20 @@ local function detectRole()
755
838
  return "client"
756
839
  end
757
840
  local initialRole = detectRole()
841
+ local activeRequests = {}
842
+ local completedRequests = {}
843
+ local completedRequestOrder = {}
844
+ local COMPLETED_REQUEST_LIMIT = 500
845
+ local syncThreadRequests = {}
846
+ local cancelledSyncRequests = {}
847
+ local function isCancelledForThread(co)
848
+ local _co = co
849
+ local reqId = syncThreadRequests[_co]
850
+ if reqId == nil then
851
+ return false
852
+ end
853
+ return cancelledSyncRequests[reqId] ~= nil
854
+ end
758
855
  local routeMap = {
759
856
  ["/api/file-tree"] = QueryHandlers.getFileTree,
760
857
  ["/api/search-files"] = QueryHandlers.searchFiles,
@@ -843,19 +940,119 @@ local function processRequest(request)
843
940
  }
844
941
  end
845
942
  end
846
- local function sendResponse(conn, requestId, responseData)
847
- pcall(function()
848
- HttpService:RequestAsync({
943
+ local function sendResponse(conn, requestId, responseData, serverEpoch, deliveryAttempt, leaseToken)
944
+ local body = {
945
+ requestId = requestId,
946
+ response = responseData,
947
+ }
948
+ if serverEpoch ~= nil then
949
+ body.serverEpoch = serverEpoch
950
+ end
951
+ if deliveryAttempt ~= nil then
952
+ body.deliveryAttempt = deliveryAttempt
953
+ end
954
+ if leaseToken ~= nil then
955
+ body.leaseToken = leaseToken
956
+ end
957
+ local ok, result = pcall(function()
958
+ local _object = {
849
959
  Url = `{conn.serverUrl}/response`,
850
960
  Method = "POST",
851
- Headers = {
852
- ["Content-Type"] = "application/json",
853
- },
854
- Body = HttpService:JSONEncode({
855
- requestId = requestId,
856
- response = responseData,
857
- }),
858
- })
961
+ }
962
+ local _left = "Headers"
963
+ local _object_1 = {
964
+ ["Content-Type"] = "application/json",
965
+ }
966
+ local _value = conn.sessionToken
967
+ for _k, _v in (if _value ~= "" and _value then {
968
+ Authorization = `Bearer {conn.sessionToken}`,
969
+ } else {}) do
970
+ _object_1[_k] = _v
971
+ end
972
+ _object[_left] = _object_1
973
+ _object.Body = HttpService:JSONEncode(body)
974
+ return HttpService:RequestAsync(_object)
975
+ end)
976
+ if not ok then
977
+ warn(`[BloxForge] Plugin response serialization failed for {requestId}: {tostring(result)}`)
978
+ elseif not result.Success then
979
+ warn(`[BloxForge] Failed to deliver response for {requestId}: HTTP {result.StatusCode}`)
980
+ end
981
+ end
982
+ local function handleRequestOnce(conn, requestId, request, serverEpoch, deliveryAttempt, leaseToken)
983
+ -- If we've already completed this request, just re-deliver the cached response
984
+ local _requestId = requestId
985
+ local completed = completedRequests[_requestId]
986
+ if completed then
987
+ sendResponse(conn, requestId, completed.response, serverEpoch, deliveryAttempt, leaseToken)
988
+ return nil
989
+ end
990
+ -- If it's actively being processed, do nothing. The running thread will deliver
991
+ -- the response when it finishes.
992
+ local _requestId_1 = requestId
993
+ if activeRequests[_requestId_1] ~= nil then
994
+ return nil
995
+ end
996
+ -- Acknowledge the fenced request so the server knows we've started work.
997
+ -- This prevents the lease from expiring while we're executing long-running operations.
998
+ if serverEpoch ~= nil and deliveryAttempt ~= nil and leaseToken ~= nil then
999
+ task.spawn(function()
1000
+ pcall(function()
1001
+ local _object = {
1002
+ Url = `{conn.serverUrl}/ack`,
1003
+ Method = "POST",
1004
+ }
1005
+ local _left = "Headers"
1006
+ local _object_1 = {
1007
+ ["Content-Type"] = "application/json",
1008
+ }
1009
+ local _value = conn.sessionToken
1010
+ for _k, _v in (if _value ~= "" and _value then {
1011
+ Authorization = `Bearer {conn.sessionToken}`,
1012
+ } else {}) do
1013
+ _object_1[_k] = _v
1014
+ end
1015
+ _object[_left] = _object_1
1016
+ _object.Body = HttpService:JSONEncode({
1017
+ pluginSessionId = pluginSessionId,
1018
+ requestId = requestId,
1019
+ serverEpoch = serverEpoch,
1020
+ deliveryAttempt = deliveryAttempt,
1021
+ leaseToken = leaseToken,
1022
+ })
1023
+ HttpService:RequestAsync(_object)
1024
+ end)
1025
+ end)
1026
+ end
1027
+ local _requestId_2 = requestId
1028
+ activeRequests[_requestId_2] = true
1029
+ task.spawn(function()
1030
+ local co = coroutine.running()
1031
+ local _requestId_3 = requestId
1032
+ syncThreadRequests[co] = _requestId_3
1033
+ local ok, result = pcall(function()
1034
+ return processRequest(request)
1035
+ end)
1036
+ syncThreadRequests[co] = nil
1037
+ local response = if ok then result else {
1038
+ error = tostring(result),
1039
+ }
1040
+ local _requestId_4 = requestId
1041
+ activeRequests[_requestId_4] = nil
1042
+ if #completedRequestOrder >= COMPLETED_REQUEST_LIMIT then
1043
+ local oldest = table.remove(completedRequestOrder, 1)
1044
+ if oldest ~= nil then
1045
+ completedRequests[oldest] = nil
1046
+ end
1047
+ end
1048
+ local _requestId_5 = requestId
1049
+ local _arg1 = {
1050
+ response = response,
1051
+ }
1052
+ completedRequests[_requestId_5] = _arg1
1053
+ local _requestId_6 = requestId
1054
+ table.insert(completedRequestOrder, _requestId_6)
1055
+ sendResponse(conn, requestId, response, serverEpoch, deliveryAttempt, leaseToken)
859
1056
  end)
860
1057
  end
861
1058
  local function getConnectionStatus(connIndex)
@@ -971,8 +1168,12 @@ function sendReady(conn)
971
1168
  local parseOk, readyData = pcall(function()
972
1169
  return HttpService:JSONDecode(readyResult.Body)
973
1170
  end)
974
- local _value = parseOk and readyData.assignedRole
1171
+ local _value = parseOk and readyData.sessionToken
975
1172
  if _value ~= "" and _value then
1173
+ conn.sessionToken = readyData.sessionToken
1174
+ end
1175
+ local _value_1 = parseOk and readyData.assignedRole
1176
+ if _value_1 ~= "" and _value_1 then
976
1177
  assignedRole = readyData.assignedRole
977
1178
  end
978
1179
  local _condition = parseOk
@@ -984,12 +1185,51 @@ function sendReady(conn)
984
1185
  end
985
1186
  end
986
1187
  lastReadyInstanceId = if _condition then readyData.instanceId else instanceId
1188
+ local _condition_1 = parseOk
1189
+ if _condition_1 then
1190
+ _condition_1 = readyData.serverEpoch
1191
+ if _condition_1 ~= "" and _condition_1 then
1192
+ -- ▼ ReadonlyMap.size ▼
1193
+ local _size = 0
1194
+ for _ in completedRequests do
1195
+ _size += 1
1196
+ end
1197
+ -- ▲ ReadonlyMap.size ▲
1198
+ _condition_1 = _size > 0
1199
+ end
1200
+ end
1201
+ if _condition_1 ~= "" and _condition_1 then
1202
+ local receipts = {}
1203
+ for id, _ in completedRequests do
1204
+ local _arg0 = {
1205
+ requestId = id,
1206
+ completedAt = tick() * 1000,
1207
+ }
1208
+ table.insert(receipts, _arg0)
1209
+ end
1210
+ task.spawn(function()
1211
+ pcall(function()
1212
+ return HttpService:RequestAsync({
1213
+ Url = `{conn.serverUrl}/reconcile`,
1214
+ Method = "POST",
1215
+ Headers = {
1216
+ ["Content-Type"] = "application/json",
1217
+ },
1218
+ Body = HttpService:JSONEncode({
1219
+ pluginSessionId = pluginSessionId,
1220
+ serverEpoch = readyData.serverEpoch,
1221
+ receipts = receipts,
1222
+ }),
1223
+ })
1224
+ end)
1225
+ end)
1226
+ end
987
1227
  ServerUrlSettings.rememberServerUrl(conn.serverUrl)
988
- local _condition_1 = assignedRole
989
- if _condition_1 == nil then
990
- _condition_1 = detectRole()
1228
+ local _condition_2 = assignedRole
1229
+ if _condition_2 == nil then
1230
+ _condition_2 = detectRole()
991
1231
  end
992
- local connectedRole = _condition_1
1232
+ local connectedRole = _condition_2
993
1233
  if readyFailureLogKeys[readyLogKey] ~= nil then
994
1234
  readyFailureLogKeys[readyLogKey] = nil
995
1235
  print(`[BloxForge] /ready connected for {instanceId}/{connectedRole} via {conn.serverUrl}`)
@@ -1000,30 +1240,53 @@ local function streamUrl(serverUrl)
1000
1240
  local websocketUrl = string.gsub(serverUrl, "^http", "ws")
1001
1241
  return `{websocketUrl}/stream?pluginSessionId={pluginSessionId}`
1002
1242
  end
1003
- local function sendStreamResponse(conn, requestId, response)
1243
+ local function sendStreamResponse(conn, requestId, response, serverEpoch, deliveryAttempt, leaseToken)
1004
1244
  if not conn.streamOpen or not conn.streamClient then
1005
1245
  return nil
1006
1246
  end
1007
- pcall(function()
1008
- return conn.streamClient:Send(HttpService:JSONEncode({
1009
- type = "response",
1010
- requestId = requestId,
1011
- response = response,
1012
- }))
1247
+ local body = {
1248
+ type = "response",
1249
+ requestId = requestId,
1250
+ response = response,
1251
+ }
1252
+ if serverEpoch ~= nil then
1253
+ body.serverEpoch = serverEpoch
1254
+ end
1255
+ if deliveryAttempt ~= nil then
1256
+ body.deliveryAttempt = deliveryAttempt
1257
+ end
1258
+ if leaseToken ~= nil then
1259
+ body.leaseToken = leaseToken
1260
+ end
1261
+ local ok, result = pcall(function()
1262
+ return conn.streamClient:Send(HttpService:JSONEncode(body))
1013
1263
  end)
1264
+ if not ok then
1265
+ warn(`[BloxForge] Failed to send stream response for {requestId}: {tostring(result)}`)
1266
+ end
1014
1267
  end
1015
1268
  local function startRequestStream(conn)
1016
1269
  if not conn.isActive or conn.streamClient then
1017
1270
  return nil
1018
1271
  end
1019
1272
  local ok, stream = pcall(function()
1020
- return HttpService:CreateWebStreamClient(Enum.WebStreamClientType.WebSocket, {
1273
+ local _exp = Enum.WebStreamClientType.WebSocket
1274
+ local _object = {
1021
1275
  Url = streamUrl(conn.serverUrl),
1022
1276
  Method = "GET",
1023
- Headers = {
1024
- ["Content-Type"] = "application/json",
1025
- },
1026
- })
1277
+ }
1278
+ local _left = "Headers"
1279
+ local _object_1 = {
1280
+ ["Content-Type"] = "application/json",
1281
+ }
1282
+ local _value = conn.sessionToken
1283
+ for _k, _v in (if _value ~= "" and _value then {
1284
+ Authorization = `Bearer {conn.sessionToken}`,
1285
+ } else {}) do
1286
+ _object_1[_k] = _v
1287
+ end
1288
+ _object[_left] = _object_1
1289
+ return HttpService:CreateWebStreamClient(_exp, _object)
1027
1290
  end)
1028
1291
  if not ok or not stream then
1029
1292
  return nil
@@ -1055,14 +1318,7 @@ local function startRequestStream(conn)
1055
1318
  if _condition then
1056
1319
  return nil
1057
1320
  end
1058
- task.spawn(function()
1059
- local handled, response = pcall(function()
1060
- return processRequest(frame.request)
1061
- end)
1062
- sendStreamResponse(conn, frame.requestId, if handled then response else {
1063
- error = tostring(response),
1064
- })
1065
- end)
1321
+ handleRequestOnce(conn, frame.requestId, frame.request, frame.serverEpoch, frame.deliveryAttempt, frame.leaseToken)
1066
1322
  end)
1067
1323
  local close = function()
1068
1324
  if conn.streamClient ~= stream then
@@ -1085,13 +1341,22 @@ local function pollForRequests(connIndex)
1085
1341
  end
1086
1342
  conn.isPolling = true
1087
1343
  local success, result = pcall(function()
1088
- return HttpService:RequestAsync({
1344
+ local _object = {
1089
1345
  Url = `{conn.serverUrl}/poll?pluginSessionId={pluginSessionId}`,
1090
1346
  Method = "GET",
1091
- Headers = {
1092
- ["Content-Type"] = "application/json",
1093
- },
1094
- })
1347
+ }
1348
+ local _left = "Headers"
1349
+ local _object_1 = {
1350
+ ["Content-Type"] = "application/json",
1351
+ }
1352
+ local _value = conn.sessionToken
1353
+ for _k, _v in (if _value ~= "" and _value then {
1354
+ Authorization = `Bearer {conn.sessionToken}`,
1355
+ } else {}) do
1356
+ _object_1[_k] = _v
1357
+ end
1358
+ _object[_left] = _object_1
1359
+ return HttpService:RequestAsync(_object)
1095
1360
  end)
1096
1361
  conn.isPolling = false
1097
1362
  local ui = UI.getElements()
@@ -1226,19 +1491,13 @@ local function pollForRequests(connIndex)
1226
1491
  UI.startPulseAnimation()
1227
1492
  end
1228
1493
  end
1494
+ if data.cancellations then
1495
+ for _, c in data.cancellations do
1496
+ cancelledSyncRequests[c] = true
1497
+ end
1498
+ end
1229
1499
  if data.request and mcpConnected then
1230
- task.spawn(function()
1231
- local ok, response = pcall(function()
1232
- return processRequest(data.request)
1233
- end)
1234
- if ok then
1235
- sendResponse(conn, data.requestId, response)
1236
- else
1237
- sendResponse(conn, data.requestId, {
1238
- error = tostring(response),
1239
- })
1240
- end
1241
- end)
1500
+ handleRequestOnce(conn, data.requestId, data.request, data.serverEpoch, data.deliveryAttempt, data.leaseToken)
1242
1501
  end
1243
1502
  elseif conn.isActive then
1244
1503
  conn.consecutiveFailures += 1
@@ -1489,6 +1748,7 @@ local function checkForUpdates()
1489
1748
  end
1490
1749
  return {
1491
1750
  getConnectionStatus = getConnectionStatus,
1751
+ isCancelledForThread = isCancelledForThread,
1492
1752
  activatePlugin = activatePlugin,
1493
1753
  deactivatePlugin = deactivatePlugin,
1494
1754
  deactivateAll = deactivateAll,
@@ -1617,9 +1877,9 @@ local function computeBridgeStamp()
1617
1877
  for i = 1, #combined do
1618
1878
  h = (h * 33 + (string.byte(combined, i))) % 2147483647
1619
1879
  end
1620
- -- "3.0.0-rc.1" is replaced with the package version at package time
1880
+ -- "3.0.0" is replaced with the package version at package time
1621
1881
  -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1622
- return `{tostring(h)}-3.0.0-rc.1`
1882
+ return `{tostring(h)}-3.0.0`
1623
1883
  end
1624
1884
  local BRIDGE_STAMP = computeBridgeStamp()
1625
1885
  local function setSource(scriptInst, source)
@@ -4300,6 +4560,7 @@ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
4300
4560
  -- discard its result on completion.
4301
4561
  local LuauExec = TS.import(script, script.Parent.Parent, "LuauExec")
4302
4562
  local JobRegistry = TS.import(script, script.Parent.Parent, "JobRegistry")
4563
+ local Communication = TS.import(script, script.Parent.Parent, "Communication")
4303
4564
  -- Install a sanctioned progress/cancel API once. Server-generated long-running
4304
4565
  -- Luau (terrain/template/batch builders) can call _G.__mcp.progress(done,total,msg)
4305
4566
  -- and _G.__mcp.checkCancelled() to report progress and bail early. We do NOT
@@ -4313,7 +4574,7 @@ local function installMcpGlobal()
4313
4574
  JobRegistry.reportProgress(coroutine.running(), done, total, message, stage)
4314
4575
  end,
4315
4576
  checkCancelled = function()
4316
- return JobRegistry.isCancelledForThread(coroutine.running())
4577
+ return JobRegistry.isCancelledForThread(coroutine.running()) or Communication.isCancelledForThread(coroutine.running())
4317
4578
  end,
4318
4579
  }
4319
4580
  end
@@ -10993,6 +11254,36 @@ end
10993
11254
  local function nowSec()
10994
11255
  return DateTime.now().UnixTimestampMillis / 1000
10995
11256
  end
11257
+ -- Studio can expose binary-bearing Output messages that JSONEncode rejects.
11258
+ -- Preserve valid UTF-8 and make only malformed bytes visible and JSON-safe.
11259
+ local function escapeInvalidUtf8(message)
11260
+ local valid = utf8.len(message)
11261
+ if type(valid) == "number" then
11262
+ return message
11263
+ end
11264
+ local parts = {}
11265
+ local cursor = 1
11266
+ while cursor <= #message do
11267
+ local suffixValid, invalidPosition = utf8.len(message, cursor)
11268
+ if type(suffixValid) == "number" then
11269
+ local _arg0 = string.sub(message, cursor)
11270
+ table.insert(parts, _arg0)
11271
+ break
11272
+ end
11273
+ if not (type(invalidPosition) == "number") then
11274
+ break
11275
+ end
11276
+ if invalidPosition > cursor then
11277
+ local _arg0 = string.sub(message, cursor, invalidPosition - 1)
11278
+ table.insert(parts, _arg0)
11279
+ end
11280
+ local invalidByte = string.byte(message, invalidPosition)
11281
+ local _arg0 = string.format("\\x%02X", invalidByte)
11282
+ table.insert(parts, _arg0)
11283
+ cursor = invalidPosition + 1
11284
+ end
11285
+ return table.concat(parts, "")
11286
+ end
10996
11287
  local function dropOldestUntilFits(incomingBytes)
10997
11288
  while #entries > 0 and (totalBytes + incomingBytes > MAX_BYTES or #entries >= HARD_ENTRY_CAP) do
10998
11289
  local dropped = table.remove(entries, 1)
@@ -11001,13 +11292,18 @@ local function dropOldestUntilFits(incomingBytes)
11001
11292
  end
11002
11293
  end
11003
11294
  local function pushEntry(message, level, ts)
11004
- local bytes = #message
11295
+ local safeMessage = escapeInvalidUtf8(message)
11296
+ local bytes = #safeMessage
11297
+ if bytes > MAX_BYTES then
11298
+ totalDropped += 1
11299
+ return nil
11300
+ end
11005
11301
  dropOldestUntilFits(bytes)
11006
11302
  local _arg0 = {
11007
11303
  seq = nextSeq,
11008
11304
  ts = ts,
11009
11305
  level = level,
11010
- message = message,
11306
+ message = safeMessage,
11011
11307
  }
11012
11308
  table.insert(entries, _arg0)
11013
11309
  nextSeq += 1
@@ -11299,9 +11595,9 @@ return {
11299
11595
  <Properties>
11300
11596
  <string name="Name">State</string>
11301
11597
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
11302
- local CURRENT_VERSION = "3.0.0-rc.1"
11598
+ local CURRENT_VERSION = "3.0.0"
11303
11599
  local PLUGIN_VARIANT = "inspector"
11304
- local PROTOCOL_VERSION = 1
11600
+ local PROTOCOL_VERSION = 3
11305
11601
  local MAX_CONNECTIONS = 5
11306
11602
  local BASE_PORT = 58741
11307
11603
  local activeTabIndex = 0