@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.
@@ -67,9 +67,9 @@
67
67
  }
68
68
  },
69
69
  "node_modules/ajv": {
70
- "version": "8.17.1",
71
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
72
- "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
70
+ "version": "8.20.0",
71
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
72
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
73
73
  "dev": true,
74
74
  "license": "MIT",
75
75
  "dependencies": {
@@ -234,9 +234,9 @@
234
234
  "license": "MIT"
235
235
  },
236
236
  "node_modules/fast-uri": {
237
- "version": "3.1.0",
238
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
239
- "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
237
+ "version": "3.1.3",
238
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
239
+ "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
240
240
  "dev": true,
241
241
  "funding": [
242
242
  {
@@ -466,9 +466,9 @@
466
466
  "license": "MIT"
467
467
  },
468
468
  "node_modules/picomatch": {
469
- "version": "2.3.1",
470
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
471
- "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
469
+ "version": "2.3.2",
470
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
471
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
472
472
  "dev": true,
473
473
  "license": "MIT",
474
474
  "engines": {
@@ -75,6 +75,7 @@ const BROKER_OWNER_ATTRIBUTE = "__MCPBrokerOwner";
75
75
  interface ProxyEntry {
76
76
  pluginSessionId: string;
77
77
  role: string;
78
+ sessionToken: string;
78
79
  }
79
80
 
80
81
  interface BrokerEnvelope {
@@ -111,10 +112,14 @@ const CLIENT_BROKER_ALLOWED_ENDPOINTS = new Set<string>([
111
112
 
112
113
  interface ReadyResponseBody {
113
114
  assignedRole?: string;
115
+ sessionToken?: string;
114
116
  }
115
117
 
116
118
  interface PollResponseBody {
117
119
  requestId?: string;
120
+ serverEpoch?: string;
121
+ deliveryAttempt?: number;
122
+ leaseToken?: string;
118
123
  request?: {
119
124
  endpoint: string;
120
125
  data?: Record<string, unknown>;
@@ -125,17 +130,42 @@ interface PollResponseBody {
125
130
  knownInstance?: boolean;
126
131
  }
127
132
 
133
+ interface DeliveryFence {
134
+ serverEpoch: string;
135
+ deliveryAttempt: number;
136
+ leaseToken: string;
137
+ }
138
+
128
139
  // Throttle re-ready calls per proxyId so a brief window of unknownInstance
129
140
  // polls doesn't cause a re-register stampede.
130
141
  const lastReadyByProxy = new Map<string, number>();
142
+ const completedProxyRequests = new Map<string, { response: unknown }>();
143
+ const completedProxyRequestOrder: string[] = [];
144
+ const COMPLETED_PROXY_REQUEST_LIMIT = 500;
145
+
146
+ function rememberCompletedProxyRequest(requestId: string, response: unknown): void {
147
+ completedProxyRequests.set(requestId, { response });
148
+ completedProxyRequestOrder.push(requestId);
149
+ if (completedProxyRequestOrder.size() > COMPLETED_PROXY_REQUEST_LIMIT) {
150
+ completedProxyRequests.delete(completedProxyRequestOrder.shift()!);
151
+ }
152
+ }
153
+
154
+ function sendProxyResponse(requestId: string, response: unknown, proxy: ProxyEntry, fence: DeliveryFence): void {
155
+ const [ok, result] = postJson("/response", { requestId, pluginSessionId: proxy.pluginSessionId, response, ...fence }, proxy.sessionToken);
156
+ if (!ok || !result || !result.Success) {
157
+ warn(`[BloxForge] proxy response delivery failed: ${formatPostJsonFailure("/response", ok, result)}`);
158
+ }
159
+ }
131
160
 
132
- function reRegisterProxy(proxyId: string, role: string): void {
161
+ function reRegisterProxy(proxy: ProxyEntry, role: string): void {
162
+ const proxyId = proxy.pluginSessionId;
133
163
  const now = tick();
134
164
  const last = lastReadyByProxy.get(proxyId) ?? 0;
135
165
  if (now - last < 2) return;
136
166
  lastReadyByProxy.set(proxyId, now);
137
- pcall(() =>
138
- postJson("/ready", {
167
+ pcall(() => {
168
+ const [ok, result] = postJson("/ready", {
139
169
  pluginSessionId: proxyId,
140
170
  instanceId: computeInstanceId(),
141
171
  role,
@@ -146,8 +176,12 @@ function reRegisterProxy(proxyId: string, role: string): void {
146
176
  pluginVersion: State.CURRENT_VERSION,
147
177
  pluginVariant: State.PLUGIN_VARIANT,
148
178
  protocolVersion: State.PROTOCOL_VERSION,
149
- }),
150
- );
179
+ });
180
+ if (ok && result && result.Success) {
181
+ const body = HttpService.JSONDecode(result.Body) as ReadyResponseBody;
182
+ if (body.sessionToken !== undefined) proxy.sessionToken = body.sessionToken;
183
+ }
184
+ });
151
185
  }
152
186
 
153
187
  function forkRole(): "edit" | "server" | "client" {
@@ -156,12 +190,14 @@ function forkRole(): "edit" | "server" | "client" {
156
190
  return "client";
157
191
  }
158
192
 
159
- function postJson(endpoint: string, body: Record<string, unknown>) {
193
+ function postJson(endpoint: string, body: Record<string, unknown>, sessionToken?: string) {
194
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
195
+ if (sessionToken !== undefined && sessionToken !== "") headers.Authorization = `Bearer ${sessionToken}`;
160
196
  return pcall(() =>
161
197
  HttpService.RequestAsync({
162
198
  Url: `${mcpUrl}${endpoint}`,
163
199
  Method: "POST",
164
- Headers: { "Content-Type": "application/json" },
200
+ Headers: headers,
165
201
  Body: HttpService.JSONEncode(body),
166
202
  }),
167
203
  );
@@ -312,7 +348,7 @@ function unregisterProxy(player: Player, entry?: ProxyEntry): void {
312
348
  if (!proxy) return;
313
349
  proxyByPlayer.delete(player);
314
350
  proxyRegisterFailuresByPlayer.delete(player);
315
- postJson("/disconnect", { pluginSessionId: proxy.pluginSessionId });
351
+ postJson("/disconnect", { pluginSessionId: proxy.pluginSessionId }, proxy.sessionToken);
316
352
  }
317
353
 
318
354
  function disconnectAllProxies(): void {
@@ -323,7 +359,8 @@ function disconnectAllProxies(): void {
323
359
  proxyRegisterFailuresByPlayer.clear();
324
360
  }
325
361
 
326
- function pollProxy(proxyId: string, player: Player, rf: RemoteFunction) {
362
+ function pollProxy(proxy: ProxyEntry, player: Player, rf: RemoteFunction) {
363
+ const proxyId = proxy.pluginSessionId;
327
364
  while (player.Parent !== undefined && proxyByPlayer.has(player)) {
328
365
  if (!RunService.IsRunning()) {
329
366
  unregisterProxy(player);
@@ -333,18 +370,32 @@ function pollProxy(proxyId: string, player: Player, rf: RemoteFunction) {
333
370
  HttpService.RequestAsync({
334
371
  Url: `${mcpUrl}/poll?pluginSessionId=${proxyId}`,
335
372
  Method: "GET",
336
- Headers: { "Content-Type": "application/json" },
373
+ Headers: proxy.sessionToken !== ""
374
+ ? { "Content-Type": "application/json", Authorization: `Bearer ${proxy.sessionToken}` }
375
+ : { "Content-Type": "application/json" },
337
376
  }),
338
377
  );
378
+ if (ok && res && res.StatusCode === 401) {
379
+ reRegisterProxy(proxy, "client");
380
+ }
339
381
  if (ok && res && (res.Success || res.StatusCode === 503)) {
340
382
  const [okJson, body] = pcall(() => HttpService.JSONDecode(res.Body) as PollResponseBody);
341
383
  if (okJson && body) {
342
384
  // Server lost our proxy registration (process restart, etc.) -
343
385
  // re-register so the next poll cycle starts routing again.
344
386
  if (body.knownInstance === false) {
345
- reRegisterProxy(proxyId, "client");
387
+ reRegisterProxy(proxy, "client");
346
388
  }
347
389
  if (body.request && body.requestId !== undefined) {
390
+ if (!body.serverEpoch || body.deliveryAttempt === undefined || !body.leaseToken) continue;
391
+ const fence = { serverEpoch: body.serverEpoch, deliveryAttempt: body.deliveryAttempt, leaseToken: body.leaseToken };
392
+ const cached = completedProxyRequests.get(body.requestId);
393
+ if (cached !== undefined) {
394
+ sendProxyResponse(body.requestId, cached.response, proxy, fence);
395
+ task.wait(0.5);
396
+ continue;
397
+ }
398
+ postJson("/ack", { requestId: body.requestId, pluginSessionId: proxyId, ...fence }, proxy.sessionToken);
348
399
  const request = body.request;
349
400
  let response: unknown;
350
401
  if (CLIENT_BROKER_ALLOWED_ENDPOINTS.has(request.endpoint)) {
@@ -366,7 +417,8 @@ function pollProxy(proxyId: string, player: Player, rf: RemoteFunction) {
366
417
  `Allowed: ${allowed.join(", ")}.`,
367
418
  };
368
419
  }
369
- postJson("/response", { requestId: body.requestId, response });
420
+ rememberCompletedProxyRequest(body.requestId, response);
421
+ sendProxyResponse(body.requestId, response, proxy, fence);
370
422
  }
371
423
  }
372
424
  }
@@ -396,12 +448,13 @@ function registerProxy(player: Player, rf: RemoteFunction) {
396
448
  }
397
449
  const body = HttpService.JSONDecode(res.Body) as ReadyResponseBody;
398
450
  const assigned = body.assignedRole ?? "client";
399
- proxyByPlayer.set(player, { pluginSessionId: proxyId, role: assigned });
451
+ const entry = { pluginSessionId: proxyId, role: assigned, sessionToken: body.sessionToken ?? "" };
452
+ proxyByPlayer.set(player, entry);
400
453
  if (proxyRegisterFailuresByPlayer.has(player)) {
401
454
  proxyRegisterFailuresByPlayer.delete(player);
402
455
  print(`[BloxForge] proxy registered for ${player.Name} as ${assigned} via ${mcpUrl}`);
403
456
  }
404
- task.spawn(pollProxy, proxyId, player, rf);
457
+ task.spawn(pollProxy, entry, player, rf);
405
458
  }
406
459
 
407
460
  // (Removed: startEditProxyLoop. The play-server DM no longer registers an
@@ -54,8 +54,10 @@ function computeInstanceId(): string {
54
54
 
55
55
  let assignedRole: string | undefined;
56
56
  let duplicateInstanceRole = false;
57
- let hasVersionMismatch = false;
57
+ const versionMismatchConnections = new Set<Connection>();
58
58
  let lastVersionMismatchWarningKey: string | undefined;
59
+ const protocolMismatchConnections = new Set<Connection>();
60
+ let lastProtocolMismatchWarningKey: string | undefined;
59
61
  let lastReadyInstanceId: string | undefined;
60
62
  const readyFailureLogKeys = new Set<string>();
61
63
 
@@ -96,6 +98,20 @@ function detectRole(): string {
96
98
 
97
99
  const initialRole = detectRole();
98
100
 
101
+ const activeRequests = new Set<string>();
102
+ const completedRequests = new Map<string, { response: unknown }>();
103
+ const completedRequestOrder: string[] = [];
104
+ const COMPLETED_REQUEST_LIMIT = 500;
105
+
106
+ const syncThreadRequests = new Map<thread, string>();
107
+ const cancelledSyncRequests = new Set<string>();
108
+
109
+ function isCancelledForThread(co: thread): boolean {
110
+ const reqId = syncThreadRequests.get(co);
111
+ if (reqId === undefined) return false;
112
+ return cancelledSyncRequests.has(reqId);
113
+ }
114
+
99
115
  type Handler = (data: Record<string, unknown>) => unknown;
100
116
 
101
117
  const routeMap: Record<string, Handler> = {
@@ -200,15 +216,93 @@ function processRequest(request: RequestPayload): unknown {
200
216
  }
201
217
  }
202
218
 
203
- function sendResponse(conn: Connection, requestId: string, responseData: unknown) {
204
- pcall(() => {
205
- HttpService.RequestAsync({
219
+ function sendResponse(conn: Connection, requestId: string, responseData: unknown, serverEpoch?: string, deliveryAttempt?: number, leaseToken?: string) {
220
+ const body: Record<string, unknown> = { requestId, response: responseData };
221
+ if (serverEpoch !== undefined) body.serverEpoch = serverEpoch;
222
+ if (deliveryAttempt !== undefined) body.deliveryAttempt = deliveryAttempt;
223
+ if (leaseToken !== undefined) body.leaseToken = leaseToken;
224
+
225
+ const [ok, result] = pcall(() => {
226
+ return HttpService.RequestAsync({
206
227
  Url: `${conn.serverUrl}/response`,
207
228
  Method: "POST",
208
- Headers: { "Content-Type": "application/json" },
209
- Body: HttpService.JSONEncode({ requestId, response: responseData }),
229
+ Headers: {
230
+ "Content-Type": "application/json",
231
+ ...(conn.sessionToken ? { "Authorization": `Bearer ${conn.sessionToken}` } : {})
232
+ },
233
+ Body: HttpService.JSONEncode(body),
210
234
  });
211
235
  });
236
+
237
+ if (!ok) {
238
+ warn(`[BloxForge] Plugin response serialization failed for ${requestId}: ${tostring(result)}`);
239
+ } else if (!result.Success) {
240
+ warn(`[BloxForge] Failed to deliver response for ${requestId}: HTTP ${result.StatusCode}`);
241
+ }
242
+ }
243
+
244
+ function handleRequestOnce(
245
+ conn: Connection,
246
+ requestId: string,
247
+ request: RequestPayload,
248
+ serverEpoch?: string,
249
+ deliveryAttempt?: number,
250
+ leaseToken?: string
251
+ ) {
252
+ // If we've already completed this request, just re-deliver the cached response
253
+ const completed = completedRequests.get(requestId);
254
+ if (completed) {
255
+ sendResponse(conn, requestId, completed.response, serverEpoch, deliveryAttempt, leaseToken);
256
+ return;
257
+ }
258
+
259
+ // If it's actively being processed, do nothing. The running thread will deliver
260
+ // the response when it finishes.
261
+ if (activeRequests.has(requestId)) return;
262
+
263
+ // Acknowledge the fenced request so the server knows we've started work.
264
+ // This prevents the lease from expiring while we're executing long-running operations.
265
+ if (serverEpoch !== undefined && deliveryAttempt !== undefined && leaseToken !== undefined) {
266
+ task.spawn(() => {
267
+ pcall(() => {
268
+ HttpService.RequestAsync({
269
+ Url: `${conn.serverUrl}/ack`,
270
+ Method: "POST",
271
+ Headers: {
272
+ "Content-Type": "application/json",
273
+ ...(conn.sessionToken ? { "Authorization": `Bearer ${conn.sessionToken}` } : {})
274
+ },
275
+ Body: HttpService.JSONEncode({
276
+ pluginSessionId,
277
+ requestId,
278
+ serverEpoch,
279
+ deliveryAttempt,
280
+ leaseToken
281
+ }),
282
+ });
283
+ });
284
+ });
285
+ }
286
+
287
+ activeRequests.add(requestId);
288
+ task.spawn(() => {
289
+ const co = coroutine.running();
290
+ syncThreadRequests.set(co, requestId);
291
+ const [ok, result] = pcall(() => processRequest(request));
292
+ syncThreadRequests.delete(co);
293
+
294
+ const response = ok ? result : { error: tostring(result) };
295
+ activeRequests.delete(requestId);
296
+
297
+ if (completedRequestOrder.size() >= COMPLETED_REQUEST_LIMIT) {
298
+ const oldest = completedRequestOrder.remove(0);
299
+ if (oldest !== undefined) completedRequests.delete(oldest);
300
+ }
301
+ completedRequests.set(requestId, { response });
302
+ completedRequestOrder.push(requestId);
303
+
304
+ sendResponse(conn, requestId, response, serverEpoch, deliveryAttempt, leaseToken);
305
+ });
212
306
  }
213
307
 
214
308
  function getConnectionStatus(connIndex: number): string {
@@ -310,12 +404,34 @@ function sendReady(conn: Connection): void {
310
404
  const [parseOk, readyData] = pcall(
311
405
  () => HttpService.JSONDecode(readyResult.Body) as ReadyResponse,
312
406
  );
407
+ if (parseOk && readyData.sessionToken) {
408
+ conn.sessionToken = readyData.sessionToken;
409
+ }
313
410
  if (parseOk && readyData.assignedRole) {
314
411
  assignedRole = readyData.assignedRole;
315
412
  }
316
413
  lastReadyInstanceId = parseOk && typeIs(readyData.instanceId, "string") && readyData.instanceId !== ""
317
414
  ? readyData.instanceId
318
415
  : instanceId;
416
+
417
+ if (parseOk && readyData.serverEpoch && completedRequests.size() > 0) {
418
+ const receipts: { requestId: string; completedAt: number }[] = [];
419
+ for (const [id, _] of completedRequests) {
420
+ receipts.push({ requestId: id, completedAt: tick() * 1000 });
421
+ }
422
+ task.spawn(() => {
423
+ pcall(() => HttpService.RequestAsync({
424
+ Url: `${conn.serverUrl}/reconcile`,
425
+ Method: "POST",
426
+ Headers: { "Content-Type": "application/json" },
427
+ Body: HttpService.JSONEncode({
428
+ pluginSessionId,
429
+ serverEpoch: readyData.serverEpoch,
430
+ receipts,
431
+ }),
432
+ }));
433
+ });
434
+ }
319
435
  ServerUrlSettings.rememberServerUrl(conn.serverUrl);
320
436
  const connectedRole = assignedRole ?? detectRole();
321
437
  if (readyFailureLogKeys.has(readyLogKey)) {
@@ -330,16 +446,30 @@ function streamUrl(serverUrl: string): string {
330
446
  return `${websocketUrl}/stream?pluginSessionId=${pluginSessionId}`;
331
447
  }
332
448
 
333
- function sendStreamResponse(conn: Connection, requestId: string, response: unknown) {
449
+ function sendStreamResponse(conn: Connection, requestId: string, response: unknown, serverEpoch?: string, deliveryAttempt?: number, leaseToken?: string) {
334
450
  if (!conn.streamOpen || !conn.streamClient) return;
335
- pcall(() => conn.streamClient!.Send(HttpService.JSONEncode({ type: "response", requestId, response })));
451
+
452
+ const body: Record<string, unknown> = { type: "response", requestId, response };
453
+ if (serverEpoch !== undefined) body.serverEpoch = serverEpoch;
454
+ if (deliveryAttempt !== undefined) body.deliveryAttempt = deliveryAttempt;
455
+ if (leaseToken !== undefined) body.leaseToken = leaseToken;
456
+
457
+ const [ok, result] = pcall(() => conn.streamClient!.Send(HttpService.JSONEncode(body)));
458
+ if (!ok) warn(`[BloxForge] Failed to send stream response for ${requestId}: ${tostring(result)}`);
336
459
  }
337
460
 
338
461
  function startRequestStream(conn: Connection) {
339
462
  if (!conn.isActive || conn.streamClient) return;
340
463
  const [ok, stream] = pcall(() => HttpService.CreateWebStreamClient(
341
464
  Enum.WebStreamClientType.WebSocket,
342
- { Url: streamUrl(conn.serverUrl), Method: "GET", Headers: { "Content-Type": "application/json" } },
465
+ {
466
+ Url: streamUrl(conn.serverUrl),
467
+ Method: "GET",
468
+ Headers: {
469
+ "Content-Type": "application/json",
470
+ ...(conn.sessionToken ? { "Authorization": `Bearer ${conn.sessionToken}` } : {})
471
+ }
472
+ },
343
473
  ));
344
474
  if (!ok || !stream) return;
345
475
 
@@ -352,12 +482,10 @@ function startRequestStream(conn: Connection) {
352
482
  });
353
483
  stream.MessageReceived.Connect((message) => {
354
484
  if (conn.streamClient !== stream) return;
355
- const [parsed, frame] = pcall(() => HttpService.JSONDecode(message) as { type?: string; requestId?: string; request?: RequestPayload });
485
+ const [parsed, frame] = pcall(() => HttpService.JSONDecode(message) as { type?: string; requestId?: string; request?: RequestPayload; serverEpoch?: string; deliveryAttempt?: number; leaseToken?: string });
356
486
  if (!parsed || frame.type !== "request" || !frame.requestId || !frame.request) return;
357
- task.spawn(() => {
358
- const [handled, response] = pcall(() => processRequest(frame.request!));
359
- sendStreamResponse(conn, frame.requestId!, handled ? response : { error: tostring(response) });
360
- });
487
+
488
+ handleRequestOnce(conn, frame.requestId!, frame.request!, frame.serverEpoch, frame.deliveryAttempt, frame.leaseToken);
361
489
  });
362
490
  const close = () => {
363
491
  if (conn.streamClient !== stream) return;
@@ -380,7 +508,10 @@ function pollForRequests(connIndex: number) {
380
508
  return HttpService.RequestAsync({
381
509
  Url: `${conn.serverUrl}/poll?pluginSessionId=${pluginSessionId}`,
382
510
  Method: "GET",
383
- Headers: { "Content-Type": "application/json" },
511
+ Headers: {
512
+ "Content-Type": "application/json",
513
+ ...(conn.sessionToken ? { "Authorization": `Bearer ${conn.sessionToken}` } : {})
514
+ },
384
515
  });
385
516
  });
386
517
 
@@ -408,16 +539,30 @@ function pollForRequests(connIndex: number) {
408
539
  conn.lastMcpOk = mcpConnected;
409
540
  const serverVersion = data.serverVersion ?? "unknown";
410
541
  if (data.versionMismatch === true) {
411
- hasVersionMismatch = true;
542
+ versionMismatchConnections.add(conn);
412
543
  const warningKey = `${State.CURRENT_VERSION}:${serverVersion}`;
413
544
  if (lastVersionMismatchWarningKey !== warningKey) {
414
545
  lastVersionMismatchWarningKey = warningKey;
415
546
  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.`);
416
547
  }
417
548
  UI.showBanner("version-mismatch", `Plugin v${State.CURRENT_VERSION} / MCP v${serverVersion} mismatch`);
418
- } else if (hasVersionMismatch) {
419
- hasVersionMismatch = false;
420
- UI.hideBanner("version-mismatch");
549
+ } else {
550
+ versionMismatchConnections.delete(conn);
551
+ if (versionMismatchConnections.size() === 0) UI.hideBanner("version-mismatch");
552
+ }
553
+
554
+ const serverProtocol = data.serverProtocolVersion ?? 0;
555
+ if (data.protocolMismatch === true) {
556
+ protocolMismatchConnections.add(conn);
557
+ const warningKey = `${State.PROTOCOL_VERSION}:${serverProtocol}`;
558
+ if (lastProtocolMismatchWarningKey !== warningKey) {
559
+ lastProtocolMismatchWarningKey = warningKey;
560
+ 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.`);
561
+ }
562
+ UI.showBanner("protocol-mismatch", `Protocol mismatch: Plugin protocol v${State.PROTOCOL_VERSION} / MCP protocol v${serverProtocol}`);
563
+ } else {
564
+ protocolMismatchConnections.delete(conn);
565
+ if (protocolMismatchConnections.size() === 0) UI.hideBanner("protocol-mismatch");
421
566
  }
422
567
 
423
568
  // Server tells us when its in-memory instances map doesn't have us
@@ -473,15 +618,14 @@ function pollForRequests(connIndex: number) {
473
618
  }
474
619
  }
475
620
 
621
+ if (data.cancellations) {
622
+ for (const c of data.cancellations) {
623
+ cancelledSyncRequests.add(c);
624
+ }
625
+ }
626
+
476
627
  if (data.request && mcpConnected) {
477
- task.spawn(() => {
478
- const [ok, response] = pcall(() => processRequest(data.request!));
479
- if (ok) {
480
- sendResponse(conn, data.requestId!, response);
481
- } else {
482
- sendResponse(conn, data.requestId!, { error: tostring(response) });
483
- }
484
- });
628
+ handleRequestOnce(conn, data.requestId!, data.request, data.serverEpoch, data.deliveryAttempt, data.leaseToken);
485
629
  }
486
630
  } else if (conn.isActive) {
487
631
  conn.consecutiveFailures++;
@@ -629,6 +773,10 @@ function deactivatePlugin(connIndex?: number) {
629
773
 
630
774
  conn.isActive = false;
631
775
  conn.lastMcpOk = false;
776
+ versionMismatchConnections.delete(conn);
777
+ protocolMismatchConnections.delete(conn);
778
+ if (versionMismatchConnections.size() === 0) UI.hideBanner("version-mismatch");
779
+ if (protocolMismatchConnections.size() === 0) UI.hideBanner("protocol-mismatch");
632
780
 
633
781
  if (idx === State.getActiveTabIndex()) UI.updateUIState();
634
782
  UI.updateTabDot(idx);
@@ -679,7 +827,7 @@ function checkForUpdates() {
679
827
  if (ok && data?.version) {
680
828
  const latestVersion = data.version;
681
829
  if (Utils.compareVersions(State.CURRENT_VERSION, latestVersion) < 0) {
682
- if (!hasVersionMismatch) {
830
+ if (versionMismatchConnections.size() === 0) {
683
831
  UI.showBanner("update", `v${latestVersion} available - github.com/princeofscale/bloxforge`);
684
832
  }
685
833
  }
@@ -690,6 +838,7 @@ function checkForUpdates() {
690
838
 
691
839
  export = {
692
840
  getConnectionStatus,
841
+ isCancelledForThread,
693
842
  activatePlugin,
694
843
  deactivatePlugin,
695
844
  deactivateAll,
@@ -68,7 +68,6 @@ function computeWrapperLineOffset(): number {
68
68
  const before = string.sub(probe, 1, (start as number) - 1);
69
69
  return countLines(before) - 1;
70
70
  }
71
- const WRAPPER_LINE_OFFSET = computeWrapperLineOffset();
72
71
 
73
72
  // Count source lines so the wrapper can filter traceback frames that fall
74
73
  // outside the user code range (the wrapper's own preamble/postamble lines).
@@ -97,7 +96,7 @@ function renderWrapper(
97
96
  code: string,
98
97
  payloadPattern: string,
99
98
  ): string {
100
- return `return ((function()
99
+ return `return (function()
101
100
  \tlocal __mcp_traceback
102
101
  \tlocal __mcp_remap
103
102
  \tlocal __mcp_LINE_OFFSET = ${lineOffset}
@@ -214,7 +213,7 @@ ${code}
214
213
  \t__mcp_remap = function(s)
215
214
  \t\t-- Two chunk-name formats can reference our payload:
216
215
  \t\t-- * "Workspace.__MCPExecLuauPayload:N" — ModuleScript:require fallback path
217
- \t\t-- * "[string \\"return ((function()...\\"]:N" — loadstring() (default in plugin)
216
+ \t\t-- * "[string \\"return (function()...\\"]:N" — loadstring() (default in plugin)
218
217
  \t\t-- Subtract LINE_OFFSET to get the user-relative number, then clamp.
219
218
  \t\t-- Clamping matters for unclosed constructs ("local x = (") where the
220
219
  \t\t-- parser keeps reading into wrapper postamble and reports a payload
@@ -278,6 +277,10 @@ ${code}
278
277
  end)()`;
279
278
  }
280
279
 
280
+ // roblox-ts emits function declarations as ordered local assignments, so this
281
+ // must run after every helper used by computeWrapperLineOffset is initialized.
282
+ const WRAPPER_LINE_OFFSET = computeWrapperLineOffset();
283
+
281
284
  function buildWrapper(code: string, payloadInstanceName = PAYLOAD_INSTANCE_NAME): string {
282
285
  // The line offset is DERIVED from the rendered template (see
283
286
  // computeWrapperLineOffset) instead of hand-maintained, so reordering the
@@ -46,6 +46,30 @@ function nowSec(): number {
46
46
  return DateTime.now().UnixTimestampMillis / 1000;
47
47
  }
48
48
 
49
+ // Studio can expose binary-bearing Output messages that JSONEncode rejects.
50
+ // Preserve valid UTF-8 and make only malformed bytes visible and JSON-safe.
51
+ function escapeInvalidUtf8(message: string): string {
52
+ const [valid] = utf8.len(message);
53
+ if (typeIs(valid, "number")) return message;
54
+
55
+ const parts: string[] = [];
56
+ let cursor = 1;
57
+ while (cursor <= message.size()) {
58
+ const [suffixValid, invalidPosition] = utf8.len(message, cursor);
59
+ if (typeIs(suffixValid, "number")) {
60
+ parts.push(string.sub(message, cursor));
61
+ break;
62
+ }
63
+ if (!typeIs(invalidPosition, "number")) break;
64
+ if (invalidPosition > cursor) parts.push(string.sub(message, cursor, invalidPosition - 1));
65
+
66
+ const [invalidByte] = string.byte(message, invalidPosition);
67
+ parts.push(string.format("\\x%02X", invalidByte));
68
+ cursor = invalidPosition + 1;
69
+ }
70
+ return parts.join("");
71
+ }
72
+
49
73
  function dropOldestUntilFits(incomingBytes: number): void {
50
74
  while (
51
75
  entries.size() > 0 &&
@@ -58,9 +82,14 @@ function dropOldestUntilFits(incomingBytes: number): void {
58
82
  }
59
83
 
60
84
  function pushEntry(message: string, level: LogLevel, ts: number): void {
61
- const bytes = message.size();
85
+ const safeMessage = escapeInvalidUtf8(message);
86
+ const bytes = safeMessage.size();
87
+ if (bytes > MAX_BYTES) {
88
+ totalDropped += 1;
89
+ return;
90
+ }
62
91
  dropOldestUntilFits(bytes);
63
- entries.push({ seq: nextSeq, ts, level, message });
92
+ entries.push({ seq: nextSeq, ts, level, message: safeMessage });
64
93
  nextSeq += 1;
65
94
  totalBytes += bytes;
66
95
  }
@@ -2,7 +2,7 @@ import { Connection } from "../types";
2
2
 
3
3
  const CURRENT_VERSION = "__VERSION__";
4
4
  const PLUGIN_VARIANT = "__PLUGIN_VARIANT__";
5
- const PROTOCOL_VERSION = 1;
5
+ const PROTOCOL_VERSION = 3;
6
6
  const MAX_CONNECTIONS = 5;
7
7
  const BASE_PORT = 58741;
8
8
  let activeTabIndex = 0;
@@ -7,6 +7,7 @@
7
7
 
8
8
  import LuauExec from "../LuauExec";
9
9
  import JobRegistry from "../JobRegistry";
10
+ import Communication from "../Communication";
10
11
 
11
12
  // Install a sanctioned progress/cancel API once. Server-generated long-running
12
13
  // Luau (terrain/template/batch builders) can call _G.__mcp.progress(done,total,msg)
@@ -19,7 +20,7 @@ function installMcpGlobal(): void {
19
20
  progress: (done: number, total?: number, message?: string, stage?: string) => {
20
21
  JobRegistry.reportProgress(coroutine.running(), done, total, message, stage);
21
22
  },
22
- checkCancelled: () => JobRegistry.isCancelledForThread(coroutine.running()),
23
+ checkCancelled: () => JobRegistry.isCancelledForThread(coroutine.running()) || Communication.isCancelledForThread(coroutine.running()),
23
24
  };
24
25
  }
25
26
  installMcpGlobal();
@@ -3,6 +3,7 @@
3
3
  export interface Connection {
4
4
  port: number;
5
5
  serverUrl: string;
6
+ sessionToken?: string;
6
7
  isActive: boolean;
7
8
  pollInterval: number;
8
9
  lastPoll: number;
@@ -43,10 +44,14 @@ export interface PollResponse {
43
44
  protocolMismatch?: boolean;
44
45
  request?: RequestPayload;
45
46
  requestId?: string;
47
+ serverEpoch?: string;
48
+ deliveryAttempt?: number;
49
+ leaseToken?: string;
46
50
  // Server signals knownInstance=false when its in-memory instances map
47
51
  // doesn't contain our pluginSessionId (typically after an MCP process
48
52
  // restart). The plugin re-issues /ready when it sees this.
49
53
  knownInstance?: boolean;
54
+ cancellations?: string[];
50
55
  }
51
56
 
52
57
  export interface Job {
@@ -74,6 +79,8 @@ export interface ReadyResponse {
74
79
  serverProtocolVersion?: number;
75
80
  versionMismatch?: boolean;
76
81
  protocolMismatch?: boolean;
82
+ sessionToken?: string;
83
+ serverEpoch?: string;
77
84
  error?: string;
78
85
  message?: string;
79
86
  }