pmx-canvas 0.9.2 → 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 (87) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +9 -3
  2. package/.github/extensions/pmx-canvas/runtime.json +1 -1
  3. package/.github/extensions/pmx-canvas/tools.json +9278 -2014
  4. package/CHANGELOG.md +34 -1
  5. package/dist/canvas/global.css +12 -17
  6. package/dist/canvas/index.js +58 -58
  7. package/dist/canvas/mcp-app.html +75 -80
  8. package/dist/types/client/canvas/submit-without-navigation.d.ts +3 -0
  9. package/dist/types/client/state/intent-bridge.d.ts +0 -3
  10. package/dist/types/client/state/presence-store.d.ts +3 -3
  11. package/dist/types/client/types.d.ts +1 -1
  12. package/dist/types/mcp/canvas-access.d.ts +1 -12
  13. package/dist/types/mcp/resource-subscriptions.d.ts +7 -0
  14. package/dist/types/mcp/server.d.ts +2 -0
  15. package/dist/types/server/ax-wait.d.ts +1 -0
  16. package/dist/types/server/canvas-operations.d.ts +1 -0
  17. package/dist/types/server/canvas-state.d.ts +2 -0
  18. package/dist/types/server/index.d.ts +5 -5
  19. package/dist/types/server/knowledge-lifecycle.d.ts +11 -2
  20. package/dist/types/server/lifecycle-status.d.ts +3 -1
  21. package/dist/types/server/operations/http.d.ts +10 -0
  22. package/dist/types/server/operations/index.d.ts +2 -2
  23. package/dist/types/server/operations/invoker.d.ts +4 -3
  24. package/dist/types/server/operations/mcp-contracts.d.ts +20 -0
  25. package/dist/types/server/operations/mcp.d.ts +2 -0
  26. package/dist/types/server/operations/ops/batch.d.ts +1 -0
  27. package/dist/types/server/operations/registry.d.ts +2 -2
  28. package/dist/types/server/operations/types.d.ts +12 -2
  29. package/dist/types/server/server.d.ts +2 -0
  30. package/dist/types/server/tour-control.d.ts +6 -3
  31. package/dist/types/server/webpage-node.d.ts +1 -0
  32. package/docs/api-reference.md +2 -2
  33. package/docs/cli.md +10 -1
  34. package/docs/design/Chrome.dc.html +1 -0
  35. package/docs/design.md +3 -1
  36. package/docs/http-api.md +31 -0
  37. package/docs/mcp.md +98 -22
  38. package/docs/node-types.md +3 -0
  39. package/docs/plans/plan-019-post-0.9-roadmap.md +187 -11
  40. package/docs/sdk.md +10 -0
  41. package/package.json +1 -1
  42. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/extension.mjs +9 -3
  43. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +1 -1
  44. package/plugins/pmx-canvas/plugin.json +1 -1
  45. package/plugins/pmx-canvas/runtime.json +1 -1
  46. package/plugins/pmx-canvas/tools.json +9278 -2014
  47. package/skills/pmx-canvas/SKILL.md +12 -7
  48. package/skills/pmx-canvas/references/full-reference.md +35 -2
  49. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +2 -2
  50. package/skills/pmx-canvas/references/installing-pmx-canvas.md +7 -7
  51. package/src/client/canvas/BoardCopyDialog.tsx +2 -1
  52. package/src/client/canvas/KnowledgeDialog.tsx +3 -0
  53. package/src/client/canvas/LifecyclePanel.tsx +4 -0
  54. package/src/client/canvas/RelationPresetsDialog.tsx +2 -0
  55. package/src/client/canvas/ToolRail.tsx +2 -0
  56. package/src/client/canvas/submit-without-navigation.ts +14 -0
  57. package/src/client/state/canvas-store.ts +12 -24
  58. package/src/client/state/intent-bridge.ts +0 -6
  59. package/src/client/state/presence-store.ts +3 -3
  60. package/src/client/state/sse-bridge.ts +11 -6
  61. package/src/client/theme/global.css +12 -17
  62. package/src/mcp/canvas-access.ts +6 -47
  63. package/src/mcp/resource-subscriptions.ts +181 -0
  64. package/src/mcp/server.ts +185 -219
  65. package/src/server/ax-wait.ts +21 -6
  66. package/src/server/canvas-operations.ts +6 -2
  67. package/src/server/canvas-state.ts +10 -0
  68. package/src/server/index.ts +20 -32
  69. package/src/server/knowledge-lifecycle.ts +66 -0
  70. package/src/server/lifecycle-status.ts +79 -7
  71. package/src/server/operations/http.ts +69 -21
  72. package/src/server/operations/index.ts +8 -1
  73. package/src/server/operations/invoker.ts +53 -5
  74. package/src/server/operations/mcp-contracts.ts +339 -0
  75. package/src/server/operations/mcp.ts +153 -18
  76. package/src/server/operations/ops/ax-await.ts +18 -10
  77. package/src/server/operations/ops/ax-read.ts +2 -0
  78. package/src/server/operations/ops/ax-timeline.ts +2 -2
  79. package/src/server/operations/ops/batch.ts +27 -8
  80. package/src/server/operations/ops/lifecycle.ts +3 -7
  81. package/src/server/operations/ops/nodes.ts +20 -8
  82. package/src/server/operations/ops/viewport.ts +3 -7
  83. package/src/server/operations/registry.ts +24 -4
  84. package/src/server/operations/types.ts +16 -2
  85. package/src/server/server.ts +23 -10
  86. package/src/server/tour-control.ts +17 -8
  87. package/src/server/webpage-node.ts +9 -4
