pmx-canvas 0.9.3 → 0.10.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.
Files changed (56) hide show
  1. package/.github/extensions/pmx-canvas/runtime.json +1 -1
  2. package/.github/extensions/pmx-canvas/tools.json +9278 -2014
  3. package/CHANGELOG.md +22 -1
  4. package/dist/canvas/index.js +1 -1
  5. package/dist/canvas/mcp-app.html +1 -1
  6. package/dist/types/mcp/canvas-access.d.ts +1 -12
  7. package/dist/types/mcp/resource-subscriptions.d.ts +7 -0
  8. package/dist/types/mcp/server.d.ts +2 -0
  9. package/dist/types/server/ax-wait.d.ts +1 -0
  10. package/dist/types/server/canvas-operations.d.ts +1 -0
  11. package/dist/types/server/index.d.ts +3 -3
  12. package/dist/types/server/operations/http.d.ts +10 -0
  13. package/dist/types/server/operations/index.d.ts +2 -2
  14. package/dist/types/server/operations/invoker.d.ts +4 -3
  15. package/dist/types/server/operations/mcp-contracts.d.ts +20 -0
  16. package/dist/types/server/operations/mcp.d.ts +2 -0
  17. package/dist/types/server/operations/ops/batch.d.ts +1 -0
  18. package/dist/types/server/operations/registry.d.ts +2 -2
  19. package/dist/types/server/operations/types.d.ts +12 -2
  20. package/dist/types/server/server.d.ts +2 -0
  21. package/dist/types/server/webpage-node.d.ts +1 -0
  22. package/docs/api-reference.md +2 -2
  23. package/docs/cli.md +1 -1
  24. package/docs/http-api.md +31 -0
  25. package/docs/mcp.md +93 -21
  26. package/docs/plans/plan-019-post-0.9-roadmap.md +123 -6
  27. package/docs/sdk.md +5 -2
  28. package/package.json +1 -1
  29. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +1 -1
  30. package/plugins/pmx-canvas/plugin.json +1 -1
  31. package/plugins/pmx-canvas/runtime.json +1 -1
  32. package/plugins/pmx-canvas/tools.json +9278 -2014
  33. package/skills/pmx-canvas/SKILL.md +9 -4
  34. package/skills/pmx-canvas/references/full-reference.md +35 -2
  35. package/skills/pmx-canvas/references/installing-pmx-canvas.md +7 -7
  36. package/src/client/canvas/ToolRail.tsx +2 -0
  37. package/src/mcp/canvas-access.ts +6 -47
  38. package/src/mcp/resource-subscriptions.ts +181 -0
  39. package/src/mcp/server.ts +185 -219
  40. package/src/server/ax-wait.ts +21 -6
  41. package/src/server/canvas-operations.ts +6 -2
  42. package/src/server/index.ts +12 -4
  43. package/src/server/operations/http.ts +69 -21
  44. package/src/server/operations/index.ts +8 -1
  45. package/src/server/operations/invoker.ts +53 -5
  46. package/src/server/operations/mcp-contracts.ts +339 -0
  47. package/src/server/operations/mcp.ts +153 -18
  48. package/src/server/operations/ops/ax-await.ts +18 -10
  49. package/src/server/operations/ops/ax-read.ts +2 -0
  50. package/src/server/operations/ops/ax-timeline.ts +2 -2
  51. package/src/server/operations/ops/batch.ts +27 -8
  52. package/src/server/operations/ops/nodes.ts +18 -3
  53. package/src/server/operations/registry.ts +24 -4
  54. package/src/server/operations/types.ts +16 -2
  55. package/src/server/server.ts +10 -0
  56. package/src/server/webpage-node.ts +9 -4
package/src/mcp/server.ts CHANGED
@@ -27,10 +27,14 @@ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mc
27
27
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
28
28
  import { z } from 'zod';
29
29
  import { AX_SOURCES } from '../server/operations/ops/ax-shared.js';
30
- import { canvasState, describeCanvasSchema, stopCanvasServer } from '../server/index.js';
30
+ import { describeCanvasSchema, stopCanvasServer } from '../server/index.js';
31
31
  import { AX_INTERACTION_TYPES } from '../server/ax-interaction.js';
32
32
  import { buildPendingAxActivity } from '../server/ax-state.js';
