@princeofscale/bloxforge 2.20.2 → 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.
@@ -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
@@ -708,8 +791,10 @@ local function computeInstanceId()
708
791
  end
709
792
  local assignedRole
710
793
  local duplicateInstanceRole = false
711
- local hasVersionMismatch = false
794
+ local versionMismatchConnections = {}
712
795
  local lastVersionMismatchWarningKey
796
+ local protocolMismatchConnections = {}
797
+ local lastProtocolMismatchWarningKey
713
798
  local lastReadyInstanceId
714
799
  local readyFailureLogKeys = {}
715
800
  -- Cache the published place name from MarketplaceService:GetProductInfo so
@@ -753,6 +838,20 @@ local function detectRole()
753
838
  return "client"
754
839
  end
755
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
756
855
  local routeMap = {
757
856
  ["/api/file-tree"] = QueryHandlers.getFileTree,
758
857
  ["/api/search-files"] = QueryHandlers.searchFiles,
@@ -841,19 +940,119 @@ local function processRequest(request)
841
940
  }
842
941
  end
843
942
  end
844
- local function sendResponse(conn, requestId, responseData)
845
- pcall(function()
846
- 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 = {
847
959
  Url = `{conn.serverUrl}/response`,
848
960
  Method = "POST",
849
- Headers = {
850
- ["Content-Type"] = "application/json",
851
- },
852
- Body = HttpService:JSONEncode({
853
- requestId = requestId,
854
- response = responseData,
855
- }),
856
- })
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)
857
1056
  end)
858
1057
  end
859
1058
  local function getConnectionStatus(connIndex)
@@ -969,8 +1168,12 @@ function sendReady(conn)
969
1168
  local parseOk, readyData = pcall(function()
970
1169
  return HttpService:JSONDecode(readyResult.Body)
971
1170
  end)
972
- local _value = parseOk and readyData.assignedRole
1171
+ local _value = parseOk and readyData.sessionToken
973
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
974
1177
  assignedRole = readyData.assignedRole
975
1178
  end
976
1179
  local _condition = parseOk
@@ -982,12 +1185,51 @@ function sendReady(conn)
982
1185
  end
983
1186
  end
984
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
985
1227
  ServerUrlSettings.rememberServerUrl(conn.serverUrl)
986
- local _condition_1 = assignedRole
987
- if _condition_1 == nil then
988
- _condition_1 = detectRole()
1228
+ local _condition_2 = assignedRole
1229
+ if _condition_2 == nil then
1230
+ _condition_2 = detectRole()
989
1231
  end
990
- local connectedRole = _condition_1
1232
+ local connectedRole = _condition_2
991
1233
  if readyFailureLogKeys[readyLogKey] ~= nil then
992
1234
  readyFailureLogKeys[readyLogKey] = nil
993
1235
  print(`[BloxForge] /ready connected for {instanceId}/{connectedRole} via {conn.serverUrl}`)
@@ -998,30 +1240,53 @@ local function streamUrl(serverUrl)
998
1240
  local websocketUrl = string.gsub(serverUrl, "^http", "ws")
999
1241
  return `{websocketUrl}/stream?pluginSessionId={pluginSessionId}`
1000
1242
  end
1001
- local function sendStreamResponse(conn, requestId, response)
1243
+ local function sendStreamResponse(conn, requestId, response, serverEpoch, deliveryAttempt, leaseToken)
1002
1244
  if not conn.streamOpen or not conn.streamClient then
1003
1245
  return nil
1004
1246
  end
1005
- pcall(function()
1006
- return conn.streamClient:Send(HttpService:JSONEncode({
1007
- type = "response",
1008
- requestId = requestId,
1009
- response = response,
1010
- }))
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))
1011
1263
  end)
1264
+ if not ok then
1265
+ warn(`[BloxForge] Failed to send stream response for {requestId}: {tostring(result)}`)
1266
+ end
1012
1267
  end
1013
1268
  local function startRequestStream(conn)
1014
1269
  if not conn.isActive or conn.streamClient then
1015
1270
  return nil
1016
1271
  end
