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
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { executeOperation, listOperations } from './registry.js';
10
10
  import { SOURCE_LABEL_RE } from '../agent-presence.js';
11
- import { OperationError } from './types.js';
11
+ import { OperationError, type OperationExecutionOptions, type OperationProgress } from './types.js';
12
12
  import { isWorkbenchToken, refuseStaleWorkbenchWrite } from '../workbench-auth.js';
13
13
 
14
14
  function responseJson(data: unknown, status = 200): Response {
@@ -20,6 +20,57 @@ function responseJson(data: unknown, status = 200): Response {
20
20
  });
21
21
  }
22
22
 
23
+ /** Opt-in NDJSON; the terminal frame retains the ordinary HTTP status/body. */
24
+ export type OperationStreamFrame =
25
+ | { type: 'progress'; progress: OperationProgress }
26
+ | { type: 'result'; status: number; body: unknown };
27
+
28
+ function operationFailure(name: string, error: unknown, signal: AbortSignal): Response {
29
+ if (signal.aborted) return responseJson({ ok: false, cancelled: true, error: 'Operation cancelled.' }, 499);
30
+ if (error instanceof OperationError) {
31
+ return responseJson({ ok: false, error: error.message, ...(error.details ?? {}) }, error.status);
32
+ }
33
+ // Keep unexpected errors server-side, never leak a Bun development overlay.
34
+ console.error(`[operation] unhandled error dispatching ${name}:`, error);
35
+ return responseJson({ ok: false, error: 'Internal error processing the request.' }, 500);
36
+ }
37
+
38
+ function progressResponse(
39
+ req: Request,
40
+ name: string,
41
+ run: (options: OperationExecutionOptions) => Promise<Response>,
42
+ ): Response {
43
+ const cancelled = new AbortController();
44
+ const signal = AbortSignal.any([req.signal, cancelled.signal]);
45
+ const encoder = new TextEncoder();
46
+ let closed = false;
47
+ const stream = new ReadableStream<Uint8Array>({
48
+ async start(controller) {
49
+ const send = (frame: OperationStreamFrame): void => {
50
+ if (!closed) controller.enqueue(encoder.encode(`${JSON.stringify(frame)}\n`));
51
+ };
52
+ let response: Response;
53
+ try {
54
+ response = await run({ signal, onProgress: (progress) => send({ type: 'progress', progress }) });
55
+ } catch (error) {
56
+ response = operationFailure(name, error, signal);
57
+ }
58
+ if (!closed) {
59
+ send({ type: 'result', status: response.status, body: await response.json() });
60
+ closed = true;
61
+ controller.close();
62
+ }
63
+ },
64
+ cancel() {
65
+ closed = true;
66
+ cancelled.abort();
67
+ },
68
+ });
69
+ return new Response(stream, {
70
+ headers: { 'Content-Type': 'application/x-ndjson', 'Cache-Control': 'no-store', 'X-Accel-Buffering': 'no' },
71
+ });
72
+ }
73
+
23
74
  export function matchOperationPath(template: string, pathname: string): Record<string, string> | null {
24
75
  const templateSegments = template.split('/');
25
76
  const pathSegments = pathname.split('/');
@@ -83,6 +134,7 @@ export async function dispatchOperationRoute(req: Request, url: URL): Promise<Re
83
134
  const params = matchOperationPath(route.path, url.pathname);
84
135
  if (!params) continue;
85
136
  try {
137
+ req.signal.throwIfAborted();
86
138
  const input = route.readInput
87
139
  ? await route.readInput(req, params, url)
88
140
  : await readOperationInput(req, params, url);
@@ -100,27 +152,23 @@ export async function dispatchOperationRoute(req: Request, url: URL): Promise<Re
100
152
  // HTTP callers read as 'api'.
101
153
  const sourceHeader = req.headers.get('x-pmx-source');
102
154
  const source = sourceHeader && SOURCE_LABEL_RE.test(sourceHeader) ? sourceHeader : 'api';
103
- const result = await executeOperation(op.name, input, {
104
- // The legacy marker remains a UI-policy signal (ghost suppression).
105
- // Only the unguessable boot token classifies authorship as human.
106
- suppressAutoGhost: workbenchMarked,
107
- fromWorkbench: workbenchMarked,
108
- humanAuthor,
109
- source,
110
- proxiedRead: req.headers.get('x-pmx-proxied-read') === '1',
111
- });
112
- return responseJson(result, route.status ? route.status(result) : 200);
155
+ const run = async (options: OperationExecutionOptions): Promise<Response> => {
156
+ const result = await executeOperation(op.name, input, {
157
+ ...options,
158
+ // The legacy marker remains a UI-policy signal (ghost suppression).
159
+ // Only the unguessable boot token classifies authorship as human.
160
+ suppressAutoGhost: workbenchMarked,
161
+ fromWorkbench: workbenchMarked,
162
+ humanAuthor,
163
+ source,
164
+ proxiedRead: req.headers.get('x-pmx-proxied-read') === '1',
165
+ });
166
+ return responseJson(result, route.status ? route.status(result) : 200);
167
+ };
168
+ if (req.headers.get('accept') === 'application/x-ndjson') return progressResponse(req, op.name, run);
169
+ return await run({ signal: req.signal });
113
170
  } catch (error) {
114
- if (error instanceof OperationError) {
115
- return responseJson({ ok: false, error: error.message, ...(error.details ?? {}) }, error.status);
116
- }
117
- // An unexpected (non-OperationError) throw from a handler MUST NOT escape the
118
- // dispatcher: Bun.serve has no per-request boundary, so an escaped throw renders
119
- // its dev error overlay (HTTP 500 text/html leaking the absolute server source
120
- // path + stack). Return a clean JSON 500 with a generic message instead (the
121
- // real error is logged server-side, never echoed to the client).
122
- console.error(`[operation] unhandled error dispatching ${op.name}:`, error);
123
- return responseJson({ ok: false, error: 'Internal error processing the request.' }, 500);
171
+ return operationFailure(op.name, error, req.signal);
124
172
  }
125
173
  }