33
- import { registerOperationTools, registerCompositeTools } from '../server/operations/index.js';
33
+ import {
34
+ registerOperationTools,
35
+ registerCompositeTools,
36
+ registerOperationHelpTool,
37
+ } from '../server/operations/index.js';
34
38
  import { agentSourceLabel } from '../server/operations/invoker.js';
35
39
  import { contextReadFromPayload, pinnedBoardReads, graphReads } from '../server/context-reads.js';
36
40
  import type { MemoryGraph } from '../shared/memory-graph.js';
@@ -45,11 +49,7 @@ import {
45
49
  import { listBundledSkills, readBundledSkill } from '../server/bundled-skills.js';
46
50
  import { registerSkillExtension } from './skills.js';
47
51
  import { ContextUriTemplate } from './context-uri-template.js';
48
-
49
- let canvas: CanvasAccess | null = null;
50
- let resourceNotificationServer: McpServer | null = null;
51
- let localResourceNotificationsStarted = false;
52
- let remoteResourceNotificationsBaseUrl: string | null = null;
52
+ import { registerResourceSubscriptions } from './resource-subscriptions.js';
53
53
 
54
54
  // Resolved from the sibling package.json so it stays accurate through bunx,
55
55
  // global npm installs, and repo-local runs (no hard-coded string, no build
@@ -71,115 +71,6 @@ function structuredSchemaDescription(): string {
71
71
  .join(', ');
72
72
  }
73
73
 
74
- async function ensureCanvas(): Promise<CanvasAccess> {
75
- if (!canvas) {
76
- canvas = await createCanvasAccess();
77
- } else {
78
- canvas = await refreshCanvasAccess(canvas);
79
- }
80
- startResourceNotifications(canvas);
81
- return canvas;
82
- }
83
-
84
- function sleep(ms: number): Promise<void> {
85
- return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
86
- }
87
-
88
- function sendCanvasResourceNotifications(type: 'nodes' | 'pins' | 'ax' | 'ax-timeline' = 'nodes'): void {
89
- const server = resourceNotificationServer;
90
- if (!server) return;
91
- try {
92
- if (type === 'pins' || type === 'nodes') {
93
- server.server.sendResourceUpdated({ uri: 'canvas://pinned-context' });
94
- }
95
- if (type === 'pins' || type === 'ax') {
96
- server.server.sendResourceUpdated({ uri: 'canvas://ax' });
97
- server.server.sendResourceUpdated({ uri: 'canvas://ax-context' });
98
- server.server.sendResourceUpdated({ uri: 'canvas://ax-timeline' });
99
- server.server.sendResourceUpdated({ uri: 'canvas://ax-work' });
100
- }
101
- if (type === 'ax-timeline') {
102
- server.server.sendResourceUpdated({ uri: 'canvas://ax-timeline' });
103
- server.server.sendResourceUpdated({ uri: 'canvas://ax-context' });
104
- server.server.sendResourceUpdated({ uri: 'canvas://ax-pending-steering' });
105
- server.server.sendResourceUpdated({ uri: 'canvas://ax-delivery' });
106
- }
107
- server.server.sendResourceUpdated({ uri: 'canvas://layout' });
108
- server.server.sendResourceUpdated({ uri: 'canvas://boards' });
109
- server.server.sendResourceUpdated({ uri: 'canvas://summary' });
110
- server.server.sendResourceUpdated({ uri: 'canvas://context' });
111
- server.server.sendResourceUpdated({ uri: 'canvas://graph' });
112
- server.server.sendResourceUpdated({ uri: 'canvas://spatial-context' });
113
- server.server.sendResourceUpdated({ uri: 'canvas://history' });
114
- server.server.sendResourceUpdated({ uri: 'canvas://code-graph' });
115
- } catch (error) {
116
- console.debug('[mcp] resource notification failed', error);
117
- }
118
- }
119
-
120
- function handleRemoteSseFrame(frame: string): void {
121
- const eventLine = frame.split('\n').find((line) => line.startsWith('event: '));
122
- const event = eventLine?.slice('event: '.length).trim() ?? '';
123
- if (!event || event === 'connected' || event === 'ping') return;
124
- sendCanvasResourceNotifications(
125
- // boards-changed covers board pins and switches, both of which change pinned context.
126
- event === 'context-pins-changed' || event === 'boards-changed'
127
- ? 'pins'
128
- : event === 'ax-state-changed'
129
- ? 'ax'
130
- : event === 'ax-event-created'
131
- ? 'ax-timeline'
132
- : 'nodes',
133
- );
134
- }
135
-
136
- async function watchRemoteCanvasEvents(baseUrl: string): Promise<void> {
137
- const decoder = new TextDecoder();
138
- while (true) {
139
- try {
140
- const response = await fetch(`${baseUrl}/api/workbench/events`);
141
- if (!response.ok || !response.body) {
142
- await sleep(1_000);
143
- continue;
144
- }
145
-
146
- const reader = response.body.getReader();
147
- let buffer = '';
148
- while (true) {
149
- const { done, value } = await reader.read();
150
- if (done) break;
151
- buffer += decoder.decode(value, { stream: true });
152
- const frames = buffer.split('\n\n');
153
- buffer = frames.pop() ?? '';
154
- for (const frame of frames) handleRemoteSseFrame(frame);
155
- }
156
- } catch (error) {
157
- console.debug('[mcp] remote canvas event stream failed', error);
158
- }
159
- await sleep(1_000);
160
- }
161
- }
162
-
163
- function startResourceNotifications(c: CanvasAccess): void {
164
- const server = resourceNotificationServer;
165
- if (!server) return;
166
-
167
- if (c.remoteBaseUrl) {
168
- if (remoteResourceNotificationsBaseUrl !== c.remoteBaseUrl) {
169
- remoteResourceNotificationsBaseUrl = c.remoteBaseUrl;
170
- void watchRemoteCanvasEvents(c.remoteBaseUrl);
171
- }
172
- return;
173
- }
174
-
175
- if (localResourceNotificationsStarted) return;
176
- localResourceNotificationsStarted = true;
177
-
178
- canvasState.onChange((type) => {
179
- sendCanvasResourceNotifications(type);
180
- });
181
- }
182
-
183
74
  function encodeBase64(bytes: Uint8Array): string {
184
75
  return Buffer.from(bytes).toString('base64');
185
76
  }
@@ -219,7 +110,7 @@ function buildSummaryFromLayout(
219
110
  };
220
111
  }
