@princeofscale/bloxforge-inspector 3.0.0-rc.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1455 -378
- package/package.json +1 -1
- package/studio-plugin/MCPInspectorPlugin.rbxmx +380 -84
- package/studio-plugin/MCPPlugin.rbxmx +380 -84
- package/studio-plugin/src/modules/ClientBroker.ts +67 -14
- package/studio-plugin/src/modules/Communication.ts +151 -22
- 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
|
@@ -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
|
|
@@ -98,6 +98,20 @@ function detectRole(): string {
|
|
|
98
98
|
|
|
99
99
|
const initialRole = detectRole();
|
|
100
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
|
+
|
|
101
115
|
type Handler = (data: Record<string, unknown>) => unknown;
|
|
102
116
|
|
|
103
117
|
const routeMap: Record<string, Handler> = {
|
|
@@ -202,14 +216,92 @@ function processRequest(request: RequestPayload): unknown {
|
|
|
202
216
|
}
|
|
203
217
|
}
|
|
204
218
|
|
|
205
|
-
function sendResponse(conn: Connection, requestId: string, responseData: unknown) {
|
|
206
|
-
|
|
207
|
-
|
|
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({
|
|
208
227
|
Url: `${conn.serverUrl}/response`,
|
|
209
228
|
Method: "POST",
|
|
210
|
-
Headers: {
|
|
211
|
-
|
|
229
|
+
Headers: {
|
|
230
|
+
"Content-Type": "application/json",
|
|
231
|
+
...(conn.sessionToken ? { "Authorization": `Bearer ${conn.sessionToken}` } : {})
|
|
232
|
+
},
|
|
233
|
+
Body: HttpService.JSONEncode(body),
|
|
234
|
+
});
|
|
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
|
+
});
|
|
212
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);
|
|
213
305
|
});
|
|
214
306
|
}
|
|
215
307
|
|
|
@@ -312,12 +404,34 @@ function sendReady(conn: Connection): void {
|
|
|
312
404
|
const [parseOk, readyData] = pcall(
|
|
313
405
|
() => HttpService.JSONDecode(readyResult.Body) as ReadyResponse,
|
|
314
406
|
);
|
|
407
|
+
if (parseOk && readyData.sessionToken) {
|
|
408
|
+
conn.sessionToken = readyData.sessionToken;
|
|
409
|
+
}
|
|
315
410
|
if (parseOk && readyData.assignedRole) {
|
|
316
411
|
assignedRole = readyData.assignedRole;
|
|
317
412
|
}
|
|
318
413
|
lastReadyInstanceId = parseOk && typeIs(readyData.instanceId, "string") && readyData.instanceId !== ""
|
|
319
414
|
? readyData.instanceId
|
|
320
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
|
+
}
|
|
321
435
|
ServerUrlSettings.rememberServerUrl(conn.serverUrl);
|
|
322
436
|
const connectedRole = assignedRole ?? detectRole();
|
|
323
437
|
if (readyFailureLogKeys.has(readyLogKey)) {
|
|
@@ -332,16 +446,30 @@ function streamUrl(serverUrl: string): string {
|
|
|
332
446
|
return `${websocketUrl}/stream?pluginSessionId=${pluginSessionId}`;
|
|
333
447
|
}
|
|
334
448
|
|
|
335
|
-
function sendStreamResponse(conn: Connection, requestId: string, response: unknown) {
|
|
449
|
+
function sendStreamResponse(conn: Connection, requestId: string, response: unknown, serverEpoch?: string, deliveryAttempt?: number, leaseToken?: string) {
|
|
336
450
|
if (!conn.streamOpen || !conn.streamClient) return;
|
|
337
|
-
|
|
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)}`);
|
|
338
459
|
}
|
|
339
460
|
|
|
340
461
|
function startRequestStream(conn: Connection) {
|
|
341
462
|
if (!conn.isActive || conn.streamClient) return;
|
|
342
463
|
const [ok, stream] = pcall(() => HttpService.CreateWebStreamClient(
|
|
343
464
|
Enum.WebStreamClientType.WebSocket,
|
|
344
|
-
{
|
|
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
|
+
},
|
|
345
473
|
));
|
|
346
474
|
if (!ok || !stream) return;
|
|
347
475
|
|
|
@@ -354,12 +482,10 @@ function startRequestStream(conn: Connection) {
|
|
|
354
482
|
});
|
|
355
483
|
stream.MessageReceived.Connect((message) => {
|
|
356
484
|
if (conn.streamClient !== stream) return;
|
|
357
|
-
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 });
|
|
358
486
|
if (!parsed || frame.type !== "request" || !frame.requestId || !frame.request) return;
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
sendStreamResponse(conn, frame.requestId!, handled ? response : { error: tostring(response) });
|
|
362
|
-
});
|
|
487
|
+
|
|
488
|
+
handleRequestOnce(conn, frame.requestId!, frame.request!, frame.serverEpoch, frame.deliveryAttempt, frame.leaseToken);
|
|
363
489
|
});
|
|
364
490
|
const close = () => {
|
|
365
491
|
if (conn.streamClient !== stream) return;
|
|
@@ -382,7 +508,10 @@ function pollForRequests(connIndex: number) {
|
|
|
382
508
|
return HttpService.RequestAsync({
|
|
383
509
|
Url: `${conn.serverUrl}/poll?pluginSessionId=${pluginSessionId}`,
|
|
384
510
|
Method: "GET",
|
|
385
|
-
Headers: {
|
|
511
|
+
Headers: {
|
|
512
|
+
"Content-Type": "application/json",
|
|
513
|
+
...(conn.sessionToken ? { "Authorization": `Bearer ${conn.sessionToken}` } : {})
|
|
514
|
+
},
|
|
386
515
|
});
|
|
387
516
|
});
|
|
388
517
|
|
|
@@ -489,15 +618,14 @@ function pollForRequests(connIndex: number) {
|
|
|
489
618
|
}
|
|
490
619
|
}
|
|
491
620
|
|
|
621
|
+
if (data.cancellations) {
|
|
622
|
+
for (const c of data.cancellations) {
|
|
623
|
+
cancelledSyncRequests.add(c);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
492
627
|
if (data.request && mcpConnected) {
|
|
493
|
-
|
|
494
|
-
const [ok, response] = pcall(() => processRequest(data.request!));
|
|
495
|
-
if (ok) {
|
|
496
|
-
sendResponse(conn, data.requestId!, response);
|
|
497
|
-
} else {
|
|
498
|
-
sendResponse(conn, data.requestId!, { error: tostring(response) });
|
|
499
|
-
}
|
|
500
|
-
});
|
|
628
|
+
handleRequestOnce(conn, data.requestId!, data.request, data.serverEpoch, data.deliveryAttempt, data.leaseToken);
|
|
501
629
|
}
|
|
502
630
|
} else if (conn.isActive) {
|
|
503
631
|
conn.consecutiveFailures++;
|
|
@@ -710,6 +838,7 @@ function checkForUpdates() {
|
|
|
710
838
|
|
|
711
839
|
export = {
|
|
712
840
|
getConnectionStatus,
|
|
841
|
+
isCancelledForThread,
|
|
713
842
|
activatePlugin,
|
|
714
843
|
deactivatePlugin,
|
|
715
844
|
deactivateAll,
|
|
@@ -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
|
}
|