1017
1272
  local ok, stream = pcall(function()
1018
- return HttpService:CreateWebStreamClient(Enum.WebStreamClientType.WebSocket, {
1273
+ local _exp = Enum.WebStreamClientType.WebSocket
1274
+ local _object = {
1019
1275
  Url = streamUrl(conn.serverUrl),
1020
1276
  Method = "GET",
1021
- Headers = {
1022
- ["Content-Type"] = "application/json",
1023
- },
1024
- })
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)
1025
1290
  end)
1026
1291
  if not ok or not stream then
1027
1292
  return nil
@@ -1053,14 +1318,7 @@ local function startRequestStream(conn)
1053
1318
  if _condition then
1054
1319
  return nil
1055
1320
  end
1056
- task.spawn(function()
1057
- local handled, response = pcall(function()
1058
- return processRequest(frame.request)
1059
- end)
1060
- sendStreamResponse(conn, frame.requestId, if handled then response else {
1061
- error = tostring(response),
1062
- })
1063
- end)
1321
+ handleRequestOnce(conn, frame.requestId, frame.request, frame.serverEpoch, frame.deliveryAttempt, frame.leaseToken)
1064
1322
  end)
1065
1323
  local close = function()
1066
1324
  if conn.streamClient ~= stream then
@@ -1083,13 +1341,22 @@ local function pollForRequests(connIndex)
1083
1341
  end
1084
1342
  conn.isPolling = true
1085
1343
  local success, result = pcall(function()
1086
- return HttpService:RequestAsync({
1344
+ local _object = {
1087
1345
  Url = `{conn.serverUrl}/poll?pluginSessionId={pluginSessionId}`,
1088
1346
  Method = "GET",
1089
- Headers = {
1090
- ["Content-Type"] = "application/json",
1091
- },
1092
- })
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)
1093
1360
  end)
1094
1361
  conn.isPolling = false
1095
1362
  local ui = UI.getElements()
@@ -1118,16 +1385,49 @@ local function pollForRequests(connIndex)
1118
1385
  end
1119
1386
  local serverVersion = _condition
1120
1387
  if data.versionMismatch == true then
1121
- hasVersionMismatch = true
1388
+ versionMismatchConnections[conn] = true
1122
1389
  local warningKey = `{State.CURRENT_VERSION}:{serverVersion}`
1123
1390
  if lastVersionMismatchWarningKey ~= warningKey then
1124
1391
  lastVersionMismatchWarningKey = warningKey
1125
1392
  warn(`[BloxForge] Version mismatch: Studio plugin v{State.CURRENT_VERSION} / MCP v{serverVersion}. Run npx -y @princeofscale/bloxforge@latest --auto-install-plugin and restart Studio.`)
1126
1393
  end
1127
1394
  UI.showBanner("version-mismatch", `Plugin v{State.CURRENT_VERSION} / MCP v{serverVersion} mismatch`)
1128
- elseif hasVersionMismatch then
1129
- hasVersionMismatch = false
1130
- UI.hideBanner("version-mismatch")
1395
+ else
1396
+ versionMismatchConnections[conn] = nil
1397
+ -- ▼ ReadonlySet.size ▼
1398
+ local _size = 0
1399
+ for _ in versionMismatchConnections do
1400
+ _size += 1
1401
+ end
1402
+ -- ▲ ReadonlySet.size ▲
1403
+ if _size == 0 then
1404
+ UI.hideBanner("version-mismatch")
1405
+ end
1406
+ end
1407
+ local _condition_1 = data.serverProtocolVersion
1408
+ if _condition_1 == nil then
1409
+ _condition_1 = 0
1410
+ end
1411
+ local serverProtocol = _condition_1
1412
+ if data.protocolMismatch == true then
1413
+ protocolMismatchConnections[conn] = true
1414
+ local warningKey = `{State.PROTOCOL_VERSION}:{serverProtocol}`
1415
+ if lastProtocolMismatchWarningKey ~= warningKey then
1416
+ lastProtocolMismatchWarningKey = warningKey
1417
+ warn(`[BloxForge] Protocol mismatch: Studio plugin protocol v{State.PROTOCOL_VERSION} / MCP protocol v{serverProtocol}. Run npx -y @princeofscale/bloxforge@latest --auto-install-plugin and restart Studio.`)
1418
+ end
1419
+ UI.showBanner("protocol-mismatch", `Protocol mismatch: Plugin protocol v{State.PROTOCOL_VERSION} / MCP protocol v{serverProtocol}`)
1420
+ else
1421
+ protocolMismatchConnections[conn] = nil
1422
+ -- ▼ ReadonlySet.size ▼
1423
+ local _size = 0
1424
+ for _ in protocolMismatchConnections do
1425
+ _size += 1
1426
+ end
1427
+ -- ▲ ReadonlySet.size ▲
1428
+ if _size == 0 then
1429
+ UI.hideBanner("protocol-mismatch")
1430
+ end
1131
1431
  end
