@aliceshimada/mica 1.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/CHANGELOG.md +14 -0
- package/CONTRIBUTING.md +22 -0
- package/LICENSE +21 -0
- package/README.md +308 -0
- package/README.zh-CN.md +308 -0
- package/SECURITY.md +22 -0
- package/dist/src/backend/agentRegistry.js +115 -0
- package/dist/src/backend/backendQueue.js +212 -0
- package/dist/src/backend/backendState.js +99 -0
- package/dist/src/backend/notebookRegistry.js +136 -0
- package/dist/src/backend/protocol.js +32 -0
- package/dist/src/bridge/httpBridge.js +366 -0
- package/dist/src/bridge/requestQueue.js +200 -0
- package/dist/src/bun/dashboard.js +387 -0
- package/dist/src/bun/httpServer.js +356 -0
- package/dist/src/bun/index.js +91 -0
- package/dist/src/cli/doctor.js +235 -0
- package/dist/src/cli/index.js +125 -0
- package/dist/src/index.js +54 -0
- package/dist/src/mcp/backendTools.js +216 -0
- package/dist/src/mcp/descriptions.js +6 -0
- package/dist/src/mcp/prompts.js +52 -0
- package/dist/src/mcp/toolResults.js +183 -0
- package/dist/src/mcp/toolSchemas.js +60 -0
- package/dist/src/mcp/tools.js +161 -0
- package/dist/src/runtime/config.js +76 -0
- package/dist/src/runtime/session.js +14 -0
- package/dist/src/runtimeOptions.js +3 -0
- package/dist/src/types.js +2 -0
- package/package.json +63 -0
- package/paclet/FrontEnd/Palettes/MMAAgentBridge.nb +22 -0
- package/paclet/Kernel/MMAAgentBridge.wl +1831 -0
- package/paclet/Kernel/init.wl +1 -0
- package/paclet/PacletInfo.wl +14 -0
- package/scripts/install.js +526 -0
- package/src/bun/index.ts +120 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import { DEFAULT_BRIDGE_HOST, DEFAULT_BRIDGE_PORT } from "../types.js";
|
|
3
|
+
const DEFAULT_PALETTE_STALE_TIMEOUT_MS = 30_000;
|
|
4
|
+
export class HttpBridge {
|
|
5
|
+
queue;
|
|
6
|
+
options;
|
|
7
|
+
server;
|
|
8
|
+
attachedNotebook;
|
|
9
|
+
permissions;
|
|
10
|
+
notebooks = new Map();
|
|
11
|
+
activeNotebookId;
|
|
12
|
+
/** Timestamp (ms) of the last Palette heartbeat. 0 = never connected. */
|
|
13
|
+
lastPaletteHeartbeat = 0;
|
|
14
|
+
paletteStaleTimeoutMs;
|
|
15
|
+
static MAX_BODY_BYTES = 1024 * 1024; // 1 MiB
|
|
16
|
+
constructor(queue, options = {}) {
|
|
17
|
+
this.queue = queue;
|
|
18
|
+
this.options = options;
|
|
19
|
+
this.paletteStaleTimeoutMs =
|
|
20
|
+
options.paletteStaleTimeoutMs ?? DEFAULT_PALETTE_STALE_TIMEOUT_MS;
|
|
21
|
+
this.server = http.createServer((request, response) => {
|
|
22
|
+
this.handle(request, response);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
get port() {
|
|
26
|
+
const address = this.server.address();
|
|
27
|
+
return address?.port ?? this.options.port ?? DEFAULT_BRIDGE_PORT;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Return a snapshot of the current bridge status.
|
|
31
|
+
*
|
|
32
|
+
* Exposed so the MCP `mma_status` tool can report real Palette connection
|
|
33
|
+
* and notebook attachment state instead of hardcoded false values.
|
|
34
|
+
*/
|
|
35
|
+
statusSnapshot() {
|
|
36
|
+
return this.status();
|
|
37
|
+
}
|
|
38
|
+
async start() {
|
|
39
|
+
const host = this.options.host ?? DEFAULT_BRIDGE_HOST;
|
|
40
|
+
const port = this.options.port ?? DEFAULT_BRIDGE_PORT;
|
|
41
|
+
await new Promise((resolve) => this.server.listen(port, host, resolve));
|
|
42
|
+
}
|
|
43
|
+
async stop() {
|
|
44
|
+
// Close the HTTP server first to prevent new interactions, then drain
|
|
45
|
+
// the queue so pending MCP promises don't hang.
|
|
46
|
+
if (this.server.listening) {
|
|
47
|
+
await new Promise((resolve, reject) => {
|
|
48
|
+
this.server.close((error) => (error ? reject(error) : resolve()));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
this.queue.drain("HTTP bridge stopped");
|
|
52
|
+
}
|
|
53
|
+
async handle(request, response) {
|
|
54
|
+
try {
|
|
55
|
+
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
56
|
+
if (request.method === "GET" && url.pathname === "/status") {
|
|
57
|
+
this.sendJson(response, 200, this.status());
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (request.method === "POST" && url.pathname === "/attach") {
|
|
61
|
+
const body = await this.readJson(request);
|
|
62
|
+
if (typeof body !== "object" || body === null || Object.keys(body).length === 0) {
|
|
63
|
+
this.sendJson(response, 400, {
|
|
64
|
+
error: { code: "BAD_REQUEST", message: "attach body must be a non-empty JSON object" }
|
|
65
|
+
});
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const attach = body;
|
|
69
|
+
const permissions = Object.prototype.hasOwnProperty.call(attach, "permissions")
|
|
70
|
+
? this.parsePermissions(attach.permissions)
|
|
71
|
+
: undefined;
|
|
72
|
+
if (Object.prototype.hasOwnProperty.call(attach, "permissions") && !permissions) {
|
|
73
|
+
this.sendJson(response, 400, {
|
|
74
|
+
error: { code: "BAD_REQUEST", message: "permissions must include boolean values" }
|
|
75
|
+
});
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
this.attachedNotebook = permissions ? { ...attach, permissions } : attach;
|
|
79
|
+
this.permissions = permissions;
|
|
80
|
+
if (typeof attach.notebookId === "string" && attach.notebookId.length > 0) {
|
|
81
|
+
this.upsertNotebook({
|
|
82
|
+
notebookId: attach.notebookId,
|
|
83
|
+
notebookTitle: attach.notebookTitle,
|
|
84
|
+
notebookPath: attach.notebookPath,
|
|
85
|
+
wolframVersion: attach.wolframVersion,
|
|
86
|
+
platform: attach.platform,
|
|
87
|
+
permissions,
|
|
88
|
+
lastSeenAt: Date.now()
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
this.heartbeat();
|
|
92
|
+
this.sendJson(response, 200, { ok: true });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (request.method === "POST" && url.pathname === "/notebooks/upsert") {
|
|
96
|
+
const body = await this.readJson(request);
|
|
97
|
+
if (!this.isNotebookPayload(body)) {
|
|
98
|
+
this.sendJson(response, 400, {
|
|
99
|
+
error: { code: "BAD_REQUEST", message: "notebookId is required" }
|
|
100
|
+
});
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
this.upsertNotebook({
|
|
104
|
+
notebookId: body.notebookId,
|
|
105
|
+
notebookTitle: body.notebookTitle,
|
|
106
|
+
notebookPath: body.notebookPath,
|
|
107
|
+
wolframVersion: body.wolframVersion,
|
|
108
|
+
platform: body.platform,
|
|
109
|
+
permissions: this.parsePermissions(body),
|
|
110
|
+
lastSeenAt: Date.now()
|
|
111
|
+
});
|
|
112
|
+
if (!this.activeNotebookId) {
|
|
113
|
+
this.activeNotebookId = body.notebookId;
|
|
114
|
+
}
|
|
115
|
+
this.heartbeat();
|
|
116
|
+
this.sendJson(response, 200, { ok: true, activeNotebookId: this.activeNotebookId });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (request.method === "GET" && url.pathname === "/notebooks") {
|
|
120
|
+
this.sendJson(response, 200, {
|
|
121
|
+
notebooks: this.listNotebooks(),
|
|
122
|
+
activeNotebookId: this.activeNotebookId
|
|
123
|
+
});
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (request.method === "POST" && url.pathname === "/notebooks/select") {
|
|
127
|
+
const body = await this.readJson(request);
|
|
128
|
+
const notebookId = this.getNotebookId(body);
|
|
129
|
+
if (!notebookId) {
|
|
130
|
+
this.sendJson(response, 400, {
|
|
131
|
+
error: { code: "BAD_REQUEST", message: "notebookId is required" }
|
|
132
|
+
});
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (!this.notebooks.has(notebookId)) {
|
|
136
|
+
this.sendJson(response, 404, {
|
|
137
|
+
error: { code: "NOTEBOOK_NOT_FOUND", message: `Unknown notebookId: ${notebookId}` }
|
|
138
|
+
});
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
this.activeNotebookId = notebookId;
|
|
142
|
+
this.heartbeat();
|
|
143
|
+
this.sendJson(response, 200, { ok: true, activeNotebookId: this.activeNotebookId });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (request.method === "GET" && url.pathname === "/poll") {
|
|
147
|
+
const activeNotebookId = url.searchParams.get("activeNotebookId");
|
|
148
|
+
if (activeNotebookId && this.notebooks.has(activeNotebookId)) {
|
|
149
|
+
this.activeNotebookId = activeNotebookId;
|
|
150
|
+
}
|
|
151
|
+
this.heartbeat();
|
|
152
|
+
const requestInfo = this.queue.claimNext();
|
|
153
|
+
const body = {
|
|
154
|
+
status: this.status(),
|
|
155
|
+
cancelRequests: this.queue.listCancellations(),
|
|
156
|
+
request: requestInfo
|
|
157
|
+
};
|
|
158
|
+
this.sendJson(response, 200, body);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (request.method === "POST" && url.pathname === "/permissions") {
|
|
162
|
+
const body = await this.readJson(request);
|
|
163
|
+
const permissions = this.parsePermissions(body);
|
|
164
|
+
if (!permissions) {
|
|
165
|
+
this.sendJson(response, 400, {
|
|
166
|
+
error: { code: "BAD_REQUEST", message: "permissions must include boolean values" }
|
|
167
|
+
});
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
this.permissions = permissions;
|
|
171
|
+
if (this.attachedNotebook) {
|
|
172
|
+
this.attachedNotebook = { ...this.attachedNotebook, permissions };
|
|
173
|
+
}
|
|
174
|
+
const attachedNotebookId = this.getAttachedNotebookId();
|
|
175
|
+
if (attachedNotebookId) {
|
|
176
|
+
this.upsertNotebook({
|
|
177
|
+
notebookId: attachedNotebookId,
|
|
178
|
+
notebookTitle: this.attachedNotebook?.notebookTitle,
|
|
179
|
+
notebookPath: this.attachedNotebook?.notebookPath,
|
|
180
|
+
wolframVersion: this.attachedNotebook?.wolframVersion,
|
|
181
|
+
platform: this.attachedNotebook?.platform,
|
|
182
|
+
permissions,
|
|
183
|
+
lastSeenAt: Date.now()
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
this.sendJson(response, 200, { ok: true });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (request.method === "GET" && url.pathname === "/requests") {
|
|
190
|
+
this.heartbeat();
|
|
191
|
+
this.sendJson(response, 200, { request: this.queue.claimNext() });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (request.method === "POST" && url.pathname === "/result") {
|
|
195
|
+
const body = (await this.readJson(request));
|
|
196
|
+
const accepted = body.ok
|
|
197
|
+
? this.queue.resolveSuccess(body.requestId, body.result)
|
|
198
|
+
: this.queue.resolveFailure(body.requestId, body.error.code, body.error.message);
|
|
199
|
+
this.sendJson(response, accepted ? 200 : 404, { ok: accepted });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (request.method === "GET" && url.pathname === "/cancellations") {
|
|
203
|
+
this.sendJson(response, 200, { cancelRequests: this.queue.listCancellations() });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (request.method === "POST" && url.pathname === "/cancel") {
|
|
207
|
+
const body = (await this.readJson(request));
|
|
208
|
+
if (!body.requestId) {
|
|
209
|
+
this.sendJson(response, 400, { error: { code: "BAD_REQUEST", message: "requestId is required" } });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const accepted = this.queue.cancelFromPalette(body.requestId, body.reason ?? "USER_CANCELLED_IN_PALETTE");
|
|
213
|
+
this.sendJson(response, accepted ? 200 : 404, { ok: accepted });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
this.sendJson(response, 404, { error: { code: "NOT_FOUND", message: `${request.method} ${url.pathname}` } });
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
220
|
+
if (msg === "PAYLOAD_TOO_LARGE") {
|
|
221
|
+
this.sendJson(response, 413, { error: { code: "PAYLOAD_TOO_LARGE", message: "Request body exceeds 1 MiB limit" } });
|
|
222
|
+
}
|
|
223
|
+
else if (msg === "MALFORMED_JSON") {
|
|
224
|
+
this.sendJson(response, 400, { error: { code: "BAD_REQUEST", message: "Malformed JSON body" } });
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
this.sendJson(response, 500, { error: { code: "INTERNAL_ERROR", message: msg } });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
status() {
|
|
232
|
+
const permissions = this.permissions ?? this.attachedNotebook?.permissions;
|
|
233
|
+
const runningRequest = this.queue.runningRequestSnapshot();
|
|
234
|
+
const notebookAttached = Boolean(this.attachedNotebook) || Boolean(this.activeNotebookId && this.notebooks.has(this.activeNotebookId));
|
|
235
|
+
return {
|
|
236
|
+
server: "running",
|
|
237
|
+
paletteConnected: this.isPaletteConnected(),
|
|
238
|
+
notebookAttached,
|
|
239
|
+
attachedNotebook: this.attachedNotebook,
|
|
240
|
+
permissions,
|
|
241
|
+
activeNotebookId: this.activeNotebookId,
|
|
242
|
+
notebooks: this.listNotebooks(),
|
|
243
|
+
transportMode: "main-kernel",
|
|
244
|
+
executorState: runningRequest ? "running" : "idle",
|
|
245
|
+
runningRequest,
|
|
246
|
+
pendingRequests: this.queue.pendingCount()
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
upsertNotebook(notebook) {
|
|
250
|
+
const existing = this.notebooks.get(notebook.notebookId);
|
|
251
|
+
this.notebooks.set(notebook.notebookId, {
|
|
252
|
+
...(existing ?? { notebookId: notebook.notebookId, lastSeenAt: notebook.lastSeenAt }),
|
|
253
|
+
...notebook
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
listNotebooks() {
|
|
257
|
+
return [...this.notebooks.values()];
|
|
258
|
+
}
|
|
259
|
+
getNotebookId(payload) {
|
|
260
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
const record = payload;
|
|
264
|
+
return typeof record.notebookId === "string" && record.notebookId.length > 0 ? record.notebookId : undefined;
|
|
265
|
+
}
|
|
266
|
+
isNotebookPayload(payload) {
|
|
267
|
+
return this.getNotebookId(payload) !== undefined;
|
|
268
|
+
}
|
|
269
|
+
getAttachedNotebookId() {
|
|
270
|
+
if (!this.attachedNotebook)
|
|
271
|
+
return undefined;
|
|
272
|
+
return typeof this.attachedNotebook.notebookId === "string"
|
|
273
|
+
? this.attachedNotebook.notebookId
|
|
274
|
+
: undefined;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Record a Palette heartbeat, refreshing the connection timestamp.
|
|
278
|
+
*
|
|
279
|
+
* Called on POST /attach and GET /requests — the two endpoints the Palette
|
|
280
|
+
* uses to interact with the bridge. If the Palette stops polling, the
|
|
281
|
+
* heartbeat expires and {@link isPaletteConnected} returns false.
|
|
282
|
+
*/
|
|
283
|
+
heartbeat() {
|
|
284
|
+
this.lastPaletteHeartbeat = Date.now();
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Whether the Palette is considered connected based on heartbeat freshness.
|
|
288
|
+
*
|
|
289
|
+
* Returns true only if the Palette has sent a heartbeat within
|
|
290
|
+
* `paletteStaleTimeoutMs`. This prevents agents from enqueuing requests
|
|
291
|
+
* that will never be claimed after the Palette dies or disconnects.
|
|
292
|
+
*/
|
|
293
|
+
isPaletteConnected() {
|
|
294
|
+
if (this.lastPaletteHeartbeat === 0)
|
|
295
|
+
return false;
|
|
296
|
+
return Date.now() - this.lastPaletteHeartbeat < this.paletteStaleTimeoutMs;
|
|
297
|
+
}
|
|
298
|
+
parsePermissions(payload) {
|
|
299
|
+
const source = this.getPermissionsSource(payload);
|
|
300
|
+
if (!source)
|
|
301
|
+
return undefined;
|
|
302
|
+
const keys = [
|
|
303
|
+
"ReadNotebook",
|
|
304
|
+
"InsertCell",
|
|
305
|
+
"ModifyCell",
|
|
306
|
+
"DeleteCell",
|
|
307
|
+
"RunCell",
|
|
308
|
+
"SaveNotebook"
|
|
309
|
+
];
|
|
310
|
+
const permissions = {};
|
|
311
|
+
for (const key of keys) {
|
|
312
|
+
if (typeof source[key] !== "boolean") {
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
permissions[key] = source[key];
|
|
316
|
+
}
|
|
317
|
+
return permissions;
|
|
318
|
+
}
|
|
319
|
+
getPermissionsSource(payload) {
|
|
320
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
const record = payload;
|
|
324
|
+
if (Object.prototype.hasOwnProperty.call(record, "permissions")) {
|
|
325
|
+
const nested = record.permissions;
|
|
326
|
+
if (typeof nested !== "object" || nested === null || Array.isArray(nested)) {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
return nested;
|
|
330
|
+
}
|
|
331
|
+
return record;
|
|
332
|
+
}
|
|
333
|
+
async readJson(request) {
|
|
334
|
+
const chunks = [];
|
|
335
|
+
let totalBytes = 0;
|
|
336
|
+
let exceeded = false;
|
|
337
|
+
for await (const chunk of request) {
|
|
338
|
+
if (exceeded)
|
|
339
|
+
continue; // drain remaining chunks
|
|
340
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
341
|
+
totalBytes += buf.length;
|
|
342
|
+
if (totalBytes > HttpBridge.MAX_BODY_BYTES) {
|
|
343
|
+
exceeded = true;
|
|
344
|
+
chunks.length = 0; // discard accumulated data
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
chunks.push(buf);
|
|
348
|
+
}
|
|
349
|
+
if (exceeded) {
|
|
350
|
+
throw new Error("PAYLOAD_TOO_LARGE");
|
|
351
|
+
}
|
|
352
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
353
|
+
if (text.length === 0)
|
|
354
|
+
return {};
|
|
355
|
+
try {
|
|
356
|
+
return JSON.parse(text);
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
throw new Error("MALFORMED_JSON");
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
sendJson(response, statusCode, body) {
|
|
363
|
+
response.writeHead(statusCode, { "content-type": "application/json; charset=utf-8" });
|
|
364
|
+
response.end(JSON.stringify(body));
|
|
365
|
+
}
|
|
366
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serial request queue for the MMA MCP bridge.
|
|
3
|
+
*
|
|
4
|
+
* Only one notebook operation is processed at a time. The MCP client enqueues
|
|
5
|
+
* tool calls, the Mathematica Palette polls and claims them, then posts results
|
|
6
|
+
* back. Cancellation is supported from both sides.
|
|
7
|
+
*
|
|
8
|
+
* ## Cancellation semantics
|
|
9
|
+
*
|
|
10
|
+
* **MCP-client cancellation** (`cancelFromMcp`):
|
|
11
|
+
* - Queued requests are rejected immediately and removed.
|
|
12
|
+
* - Claimed requests are rejected immediately and removed, and a one-shot
|
|
13
|
+
* cancellation notification is stored for the Palette to pick up via
|
|
14
|
+
* `listCancellations`. This ensures the MCP call terminates instead of
|
|
15
|
+
* hanging indefinitely.
|
|
16
|
+
*
|
|
17
|
+
* **Palette-originated cancellation** (`cancelFromPalette`):
|
|
18
|
+
* - The Palette already knows it is cancelling, so no notification is stored.
|
|
19
|
+
* The active call is rejected and removed immediately.
|
|
20
|
+
*/
|
|
21
|
+
export class RequestQueue {
|
|
22
|
+
counter = 0;
|
|
23
|
+
calls = new Map();
|
|
24
|
+
order = [];
|
|
25
|
+
/**
|
|
26
|
+
* One-shot cancellation notifications for the Palette.
|
|
27
|
+
* Populated by cancelFromMcp on claimed requests, drained by listCancellations.
|
|
28
|
+
*/
|
|
29
|
+
cancellations = [];
|
|
30
|
+
/**
|
|
31
|
+
* Enqueue a tool call and return only the promise.
|
|
32
|
+
*
|
|
33
|
+
* Convenience wrapper around {@link enqueueWithId} for callers that don't
|
|
34
|
+
* need the requestId (e.g. tests, simple fire-and-forget usage).
|
|
35
|
+
*/
|
|
36
|
+
enqueue(tool, args) {
|
|
37
|
+
return this.enqueueWithId(tool, args).promise;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Enqueue a tool call and return both the requestId and the promise.
|
|
41
|
+
*
|
|
42
|
+
* The requestId is needed by MCP tool handlers so they can wire an
|
|
43
|
+
* AbortSignal listener to {@link cancelFromMcp}.
|
|
44
|
+
*/
|
|
45
|
+
enqueueWithId(tool, args) {
|
|
46
|
+
const notebookId = typeof args.notebookId === "string" && args.notebookId.length > 0 ? args.notebookId : undefined;
|
|
47
|
+
const requestId = `req_${++this.counter}`;
|
|
48
|
+
const request = {
|
|
49
|
+
requestId,
|
|
50
|
+
tool,
|
|
51
|
+
arguments: args,
|
|
52
|
+
notebookId,
|
|
53
|
+
state: "queued",
|
|
54
|
+
createdAt: Date.now()
|
|
55
|
+
};
|
|
56
|
+
const promise = new Promise((resolve, reject) => {
|
|
57
|
+
this.calls.set(requestId, { request, resolve, reject });
|
|
58
|
+
this.order.push(requestId);
|
|
59
|
+
});
|
|
60
|
+
return { requestId, promise };
|
|
61
|
+
}
|
|
62
|
+
claimNext() {
|
|
63
|
+
if ([...this.calls.values()].some((call) => call.request.state === "claimed")) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const requestId = this.order.find((id) => this.calls.get(id)?.request.state === "queued");
|
|
67
|
+
if (!requestId)
|
|
68
|
+
return null;
|
|
69
|
+
const call = this.calls.get(requestId);
|
|
70
|
+
if (!call)
|
|
71
|
+
return null;
|
|
72
|
+
call.request.state = "claimed";
|
|
73
|
+
call.request.claimedAt = Date.now();
|
|
74
|
+
return { ...call.request, arguments: { ...call.request.arguments } };
|
|
75
|
+
}
|
|
76
|
+
runningRequestSnapshot() {
|
|
77
|
+
const running = this.order
|
|
78
|
+
.map((id) => this.calls.get(id)?.request)
|
|
79
|
+
.find((request) => request !== undefined && request.state === "claimed");
|
|
80
|
+
if (!running || running.claimedAt === undefined)
|
|
81
|
+
return null;
|
|
82
|
+
return {
|
|
83
|
+
requestId: running.requestId,
|
|
84
|
+
tool: running.tool,
|
|
85
|
+
arguments: { ...running.arguments },
|
|
86
|
+
notebookId: running.notebookId,
|
|
87
|
+
state: "claimed",
|
|
88
|
+
createdAt: running.createdAt,
|
|
89
|
+
claimedAt: running.claimedAt
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
peekQueued() {
|
|
93
|
+
return this.order
|
|
94
|
+
.map((id) => this.calls.get(id)?.request)
|
|
95
|
+
.filter((request) => request !== undefined && request.state === "queued")
|
|
96
|
+
.map((request) => ({ ...request, arguments: { ...request.arguments } }));
|
|
97
|
+
}
|
|
98
|
+
pendingCount() {
|
|
99
|
+
return [...this.calls.values()].filter((call) => call.request.state === "queued" || call.request.state === "claimed").length;
|
|
100
|
+
}
|
|
101
|
+
resolveSuccess(requestId, result) {
|
|
102
|
+
const call = this.calls.get(requestId);
|
|
103
|
+
if (!call)
|
|
104
|
+
return false;
|
|
105
|
+
if (call.request.state !== "claimed")
|
|
106
|
+
return false;
|
|
107
|
+
call.request.state = "completed";
|
|
108
|
+
call.resolve(result);
|
|
109
|
+
this.remove(requestId);
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
resolveFailure(requestId, code, message) {
|
|
113
|
+
const call = this.calls.get(requestId);
|
|
114
|
+
if (!call)
|
|
115
|
+
return false;
|
|
116
|
+
if (call.request.state !== "claimed")
|
|
117
|
+
return false;
|
|
118
|
+
call.request.state = "failed";
|
|
119
|
+
call.reject(new Error(`${code}: ${message}`));
|
|
120
|
+
this.remove(requestId);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Cancel a request from the MCP client side.
|
|
125
|
+
*
|
|
126
|
+
* - Queued: rejects the promise and removes the call immediately.
|
|
127
|
+
* - Claimed: rejects the promise, removes the call, and stores a one-shot
|
|
128
|
+
* cancellation notification for the Palette to discover via listCancellations.
|
|
129
|
+
* - Unknown/removed: returns false.
|
|
130
|
+
*/
|
|
131
|
+
cancelFromMcp(requestId, reason) {
|
|
132
|
+
const call = this.calls.get(requestId);
|
|
133
|
+
if (!call)
|
|
134
|
+
return false;
|
|
135
|
+
if (call.request.state === "queued") {
|
|
136
|
+
call.request.state = "cancelled";
|
|
137
|
+
call.reject(new Error(reason));
|
|
138
|
+
this.remove(requestId);
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
// Claimed: reject immediately so the MCP call terminates, and store a
|
|
142
|
+
// one-shot notification for the Palette to pick up.
|
|
143
|
+
call.request.state = "cancelled";
|
|
144
|
+
call.reject(new Error(reason));
|
|
145
|
+
this.cancellations.push({ requestId, reason });
|
|
146
|
+
this.remove(requestId);
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Cancel a request originating from the Palette (user action in Mathematica).
|
|
151
|
+
*
|
|
152
|
+
* Rejects the promise and removes the call immediately. No cancellation
|
|
153
|
+
* notification is stored because the Palette already knows it cancelled.
|
|
154
|
+
* Returns false if the requestId is unknown or already removed.
|
|
155
|
+
*/
|
|
156
|
+
cancelFromPalette(requestId, reason) {
|
|
157
|
+
const call = this.calls.get(requestId);
|
|
158
|
+
if (!call)
|
|
159
|
+
return false;
|
|
160
|
+
call.request.state = "cancelled";
|
|
161
|
+
call.reject(new Error(reason));
|
|
162
|
+
this.remove(requestId);
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Return pending one-shot MCP cancellation notifications and clear them.
|
|
167
|
+
*
|
|
168
|
+
* The Palette polls this endpoint to discover claimed requests that were
|
|
169
|
+
* cancelled by the MCP client. Each cancellation is reported exactly once.
|
|
170
|
+
*/
|
|
171
|
+
listCancellations() {
|
|
172
|
+
if (this.cancellations.length === 0)
|
|
173
|
+
return [];
|
|
174
|
+
const result = [...this.cancellations];
|
|
175
|
+
this.cancellations.length = 0;
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Reject all outstanding promises and clear all internal state.
|
|
180
|
+
*
|
|
181
|
+
* Used when the HTTP bridge shuts down so pending MCP tool-call promises
|
|
182
|
+
* don't hang indefinitely. Clears active calls, FIFO order, and one-shot
|
|
183
|
+
* cancellation notifications.
|
|
184
|
+
*/
|
|
185
|
+
drain(reason) {
|
|
186
|
+
for (const call of this.calls.values()) {
|
|
187
|
+
call.request.state = "cancelled";
|
|
188
|
+
call.reject(new Error(reason));
|
|
189
|
+
}
|
|
190
|
+
this.calls.clear();
|
|
191
|
+
this.order.length = 0;
|
|
192
|
+
this.cancellations.length = 0;
|
|
193
|
+
}
|
|
194
|
+
remove(requestId) {
|
|
195
|
+
this.calls.delete(requestId);
|
|
196
|
+
const index = this.order.indexOf(requestId);
|
|
197
|
+
if (index >= 0)
|
|
198
|
+
this.order.splice(index, 1);
|
|
199
|
+
}
|
|
200
|
+
}
|