@@ -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
+ }
@@ -9,9 +9,17 @@
9
9
  */
10
10
  import { z, type ZodRawShape, type ZodTypeAny } from 'zod';
11
11
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
12
+ import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
13
+ import type { ServerRequest, ServerNotification } from '@modelcontextprotocol/sdk/types.js';
12
14
  import { getOperation, listOperations } from './registry.js';
13
15
  import { agentSourceLabel, type OperationInvoker } from './invoker.js';
14
- import { OperationError, type Operation, type OperationMcpToolHost } from './types.js';
16
+ import { OperationError, type Operation, type OperationMcpToolHost, type OperationExecutionOptions } from './types.js';
17
+ import {
18
+ operationOutputSchema,
19
+ operationToolAnnotations,
20
+ operationToolError,
21
+ structuredOperationResult,
22
+ } from './mcp-contracts.js';
15
23
  import {
16
24
  CONTEXT_READ_OPS,
17
25
  contextReadFromPayload,
@@ -28,23 +36,51 @@ export interface OperationToolHost extends OperationMcpToolHost {
28
36
  recordContextRead(read: ContextReadInput): Promise<void>;
29
37
  }
30
38
 
39
+ function executionOptions(extra: RequestHandlerExtra<ServerRequest, ServerNotification>): OperationExecutionOptions {
40
+ const progressToken = extra._meta?.progressToken;
41
+ return {
42
+ signal: extra.signal,
43
+ ...(progressToken !== undefined
44
+ ? {
45
+ onProgress: (progress: Parameters<NonNullable<OperationExecutionOptions['onProgress']>>[0]) =>
46
+ extra.sendNotification({ method: 'notifications/progress', params: { ...progress, progressToken } }),
47
+ }
48
+ : {}),
49
+ };
50
+ }
51
+
31
52
  async function invokeMcpOperation(
32
53
  host: OperationToolHost,
33
54
  op: Operation,
34
55
  opInput: Record<string, unknown>,
35
56
  formatInput: Record<string, unknown>,
36
57
  consumer: string | null,
58
+ options: OperationExecutionOptions,
37
59
  ) {
60
+ options.signal?.throwIfAborted();
38
61
  const board = typeof opInput.board === 'string' && opInput.board.trim() ? opInput.board.trim() : undefined;
39
62
  const readTarget = CONTEXT_READ_OPS.has(op.name) ? await host.prepareContextRead(board) : null;
40
63
  if (readTarget?.boardId && 'board' in op.inputShape) {
41
64
  opInput = { ...opInput, board: readTarget.boardId };
42
65
  formatInput = { ...formatInput, board: readTarget.boardId };
43
66
  }
44
- const result = await host.invoker().invoke(op.name, opInput);
45
- const formatted = op.mcp?.formatResult
46
- ? await op.mcp.formatResult(result, formatInput, host)
47
- : { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
67
+ options.signal?.throwIfAborted();
68
+ const invoker = host.invoker();
69
+ const result = await invoker.invoke(op.name, opInput, options);
70
+ // Preserve the host receiver, and bind options to formatter follow-up reads
71
+ // (history flags, snapshot summary, streaming node metadata).
72
+ const formatterHost: OperationMcpToolHost = {
73
+ getPinnedNodeIds: (target) => host.getPinnedNodeIds(target),
74
+ invoker: () => ({ invoke: (name, input, followUp) => invoker.invoke(name, input, { ...options, ...followUp }) }),
75
+ };
76
+ const formatted = structuredOperationResult(
77
+ op.name,
78
+ op.mcp?.formatResult
79
+ ? await op.mcp.formatResult(result, formatInput, formatterHost)
80
+ : { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] },
81
+ );
82
+ // Do not throwIfAborted here: batch may already have returned a partial
83
+ // outcome, and cancellation must never erase completed work from the reply.
48
84
  if (readTarget) {
49
85
  const text = formatted.content
50
86
  .filter((item): item is Extract<(typeof formatted.content)[number], { type: 'text' }> => item.type === 'text')
@@ -116,17 +152,24 @@ export function registerOperationTools(server: McpServer, getHost: () => Promise
116
152
  // finding A). Loose parsing forwards unknown keys to the op layer,
117
153
  // the single authority on dead-field rejection, HTTP and MCP alike.
118
154
  inputSchema: z.looseObject({ ...op.inputShape, ...(tool.extraShape ?? {}) }),
155
+ outputSchema: operationOutputSchema([op.name]),
156
+ annotations: operationToolAnnotations(tool.toolName),
119
157
  },
120
- async (input: Record<string, unknown>) => {
158
+ async (input: Record<string, unknown>, extra) => {
121
159
  try {
122
160
  const host = await getHost();
161
+ extra.signal.throwIfAborted();
123
162
  const opInput = tool.buildInput ? tool.buildInput(input) : input;
124
- return await invokeMcpOperation(host, op, opInput, input, server.server.getClientVersion()?.name ?? null);
163
+ return await invokeMcpOperation(
164
+ host,
165
+ op,
166
+ opInput,
167
+ input,
168
+ server.server.getClientVersion()?.name ?? null,
169
+ executionOptions(extra),
170
+ );
125
171
  } catch (error) {
126
- return {
127
- content: [{ type: 'text' as const, text: error instanceof Error ? error.message : String(error) }],
128
- isError: true,
129
- };
172
+ return operationToolError(error);
130
173
  }
131
174
  },
132
175
  );
@@ -295,6 +338,88 @@ function requiredMcpFields(op: Operation): string[] {
295
338
  return keys;
296
339
  }
297
340
 
341
+ /** Registry metadata only: never boots a canvas or records a content read. */
342
+ export function registerOperationHelpTool(server: McpServer): void {
343
+ const folded = compositeFoldedOpNames();
344
+ server.registerTool(
345
+ 'canvas_help',
346
+ {
347
+ description:
348
+ 'Look up an MCP operation by registry name (e.g. node.add, ax.approval.request). Returns its public tool, argument schema and guidance. Omit op to list operation names. Does not execute operations or read canvas content.',
349
+ inputSchema: z.object({ op: z.string().min(1).optional() }),
350
+ outputSchema: z.object({
351
+ result: z.union([
352
+ z.strictObject({ operations: z.array(z.string()) }),
353
+ z.strictObject({
354
+ operation: z.string(),
355
+ tool: z.string(),
356
+ description: z.string(),
357
+ inputSchema: z.record(z.string(), z.unknown()),
358
+ }),
359
+ ]),
360
+ }),
361
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
362
+ },
363
+ ({ op: name }) => {
364
+ const operations = listOperations().filter((op) => op.mcp || folded.has(op.name));
365
+ if (name === undefined) {
366
+ const result = { operations: operations.map((op) => op.name).sort() };
367
+ return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: { result } };
368
+ }
369
+ const op = operations.find((op) => op.name === name);
370
+ if (!op) {
371
+ return {
372
+ content: [{ type: 'text', text: `Unknown MCP operation "${name}". Omit op to list available operations.` }],
373
+ isError: true,
374
+ };
375
+ }
376
+ const composite = compositeToolDefinitions.find((def) => compositeMemberOps(def).includes(name));
377
+ const publicName = (field: string) =>
378
+ Object.entries(composite?.fieldRemap ?? {}).find(([, original]) => original === field)?.[0] ?? field;
379
+ const shape = Object.fromEntries(
380
+ Object.entries({ ...op.inputShape, ...op.mcp?.extraShape }).map(([field, schema]) => [
381
+ publicName(field),
382
+ schema,
383
+ ]),
384
+ );
385
+ if (composite?.resolveOp) {
386
+ // Resolve the same kind/action matrix used by composite dispatch.
387
+ const kinds = composite.extraDiscriminatorShape?.kind;
388
+ if (!(kinds instanceof z.ZodEnum)) throw new Error(`Missing kind enum for ${composite.toolName}.`);
389
+ for (const kind of kinds.options) {
390
+ for (const action of composite.actionEnum ?? []) {
391
+ if (composite.resolveOp({ kind: String(kind), action }) === name) {
392
+ shape.kind = z.literal(kind);
393
+ shape.action = z.literal(action);
394
+ }
395
+ }
396
+ }
397
+ } else if (composite) {
398
+ const action = Object.entries(composite.actions).find(([, operation]) => operation === name)![0];
399
+ shape.action = z.literal(action);
400
+ }
401
+ const inputSchema = z.toJSONSchema(z.looseObject(shape), { target: 'draft-7', io: 'input' });
402
+ // Mirror dispatch's required-name alternatives rather than demanding both names.
403
+ for (const [field, aliases] of Object.entries(REQUIRED_FIELD_ALIASES[name] ?? {})) {
404
+ const key = publicName(field);
405
+ if (!inputSchema.required?.includes(key)) continue;
406
+ inputSchema.required = inputSchema.required.filter((required) => required !== key);
407
+ inputSchema.allOf = [
408
+ ...(inputSchema.allOf ?? []),
409
+ { anyOf: [key, ...aliases.map(publicName)].map((alternative) => ({ required: [alternative] })) },
410
+ ];
411
+ }
412
+ const result = {
413
+ operation: name,
414
+ tool: composite?.toolName ?? op.mcp!.toolName,
415
+ description: op.mcp?.description ?? composite!.description,
416
+ inputSchema,
417
+ };
418
+ return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: { result } };
419
+ },
420
+ );
421
+ }
422
+
298
423
  /**
299
424
  * Register composite (action-discriminated) MCP tools (plan-006). Each action
300
425
  * dispatches to a registered operation, reusing that op's `mcp.buildInput` and
@@ -317,10 +442,16 @@ export function registerCompositeTools(
317
442
  // must reach the op layer's dead-field rejection instead of being stripped.
318
443
  server.registerTool(
319
444
  def.toolName,
320
- { description: def.description, inputSchema: z.looseObject(buildCompositeShape(def)) },
321
- async (input: Record<string, unknown>) => {
445
+ {
446
+ description: def.description,
447
+ inputSchema: z.looseObject(buildCompositeShape(def)),
448
+ outputSchema: operationOutputSchema(compositeMemberOps(def)),
449
+ annotations: operationToolAnnotations(def.toolName),
450
+ },
451
+ async (input: Record<string, unknown>, extra) => {
322
452
  try {
323
453
  const host = await getHost();
454
+ extra.signal.throwIfAborted();
324
455
  const opName = resolveCompositeOp(def, input);
325
456
  const op = getOperation(opName);
326
457
  // Strip the composite discriminators (action + any extra, e.g. `kind`)
@@ -335,12 +466,16 @@ export function registerCompositeTools(
335
466
  );
336
467
  }
337
468
  const opInput = op.mcp?.buildInput ? op.mcp.buildInput(rest) : rest;
338
- return await invokeMcpOperation(host, op, opInput, rest, server.server.getClientVersion()?.name ?? null);
469
+ return await invokeMcpOperation(
470
+ host,
471
+ op,
472
+ opInput,
473
+ rest,
474
+ server.server.getClientVersion()?.name ?? null,
475
+ executionOptions(extra),
476
+ );
339
477
  } catch (error) {
340
- return {
341
- content: [{ type: 'text' as const, text: error instanceof Error ? error.message : String(error) }],
342
- isError: true,
343
- };
478
+ return operationToolError(error);
344
479
  }
345
480
  },
346
481
  );
@@ -39,11 +39,7 @@
39
39
  * the legacy error body — server-api only reads `missing.status`.
40
40
  *
41
41
  * Documented behavior change (accepted long-poll tradeoff, plan-007 sub-slice):
42
- * - The legacy HTTP handler passed `req.signal` to waitForAxResolution, so a
43
- * client disconnect aborted the wait early. The registry handler has no access
44
- * to the HTTP Request, so the wait now runs to its (≤120s, subscription-based,
45
- * cheap) timeout instead of aborting on disconnect. Resolution detection,
46
- * timeout, and the `{ value, pending }` result are otherwise identical.
42
+ * - Request cancellation rejects the wait and disposes its subscription/timer.
47
43
  * - Remote-MCP only (daemon mode, untested for these reads): the legacy
48
44
  * RemoteCanvasAccess.await* methods special-cased a 404 → `{ <key>:null }`. The
49
45
  * generic HttpOperationInvoker throws on 404, so an await-on-missing over a
@@ -70,9 +66,12 @@ function clampTimeoutMs(value: unknown): number {
70
66
  return Math.min(ms, AX_WAIT_MAX_MS);
71
67
  }
72
68
 
73
- /** HTTP `?waitMs` → `timeoutMs`; absent/non-positive ⇒ 0 (plain read). Matches parseAxWaitMs. */
69
+ /** Accept the operation's timeoutMs as well as the direct HTTP waitMs spelling. */
74
70
  function readWaitMsInput(_req: Request, params: Record<string, string>, url: URL): Record<string, unknown> {
75
- return { ...params, timeoutMs: clampTimeoutMs(url.searchParams.get('waitMs') ?? '') };
71
+ return {
72
+ ...params,
73
+ timeoutMs: clampTimeoutMs(url.searchParams.get('timeoutMs') ?? url.searchParams.get('waitMs') ?? ''),
74
+ };
76
75
  }
77
76
 
78
77
  /** MCP buildInput: forward id + timeoutMs (omitted ⇒ 30000), clamped to [0, max]. */
@@ -131,13 +130,16 @@ const axApprovalGetOperation = defineOperation<z.infer<typeof awaitInputSchema>,
131
130
  };