1132
1432
  -- Server tells us when its in-memory instances map doesn't have us
1133
1433
  -- (e.g. after an MCP process restart, or after it reaped us during a
@@ -1146,12 +1446,12 @@ local function pollForRequests(connIndex)
1146
1446
  local el = ui
1147
1447
  el.step1Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
1148
1448
  el.step1Label.Text = "HTTP server (OK)"
1149
- local _condition_1 = mcpConnected
1150
- if _condition_1 then
1449
+ local _condition_2 = mcpConnected
1450
+ if _condition_2 then
1151
1451
  local _value = (string.find(el.statusLabel.Text, "Connected"))
1152
- _condition_1 = not (_value ~= 0 and _value == _value and _value)
1452
+ _condition_2 = not (_value ~= 0 and _value == _value and _value)
1153
1453
  end
1154
- if _condition_1 then
1454
+ if _condition_2 then
1155
1455
  el.statusLabel.Text = "Connected"
1156
1456
  el.statusLabel.TextColor3 = Color3.fromRGB(34, 197, 94)
1157
1457
  el.statusIndicator.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
@@ -1182,28 +1482,22 @@ local function pollForRequests(connIndex)
1182
1482
  conn.mcpWaitStartTime = tick()
1183
1483
  end
1184
1484
  local _exp = tick()
1185
- local _condition_2 = conn.mcpWaitStartTime
1186
- if _condition_2 == nil then
1187
- _condition_2 = tick()
1485
+ local _condition_3 = conn.mcpWaitStartTime
1486
+ if _condition_3 == nil then
1487
+ _condition_3 = tick()
1188
1488
  end
1189
- local elapsed = _exp - _condition_2
1489
+ local elapsed = _exp - _condition_3
1190
1490
  el.troubleshootLabel.Visible = elapsed > 8
1191
1491
  UI.startPulseAnimation()
1192
1492
  end
1193
1493
  end
1494
+ if data.cancellations then
1495
+ for _, c in data.cancellations do
1496
+ cancelledSyncRequests[c] = true
1497
+ end
1498
+ end
1194
1499
  if data.request and mcpConnected then
1195
- task.spawn(function()
1196
- local ok, response = pcall(function()
1197
- return processRequest(data.request)
1198
- end)
1199
- if ok then
1200
- sendResponse(conn, data.requestId, response)
1201
- else
1202
- sendResponse(conn, data.requestId, {
1203
- error = tostring(response),
1204
- })
1205
- end
1206
- end)
1500
+ handleRequestOnce(conn, data.requestId, data.request, data.serverEpoch, data.deliveryAttempt, data.leaseToken)
1207
1501
  end
1208
1502
  elseif conn.isActive then
1209
1503
  conn.consecutiveFailures += 1
@@ -1356,6 +1650,26 @@ function deactivatePlugin(connIndex)
1356
1650
  end
1357
1651
  conn.isActive = false
1358
1652
  conn.lastMcpOk = false
1653
+ versionMismatchConnections[conn] = nil
1654
+ protocolMismatchConnections[conn] = nil
1655
+ -- ▼ ReadonlySet.size ▼
1656
+ local _size = 0
1657
+ for _ in versionMismatchConnections do
1658
+ _size += 1
1659
+ end
1660
+ -- ▲ ReadonlySet.size ▲
1661
+ if _size == 0 then
1662
+ UI.hideBanner("version-mismatch")
1663
+ end
1664
+ -- ▼ ReadonlySet.size ▼
1665
+ local _size_1 = 0
1666
+ for _ in protocolMismatchConnections do
1667
+ _size_1 += 1
1668
+ end
1669
+ -- ▲ ReadonlySet.size ▲
1670
+ if _size_1 == 0 then
1671
+ UI.hideBanner("protocol-mismatch")
1672
+ end
1359
1673
  if idx == State.getActiveTabIndex() then
1360
1674
  UI.updateUIState()
1361
1675
  end
@@ -1418,7 +1732,13 @@ local function checkForUpdates()
1418
1732
  if _condition ~= "" and _condition then
1419
1733
  local latestVersion = data.version
1420
1734
  if Utils.compareVersions(State.CURRENT_VERSION, latestVersion) < 0 then
1421
- if not hasVersionMismatch then
1735
+ -- ReadonlySet.size
1736
+ local _size = 0
1737
+ for _ in versionMismatchConnections do
1738
+ _size += 1
1739
+ end
1740
+ -- ▲ ReadonlySet.size ▲
1741
+ if _size == 0 then
1422
1742
  UI.showBanner("update", `v{latestVersion} available - github.com/princeofscale/bloxforge`)
1423
1743
  end
1424
1744
  end
@@ -1428,6 +1748,7 @@ local function checkForUpdates()
1428
1748
  end
1429
1749
  return {
1430
1750
  getConnectionStatus = getConnectionStatus,
1751
+ isCancelledForThread = isCancelledForThread,
1431
1752
  activatePlugin = activatePlugin,
1432
1753
  deactivatePlugin = deactivatePlugin,
1433
1754
  deactivateAll = deactivateAll,
@@ -1556,9 +1877,9 @@ local function computeBridgeStamp()
1556
1877
  for i = 1, #combined do
1557
1878
  h = (h * 33 + (string.byte(combined, i))) % 2147483647
1558
1879
  end
1559
- -- "2.20.2" is replaced with the package version at package time
1880
+ -- "3.0.0" is replaced with the package version at package time
1560
1881
  -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1561
- return `{tostring(h)}-2.20.2`
1882
+ return `{tostring(h)}-3.0.0`
1562
1883
  end
1563
1884
  local BRIDGE_STAMP = computeBridgeStamp()
1564
1885
  local function setSource(scriptInst, source)
@@ -4239,6 +4560,7 @@ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
4239
4560
  -- discard its result on completion.
4240
4561
  local LuauExec = TS.import(script, script.Parent.Parent, "LuauExec")
4241
4562
  local JobRegistry = TS.import(script, script.Parent.Parent, "JobRegistry")
4563
+ local Communication = TS.import(script, script.Parent.Parent, "Communication")
4242
4564
  -- Install a sanctioned progress/cancel API once. Server-generated long-running
4243
4565
  -- Luau (terrain/template/batch builders) can call _G.__mcp.progress(done,total,msg)
4244
4566
  -- and _G.__mcp.checkCancelled() to report progress and bail early. We do NOT
@@ -4252,7 +4574,7 @@ local function installMcpGlobal()
4252
4574
  JobRegistry.reportProgress(coroutine.running(), done, total, message, stage)
4253
4575
  end,
4254
4576
  checkCancelled = function()
4255
- return JobRegistry.isCancelledForThread(coroutine.running())
4577
+ return JobRegistry.isCancelledForThread(coroutine.running()) or Communication.isCancelledForThread(coroutine.running())
4256
4578
  end,
4257
4579
  }
4258
4580
  end
@@ -10353,7 +10675,6 @@ local function computeWrapperLineOffset()
10353
10675
  local before = string.sub(probe, 1, start - 1)
10354
10676
  return countLines(before) - 1
10355
10677
  end
10356
- local WRAPPER_LINE_OFFSET = computeWrapperLineOffset()
10357
10678
  -- Count source lines so the wrapper can filter traceback frames that fall
10358
10679
  -- outside the user code range (the wrapper's own preamble/postamble lines).
10359
10680
  function countLines(s)
@@ -10388,7 +10709,7 @@ end
10388
10709
  -- instead of hand-maintained, so reordering preamble lines can never desync
10389
10710
  -- __mcp_LINE_OFFSET / remapPayloadLines from the real line count.
10390
10711
  function renderWrapper(lineOffset, userLines, code, payloadPattern)
10391
- return `return ((function()\
10712
+ return `return (function()\
10392
10713
  \tlocal __mcp_traceback\
10393
10714
  \tlocal __mcp_remap\
10394
10715
  \tlocal __mcp_LINE_OFFSET = {lineOffset}\
@@ -10505,7 +10826,7 @@ function renderWrapper(lineOffset, userLines, code, payloadPattern)
10505
10826
  \t__mcp_remap = function(s)\
10506
10827
  \t\t-- Two chunk-name formats can reference our payload:\
10507
10828
  \t\t-- * "Workspace.__MCPExecLuauPayload:N" — ModuleScript:require fallback path\
10508
- \t\t-- * "[string \\"return ((function()...\\"]:N" — loadstring() (default in plugin)\
10829
+ \t\t-- * "[string \\"return (function()...\\"]:N" — loadstring() (default in plugin)\
10509
10830
  \t\t-- Subtract LINE_OFFSET to get the user-relative number, then clamp.\
10510
10831
  \t\t-- Clamping matters for unclosed constructs ("local x = (") where the\
10511
10832
  \t\t-- parser keeps reading into wrapper postamble and reports a payload\
@@ -10568,6 +10889,9 @@ function renderWrapper(lineOffset, userLines, code, payloadPattern)
10568
10889
  \treturn \{ ok = ok, value = errOrValue, output = __mcp_output \}\
10569
10890
  end)()`
10570
10891
  end
10892
+ -- roblox-ts emits function declarations as ordered local assignments, so this
10893
+ -- must run after every helper used by computeWrapperLineOffset is initialized.
10894
+ local WRAPPER_LINE_OFFSET = computeWrapperLineOffset()
10571
10895
  local function buildWrapper(code, payloadInstanceName)
10572
10896
  if payloadInstanceName == nil then
10573
10897
  payloadInstanceName = PAYLOAD_INSTANCE_NAME
@@ -10930,6 +11254,36 @@ end
10930
11254
  local function nowSec()
10931
11255
  return DateTime.now().UnixTimestampMillis / 1000
10932
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
10933
11287
  local function dropOldestUntilFits(incomingBytes)
10934
11288
  while #entries > 0 and (totalBytes + incomingBytes > MAX_BYTES or #entries >= HARD_ENTRY_CAP) do
10935
11289
  local dropped = table.remove(entries, 1)
@@ -10938,13 +11292,18 @@ local function dropOldestUntilFits(incomingBytes)
10938
11292
  end
10939
11293
  end
10940
11294
  local function pushEntry(message, level, ts)
10941
- 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
10942
11301
  dropOldestUntilFits(bytes)
10943
11302
  local _arg0 = {
10944
11303
  seq = nextSeq,
10945
11304
  ts = ts,
10946
11305
  level = level,
10947
- message = message,
11306
+ message = safeMessage,
10948
11307
  }
10949
11308
  table.insert(entries, _arg0)
10950
11309
  nextSeq += 1
@@ -11236,9 +11595,9 @@ return {
11236
11595
  <Properties>
11237
11596
  <string name="Name">State</string>
11238
11597
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
11239
- local CURRENT_VERSION = "2.20.2"
11598
+ local CURRENT_VERSION = "3.0.0"
11240
11599
  local PLUGIN_VARIANT = "main"
11241
- local PROTOCOL_VERSION = 1
11600
+ local PROTOCOL_VERSION = 3
11242
11601
  local MAX_CONNECTIONS = 5
11243
11602
  local BASE_PORT = 58741
11244
11603
  local activeTabIndex = 0