@expanse-ade/mcp 0.9.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 ADDED
@@ -0,0 +1,1028 @@
1
+ // src/server/mcpHttp.ts
2
+ import { createServer } from "http";
3
+ import express from "express";
4
+ import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
5
+
6
+ // src/constants.ts
7
+ var MCP_PATH = "/mcp";
8
+ var HEADER_SESSION_ID = "mcp-session-id";
9
+ var TOOL_PING = "ping";
10
+ var TOOL_ORCHESTRATOR_PING = "orchestrator_ping";
11
+ var TOOL_SPAWN_BOARD = "spawn_board";
12
+ var TOOL_CLOSE_BOARD = "close_board";
13
+ var TOOL_CONFIGURE_BOARD = "configure_board";
14
+ var TOOL_HANDOFF_PROMPT = "handoff_prompt";
15
+ var TOOL_ASSIGN_PROMPT = "assign_prompt";
16
+ var TOOL_INTERRUPT = "interrupt";
17
+ var TOOL_RELAY_PROMPT = "relay_prompt";
18
+ var TOOL_WRITE_RESULT = "write_result";
19
+ var SPAWNABLE_BOARD_TYPES = ["terminal", "browser", "planning"];
20
+ var MAX_OUTPUT_PAGE = 25e3;
21
+ var TOOL_WAIT_FOR_IDLE = "wait_for_idle";
22
+ var TOOL_WAIT_FOR_ALL = "wait_for_all";
23
+ var DEFAULT_BARRIER_TIMEOUT_MS = 30 * 6e4;
24
+
25
+ // src/auth/verifier.ts
26
+ import { InvalidTokenError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
27
+ function createVerifier(store) {
28
+ return {
29
+ async verifyAccessToken(token) {
30
+ const row = store.get(token);
31
+ if (!row) throw new InvalidTokenError("Unknown or revoked token");
32
+ return {
33
+ token,
34
+ clientId: row.boardId,
35
+ scopes: row.scopes,
36
+ expiresAt: row.expiresAt,
37
+ extra: { tier: row.tier, boardId: row.boardId }
38
+ };
39
+ }
40
+ };
41
+ }
42
+
43
+ // src/security/origin.ts
44
+ function originGuard(getAllowed) {
45
+ return (req, res, next) => {
46
+ const origin = req.headers.origin;
47
+ if (origin === void 0) {
48
+ next();
49
+ return;
50
+ }
51
+ if (getAllowed().includes(origin)) {
52
+ next();
53
+ return;
54
+ }
55
+ res.status(403).json({ error: "forbidden_origin" });
56
+ };
57
+ }
58
+
59
+ // src/security/host.ts
60
+ import { isIP } from "net";
61
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1", "0:0:0:0:0:0:0:1"]);
62
+ function parseHostname(host) {
63
+ const h = host.trim();
64
+ if (h.startsWith("[")) {
65
+ const end = h.indexOf("]");
66
+ return end === -1 ? h.slice(1) : h.slice(1, end);
67
+ }
68
+ if ((h.match(/:/g)?.length ?? 0) > 1) return h;
69
+ const idx = h.indexOf(":");
70
+ return idx === -1 ? h : h.slice(0, idx);
71
+ }
72
+ function isLoopbackHost(host) {
73
+ if (host === "") return false;
74
+ const h = parseHostname(host).toLowerCase();
75
+ if (LOOPBACK_HOSTS.has(h)) return true;
76
+ if (isIP(h) === 4 && h.startsWith("127.")) return true;
77
+ return false;
78
+ }
79
+ function hostGuard() {
80
+ return (req, res, next) => {
81
+ const host = req.headers.host;
82
+ if (host !== void 0 && isLoopbackHost(host)) {
83
+ next();
84
+ return;
85
+ }
86
+ res.status(403).json({ error: "forbidden_host" });
87
+ };
88
+ }
89
+
90
+ // src/server/factory.ts
91
+ import { McpServer as McpServer5 } from "@modelcontextprotocol/sdk/server/mcp.js";
92
+
93
+ // package.json
94
+ var package_default = {
95
+ name: "@expanse-ade/mcp",
96
+ version: "0.9.0",
97
+ packageManager: "pnpm@9.15.9",
98
+ description: "MCP server layer for Canvas ADE \xE2\u20AC\u201D lets AI agents in Terminal boards orchestrate the canvas (command board / swarm).",
99
+ type: "module",
100
+ repository: {
101
+ type: "git",
102
+ url: "git+https://github.com/ch923dev/canvas-ade-mcp.git"
103
+ },
104
+ publishConfig: {
105
+ access: "public"
106
+ },
107
+ main: "./dist/index.js",
108
+ types: "./dist/index.d.ts",
109
+ exports: {
110
+ ".": {
111
+ types: "./dist/index.d.ts",
112
+ import: "./dist/index.js"
113
+ }
114
+ },
115
+ files: [
116
+ "dist"
117
+ ],
118
+ engines: {
119
+ node: ">=20"
120
+ },
121
+ scripts: {
122
+ build: "tsup",
123
+ typecheck: "tsc --noEmit",
124
+ test: "vitest run --project contract",
125
+ "test:live": "vitest run --project live",
126
+ lint: "eslint .",
127
+ format: "prettier --write .",
128
+ "format:check": "prettier --check ."
129
+ },
130
+ license: "MIT",
131
+ dependencies: {
132
+ "@modelcontextprotocol/sdk": "^1.29.0",
133
+ express: "^5.2.1",
134
+ zod: "^4.4.3"
135
+ },
136
+ devDependencies: {
137
+ "@eslint/js": "^10.0.1",
138
+ "@modelcontextprotocol/inspector": "^0.21.2",
139
+ "@types/express": "^5.0.6",
140
+ "@types/node": "^25.9.1",
141
+ eslint: "^10.4.1",
142
+ "eslint-config-prettier": "^10.1.8",
143
+ prettier: "^3.8.3",
144
+ tsup: "^8.5.1",
145
+ typescript: "^6.0.3",
146
+ "typescript-eslint": "^8.60.0",
147
+ vitest: "^4.1.7"
148
+ }
149
+ };
150
+
151
+ // src/resources/boards.ts
152
+ import { ResourceTemplate as ResourceTemplate4 } from "@modelcontextprotocol/sdk/server/mcp.js";
153
+
154
+ // src/resources/boardStates.ts
155
+ function groupBoardsByStatus(boards) {
156
+ const grouped = {};
157
+ for (const b of boards) {
158
+ ;
159
+ (grouped[b.status] ??= []).push(b.id);
160
+ }
161
+ return grouped;
162
+ }
163
+ function registerBoardStatesResource(server, orchestrator) {
164
+ server.registerResource(
165
+ "board-states",
166
+ "canvas://board-states",
167
+ {
168
+ description: "Boards on the canvas grouped by status bucket ({ bucket: boardId[] }).",
169
+ mimeType: "application/json"
170
+ },
171
+ async (uri) => ({
172
+ contents: [
173
+ {
174
+ uri: uri.href,
175
+ text: JSON.stringify(groupBoardsByStatus(await orchestrator.listBoards()))
176
+ }
177
+ ]
178
+ })
179
+ );
180
+ }
181
+
182
+ // src/resources/attention.ts
183
+ var ATTENTION_URI = "canvas://attention";
184
+ var ATTENTION_BUCKETS = /* @__PURE__ */ new Set([
185
+ "blocked",
186
+ "awaiting-review",
187
+ "failed"
188
+ ]);
189
+ function selectAttention(boards) {
190
+ return boards.filter((b) => ATTENTION_BUCKETS.has(b.status));
191
+ }
192
+ function registerAttentionResource(server, orchestrator) {
193
+ server.registerResource(
194
+ "attention",
195
+ ATTENTION_URI,
196
+ {
197
+ description: "Boards needing a human (blocked / awaiting-review / failed).",
198
+ mimeType: "application/json"
199
+ },
200
+ async (uri) => ({
201
+ contents: [
202
+ { uri: uri.href, text: JSON.stringify(selectAttention(await orchestrator.listBoards())) }
203
+ ]
204
+ })
205
+ );
206
+ }
207
+
208
+ // src/resources/output.ts
209
+ import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
210
+ function first(v) {
211
+ return Array.isArray(v) ? v[0] : v;
212
+ }
213
+ async function readPage(orchestrator, uriHref, variables) {
214
+ const id = first(variables.id);
215
+ if (!id) throw new Error("canvas://board/{id}/output: missing board id");
216
+ const rawCursor = first(variables.cursor);
217
+ let opts;
218
+ if (rawCursor !== void 0) {
219
+ const cursor = Number(rawCursor);
220
+ if (!Number.isInteger(cursor) || cursor < 0) {
221
+ throw new Error(`canvas://board/${id}/output: invalid cursor "${rawCursor}"`);
222
+ }
223
+ opts = { cursor };
224
+ }
225
+ const out = await orchestrator.boardOutput(id, opts);
226
+ let page = out;
227
+ if (out.text.length > MAX_OUTPUT_PAGE) {
228
+ const text = out.text.slice(-MAX_OUTPUT_PAGE);
229
+ page = {
230
+ ...out,
231
+ text,
232
+ returned: text.length,
233
+ nextCursor: (opts?.cursor ?? 0) + text.length
234
+ };
235
+ }
236
+ return { contents: [{ uri: uriHref, text: JSON.stringify(page) }] };
237
+ }
238
+ function registerBoardOutputResource(server, orchestrator) {
239
+ const meta = {
240
+ description: "A capped, paginated, ANSI-stripped page of a board's recent output (tail-anchored; pass nextCursor as ?cursor for older).",
241
+ mimeType: "application/json"
242
+ };
243
+ server.registerResource(
244
+ "board-output",
245
+ new ResourceTemplate("canvas://board/{id}/output", { list: void 0 }),
246
+ meta,
247
+ (uri, variables) => readPage(orchestrator, uri.href, variables)
248
+ );
249
+ server.registerResource(
250
+ "board-output-paged",
251
+ new ResourceTemplate("canvas://board/{id}/output{?cursor}", { list: void 0 }),
252
+ meta,
253
+ (uri, variables) => readPage(orchestrator, uri.href, variables)
254
+ );
255
+ }
256
+
257
+ // src/resources/result.ts
258
+ import { ResourceTemplate as ResourceTemplate2 } from "@modelcontextprotocol/sdk/server/mcp.js";
259
+ function registerBoardResultResource(server, orchestrator) {
260
+ server.registerResource(
261
+ "board-result",
262
+ new ResourceTemplate2("canvas://board/{id}/result", { list: void 0 }),
263
+ {
264
+ description: "A board's structured last result (verdict + summary + references, not raw logs). Empty shell until a result is recorded.",
265
+ mimeType: "application/json"
266
+ },
267
+ async (uri, variables) => {
268
+ const id = Array.isArray(variables.id) ? variables.id[0] : variables.id;
269
+ if (!id) throw new Error("canvas://board/{id}/result: missing board id");
270
+ const result = await orchestrator.boardResult(id);
271
+ return { contents: [{ uri: uri.href, text: JSON.stringify(result) }] };
272
+ }
273
+ );
274
+ }
275
+
276
+ // src/resources/memory.ts
277
+ import { ResourceTemplate as ResourceTemplate3 } from "@modelcontextprotocol/sdk/server/mcp.js";
278
+ function registerMemoryResources(server, orchestrator) {
279
+ server.registerResource(
280
+ "memory",
281
+ "canvas://memory",
282
+ {
283
+ description: "Project memory index (passive context). Empty shell when no memory exists.",
284
+ mimeType: "application/json"
285
+ },
286
+ async (uri) => ({
287
+ contents: [{ uri: uri.href, text: JSON.stringify(await orchestrator.projectMemory()) }]
288
+ })
289
+ );
290
+ server.registerResource(
291
+ "board-summary",
292
+ new ResourceTemplate3("canvas://board/{id}/summary", { list: void 0 }),
293
+ {
294
+ description: "A board's memory summary (passive context). Empty shell when none exists.",
295
+ mimeType: "application/json"
296
+ },
297
+ async (uri, variables) => {
298
+ const id = Array.isArray(variables.id) ? variables.id[0] : variables.id;
299
+ if (!id) throw new Error("canvas://board/{id}/summary: missing board id");
300
+ return { contents: [{ uri: uri.href, text: JSON.stringify(await orchestrator.boardSummary(id)) }] };
301
+ }
302
+ );
303
+ }
304
+
305
+ // src/resources/boards.ts
306
+ function registerBoardResources(server, orchestrator) {
307
+ server.registerResource(
308
+ "boards",
309
+ "canvas://boards",
310
+ { description: "List of boards currently on the canvas.", mimeType: "application/json" },
311
+ async (uri) => ({
312
+ contents: [{ uri: uri.href, text: JSON.stringify(await orchestrator.listBoards()) }]
313
+ })
314
+ );
315
+ server.registerResource(
316
+ "board-status",
317
+ new ResourceTemplate4("canvas://board/{id}/status", { list: void 0 }),
318
+ {
319
+ description: "A single board's coarse status bucket (idle/running/awaiting-review/blocked/failed/static).",
320
+ mimeType: "application/json"
321
+ },
322
+ async (uri, variables) => {
323
+ const id = Array.isArray(variables.id) ? variables.id[0] : variables.id;
324
+ if (!id) throw new Error("canvas://board/{id}/status: missing board id");
325
+ const status = await orchestrator.boardStatus(id);
326
+ return { contents: [{ uri: uri.href, text: JSON.stringify({ id, status }) }] };
327
+ }
328
+ );
329
+ registerBoardStatesResource(server, orchestrator);
330
+ registerAttentionResource(server, orchestrator);
331
+ registerBoardOutputResource(server, orchestrator);
332
+ registerBoardResultResource(server, orchestrator);
333
+ registerMemoryResources(server, orchestrator);
334
+ }
335
+
336
+ // src/prompts/index.ts
337
+ function registerPrompts(_server) {
338
+ }
339
+
340
+ // src/server/tools/spawnBoard.ts
341
+ import { z } from "zod";
342
+ function registerSpawnBoard(server, orchestrator) {
343
+ server.registerTool(
344
+ TOOL_SPAWN_BOARD,
345
+ {
346
+ description: "Create a new board on the canvas. type is one of terminal | browser | planning. Optional prompt (terminal launch command / agent task) and cwd (working directory). Returns the new board id. Subject to a concurrency cap.",
347
+ inputSchema: {
348
+ type: z.enum(SPAWNABLE_BOARD_TYPES),
349
+ prompt: z.string().optional(),
350
+ cwd: z.string().optional()
351
+ }
352
+ },
353
+ async (args) => {
354
+ const { id } = await orchestrator.spawnBoard({
355
+ type: args.type,
356
+ prompt: args.prompt,
357
+ cwd: args.cwd
358
+ });
359
+ return { content: [{ type: "text", text: id }] };
360
+ }
361
+ );
362
+ }
363
+
364
+ // src/server/tools/closeBoard.ts
365
+ import { z as z2 } from "zod";
366
+ function registerCloseBoard(server, orchestrator) {
367
+ server.registerTool(
368
+ TOOL_CLOSE_BOARD,
369
+ {
370
+ description: "Close a board by id (graceful PTY drain, then removed from the canvas). Idempotent \u2014 closing an already-gone board succeeds.",
371
+ inputSchema: { id: z2.string().min(1) }
372
+ },
373
+ async (args) => {
374
+ await orchestrator.closeBoard(args.id);
375
+ return { content: [{ type: "text", text: `closed ${args.id}` }] };
376
+ }
377
+ );
378
+ }
379
+
380
+ // src/server/tools/configureBoard.ts
381
+ import { z as z3 } from "zod";
382
+ function registerConfigureBoard(server, orchestrator) {
383
+ server.registerTool(
384
+ TOOL_CONFIGURE_BOARD,
385
+ {
386
+ description: "Change a board config by id. At least one of shell | launchCommand | cwd is required. Applies to the board type that owns the key (shell/launchCommand/cwd are terminal config).",
387
+ inputSchema: {
388
+ id: z3.string().min(1),
389
+ shell: z3.string().optional(),
390
+ launchCommand: z3.string().optional(),
391
+ cwd: z3.string().optional()
392
+ }
393
+ },
394
+ async (args) => {
395
+ const config = {};
396
+ if (args.shell !== void 0) config.shell = args.shell;
397
+ if (args.launchCommand !== void 0) config.launchCommand = args.launchCommand;
398
+ if (args.cwd !== void 0) config.cwd = args.cwd;
399
+ if (Object.keys(config).length === 0) {
400
+ return {
401
+ isError: true,
402
+ content: [
403
+ { type: "text", text: "configure_board: at least one of shell/launchCommand/cwd required" }
404
+ ]
405
+ };
406
+ }
407
+ await orchestrator.configureBoard(args.id, config);
408
+ return { content: [{ type: "text", text: `configured ${args.id}` }] };
409
+ }
410
+ );
411
+ }
412
+
413
+ // src/server/tools/handoffPrompt.ts
414
+ import { z as z5 } from "zod";
415
+
416
+ // src/server/tools/promptSchema.ts
417
+ import { z as z4 } from "zod";
418
+ function hasControlChar(s) {
419
+ for (let i = 0; i < s.length; i++) {
420
+ const c = s.charCodeAt(i);
421
+ if (c === 9) continue;
422
+ if (c < 32 || c === 127) return true;
423
+ }
424
+ return false;
425
+ }
426
+ var dispatchPromptSchema = z4.string().min(1).refine((s) => !hasControlChar(s), {
427
+ message: "prompt must not contain control characters (CR/LF/C0/DEL)"
428
+ });
429
+
430
+ // src/server/tools/handoffPrompt.ts
431
+ function registerHandoffPrompt(server, orchestrator) {
432
+ server.registerTool(
433
+ TOOL_HANDOFF_PROMPT,
434
+ {
435
+ description: "Hand off a prompt to a target terminal board by id: write it into that board and block until the board goes idle, then return its structured last result. Terminal targets only; requires human confirmation. boardId + prompt are required.",
436
+ inputSchema: {
437
+ boardId: z5.string().min(1),
438
+ prompt: dispatchPromptSchema
439
+ }
440
+ },
441
+ async (args) => {
442
+ const result = await orchestrator.handoffPrompt(args.boardId, args.prompt);
443
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
444
+ }
445
+ );
446
+ }
447
+
448
+ // src/server/tools/assignPrompt.ts
449
+ import { z as z6 } from "zod";
450
+ function registerAssignPrompt(server, orchestrator) {
451
+ server.registerTool(
452
+ TOOL_ASSIGN_PROMPT,
453
+ {
454
+ description: "Assign a prompt to a target terminal board by id: write it into that board and return immediately (fire-and-forget \u2014 no waiting for the board to finish). Terminal targets only; requires human confirmation. boardId + prompt are required.",
455
+ inputSchema: {
456
+ boardId: z6.string().min(1),
457
+ prompt: dispatchPromptSchema
458
+ }
459
+ },
460
+ async (args) => {
461
+ await orchestrator.dispatchPrompt(args.boardId, args.prompt);
462
+ return { content: [{ type: "text", text: `assigned prompt to ${args.boardId}` }] };
463
+ }
464
+ );
465
+ }
466
+
467
+ // src/server/tools/writeResult.ts
468
+ import { z as z7 } from "zod";
469
+ function registerWriteResult(server, orchestrator, ctx) {
470
+ server.registerTool(
471
+ TOOL_WRITE_RESULT,
472
+ {
473
+ description: "Record THIS board's structured last result (status / summary / references). All fields optional. Writes only the calling board (no target id is accepted).",
474
+ inputSchema: {
475
+ status: z7.string().optional(),
476
+ summary: z7.string().optional(),
477
+ refs: z7.array(z7.string()).optional()
478
+ }
479
+ },
480
+ async (args) => {
481
+ if (!ctx.boardId) {
482
+ return {
483
+ isError: true,
484
+ content: [{ type: "text", text: "write_result: no board bound to this session" }]
485
+ };
486
+ }
487
+ await orchestrator.writeResult(ctx.boardId, {
488
+ status: args.status,
489
+ summary: args.summary,
490
+ refs: args.refs
491
+ });
492
+ return { content: [{ type: "text", text: "result recorded" }] };
493
+ }
494
+ );
495
+ }
496
+
497
+ // src/server/tools/interrupt.ts
498
+ import { z as z8 } from "zod";
499
+ function registerInterrupt(server, orchestrator) {
500
+ server.registerTool(
501
+ TOOL_INTERRUPT,
502
+ {
503
+ description: "Interrupt a target terminal board by id: send Ctrl-C to stop its running command. Terminal targets only; requires human confirmation. boardId is required.",
504
+ inputSchema: {
505
+ boardId: z8.string().min(1)
506
+ }
507
+ },
508
+ async (args) => {
509
+ await orchestrator.interrupt(args.boardId);
510
+ return { content: [{ type: "text", text: `interrupted ${args.boardId}` }] };
511
+ }
512
+ );
513
+ }
514
+
515
+ // src/server/tools/relayPrompt.ts
516
+ import { z as z9 } from "zod";
517
+ function registerRelayPrompt(server, orchestrator, ctx, commandBoardId) {
518
+ server.registerTool(
519
+ TOOL_RELAY_PROMPT,
520
+ {
521
+ description: "Relay a prompt from one terminal board to another along an orchestration connector (sourceId \u2192 targetId): the cable must already exist and both boards must be terminals. Requires human confirmation. sourceId, targetId, prompt required.",
522
+ inputSchema: {
523
+ sourceId: z9.string().min(1),
524
+ targetId: z9.string().min(1),
525
+ prompt: dispatchPromptSchema
526
+ }
527
+ },
528
+ async (args) => {
529
+ if (commandBoardId !== void 0 && ctx.boardId !== commandBoardId) {
530
+ return {
531
+ isError: true,
532
+ content: [
533
+ { type: "text", text: "relay_prompt: caller is not the designated command orchestrator" }
534
+ ]
535
+ };
536
+ }
537
+ await orchestrator.relayPrompt(args.sourceId, args.targetId, args.prompt);
538
+ return { content: [{ type: "text", text: `relayed ${args.sourceId} \u2192 ${args.targetId}` }] };
539
+ }
540
+ );
541
+ }
542
+
543
+ // src/server/tools/barriers.ts
544
+ import { z as z10 } from "zod";
545
+
546
+ // src/server/barrierWaiter.ts
547
+ var isSettled = (status) => status !== "running";
548
+ function waitForBoards(opts) {
549
+ const { orchestrator, targets, timeoutMs } = opts;
550
+ const order = targets.slice();
551
+ const pending = new Set(targets);
552
+ const settled = /* @__PURE__ */ new Map();
553
+ let done = false;
554
+ let unsub = () => {
555
+ };
556
+ let timer;
557
+ let resolveFn;
558
+ const promise = new Promise((resolve) => {
559
+ resolveFn = resolve;
560
+ });
561
+ const finish = (fillStatus) => {
562
+ if (done) return;
563
+ done = true;
564
+ unsub();
565
+ if (timer) clearTimeout(timer);
566
+ resolveFn(order.map((id) => settled.get(id) ?? { id, status: fillStatus }));
567
+ };
568
+ const recordSettle = async (id, status) => {
569
+ if (done || !pending.has(id)) return;
570
+ let entry = { id, status };
571
+ if (status === "idle") {
572
+ const r = await orchestrator.boardResult(id);
573
+ if (r.present) entry = { id, status, result: r };
574
+ }
575
+ if (done || !pending.has(id)) return;
576
+ settled.set(id, entry);
577
+ pending.delete(id);
578
+ if (pending.size === 0) finish("timed-out");
579
+ };
580
+ unsub = orchestrator.subscribeStatus((change) => {
581
+ if (isSettled(change.status)) void recordSettle(change.id, change.status);
582
+ });
583
+ void (async () => {
584
+ const boards = await orchestrator.listBoards();
585
+ if (done) return;
586
+ const current = new Map(boards.map((b) => [b.id, b.status]));
587
+ for (const id of order) {
588
+ if (done) return;
589
+ const st = current.get(id);
590
+ if (st === void 0) await recordSettle(id, "gone");
591
+ else if (isSettled(st)) await recordSettle(id, st);
592
+ }
593
+ })();
594
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
595
+ timer = setTimeout(() => finish("timed-out"), timeoutMs);
596
+ timer.unref?.();
597
+ }
598
+ return { promise, cancel: () => finish("gone") };
599
+ }
600
+
601
+ // src/server/tools/barriers.ts
602
+ function resolveBarrierTimeout(arg) {
603
+ if (arg !== void 0) return arg;
604
+ const env = process.env.CANVAS_ADE_BARRIER_TIMEOUT_MS;
605
+ if (env !== void 0) {
606
+ const n = Number(env);
607
+ if (Number.isFinite(n) && n > 0) return n;
608
+ }
609
+ return DEFAULT_BARRIER_TIMEOUT_MS;
610
+ }
611
+ function registerBarrierTools(server, orchestrator) {
612
+ const active = /* @__PURE__ */ new Set();
613
+ const run = async (targets, timeoutMs) => {
614
+ const handle = waitForBoards({ orchestrator, targets, timeoutMs });
615
+ active.add(handle.cancel);
616
+ try {
617
+ return await handle.promise;
618
+ } finally {
619
+ active.delete(handle.cancel);
620
+ }
621
+ };
622
+ server.registerTool(
623
+ TOOL_WAIT_FOR_IDLE,
624
+ {
625
+ description: "Block until a target board leaves the running state, then report how it settled (idle/awaiting-review/blocked/failed/static/gone, or timed-out). Returns the board id + status (+ the board's last write_result when idle). boardId is required; optional timeoutMs (omit for the default backstop; <=0 to wait indefinitely).",
626
+ inputSchema: {
627
+ boardId: z10.string().min(1),
628
+ timeoutMs: z10.number().optional()
629
+ }
630
+ },
631
+ async (args) => {
632
+ const results = await run([args.boardId], resolveBarrierTimeout(args.timeoutMs));
633
+ const r = results[0] ?? { id: args.boardId, status: "timed-out" };
634
+ return { content: [{ type: "text", text: JSON.stringify(r) }] };
635
+ }
636
+ );
637
+ server.registerTool(
638
+ TOOL_WAIT_FOR_ALL,
639
+ {
640
+ description: "Block until EVERY target board has left the running state, then report each one (same statuses as wait_for_idle) plus allIdle (true when every target settled to idle). boardIds is a non-empty array; optional timeoutMs (omit for the default backstop; <=0 to wait indefinitely).",
641
+ inputSchema: {
642
+ boardIds: z10.array(z10.string().min(1)).min(1),
643
+ timeoutMs: z10.number().optional()
644
+ }
645
+ },
646
+ async (args) => {
647
+ const boards = await run(args.boardIds, resolveBarrierTimeout(args.timeoutMs));
648
+ const allIdle = boards.every((b) => b.status === "idle");
649
+ return { content: [{ type: "text", text: JSON.stringify({ boards, allIdle }) }] };
650
+ }
651
+ );
652
+ return () => {
653
+ for (const cancel of active) cancel();
654
+ };
655
+ }
656
+
657
+ // src/server/resourceSubscriptions.ts
658
+ import {
659
+ SubscribeRequestSchema,
660
+ UnsubscribeRequestSchema
661
+ } from "@modelcontextprotocol/sdk/types.js";
662
+ function installResourceSubscriptions(server) {
663
+ const uris = /* @__PURE__ */ new Set();
664
+ server.server.registerCapabilities({ resources: { subscribe: true } });
665
+ server.server.setRequestHandler(SubscribeRequestSchema, async (req) => {
666
+ uris.add(req.params.uri);
667
+ return {};
668
+ });
669
+ server.server.setRequestHandler(UnsubscribeRequestSchema, async (req) => {
670
+ uris.delete(req.params.uri);
671
+ return {};
672
+ });
673
+ return { isSubscribed: (uri) => uris.has(uri) };
674
+ }
675
+
676
+ // src/server/attentionNotifier.ts
677
+ function createAttentionNotifier(deps) {
678
+ const { server, orchestrator, isSubscribed } = deps;
679
+ const inAttention = /* @__PURE__ */ new Set();
680
+ const unsub = orchestrator.subscribeStatus((change) => {
681
+ const nowAttn = ATTENTION_BUCKETS.has(change.status);
682
+ const wasAttn = inAttention.has(change.id);
683
+ if (nowAttn === wasAttn) return;
684
+ if (nowAttn) inAttention.add(change.id);
685
+ else inAttention.delete(change.id);
686
+ if (!isSubscribed(ATTENTION_URI)) return;
687
+ try {
688
+ server.server.sendResourceUpdated({ uri: ATTENTION_URI });
689
+ } catch {
690
+ }
691
+ });
692
+ return { dispose: unsub };
693
+ }
694
+
695
+ // src/server/factory.ts
696
+ var SERVER_INFO = { name: "canvas-ade-mcp", version: package_default.version };
697
+ var ServerFactory = class {
698
+ /**
699
+ * @param commandBoardId Optional single command-orchestrator board id (BUG-021). When set,
700
+ * `relay_prompt` is restricted to that token-bound identity so a second orchestrator-tier
701
+ * token can't drive cables it doesn't own. Left undefined → relay open to any orchestrator
702
+ * (the prior single-token behaviour).
703
+ */
704
+ constructor(orchestrator, commandBoardId) {
705
+ this.orchestrator = orchestrator;
706
+ this.commandBoardId = commandBoardId;
707
+ }
708
+ orchestrator;
709
+ commandBoardId;
710
+ getServer(ctx) {
711
+ const server = new McpServer5(SERVER_INFO);
712
+ const disposers = [];
713
+ server.registerTool(TOOL_PING, { description: 'Health check. Returns "pong".' }, async () => ({
714
+ content: [{ type: "text", text: "pong" }]
715
+ }));
716
+ if (ctx.tier === "orchestrator") {
717
+ server.registerTool(
718
+ TOOL_ORCHESTRATOR_PING,
719
+ { description: 'Orchestrator-only health check. Returns "orchestrator-pong".' },
720
+ async () => ({ content: [{ type: "text", text: "orchestrator-pong" }] })
721
+ );
722
+ registerSpawnBoard(server, this.orchestrator);
723
+ registerCloseBoard(server, this.orchestrator);
724
+ registerConfigureBoard(server, this.orchestrator);
725
+ registerHandoffPrompt(server, this.orchestrator);
726
+ registerAssignPrompt(server, this.orchestrator);
727
+ registerInterrupt(server, this.orchestrator);
728
+ registerRelayPrompt(server, this.orchestrator, ctx, this.commandBoardId);
729
+ disposers.push(registerBarrierTools(server, this.orchestrator));
730
+ }
731
+ registerWriteResult(server, this.orchestrator, ctx);
732
+ registerBoardResources(server, this.orchestrator);
733
+ registerPrompts(server);
734
+ const subs = installResourceSubscriptions(server);
735
+ const notifier = createAttentionNotifier({
736
+ server,
737
+ orchestrator: this.orchestrator,
738
+ isSubscribed: subs.isSubscribed
739
+ });
740
+ disposers.push(() => notifier.dispose());
741
+ return {
742
+ server,
743
+ dispose: () => {
744
+ for (const d of disposers) d();
745
+ }
746
+ };
747
+ }
748
+ };
749
+
750
+ // src/server/transport.ts
751
+ import { randomUUID } from "crypto";
752
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
753
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
754
+ function rpcError(code, message) {
755
+ return { jsonrpc: "2.0", error: { code, message }, id: null };
756
+ }
757
+ var SessionManager = class {
758
+ constructor(factory) {
759
+ this.factory = factory;
760
+ }
761
+ factory;
762
+ transports = /* @__PURE__ */ new Map();
763
+ /** Per-session teardown (M5 notifier unsubscribe + in-flight barrier cancel). */
764
+ disposers = /* @__PURE__ */ new Map();
765
+ /** POST /mcp: reuse an existing session, or open a new one on initialize. */
766
+ async handlePost(req, res, ctx) {
767
+ const sid = req.header(HEADER_SESSION_ID);
768
+ if (sid !== void 0) {
769
+ const existing = this.transports.get(sid);
770
+ if (!existing) {
771
+ res.status(404).json(rpcError(-32001, "Session not found"));
772
+ return;
773
+ }
774
+ await existing.handleRequest(req, res, req.body);
775
+ return;
776
+ }
777
+ if (!isInitializeRequest(req.body)) {
778
+ res.status(400).json(rpcError(-32e3, "Bad Request: no session ID and not an initialize request"));
779
+ return;
780
+ }
781
+ const { server, dispose } = this.factory.getServer(ctx);
782
+ const transport = new StreamableHTTPServerTransport({
783
+ sessionIdGenerator: () => randomUUID(),
784
+ onsessioninitialized: (id) => {
785
+ this.transports.set(id, transport);
786
+ this.disposers.set(id, dispose);
787
+ }
788
+ });
789
+ transport.onclose = () => {
790
+ const id = transport.sessionId;
791
+ if (id !== void 0) {
792
+ this.transports.delete(id);
793
+ this.disposers.get(id)?.();
794
+ this.disposers.delete(id);
795
+ }
796
+ };
797
+ await server.connect(transport);
798
+ await transport.handleRequest(req, res, req.body);
799
+ }
800
+ /** GET (SSE) and DELETE /mcp: route to the named session. */
801
+ async handleSession(req, res) {
802
+ const sid = req.header(HEADER_SESSION_ID);
803
+ if (sid === void 0) {
804
+ res.status(400).json(rpcError(-32e3, "Missing session ID"));
805
+ return;
806
+ }
807
+ const transport = this.transports.get(sid);
808
+ if (!transport) {
809
+ res.status(404).json(rpcError(-32001, "Session not found"));
810
+ return;
811
+ }
812
+ await transport.handleRequest(req, res);
813
+ }
814
+ /**
815
+ * Tear down every live session (called on app quit). Uses `allSettled` so one
816
+ * transport whose `close()` rejects can't short-circuit the loop and leak the
817
+ * remaining sessions; the map is always cleared.
818
+ */
819
+ async closeAll() {
820
+ try {
821
+ await Promise.allSettled([...this.transports.values()].map((t) => t.close()));
822
+ } finally {
823
+ for (const dispose of this.disposers.values()) {
824
+ try {
825
+ dispose();
826
+ } catch {
827
+ }
828
+ }
829
+ this.disposers.clear();
830
+ this.transports.clear();
831
+ }
832
+ }
833
+ };
834
+
835
+ // src/server/mcpHttp.ts
836
+ function ctxFromAuth(auth) {
837
+ const extra = auth?.extra ?? {};
838
+ const tier = extra.tier === "orchestrator" ? "orchestrator" : "worker";
839
+ const boardId = typeof extra.boardId === "string" ? extra.boardId : "";
840
+ return { tier, scopes: auth?.scopes ?? [], boardId };
841
+ }
842
+ async function createMcpHttpServer(deps) {
843
+ const app = express();
844
+ app.use(hostGuard());
845
+ let allowedOrigins = [];
846
+ app.use(originGuard(() => allowedOrigins));
847
+ const verifier = createVerifier(deps.tokens);
848
+ app.use(MCP_PATH, requireBearerAuth({ verifier }));
849
+ app.use(MCP_PATH, express.json({ limit: "1mb" }));
850
+ const sessions = new SessionManager(new ServerFactory(deps.orchestrator, deps.commandBoardId));
851
+ app.post(MCP_PATH, (req, res, next) => {
852
+ sessions.handlePost(req, res, ctxFromAuth(req.auth)).catch(next);
853
+ });
854
+ app.get(MCP_PATH, (req, res, next) => {
855
+ sessions.handleSession(req, res).catch(next);
856
+ });
857
+ app.delete(MCP_PATH, (req, res, next) => {
858
+ sessions.handleSession(req, res).catch(next);
859
+ });
860
+ const httpServer = createServer(app);
861
+ await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", () => resolve()));
862
+ const address = httpServer.address();
863
+ const port = typeof address === "object" && address !== null ? address.port : 0;
864
+ allowedOrigins = [`http://127.0.0.1:${port}`, `http://localhost:${port}`];
865
+ return {
866
+ app,
867
+ httpServer,
868
+ port,
869
+ setAllowedOrigins(origins) {
870
+ allowedOrigins = origins;
871
+ },
872
+ async close() {
873
+ await sessions.closeAll();
874
+ await new Promise((resolve, reject) => {
875
+ httpServer.close((err) => err ? reject(err) : resolve());
876
+ });
877
+ }
878
+ };
879
+ }
880
+
881
+ // src/auth/tokens.ts
882
+ var TokenStore = class {
883
+ rows = /* @__PURE__ */ new Map();
884
+ mint(token, row) {
885
+ this.rows.set(token, row);
886
+ }
887
+ revoke(token) {
888
+ this.rows.delete(token);
889
+ }
890
+ get(token) {
891
+ return this.rows.get(token);
892
+ }
893
+ };
894
+
895
+ // src/auth/mint.ts
896
+ import { randomBytes } from "crypto";
897
+
898
+ // src/auth/scopes.ts
899
+ var SCOPE_READ = "read";
900
+ var SCOPE_DISPATCH = "dispatch";
901
+ var SCOPE_SPAWN = "spawn";
902
+ var SCOPE_GIT_WRITE = "git:write";
903
+ var SCOPE_ANSWER_PERMISSION = "answer_permission";
904
+ var WORKER_SCOPES = [SCOPE_READ];
905
+ var ORCHESTRATOR_SCOPES = [
906
+ SCOPE_READ,
907
+ SCOPE_DISPATCH,
908
+ SCOPE_SPAWN,
909
+ SCOPE_GIT_WRITE,
910
+ SCOPE_ANSWER_PERMISSION
911
+ ];
912
+ function defaultScopesFor(tier) {
913
+ return [...tier === "orchestrator" ? ORCHESTRATOR_SCOPES : WORKER_SCOPES];
914
+ }
915
+
916
+ // src/auth/mint.ts
917
+ var DEFAULT_TTL_SECONDS = 365 * 24 * 60 * 60;
918
+ function mintBoardToken(store, input) {
919
+ const token = randomBytes(32).toString("hex");
920
+ const ttl = input.ttlSeconds ?? DEFAULT_TTL_SECONDS;
921
+ const row = {
922
+ boardId: input.boardId,
923
+ tier: input.tier,
924
+ scopes: defaultScopesFor(input.tier),
925
+ expiresAt: Math.floor(Date.now() / 1e3) + ttl
926
+ };
927
+ store.mint(token, row);
928
+ return { token, row };
929
+ }
930
+
931
+ // src/config/mcpJson.ts
932
+ import { writeFileSync } from "fs";
933
+ import { join } from "path";
934
+ function buildMcpJson(port, token) {
935
+ return {
936
+ mcpServers: {
937
+ "canvas-ade": {
938
+ type: "http",
939
+ url: `http://127.0.0.1:${port}/mcp`,
940
+ headers: { Authorization: `Bearer ${token}` }
941
+ }
942
+ }
943
+ };
944
+ }
945
+ function writeMcpJson(dir, port, token) {
946
+ const file = join(dir, ".mcp.json");
947
+ writeFileSync(file, JSON.stringify(buildMcpJson(port, token), null, 2) + "\n", {
948
+ encoding: "utf8",
949
+ mode: 384
950
+ });
951
+ return file;
952
+ }
953
+
954
+ // src/orchestrator/mock.ts
955
+ var MockOrchestrator = class {
956
+ async listBoards() {
957
+ return [];
958
+ }
959
+ async spawnBoard(_input) {
960
+ return { id: "mock-board" };
961
+ }
962
+ async closeBoard(_boardId) {
963
+ }
964
+ async configureBoard(_boardId, _config) {
965
+ }
966
+ async dispatchPrompt(_boardId, _text) {
967
+ }
968
+ async writeResult(_boardId, _result) {
969
+ }
970
+ async interrupt(_boardId) {
971
+ }
972
+ async relayPrompt(_sourceId, _targetId, _text) {
973
+ }
974
+ async handoffPrompt(_boardId, _text) {
975
+ return { present: false };
976
+ }
977
+ async gitDiff(_boardId) {
978
+ return "";
979
+ }
980
+ async boardStatus(_boardId) {
981
+ return "idle";
982
+ }
983
+ async boardOutput(_boardId, _opts) {
984
+ return { text: "", total: 0, returned: 0, droppedOlder: false };
985
+ }
986
+ async boardResult(_boardId) {
987
+ return { present: false };
988
+ }
989
+ async projectMemory() {
990
+ return { present: false, text: "" };
991
+ }
992
+ async boardSummary(_boardId) {
993
+ return { present: false, text: "" };
994
+ }
995
+ /** @internal subscribers for the M5 status stream. */
996
+ statusListeners = /* @__PURE__ */ new Set();
997
+ subscribeStatus(listener) {
998
+ this.statusListeners.add(listener);
999
+ return () => {
1000
+ this.statusListeners.delete(listener);
1001
+ };
1002
+ }
1003
+ /** Test seam: drive a status change through the subscription fan-out. */
1004
+ __emitStatus(change) {
1005
+ for (const cb of this.statusListeners) {
1006
+ try {
1007
+ cb(change);
1008
+ } catch {
1009
+ }
1010
+ }
1011
+ }
1012
+ };
1013
+ export {
1014
+ MAX_OUTPUT_PAGE,
1015
+ MockOrchestrator,
1016
+ SCOPE_ANSWER_PERMISSION,
1017
+ SCOPE_DISPATCH,
1018
+ SCOPE_GIT_WRITE,
1019
+ SCOPE_READ,
1020
+ SCOPE_SPAWN,
1021
+ TokenStore,
1022
+ buildMcpJson,
1023
+ createMcpHttpServer,
1024
+ defaultScopesFor,
1025
+ mintBoardToken,
1026
+ writeMcpJson
1027
+ };
1028
+ //# sourceMappingURL=index.js.map