132
131
  },
133
132
  },
134
- handler: async (input) => {
133
+ handler: async (input, ctx) => {
135
134
  const id = typeof input.id === 'string' ? input.id : '';
135
+ await ctx.onProgress?.({ progress: 0, total: 1, message: 'Waiting for approval' });
136
136
  const { value, pending } = await waitForAxResolution<PmxAxApprovalGate>({
137
137
  read: () => canvasState.getApproval(id),
138
138
  isResolved: (g) => g.status !== 'pending',
139
139
  timeoutMs: clampTimeoutMs(input.timeoutMs),
140
+ signal: ctx.signal,
140
141
  });
142
+ await ctx.onProgress?.({ progress: 1, total: 1, message: pending ? 'Wait timed out' : 'Wait complete' });
141
143
  return { ok: value !== null, approvalGate: value, pending } as unknown as Record<string, unknown>;
142
144
  },
143
145
  });
@@ -170,13 +172,16 @@ const axElicitationGetOperation = defineOperation<z.infer<typeof awaitInputSchem
170
172
  };
171
173
  },
172
174
  },
173
- handler: async (input) => {
175
+ handler: async (input, ctx) => {
174
176
  const id = typeof input.id === 'string' ? input.id : '';
177
+ await ctx.onProgress?.({ progress: 0, total: 1, message: 'Waiting for elicitation' });
175
178
  const { value, pending } = await waitForAxResolution<PmxAxElicitation>({
176
179
  read: () => canvasState.getElicitation(id),
177
180
  isResolved: (e) => e.status !== 'pending',
178
181
  timeoutMs: clampTimeoutMs(input.timeoutMs),
182
+ signal: ctx.signal,
179
183
  });
184
+ await ctx.onProgress?.({ progress: 1, total: 1, message: pending ? 'Wait timed out' : 'Wait complete' });
180
185
  return { ok: value !== null, elicitation: value, pending } as unknown as Record<string, unknown>;
181
186
  },
182
187
  });