221
112
 
222
- export async function startMcpServer(): Promise<void> {
113
+ export function createMcpServer(): McpServer {
223
114
  const server = new McpServer(
224
115
  { name: 'pmx-canvas', version: readPackageVersion() },
225
116
  {
@@ -227,7 +118,19 @@ export async function startMcpServer(): Promise<void> {
227
118
  'Bundled workbench guidance: skill://pmx-canvas/SKILL.md. Discover skills via skills/list when supported, or canvas://skills; read files through resources/read. Skill delivery does not grant permission to execute bundled scripts.',
228
119
  },
229
120
  );
230
- resourceNotificationServer = server;
121
+ let canvas: Promise<CanvasAccess> | undefined;
122
+ const subscriptions = registerResourceSubscriptions(server, ensureCanvas);
123
+ async function ensureCanvas(): Promise<CanvasAccess> {
124
+ canvas = canvas ? canvas.then(refreshCanvasAccess) : createCanvasAccess();
125
+ try {
126
+ const access = await canvas;
127
+ subscriptions.attach(access);
128
+ return access;
129
+ } catch (error) {
130
+ canvas = undefined;
131
+ throw error;
132
+ }
133
+ }
231
134
  registerSkillExtension(server);
232
135
 
233
136
  /**
@@ -269,6 +172,7 @@ export async function startMcpServer(): Promise<void> {
269
172
  // as standalone tools in v0.3.0; they remain reachable via their composite
270
173
  // and canvas_batch. Remaining standalone tool names and compact/full payload
271
174
  // behavior are frozen (tests/unit/mcp-tool-freeze.test.ts, operation-parity.test.ts).
175
+ registerOperationHelpTool(server);
272
176
  registerOperationTools(server, ensureCanvas);
273
177
 
274
178
  // ── Composite (action-discriminated) tools (plan-006) ───────────
@@ -282,36 +186,58 @@ export async function startMcpServer(): Promise<void> {
282
186
  // tool; the 6 snapshot standalones are deprecated in v0.3.0 and fold into it.)
283
187
  registerCompositeTools(server, ensureCanvas);
284
188
 
285
- server.tool(
189
+ const interactionResult = z.looseObject({
190
+ ok: z.literal(true),
191
+ type: z.enum(AX_INTERACTION_TYPES),
192
+ sourceNodeId: z.string(),
193
+ primitive: z.unknown(),
194
+ });
195
+ server.registerTool(
286
196
  'canvas_ax_interaction',
287
- 'Submit a node-originated AX interaction: a capability-gated, validated event from an eligible node that maps onto an AX operation (work item, evidence, approval, review, focus, steering, event). Returns { ok: false, code } if the node type/metadata does not allow the interaction type or the payload is invalid.',
288
197
  {
289
- type: z
290
- .enum(AX_INTERACTION_TYPES)
291
- .describe('Interaction type, e.g. ax.work.create, ax.evidence.add, ax.focus.set.'),
292
- sourceNodeId: z.string().describe('The node emitting the interaction.'),
293
- payload: z
294
- .record(z.string(), z.unknown())
295
- .optional()
296
- .describe('Type-specific payload, e.g. {"title":"..."} for ax.work.create.'),
297
- sourceSurface: z.enum(['native-node', 'json-render', 'html-node', 'mcp-app', 'adapter']).optional(),
298
- correlationId: z.string().optional(),
299
- source: z.enum(AX_SOURCES).optional().describe('Optional host/source label. Defaults to mcp.'),
198
+ description:
199
+ 'Submit a capability-gated node AX interaction (work, evidence, approval, review, focus, steering or event). Rejected interactions return isError with { ok: false, code, error }.',
200
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
201
+ outputSchema: interactionResult,
202
+ inputSchema: {
203
+ type: z
204
+ .enum(AX_INTERACTION_TYPES)
205
+ .describe('Interaction type, e.g. ax.work.create, ax.evidence.add, ax.focus.set.'),
206
+ sourceNodeId: z.string().describe('The node emitting the interaction.'),
207
+ payload: z
208
+ .record(z.string(), z.unknown())
209
+ .optional()
210
+ .describe('Type-specific payload, e.g. {"title":"..."} for ax.work.create.'),
211
+ sourceSurface: z.enum(['native-node', 'json-render', 'html-node', 'mcp-app', 'adapter']).optional(),
212
+ correlationId: z.string().optional(),
213
+ source: z.enum(AX_SOURCES).optional().describe('Optional host/source label. Defaults to mcp.'),
214
+ },
300
215
  },
301
- async ({ type, sourceNodeId, payload, sourceSurface, correlationId, source }) => {
216
+ async ({ type, sourceNodeId, payload, sourceSurface, correlationId, source }, extra) => {
302
217
  try {
303
218
  const c = await ensureCanvas();
304
- const result = await c.submitAxInteraction(
219
+ extra.signal.throwIfAborted();
220
+ const result = await c.invoker().invoke(
221
+ 'ax.interaction.submit',
305
222
  {
306
223
  type,
307
224
  sourceNodeId,
308
225
  ...(payload ? { payload } : {}),
309
226
  ...(sourceSurface ? { sourceSurface } : {}),
310
227
  ...(correlationId ? { correlationId } : {}),
228
+ source: source ?? 'mcp',
311
229
  },
312
- { source: source ?? 'mcp' },
230
+ { signal: extra.signal },
313
231
  );
314
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
232
+ const failure = z
233
+ .object({ ok: z.literal(false), status: z.number(), code: z.string(), error: z.string() })
234
+ .safeParse(result);
235
+ const body = failure.success ? failure.data : interactionResult.parse(result);
236
+ return {
237
+ content: [{ type: 'text', text: JSON.stringify(body) }],
238
+ // SDK clients validate structuredContent even on isError results.
239
+ ...(failure.success ? { isError: true } : { structuredContent: body }),
240
+ };
315
241
  } catch (error) {
316
242
  return {
317
243
  content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
@@ -328,69 +254,85 @@ export async function startMcpServer(): Promise<void> {
328
254
  // canvas_resolve_mode migrated to the operation registry (plan-007 Slice B
329
255
  // wave 2): src/server/operations/ops/ax-work.ts.
330
256
 
331
- server.tool(
257
+ const activityResult = z.object({
258
+ ok: z.literal(true),
259
+ event: z.looseObject({ id: z.string() }),
260
+ workItem: z.looseObject({ id: z.string() }).nullable(),
261
+ evidence: z.looseObject({ id: z.string() }).nullable(),
262
+ review: z.looseObject({ id: z.string() }).nullable(),
263
+ });
264
+ server.registerTool(
332
265
  'canvas_ingest_activity',
333
- 'Ingest a normalized agent activity (a tool/session event your harness forwards) so the board reacts automatically — primitive A, makes AX bidirectional. Always records a timeline event; kind-driven default reactions (overridable per call via `reactions`): failure/error → work item (blocked) + review finding + evidence (logs); tool-result + outcome:"success" → evidence (tool-result); everything else (tool-start, session-*, command, note) → event only. Set any reaction to false to suppress it, or to an object to override its fields. Returns { event, workItem, evidence, review }.',
334
266
  {
335
- kind: z.enum([
336
- 'tool-start',
337
- 'tool-result',
338
- 'failure',
339
- 'error',
340
- 'session-start',
341
- 'session-end',
342
- 'command',
343
- 'note',
344
- ]),
345
- title: z.string(),
346
- summary: z.string().optional(),
347
- outcome: z.enum(['success', 'failure']).optional(),
348
- ref: z
349
- .string()
350
- .optional()
351
- .describe('A file path, URL, or commit the activity refers to (used as the review file anchor for failures).'),
352
- nodeIds: z.array(z.string()).optional(),
353
- data: z.record(z.string(), z.unknown()).optional(),
354
- reactions: z
355
- .object({
356
- workItem: z
357
- .union([
358
- z.literal(false),
359
- z.object({
360
- status: z.enum(['todo', 'in-progress', 'blocked', 'done', 'cancelled']).optional(),
361
- detail: z.string().nullable().optional(),
362
- }),
363
- ])
364
- .optional(),
365
- evidence: z
366
- .union([
367
- z.literal(false),
368
- z.object({
369
- kind: z.enum(['logs', 'tool-result', 'screenshot', 'file', 'diff', 'test-output']).optional(),
370
- body: z.string().nullable().optional(),
371
- }),
372
- ])
373
- .optional(),
374
- review: z
375
- .union([
376
- z.literal(false),
377
- z.object({
378
- severity: z.enum(['info', 'warning', 'error']).optional(),
379
- kind: z.enum(['comment', 'finding']).optional(),
380
- anchorType: z.enum(['node', 'file', 'region']).optional(),
381
- nodeId: z.string().nullable().optional(),
382
- }),
383
- ])
384
- .optional(),
385
- })
386
- .optional()
387
- .describe('Override or suppress the kind-driven default reactions.'),
388
- source: z.enum(AX_SOURCES).optional(),
267
+ description:
268
+ 'Ingest a harness activity. Always records an event. failure/error also creates blocked work, a review and log evidence; successful tool-result creates evidence. Override or suppress defaults with reactions. Returns IDs and records for event, workItem, evidence and review.',
269
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
270
+ outputSchema: activityResult,
271
+ inputSchema: {
272
+ kind: z.enum([
273
+ 'tool-start',
274
+ 'tool-result',
275
+ 'failure',
276
+ 'error',
277
+ 'session-start',
278
+ 'session-end',
279
+ 'command',
280
+ 'note',
281
+ ]),
282
+ title: z.string(),
283
+ summary: z.string().optional(),
284
+ outcome: z.enum(['success', 'failure']).optional(),
285
+ ref: z
286
+ .string()
287
+ .optional()
288
+ .describe(
289
+ 'A file path, URL, or commit the activity refers to (used as the review file anchor for failures).',
290
+ ),
291
+ nodeIds: z.array(z.string()).optional(),
292
+ data: z.record(z.string(), z.unknown()).optional(),
293
+ reactions: z
294
+ .object({
295
+ workItem: z
296
+ .union([
297
+ z.literal(false),
298
+ z.object({
299
+ status: z.enum(['todo', 'in-progress', 'blocked', 'done', 'cancelled']).optional(),
300
+ detail: z.string().nullable().optional(),
301
+ }),
302
+ ])
303
+ .optional(),
304
+ evidence: z
305
+ .union([
306
+ z.literal(false),
307
+ z.object({
308
+ kind: z.enum(['logs', 'tool-result', 'screenshot', 'file', 'diff', 'test-output']).optional(),
309
+ body: z.string().nullable().optional(),
310
+ }),
311
+ ])
312
+ .optional(),
313
+ review: z
314
+ .union([
315
+ z.literal(false),
316
+ z.object({
317
+ severity: z.enum(['info', 'warning', 'error']).optional(),
318
+ kind: z.enum(['comment', 'finding']).optional(),
319
+ anchorType: z.enum(['node', 'file', 'region']).optional(),
320
+ nodeId: z.string().nullable().optional(),
321
+ }),
322
+ ])
323
+ .optional(),
324
+ })
325
+ .optional()
326
+ .describe('Override or suppress the kind-driven default reactions.'),
327
+ source: z.enum(AX_SOURCES).optional(),
328
+ },
389
329
  },
390
- async ({ kind, title, summary, outcome, ref, nodeIds, data, reactions, source }) => {
330
+ async ({ kind, title, summary, outcome, ref, nodeIds, data, reactions, source }, extra) => {
391
331
  try {
392
332
  const c = await ensureCanvas();
393
- const result = await c.ingestActivity(
333
+ extra.signal.throwIfAborted();
334
+ const result = await c.invoker().invoke(
335
+ 'ax.activity.ingest',
394
336
  {
395
337
  kind,
396
338
  title,
@@ -400,10 +342,12 @@ export async function startMcpServer(): Promise<void> {
400
342
  ...(nodeIds !== undefined ? { nodeIds } : {}),
401
343
  ...(data !== undefined ? { data } : {}),
402
344
  ...(reactions !== undefined ? { reactions } : {}),
345
+ source: source ?? 'mcp',
403
346
  },
404
- { source: source ?? 'mcp' },
347
+ { signal: extra.signal },
405
348
  );
406
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, ...result }) }] };
349
+ const body = activityResult.parse(result);
350
+ return { content: [{ type: 'text', text: JSON.stringify(body) }], structuredContent: body };
407
351
  } catch (error) {
408
352
  return {
409
353
  content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
@@ -430,25 +374,50 @@ export async function startMcpServer(): Promise<void> {
430
374
  // binary image payload, which the registry's JSON wire shape does not model.
431
375
 
432
376
  // ── canvas_screenshot ─────────────────────────────────────────
433
- server.tool(
377
+ const screenshotResult = z.object({
378
+ bytes: z.number().int().nonnegative(),
379
+ backend: z.enum(['webkit', 'chrome']).nullable(),
380
+ width: z.number().nullable(),
381
+ height: z.number().nullable(),
382
+ mimeType: z.enum(['image/png', 'image/jpeg', 'image/webp']),
383
+ });
384
+ server.registerTool(
434
385
  'canvas_screenshot',
435
- 'Capture a screenshot from the active Bun.WebView automation session. Returns both an MCP image payload and JSON metadata. Requires an active automation session started via canvas_webview with action "start".',
436
386
  {
437
- format: z
438
- .enum(['png', 'jpeg', 'webp'])
439
- .optional()
440
- .describe('Screenshot format (default depends on Bun; png recommended)'),
441
- quality: z.number().optional().describe('Optional quality for lossy formats'),
387
+ description:
388
+ 'Capture an image and metadata from the active Bun.WebView session. Start it with canvas_webview action "start" first.',
389
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
390
+ outputSchema: screenshotResult,
391
+ inputSchema: {
392
+ format: z
393
+ .enum(['png', 'jpeg', 'webp'])
394
+ .optional()
395
+ .describe('Screenshot format (default depends on Bun; png recommended)'),
396
+ quality: z.number().optional().describe('Optional quality for lossy formats'),
397
+ },
442
398
  },
443
- async ({ format, quality }) => {
444
- const c = await ensureCanvas();
399
+ async ({ format, quality }, extra) => {
445
400
  try {
446
- const bytes = await c.screenshotAutomationWebView({
447
- ...(format ? { format } : {}),
448
- ...(typeof quality === 'number' ? { quality } : {}),
449
- });
401
+ const c = await ensureCanvas();
402
+ extra.signal.throwIfAborted();
403
+ const bytes = await c.screenshotAutomationWebView(
404
+ {
405
+ ...(format ? { format } : {}),
406
+ ...(typeof quality === 'number' ? { quality } : {}),
407
+ },
408
+ extra.signal,
409
+ );
410
+ extra.signal.throwIfAborted();
450
411
  const status = await c.getAutomationWebViewStatus();
412
+ const metadata = screenshotResult.parse({
413
+ bytes: bytes.byteLength,
414
+ backend: status.backend,
415
+ width: status.width,
416
+ height: status.height,
417
+ mimeType: format === 'jpeg' ? 'image/jpeg' : format === 'webp' ? 'image/webp' : 'image/png',
418
+ });
451
419
  return {
420
+ structuredContent: metadata,
452
421
  content: [
453
422
  {
454
423
  type: 'image',
@@ -457,17 +426,7 @@ export async function startMcpServer(): Promise<void> {
457
426
  },
458
427
  {
459
428
  type: 'text',
460
- text: JSON.stringify(
461
- {
462
- bytes: bytes.byteLength,
463
- backend: status.backend,
464
- width: status.width,
465
- height: status.height,
466
- mimeType: format === 'jpeg' ? 'image/jpeg' : format === 'webp' ? 'image/webp' : 'image/png',
467
- },
468
- null,
469
- 2,
470
- ),
429
+ text: JSON.stringify(metadata),
471
430
  },
472
431
  ],
473
432
  };
@@ -1015,6 +974,11 @@ export async function startMcpServer(): Promise<void> {
1015
974
  // canvas_validate migrated to the operation registry (plan-008 Wave 1):
1016
975
  // src/server/operations/ops/validate.ts.
1017
976
 
977
+ return server;
978
+ }
979
+
980
+ export async function startMcpServer(): Promise<void> {
981
+ const server = createMcpServer();
1018
982
  // Connect via stdio
1019
983
  const transport = new StdioServerTransport();
1020
984
  await server.connect(transport);
@@ -1024,7 +988,9 @@ export async function startMcpServer(): Promise<void> {
1024
988
  // orphaned — the auto-started canvas server and its timers keep the event
1025
989
  // loop alive forever. stopCanvasServer() checkpoints the SQLite DB before
1026
990
  // exit and is a no-op when this process only attached to a remote daemon.
991
+ const disposeSubscriptions = server.server.onclose;
1027
992
  const shutdown = (): void => {
993
+ disposeSubscriptions?.();
1028
994
  try {
1029
995
  stopCanvasServer();
1030
996
  process.exit(0);
@@ -23,27 +23,32 @@ export async function waitForAxResolution<T extends { status: string }>(opts: {
23
23
  timeoutMs: number;
24
24
  signal?: AbortSignal;
25
25
  }): Promise<AxWaitResult<T>> {
26
+ opts.signal?.throwIfAborted();
26
27
  const { read, isResolved } = opts;
27
28
  const timeoutMs = Math.max(0, Math.min(opts.timeoutMs, AX_WAIT_MAX_MS));
28
29
  const pendingOf = (v: T | null): boolean => (v ? !isResolved(v) : false);
29
30
 
30
31
  const current = read();
31
- if (!current || isResolved(current) || timeoutMs === 0 || opts.signal?.aborted) {
32
+ if (!current || isResolved(current) || timeoutMs === 0) {
32
33
  return { value: current, pending: pendingOf(current) };
33
34
  }
34
35
 
35
- return new Promise<AxWaitResult<T>>((resolve) => {
36
+ return new Promise<AxWaitResult<T>>((resolve, reject) => {
36
37
  let done = false;
37
38
  const finish = (): void => {
38
39
  if (done) return;
39
40
  done = true;
40
41
  clearTimeout(timer);
41
42
  dispose();
42
- opts.signal?.removeEventListener('abort', onSettle);
43
+ opts.signal?.removeEventListener('abort', onAbort);
44
+ if (opts.signal?.aborted) {
45
+ reject(opts.signal.reason);
46
+ return;
47
+ }
43
48
  const v = read();
44
49
  resolve({ value: v, pending: pendingOf(v) });
45
50
  };
46
- const onSettle = finish;
51
+ const onAbort = finish;
47
52
  const check = (type: string): void => {
48
53
  if (type !== 'ax') return;
49
54
  const v = read();
@@ -51,7 +56,8 @@ export async function waitForAxResolution<T extends { status: string }>(opts: {
51
56
  };
52
57
  const timer = setTimeout(finish, timeoutMs);
53
58
  const dispose = canvasState.onChange(check);
54
- opts.signal?.addEventListener('abort', onSettle, { once: true });
59
+ opts.signal?.addEventListener('abort', onAbort, { once: true });
60
+ if (opts.signal?.aborted) finish();
55
61
  });
56
62
  }
57
63
 
@@ -67,17 +73,24 @@ export async function waitForAxCondition(opts: {
67
73
  ready: () => boolean;
68
74
  timeoutMs: number;
69
75
  channel?: string;
76
+ signal?: AbortSignal;
70
77
  }): Promise<void> {
78
+ opts.signal?.throwIfAborted();
71
79
  const timeoutMs = Math.max(0, Math.min(opts.timeoutMs, AX_WAIT_MAX_MS));
72
80
  if (timeoutMs === 0 || opts.ready()) return;
73
81
  const channel = opts.channel ?? 'ax-timeline';
74
- await new Promise<void>((resolve) => {
82
+ await new Promise<void>((resolve, reject) => {
75
83
  let done = false;
76
84
  const finish = (): void => {
77
85
  if (done) return;
78
86
  done = true;
79
87
  clearTimeout(timer);
80
88
  dispose();
89
+ opts.signal?.removeEventListener('abort', finish);
90
+ if (opts.signal?.aborted) {
91
+ reject(opts.signal.reason);
92
+ return;
93
+ }
81
94
  resolve();
82
95
  };
83
96
  const check = (type: string): void => {
@@ -85,5 +98,7 @@ export async function waitForAxCondition(opts: {
85
98
  };
86
99
  const timer = setTimeout(finish, timeoutMs);
87
100
  const dispose = canvasState.onChange(check);
101
+ opts.signal?.addEventListener('abort', finish, { once: true });
102
+ if (opts.signal?.aborted) finish();
88
103
  });
89
104
  }
@@ -1059,8 +1059,9 @@ export function resolveCanvasNode(nodeRef: CanvasNodeLookupInput):
1059
1059
 
1060
1060
  export async function refreshCanvasWebpageNode(
1061
1061
  id: string,
1062
- options: { url?: string } = {},
1062
+ options: { url?: string; signal?: AbortSignal } = {},
1063
1063
  ): Promise<{ ok: boolean; id: string; error?: string }> {
1064
+ options.signal?.throwIfAborted();
1064
1065
  const existing = canvasState.getNode(id);
1065
1066
  if (!existing || existing.type !== 'webpage') {
1066
1067
  return { ok: false, id, error: `Webpage node "${id}" not found.` };
@@ -1094,7 +1095,8 @@ export async function refreshCanvasWebpageNode(
1094
1095
  }
1095
1096
 
1096
1097
  try {
1097
- const snapshot = await fetchWebpageSnapshot(normalizedUrl);
1098
+ const snapshot = await fetchWebpageSnapshot(normalizedUrl, { signal: options.signal });
1099
+ options.signal?.throwIfAborted();
1098
1100
  if (canvasState.boardGeneration !== boardGeneration) {
1099
1101
  return { ok: false, id, error: 'The board changed during webpage refresh.' };
1100
1102
  }
@@ -1131,6 +1133,8 @@ export async function refreshCanvasWebpageNode(
1131
1133
  canvasState.updateNode(id, { data: nextData });
1132
1134
  return { ok: true, id };
1133
1135
  } catch (error) {
1136
+ // Cancellation leaves the previous content/status intact, not an error card.
1137
+ options.signal?.throwIfAborted();
1134
1138
  if (canvasState.boardGeneration !== boardGeneration) {
1135
1139
  return { ok: false, id, error: 'The board changed during webpage refresh.' };
1136
1140
  }
@@ -1,8 +1,16 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
  import type { Camera, Tour, TourStop } from '../shared/tour.js';
3
- import { currentTour, exitTour, goToTourStop, moveCamera, type TourStep, type TourStepResult } from './tour-control.js';
3
+ import {
4
+ currentTour,
5
+ exitTour,
6
+ goToTourStop,
7
+ moveCamera,
8
+ type TourState,
9
+ type TourStep,
10
+ type TourStepResult,
11
+ } from './tour-control.js';
4
12
  export type { Tour, TourStop } from '../shared/tour.js';
5
- export type { TourStep, TourStepResult } from './tour-control.js';
13
+ export type { TourState, TourStep, TourStepResult } from './tour-control.js';
6
14
  import { agentPresence } from './agent-presence.js';
7
15
  import { checkFenceTarget, type FenceTarget } from './scope-fence.js';
8
16
  import type { AgentPhase, AgentPresence, AgentPresenceSnapshot } from '../shared/agent-presence.js';
@@ -612,8 +620,8 @@ export class PmxCanvas extends EventEmitter {
612
620
  );
613
621
  }
614
622
 
615
- getTour(): Tour {
616
- return currentTour().tour;
623
+ getTour(): TourState {
624
+ return currentTour();
617
625
  }
618
626
 
619
627
  setTour(tour: Tour | null): void {