@princeofscale/bloxforge-inspector 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.
- package/dist/index.js +1489 -375
- package/package.json +5 -2
- package/studio-plugin/MCPInspectorPlugin.rbxmx +460 -101
- package/studio-plugin/MCPPlugin.rbxmx +460 -101
- package/studio-plugin/package-lock.json +9 -9
- package/studio-plugin/src/modules/ClientBroker.ts +67 -14
- package/studio-plugin/src/modules/Communication.ts +177 -28
- package/studio-plugin/src/modules/LuauExec.ts +6 -3
- package/studio-plugin/src/modules/RuntimeLogBuffer.ts +31 -2
- package/studio-plugin/src/modules/State.ts +1 -1
- package/studio-plugin/src/modules/handlers/JobHandlers.ts +2 -1
- package/studio-plugin/src/types/index.d.ts +7 -0
|
@@ -67,9 +67,9 @@
|
|
|
67
67
|
}
|
|
68
68
|
},
|
|
69
69
|
"node_modules/ajv": {
|
|
70
|
-
"version": "8.
|
|
71
|
-
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.
|
|
72
|
-
"integrity": "sha512-
|
|
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.
|
|
238
|
-
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.
|
|
239
|
-
"integrity": "sha512-
|
|
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.
|
|
470
|
-
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.
|
|
471
|
-
"integrity": "sha512-
|
|
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(
|
|
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:
|
|
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(
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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: {
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
358
|
-
|
|
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: {
|
|
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
|
-
|
|
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
|
|
419
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
|
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 =
|
|
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
|
}
|