@@ -209,13 +214,16 @@ const axModeGetOperation = defineOperation<z.infer<typeof awaitInputSchema>, Rec
209
214
  };
210
215
  },
211
216
  },
212
- handler: async (input) => {
217
+ handler: async (input, ctx) => {
213
218
  const id = typeof input.id === 'string' ? input.id : '';
219
+ await ctx.onProgress?.({ progress: 0, total: 1, message: 'Waiting for mode resolution' });
214
220
  const { value, pending } = await waitForAxResolution<PmxAxModeRequest>({
215
221
  read: () => canvasState.getModeRequest(id),
216
222
  isResolved: (m) => m.status !== 'pending',
217
223
  timeoutMs: clampTimeoutMs(input.timeoutMs),
224
+ signal: ctx.signal,
218
225
  });
226
+ await ctx.onProgress?.({ progress: 1, total: 1, message: pending ? 'Wait timed out' : 'Wait complete' });
219
227
  return { ok: value !== null, modeRequest: value, pending } as unknown as Record<string, unknown>;
220
228
  },
221
229
  });
@@ -335,6 +335,8 @@ const interactionOperation = defineOperation<z.infer<typeof interactionSchema>,
335
335
  http: {
336
336
  method: 'POST',
337
337
  path: '/api/canvas/ax/interaction',
338
+ // MCP preserves the structured rejection code as well as its message.
339
+ errorBodyAsResult: true,
338
340
  // Legacy wire: 200 when accepted, the envelope's own status when refused.
339
341
  status: (result) =>
340
342
  isRecord(result) && result.ok !== true && typeof result.status === 'number' ? result.status : 200,