126
174
  return null;
@@ -77,7 +77,12 @@ export { runCanvasBatchOperation, type BatchEnvelope } from './ops/batch.js';
77
77
  // OpenMcpAppCoreResult is the SDK's cast target for executeOperation('mcpapp.open').
78
78
  export type { OpenMcpAppCoreResult } from './ops/app.js';
79
79
  export { LocalOperationInvoker, HttpOperationInvoker, type OperationInvoker } from './invoker.js';
80
- export { registerOperationTools, registerCompositeTools, type OperationToolHost } from './mcp.js';
80
+ export {
81
+ registerOperationTools,
82
+ registerCompositeTools,
83
+ registerOperationHelpTool,
84
+ type OperationToolHost,
85
+ } from './mcp.js';
81
86
  export { compositeToolDefinitions, type CompositeToolDefinition } from './composites.js';
82
87
  export {
83
88
  OperationError,
@@ -85,5 +90,7 @@ export {
85
90
  type Operation,
86
91
  type OperationContext,
87
92
  type OperationDefinition,
93
+ type OperationExecutionOptions,
88
94
  type OperationErrorStatus,
95
+ type OperationProgress,
89
96
  } from './types.js';
@@ -5,10 +5,11 @@
5
5
  */
6
6
  import { SOURCE_LABEL_RE } from '../agent-presence.js';
7
7
  import { executeOperation, getOperation } from './registry.js';
8
- import { OperationError, type OperationErrorStatus } from './types.js';
8
+ import { OperationError, type OperationErrorStatus, type OperationExecutionOptions } from './types.js';
9
+ import type { OperationStreamFrame } from './http.js';
9
10
 
10
11
  export interface OperationInvoker {
11
- invoke(name: string, input: Record<string, unknown>): Promise<unknown>;
12
+ invoke(name: string, input: Record<string, unknown>, options?: OperationExecutionOptions): Promise<unknown>;
12
13
  }
13
14
 
14
15
  /**
@@ -40,9 +41,14 @@ export class LocalOperationInvoker implements OperationInvoker {
40
41
  this.proxiedRead = source === 'mcp';
41
42
  }
42
43
 
43
- async invoke(name: string, input: Record<string, unknown>): Promise<unknown> {
44
+ async invoke(
45
+ name: string,
46
+ input: Record<string, unknown>,
47
+ options: OperationExecutionOptions = {},
48
+ ): Promise<unknown> {
44
49
  const args = this.allowExternalMcp ? input : { ...input, allowExternalMcp: false };
45
50
  return await executeOperation(name, defaultPresenceSource(name, args, this.source), {
51
+ ...options,
46
52
  source: this.source,
47
53
  proxiedRead: this.proxiedRead,
48
54
  });
@@ -53,6 +59,37 @@ function toOperationErrorStatus(status: number): OperationErrorStatus {
53
59
  return status === 404 ? 404 : status === 409 ? 409 : status === 403 ? 403 : 400;
54
60
  }
55
61
 
62
+ async function readProgressResponse(response: Response, options: OperationExecutionOptions): Promise<Response> {
63
+ if (!response.body) throw new OperationError('Missing operation progress stream.');
64
+ const reader = response.body.getReader();
65
+ const decoder = new TextDecoder();
66
+ let buffer = '';
67
+ try {
68
+ while (true) {
69
+ const { done, value } = await reader.read();
70
+ buffer += decoder.decode(value, { stream: !done });
71
+ let end = buffer.indexOf('\n');
72
+ while (end !== -1) {
73
+ const frame = JSON.parse(buffer.slice(0, end)) as OperationStreamFrame;
74
+ buffer = buffer.slice(end + 1);
75
+ if (frame.type === 'result') return Response.json(frame.body, { status: frame.status });
76
+ if (frame.type !== 'progress') throw new OperationError('Invalid operation progress frame.');
77
+ try {
78
+ await options.onProgress?.(frame.progress);
79
+ } catch (error) {
80
+ if (!options.signal?.aborted) console.error('[operation] progress callback failed:', error);
81
+ }
82
+ end = buffer.indexOf('\n');
83
+ }
84
+ if (done) throw new OperationError('Operation progress stream ended without a result.');
85
+ }
86
+ } finally {
87
+ // A fetch abort already errors the reader; preserve that original failure.
88
+ await reader.cancel().catch(() => {});
89
+ reader.releaseLock();
90
+ }
91
+ }
92
+
56
93
  /** Builds the HTTP request from the op's route template (`:id` from input, GET flags to query). */
57
94
  export class HttpOperationInvoker implements OperationInvoker {
58
95
  private readonly baseUrl: string;
@@ -70,7 +107,12 @@ export class HttpOperationInvoker implements OperationInvoker {
70
107
  this.proxiedRead = source === 'mcp';
71
108
  }
72
109
 
73
- async invoke(name: string, rawInput: Record<string, unknown>): Promise<unknown> {
110
+ async invoke(
111
+ name: string,
112
+ rawInput: Record<string, unknown>,
113
+ options: OperationExecutionOptions = {},
114
+ ): Promise<unknown> {
115
+ options.signal?.throwIfAborted();
74
116
  const args = this.allowExternalMcp ? rawInput : { ...rawInput, allowExternalMcp: false };
75
117
  const input = defaultPresenceSource(name, args, this.source);
76
118
  const op = getOperation(name);
@@ -96,8 +138,10 @@ export class HttpOperationInvoker implements OperationInvoker {
96
138
 
97
139
  let url = `${this.baseUrl}${path}`;
98
140
  const proxyHeaders: Record<string, string> = this.proxiedRead ? { 'x-pmx-proxied-read': '1' } : {};
141
+ if (options.onProgress) proxyHeaders.accept = 'application/x-ndjson';
99
142
  const init: RequestInit = {
100
143
  method: route.method,
144
+ signal: options.signal,
101
145
  headers: { 'x-pmx-source': this.source, ...proxyHeaders },
102
146
  };
103
147
  if (route.method === 'GET' || route.method === 'DELETE') {
@@ -112,7 +156,10 @@ export class HttpOperationInvoker implements OperationInvoker {
112
156
  init.body = JSON.stringify(rest);
113
157
  }
114
158
 
115
- const response = await fetch(url, init);
159
+ let response = await fetch(url, init);
160
+ if (response.headers.get('content-type')?.startsWith('application/x-ndjson')) {
161
+ response = await readProgressResponse(response, options);
162
+ }
116
163
  const text = await response.text();
117
164
  let parsed: unknown = null;
118
165
  let parsedAsJson = false;
@@ -125,6 +172,7 @@ export class HttpOperationInvoker implements OperationInvoker {
125
172
  }
126
173
  }
127
174
  if (!response.ok) {
175
+ if (response.status === 499) throw new DOMException('Operation cancelled.', 'AbortError');
128
176
  // errorBodyAsResult carries the route's STRUCTURED partial-failure
129
177
  // envelope (e.g. canvas_batch { ok:false, failedIndex, ... }) through as
130
178
  // a result. A non-JSON error body (an HTML 502 from a proxy, a foreign
@@ -0,0 +1,339 @@
1
+ /**
2
+ * MCP's output is the FORMATTER projection, never the larger HTTP payload.
3
+ * `result` wraps prose/arrays without pretending they are JSON objects. Each
4
+ * operation validates its stable fields; loose payloads preserve full:true and
5
+ * extensible node/app data. No schema advertises an unconstrained root record.
6
+ */
7
+ import { z, type ZodType } from 'zod';
8
+ import type { CallToolResult, ToolAnnotations } from '@modelcontextprotocol/sdk/types.js';
9
+ import { tourSchema, tourStopSchema } from '../../shared/tour.js';
10
+
11
+ const id = z.string().min(1);
12
+ const count = z.number().int().nonnegative();
13
+ const ok = z.looseObject({ ok: z.boolean() });
14
+ const identified = z.looseObject({ id });
15
+ const message = z.strictObject({ message: z.string() });
16
+ const node = z.looseObject({
17
+ id,
18
+ type: z.string(),
19
+ contentRevision: count.optional(),
20
+ linksRevision: count.optional(),
21
+ });
22
+ const created = z.looseObject({
23
+ ok: z.boolean(),
24
+ id,
25
+ nodeId: id.optional(),
26
+ node: node.optional(),
27
+ contentRevision: count.optional(),
28
+ });
29
+ const edge = z.looseObject({ id, from: id, to: id, type: z.string() });
30
+ const boards = z.looseObject({ ok: z.boolean(), activeBoardId: id.nullable(), boards: z.array(identified) });
31
+ const board = z.looseObject({ ok: z.boolean(), board: identified });
32
+ const viewport = z.object({ x: z.number(), y: z.number(), scale: z.number().positive() });
33
+ const webview = z.looseObject({ supported: z.boolean(), active: z.boolean() });
34
+ const entity = (key: string) => z.looseObject({ ok: z.boolean(), [key]: identified });
35
+ const gate = (key: string) =>
36
+ z.looseObject({ ok: z.boolean(), [key]: identified.nullable(), pending: z.boolean().optional() });
37
+ const history = z.looseObject({ ok: z.boolean(), description: z.string(), canUndo: z.boolean(), canRedo: z.boolean() });
38
+ const batchEntry = z.looseObject({
39
+ ok: z.boolean(),
40
+ id: id.optional(),
41
+ nodeId: id.optional(),
42
+ contentRevision: count.optional(),
43
+ });
44
+ const batch = z.discriminatedUnion('ok', [
45
+ z.looseObject({ ok: z.literal(true), results: z.array(batchEntry), refs: z.record(z.string(), batchEntry) }),
46
+ z.looseObject({
47
+ ok: z.literal(false),
48
+ failedIndex: count,
49
+ error: z.string(),
50
+ cancelled: z.boolean().optional(),
51
+ results: z.array(batchEntry),
52
+ refs: z.record(z.string(), batchEntry),
53
+ }),
54
+ ]);
55
+
56
+ /** Required stable fields, keyed by dispatched operation (including every composite action). */
57
+ const payloads: Record<string, ZodType> = {
58
+ 'pin.set': z.looseObject({ ok: z.boolean(), pinnedNodeIds: z.array(id) }),
59
+ 'ax.command.invoke': entity('event'),
60
+ 'canvas.batch': batch,
61
+ 'import.list': z.looseObject({ ok: z.boolean(), imports: z.array(identified) }),
62
+ 'import.read': z.looseObject({
63
+ ok: z.boolean(),
64
+ import: identified,
65
+ attachment: identified,
66
+ downloadPath: z.string(),
67
+ }),
68
+ 'import.submit': entity('import'),
69
+ 'import.unavailable': entity('import'),
70
+ 'snapshot.save': z.looseObject({ ok: z.boolean(), id, snapshot: identified }),
71
+ 'snapshot.list': z.looseObject({ snapshots: z.array(identified) }),
72
+ 'snapshot.restore': z.looseObject({
73
+ ok: z.boolean(),
74
+ restored: id,
75
+ summary: z.looseObject({ nodeCount: count, edgeCount: count }),
76
+ }),
77
+ 'snapshot.restore-node': z.looseObject({ ok: z.boolean(), id, restored: z.boolean() }),
78
+ 'snapshot.delete': z.looseObject({ ok: z.boolean(), deleted: id }),
79
+ 'snapshot.gc': ok,
80
+ 'snapshot.diff': message,
81
+ 'board.list': boards,
82
+ 'board.get': board,
83
+ 'board.create': board,
84
+ 'board.create-from': z.union([
85
+ board,
86
+ z.looseObject({ ok: z.boolean(), sourceBoardId: id, cards: z.array(node), edges: z.array(edge) }),
87
+ ]),
88
+ 'board.open': boards,
89
+ 'board.update': boards,
90
+ 'board.pin': boards,
91
+ 'board.unpin': boards,
92
+ 'knowledge.list': z.looseObject({
93
+ ok: z.boolean(),
94
+ categories: z.array(identified),
95
+ folders: z.array(z.looseObject({ path: z.string() })),
96
+ }),
97
+ 'knowledge.category-set': entity('category'),
98
+ 'knowledge.category-remove': z.looseObject({ ok: z.boolean(), id }),
99
+ 'knowledge.relation-preset-set': entity('preset'),
100
+ 'knowledge.relation-preset-remove': z.looseObject({ ok: z.boolean(), id }),
101
+ 'knowledge.folder-set': z.looseObject({ ok: z.boolean(), folder: z.looseObject({ path: z.string() }) }),
102
+ 'node.add': created,
103
+ 'node.get': node,
104
+ 'node.update': created,
105
+ 'node.remove': z.looseObject({ ok: z.boolean(), removed: id }),
106
+ 'node.lifecycle-get': z.looseObject({ ok: z.boolean(), boardId: id, nodeId: id, contentRevision: count }),
107
+ 'node.lifecycle-set': ok,
108
+ 'node.review': entity('record'),
109
+ 'node.lifecycle-restore': entity('record'),
110
+ 'schema.describe': z.looseObject({ ok: z.boolean(), nodeTypes: z.array(z.looseObject({ type: z.string() })) }),
111
+ 'spec.validate': ok,
112
+ 'jsonrender.add': created,
113
+ 'jsonrender.stream': created.extend({ specVersion: count, applied: count, skipped: count }),
114
+ 'graph.add': created,
115
+ 'render.workboard': z.looseObject({ ok: z.boolean(), id }),
116
+ 'edge.add': edge,
117
+ 'edge.update': z.looseObject({ id, type: z.string() }),
118
+ 'edge.remove': z.looseObject({ ok: z.boolean(), removed: id }),
119
+ 'group.create': created,
120
+ 'group.add': z.looseObject({ ok: z.boolean(), groupId: id }),
121
+ 'group.remove': z.looseObject({ ok: z.boolean(), groupId: id }),
122
+ 'canvas.undo': history,
123
+ 'canvas.redo': history,
124
+ 'tour.get': z.looseObject({ tour: tourSchema, derived: z.boolean(), position: count.nullable() }),
125
+ 'tour.set': ok,
126
+ 'tour.go': z.looseObject({ ok: z.boolean(), index: count, total: count, stop: tourStopSchema }),
127
+ 'tour.exit': ok,
128
+ 'camera.move': z.looseObject({ ok: z.boolean(), stop: tourStopSchema, viewport }),
129
+ arrange: z.looseObject({ ok: z.boolean(), layout: z.enum(['grid', 'column', 'flow']) }),
130
+ 'node.focus': z.looseObject({ ok: z.boolean(), focused: id, panned: z.boolean() }),
131
+ 'view.fit': z.looseObject({ ok: z.boolean(), viewport }),
132
+ 'canvas.clear': z.looseObject({ ok: z.boolean(), cleared: z.literal(true) }),
133
+ 'annotation.remove': ok,
134
+ 'context.get': z.looseObject({ version: z.literal(1), budgetUnit: z.literal('utf16-code-units') }),
135
+ 'memory-graph.get': z.looseObject({
136
+ nodes: z.array(z.looseObject({ key: id, boardId: id, kind: z.enum(['board', 'card']) })),
137
+ edges: z.array(z.looseObject({ id, from: id, to: id })),
138
+ depth: count,
139
+ }),
140
+ search: z.looseObject({ query: z.string(), resultCount: count, results: z.array(node) }),
141
+ 'layout.get': z.looseObject({ nodes: z.array(node), edges: z.array(edge), viewport }),
142
+ 'validate.get': ok,
143
+ 'mcpapp.open': z.looseObject({
144
+ ok: z.boolean(),
145
+ nodeId: id.nullable(),
146
+ sessionId: id,
147
+ toolCallId: id,
148
+ resourceUri: z.string(),
149
+ }),
150
+ 'diagram.open': z.looseObject({
151
+ ok: z.boolean(),
152
+ nodeId: id.nullable(),
153
+ sessionId: id,
154
+ toolCallId: id,
155
+ resourceUri: z.string(),
156
+ }),
157
+ 'webartifact.build': z.looseObject({
158
+ ok: z.boolean(),
159
+ path: z.string(),
160
+ bytes: count,
161
+ openedInCanvas: z.boolean(),
162
+ id: id.optional(),
163
+ nodeId: id.nullable().optional(),
164
+ }),
165
+ 'webview.status': webview,
166
+ 'webview.start': webview,
167
+ 'webview.stop': z.looseObject({ ok: z.boolean(), stopped: z.boolean(), webview }),
168
+ 'webview.resize': webview,
169
+ // Evaluation deliberately returns arbitrary page values, including undefined
170
+ // (serialized as an absent value). This is the one untyped leaf, not a root contract.
171
+ 'webview.evaluate': z.strictObject({ value: z.unknown().optional() }),
172
+ 'ax.get': ok,
173
+ 'ax.focus.set': z.looseObject({ ok: z.boolean(), focus: z.looseObject({ nodeIds: z.array(id) }) }),
174
+ 'ax.policy.set': ok,
175
+ 'ax.host-capability.report': ok,
176
+ 'ax.presence.get': ok,
177
+ 'ax.presence.set': ok,
178
+ 'ax.work.create': entity('workItem'),
179
+ 'ax.work.update': entity('workItem'),
180
+ 'ax.review.add': entity('reviewAnnotation'),
181
+ 'ax.approval.request': gate('approvalGate'),
182
+ 'ax.approval.resolve': gate('approvalGate'),
183
+ 'ax.approval.get': gate('approvalGate'),
184
+ 'ax.elicitation.request': gate('elicitation'),
185
+ 'ax.elicitation.respond': gate('elicitation'),
186
+ 'ax.elicitation.get': gate('elicitation'),
187
+ 'ax.mode.request': gate('modeRequest'),
188
+ 'ax.mode.resolve': gate('modeRequest'),
189
+ 'ax.mode.get': gate('modeRequest'),
190
+ 'ax.timeline.get': ok,
191
+ 'ax.event.record': entity('event'),
192
+ 'ax.evidence.add': entity('evidence'),
193
+ 'ax.steer': entity('steering'),
194
+ 'ax.reads.list': ok,
195
+ 'ax.reads.status': z.looseObject({ ok: z.boolean(), boardId: id.nullable() }),
196
+ 'ax.delivery.pending': z.looseObject({ ok: z.boolean(), pending: z.array(identified) }),
197
+ 'ax.delivery.mark': z.looseObject({ ok: z.boolean(), delivered: z.boolean() }),
198
+ 'intent.signal': entity('intent'),
199
+ 'intent.update': entity('intent'),
200
+ 'intent.clear': z.looseObject({ ok: z.boolean(), cleared: z.boolean() }),
201
+ };
202
+
203
+ function payloadSchema(operation: string): ZodType {
204
+ const schema = payloads[operation];
205
+ if (!schema) throw new Error(`Missing MCP output contract for ${operation}.`);
206
+ return schema;
207
+ }
208
+
209
+ const errorData = z.strictObject({ message: z.string() });
210
+ const formattedErrorData = z.looseObject({ ok: z.literal(false), error: z.string() });
211
+
212
+ export function operationOutputSchema(operations: string[]) {
213
+ // Shared schema identity proves equivalent validation without comparing or
214
+ // rewriting JSON Schema. Keep distinct contracts tied to their operations.
215
+ const successGroups = new Map<ZodType, string[]>();
216
+ const variants: ZodType[] = operations.flatMap((operation): ZodType[] => {
217
+ if (operation === 'canvas.batch')
218
+ return [
219
+ z.strictObject({ operation: z.literal(operation), outcome: z.literal('success'), data: batch.options[0] }),
220
+ z.strictObject({
221
+ operation: z.literal(operation),
222
+ outcome: z.literal('error'),
223
+ data: batch.options[1].extend({ results: z.array(batchEntry).length(0) }),
224
+ }),
225
+ z.strictObject({
226
+ operation: z.literal(operation),
227
+ outcome: z.literal('partial'),
228
+ data: batch.options[1].extend({ results: z.array(batchEntry).min(1) }),
229
+ }),
230
+ ];
231
+ const schema = payloadSchema(operation);
232
+ const group = successGroups.get(schema);
233
+ if (group) group.push(operation);
234
+ else successGroups.set(schema, [operation]);
235
+ return [];
236
+ });
237
+ for (const [data, names] of successGroups)
238
+ variants.push(
239
+ z.strictObject({
240
+ operation: names.length === 1 ? z.literal(names[0]) : z.enum(names),
241
+ outcome: z.literal('success'),
242
+ data,
243
+ }),
244
+ );
245
+ const formattedErrors = operations.filter((operation) => operation !== 'canvas.batch');
246
+ if (formattedErrors.length)
247
+ variants.push(
248
+ z.strictObject({
249
+ operation: z.enum(formattedErrors),
250
+ outcome: z.literal('error'),
251
+ data: z.union([errorData, formattedErrorData]),
252
+ }),
253
+ );
254
+ // null identifies an exception rather than a formatted operation result;
255
+ // it does NOT promise that no writes occurred before the exception.
256
+ return z.strictObject({
257
+ result: z.union([
258
+ ...variants,
259
+ z.strictObject({ operation: z.null(), outcome: z.literal('error'), data: errorData }),
260
+ ]),
261
+ });
262
+ }
263
+
264
+ export function structuredOperationResult(operation: string, formatted: CallToolResult): CallToolResult {
265
+ const text = formatted.content
266
+ .filter((item) => item.type === 'text')
267
+ .map((item) => item.text)
268
+ .join('\n');
269
+ let data: unknown;
270
+ try {
271
+ data = JSON.parse(text);
272
+ } catch {
273
+ data = { message: text };
274
+ }
275
+ // Error payloads can contain meaningful partial state. Never replace them
276
+ // with the thrown-error message shape, or lose batch results/refs.
277
+ if (!formatted.isError || operation === 'canvas.batch') payloadSchema(operation).parse(data);
278
+ else z.union([errorData, formattedErrorData]).parse(data);
279
+ const partial =
280
+ operation === 'canvas.batch' &&
281
+ formatted.isError &&
282
+ typeof data === 'object' &&
283
+ data !== null &&
284
+ 'results' in data &&
285
+ Array.isArray(data.results) &&
286
+ data.results.length > 0;
287
+ return {
288
+ ...formatted,
289
+ structuredContent: {
290
+ result: { operation, outcome: partial ? 'partial' : formatted.isError ? 'error' : 'success', data },
291
+ },
292
+ };
293
+ }
294
+
295
+ export function operationToolError(error: unknown): CallToolResult {
296
+ const message = error instanceof Error ? error.message : String(error);
297
+ return {
298
+ content: [{ type: 'text', text: message }],
299
+ isError: true,
300
+ structuredContent: { result: { operation: null, outcome: 'error', data: { message } } },
301
+ };
302
+ }
303
+
304
+ /**
305
+ * Explicit worst-case audit of all 21 generated tools. `mutates` only controls
306
+ * layout events and is intentionally absent here. Tuple = destructive,
307
+ * idempotent, open-world. All tools have a write action, including query's
308
+ * durable consumer cursor. Read logging alone would not make a tool a write.
309
+ */
310
+ const annotations: Record<string, readonly [boolean, boolean, boolean]> = {
311
+ canvas_pin_nodes: [true, true, false], // set/remove can replace the human's pins
312
+ canvas_invoke_command: [false, false, false], // appends an allowlisted intent, not arbitrary execution
313
+ canvas_batch: [true, false, true], // remove/replace; webpage creation can fetch
314
+ canvas_import: [false, false, false], // submit/unavailable transition a requested job
315
+ canvas_snapshot: [true, false, true], // restore/delete/gc; restoring apps can reconnect
316
+ canvas_board: [true, false, true], // category/template removal; open can rehydrate apps
317
+ canvas_node: [true, false, true], // remove/update; webpage fetching and file nodes
318
+ canvas_render: [true, false, false], // stream/workboard can replace an existing spec
319
+ canvas_edge: [true, false, false],
320
+ canvas_group: [true, false, false],
321
+ canvas_history: [true, false, true], // undo/redo can restore and reconnect app sessions
322
+ canvas_view: [true, false, false], // clear/remove annotation; tour next is not idempotent
323
+ canvas_query: [false, false, false], // context advances a durable consumer cursor
324
+ canvas_app: [true, false, true], // arbitrary external tools/build processes; existing-node replacement
325
+ canvas_webview: [true, false, true], // evaluate is arbitrary page JavaScript, not a read
326
+ canvas_ax_state: [true, false, false], // replaces policy/focus; presence can append sessions
327
+ canvas_ax_work: [true, false, false], // update replaces existing work-item fields
328
+ canvas_ax_gate: [true, false, false], // resolve changes pending gates; request creates IDs
329
+ canvas_ax_timeline: [false, false, false], // appends events/evidence/steering
330
+ canvas_ax_delivery: [false, false, false], // claim/mark consume delivery state
331
+ canvas_intent: [true, false, false], // update/clear; signal generates an ID and TTL
332
+ };
333
+
334
+ export function operationToolAnnotations(toolName: string): ToolAnnotations {
335
+ const flags = annotations[toolName];
336
+ if (!flags) throw new Error(`Missing MCP annotation audit for ${toolName}.`);
337
+ const [destructiveHint, idempotentHint, openWorldHint] = flags;
338
+ return { readOnlyHint: false, destructiveHint, idempotentHint, openWorldHint };
339
+ }