pmx-canvas 0.1.36 → 0.2.1

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 (91) hide show
  1. package/CHANGELOG.md +447 -0
  2. package/Readme.md +2 -2
  3. package/dist/json-render/index.js +89 -334
  4. package/dist/types/mcp/canvas-access.d.ts +5 -171
  5. package/dist/types/server/ax-state-manager.d.ts +267 -0
  6. package/dist/types/server/ax-state.d.ts +3 -1
  7. package/dist/types/server/canvas-db.d.ts +13 -0
  8. package/dist/types/server/canvas-operations.d.ts +1 -12
  9. package/dist/types/server/canvas-state.d.ts +8 -23
  10. package/dist/types/server/index.d.ts +6 -24
  11. package/dist/types/server/operations/composites.d.ts +121 -0
  12. package/dist/types/server/operations/http.d.ts +7 -0
  13. package/dist/types/server/operations/index.d.ts +8 -0
  14. package/dist/types/server/operations/invoker.d.ts +13 -0
  15. package/dist/types/server/operations/mcp.d.ts +15 -0
  16. package/dist/types/server/operations/ops/annotation.d.ts +2 -0
  17. package/dist/types/server/operations/ops/app.d.ts +33 -0
  18. package/dist/types/server/operations/ops/ax-await.d.ts +2 -0
  19. package/dist/types/server/operations/ops/ax-shared.d.ts +31 -0
  20. package/dist/types/server/operations/ops/ax-state.d.ts +2 -0
  21. package/dist/types/server/operations/ops/ax-timeline.d.ts +2 -0
  22. package/dist/types/server/operations/ops/ax-work.d.ts +2 -0
  23. package/dist/types/server/operations/ops/batch.d.ts +19 -0
  24. package/dist/types/server/operations/ops/edges.d.ts +2 -0
  25. package/dist/types/server/operations/ops/groups.d.ts +2 -0
  26. package/dist/types/server/operations/ops/json-render.d.ts +31 -0
  27. package/dist/types/server/operations/ops/nodes.d.ts +62 -0
  28. package/dist/types/server/operations/ops/query.d.ts +2 -0
  29. package/dist/types/server/operations/ops/snapshots.d.ts +2 -0
  30. package/dist/types/server/operations/ops/validate.d.ts +2 -0
  31. package/dist/types/server/operations/ops/viewport.d.ts +2 -0
  32. package/dist/types/server/operations/ops/webview.d.ts +2 -0
  33. package/dist/types/server/operations/registry.d.ts +15 -0
  34. package/dist/types/server/operations/types.d.ts +116 -0
  35. package/dist/types/server/operations/webview-runner.d.ts +69 -0
  36. package/docs/RELEASE.md +5 -0
  37. package/docs/adr-001-bun-only-runtime.md +46 -0
  38. package/docs/api-stability.md +57 -0
  39. package/docs/ax-host-adapter-contract.md +19 -1
  40. package/docs/ax-state-contract.md +72 -0
  41. package/docs/http-api.md +4 -0
  42. package/docs/mcp.md +61 -12
  43. package/docs/plans/plan-005-operation-registry.md +84 -0
  44. package/docs/plans/plan-006-mcp-tool-consolidation.md +109 -0
  45. package/docs/plans/plan-007-ax-domain.md +99 -0
  46. package/docs/plans/plan-008-registry-finish.md +91 -0
  47. package/docs/tech-debt-assessment-2026-06.md +90 -0
  48. package/package.json +3 -3
  49. package/skills/pmx-canvas/SKILL.md +221 -193
  50. package/skills/pmx-canvas/evals/evals.json +3 -3
  51. package/skills/pmx-canvas/references/ax-html-control-surface.md +93 -0
  52. package/skills/pmx-canvas/references/codex-app-adapter.md +13 -14
  53. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +26 -11
  54. package/src/cli/agent.ts +52 -31
  55. package/src/mcp/canvas-access.ts +30 -830
  56. package/src/mcp/server.ts +162 -2014
  57. package/src/server/ax-context.ts +8 -1
  58. package/src/server/ax-state-manager.ts +826 -0
  59. package/src/server/ax-state.ts +10 -2
  60. package/src/server/canvas-db.ts +35 -0
  61. package/src/server/canvas-operations.ts +2 -328
  62. package/src/server/canvas-schema.ts +2 -2
  63. package/src/server/canvas-state.ts +103 -465
  64. package/src/server/index.ts +54 -190
  65. package/src/server/operations/composites.ts +355 -0
  66. package/src/server/operations/http.ts +103 -0
  67. package/src/server/operations/index.ts +65 -0
  68. package/src/server/operations/invoker.ts +87 -0
  69. package/src/server/operations/mcp.ts +221 -0
  70. package/src/server/operations/ops/annotation.ts +60 -0
  71. package/src/server/operations/ops/app.ts +447 -0
  72. package/src/server/operations/ops/ax-await.ts +216 -0
  73. package/src/server/operations/ops/ax-shared.ts +38 -0
  74. package/src/server/operations/ops/ax-state.ts +249 -0
  75. package/src/server/operations/ops/ax-timeline.ts +381 -0
  76. package/src/server/operations/ops/ax-work.ts +635 -0
  77. package/src/server/operations/ops/batch.ts +365 -0
  78. package/src/server/operations/ops/edges.ts +166 -0
  79. package/src/server/operations/ops/groups.ts +176 -0
  80. package/src/server/operations/ops/json-render.ts +691 -0
  81. package/src/server/operations/ops/nodes.ts +1047 -0
  82. package/src/server/operations/ops/query.ts +281 -0
  83. package/src/server/operations/ops/snapshots.ts +366 -0
  84. package/src/server/operations/ops/validate.ts +37 -0
  85. package/src/server/operations/ops/viewport.ts +219 -0
  86. package/src/server/operations/ops/webview.ts +339 -0
  87. package/src/server/operations/registry.ts +79 -0
  88. package/src/server/operations/types.ts +150 -0
  89. package/src/server/operations/webview-runner.ts +77 -0
  90. package/src/server/server.ts +158 -2255
  91. package/src/server/web-artifacts.ts +6 -2
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Composite MCP tools (plan-006: MCP tool consolidation).
3
+ *
4
+ * A composite tool folds several single-purpose MCP tools into one tool with an
5
+ * `action` discriminator. It is a PRESENTATION-LAYER construct only: each action
6
+ * dispatches to an already-registered operation (`src/server/operations/ops/*`)
7
+ * via the same invoker, reusing that operation's own `mcp.buildInput` and
8
+ * `mcp.formatResult`. So `canvas_edge { action: "add", ... }` is byte-identical
9
+ * to the standalone `canvas_add_edge` — same op, same arg mapping, same result
10
+ * shape — by construction. No handler logic lives here.
11
+ *
12
+ * Migration (docs/api-stability.md + plan-006): composites land ADDITIVELY in
13
+ * v0.2 alongside the legacy single-purpose tools (the tool surface grows, then
14
+ * shrinks when the legacy tools are removed in v0.3). Every action here maps to a
15
+ * registry-backed operation (plan-005 slices 1–7 + plan-008 Wave 1).
16
+ *
17
+ * Still deferred (its legacy standalone tool keeps working; see plan-008): the
18
+ * `canvas_snapshot` composite (the v0.3 name collision). The action enums are
19
+ * forward-compatible: adding an action later is additive. (`canvas_webview`
20
+ * shipped in plan-008 Wave 3 via runner injection; `canvas_app` shipped in Wave 4
21
+ * — open-mcp-app / diagram / build-artifact. Wave 5 folded the last three legacy
22
+ * tools deprecate-only — NO per-action input-injection mechanism was needed:
23
+ * `canvas_add_html_node` / `canvas_add_html_primitive` → `canvas_node` action
24
+ * "add" (type:"html" [+ primitive]); `canvas_refresh_webpage_node` → `canvas_node`
25
+ * action "update" (refresh:true). `canvas_screenshot` stays standalone — it
26
+ * returns a binary image payload the composite/registry JSON wire shape does not
27
+ * model.)
28
+ *
29
+ * Not shipped here: the `canvas_snapshot` composite (plan-006 #7). Its target
30
+ * name is ALREADY a legacy standalone tool (the save-snapshot tool, op
31
+ * `snapshot.save`), so it cannot be added additively without a name clash, and
32
+ * repurposing `canvas_snapshot` to be action-discriminated now would break
33
+ * existing callers. It lands in v0.3, in the same change that removes the legacy
34
+ * single-purpose snapshot tools and frees the name.
35
+ *
36
+ * This module must never import server.ts or index.ts.
37
+ */
38
+
39
+ import { z, type ZodRawShape, type ZodTypeAny } from 'zod';
40
+
41
+ /**
42
+ * One composite MCP tool: a frozen tool name + its action→operation routing.
43
+ *
44
+ * Two flavours:
45
+ * - Single-discriminator (the wave-1 composites + the 4 single-discriminator AX
46
+ * composites): the flat `actions` map routes one `action` value → one op.
47
+ * - Two-discriminator (`canvas_ax_gate`, plan-007 Slice C): a `kind` × `action`
48
+ * matrix folds 9 ops into one tool. Set `extraDiscriminatorShape` (the `kind`
49
+ * enum), `memberOps` (the op names — used to derive the schema union + the
50
+ * deprecation notes), `actionEnum` (the action discriminator values), and
51
+ * `resolveOp` (maps `{ kind, action }` → op name, or undefined for an invalid
52
+ * combo → a loud error at dispatch). The flat `actions` map is left empty for
53
+ * these; the matrix path uses `resolveOp` instead.
54
+ */
55
+ export interface CompositeToolDefinition {
56
+ /** Frozen public tool name (see tests/unit/mcp-tool-freeze.test.ts). */
57
+ toolName: string;
58
+ description: string;
59
+ /** Human-readable action list for the `action` enum description. */
60
+ actionSummary: string;
61
+ /**
62
+ * Map of `action` value → registry operation name (single-discriminator
63
+ * composites). Empty for two-discriminator composites. Every referenced op
64
+ * MUST have an `mcp` block — its `buildInput`/`formatResult` are reused so the
65
+ * composite action matches the legacy standalone tool exactly.
66
+ */
67
+ actions: Record<string, string>;
68
+ /**
69
+ * Two-discriminator extension (e.g. `canvas_ax_gate`). The extra discriminator
70
+ * shape — a single `kind` enum — merged into the advertised schema alongside
71
+ * `action`.
72
+ */
73
+ extraDiscriminatorShape?: ZodRawShape;
74
+ /**
75
+ * Two-discriminator extension: the action enum values (used to build the
76
+ * `action` discriminator when there is no flat `actions` map to derive it from).
77
+ */
78
+ actionEnum?: readonly string[];
79
+ /**
80
+ * Two-discriminator extension: every member op name. Used to build the schema
81
+ * union (all member-op fields, optional) and to derive a deprecation note per
82
+ * member op (each mapped back to its (kind, action) by `describeOp`).
83
+ */
84
+ memberOps?: string[];
85
+ /**
86
+ * Two-discriminator extension: resolve the op name from the validated
87
+ * discriminators. Returns `undefined` for an invalid combo so dispatch can
88
+ * raise a loud error instead of silently no-op'ing.
89
+ */
90
+ resolveOp?: (input: { kind: string; action: string }) => string | undefined;
91
+ /**
92
+ * Two-discriminator extension: human-readable `(kind, action)` for a member op
93
+ * (the inverse of `resolveOp`), used to build that op's deprecation note. The
94
+ * `kind` field-collision is resolved here (see `gateFieldRemap`).
95
+ */
96
+ describeOp?: (opName: string) => { kind: string; action: string } | undefined;
97
+ /**
98
+ * Field-name remap applied to the composite's advertised schema and undone at
99
+ * dispatch. Resolves a collision between a discriminator name and a member-op
100
+ * field of the same name (e.g. `ax.approval.request` has its own `action`
101
+ * field — namespaced to `approvalAction` in the composite so the `action`
102
+ * discriminator wins, then mapped back before invoking the op). Keys are the
103
+ * composite (public) field names; values are the op field names.
104
+ */
105
+ fieldRemap?: Record<string, string>;
106
+ }
107
+
108
+ // ── canvas_ax_gate (plan-007 Slice C): kind × action → 9 ops → 1 tool ─────────
109
+
110
+ const GATE_KINDS = ['approval', 'elicitation', 'mode'] as const;
111
+ const GATE_ACTIONS = ['request', 'resolve', 'await'] as const;
112
+
113
+ /**
114
+ * Resolve a gate op from its discriminators. Note the irregularities:
115
+ * - `await` reads the long-poll GET op (`ax.<kind>.get`), NOT `ax.<kind>.await`.
116
+ * - `resolve` for an elicitation is `ax.elicitation.respond` (not `.resolve`).
117
+ * Returns undefined for an invalid combo so dispatch raises a loud error.
118
+ */
119
+ function resolveGateOp(input: { kind: string; action: string }): string | undefined {
120
+ const { kind, action } = input;
121
+ if (!(GATE_KINDS as readonly string[]).includes(kind)) return undefined;
122
+ if (action === 'request') return `ax.${kind}.request`;
123
+ if (action === 'await') return `ax.${kind}.get`;
124
+ if (action === 'resolve') return kind === 'elicitation' ? 'ax.elicitation.respond' : `ax.${kind}.resolve`;
125
+ return undefined;
126
+ }
127
+
128
+ /** The 9 gate ops, in (kind, action) order — single source for memberOps + describeOp. */
129
+ const GATE_OPS: Array<{ op: string; kind: string; action: string }> = GATE_KINDS.flatMap((kind) =>
130
+ GATE_ACTIONS.map((action) => {
131
+ const op = resolveGateOp({ kind, action });
132
+ // Every (GATE_KINDS × GATE_ACTIONS) combo must resolve. Fail loud at module
133
+ // load if a new action/kind is added without a matching resolveGateOp branch,
134
+ // rather than silently propagating the string "undefined" as an op name.
135
+ if (!op) throw new Error(`resolveGateOp has no mapping for kind="${kind}" action="${action}".`);
136
+ return { op, kind, action };
137
+ }),
138
+ );
139
+
140
+ function describeGateOp(opName: string): { kind: string; action: string } | undefined {
141
+ const match = GATE_OPS.find((entry) => entry.op === opName);
142
+ return match ? { kind: match.kind, action: match.action } : undefined;
143
+ }
144
+
145
+ export const compositeToolDefinitions: CompositeToolDefinition[] = [
146
+ {
147
+ toolName: 'canvas_node',
148
+ description:
149
+ 'Create, read, update, or remove a canvas node. One tool for node CRUD: action "add" creates a node (requires type — markdown, status, context, ledger, trace, file, image, webpage, html, group, etc.); "get" reads one node by id; "update" patches an existing node (title, content, position, size, data); "remove" deletes a node by id. For spec-driven content (json-render, graph) use canvas_render; for external/built apps use canvas_app (actions: open-mcp-app, diagram, build-artifact).',
150
+ actionSummary: 'add | get | update | remove',
151
+ actions: {
152
+ add: 'node.add',
153
+ get: 'node.get',
154
+ update: 'node.update',
155
+ remove: 'node.remove',
156
+ },
157
+ },
158
+ {
159
+ toolName: 'canvas_render',
160
+ description:
161
+ 'Spec-driven content: discover the schema, validate a payload, or create a json-render / graph node. Action "describe-schema" returns the json-render component catalog + create schemas; "validate" checks a spec or graph payload without creating a node; "add-json-render" creates a json-render node from a complete spec; "stream-json-render" progressively builds one from SpecStream patches (omit nodeId to create, pass it back to append, done=true to finish); "add-graph" creates a chart node (line, bar, pie, area, scatter, radar, stacked-bar, composed, sparkline, dot-plot, bullet, slopegraph).',
162
+ actionSummary: 'describe-schema | validate | add-json-render | stream-json-render | add-graph',
163
+ actions: {
164
+ 'describe-schema': 'schema.describe',
165
+ validate: 'spec.validate',
166
+ 'add-json-render': 'jsonrender.add',
167
+ 'stream-json-render': 'jsonrender.stream',
168
+ 'add-graph': 'graph.add',
169
+ },
170
+ },
171
+ {
172
+ toolName: 'canvas_edge',
173
+ description:
174
+ 'Add or remove an edge (connection) between two nodes. Action "add" connects two nodes (type: flow=sequential, depends-on=dependency, relation=general, references=cross-reference; from/to take node ids, or fromSearch/toSearch resolve by title/content); "remove" deletes an edge by id.',
175
+ actionSummary: 'add | remove',
176
+ actions: {
177
+ add: 'edge.add',
178
+ remove: 'edge.remove',
179
+ },
180
+ },
181
+ {
182
+ toolName: 'canvas_group',
183
+ description:
184
+ 'Manage node groups. Action "create" makes a new group (optionally with initial child node ids); "add" moves nodes into an existing group; "ungroup" dissolves a group, releasing its children.',
185
+ actionSummary: 'create | add | ungroup',
186
+ actions: {
187
+ create: 'group.create',
188
+ add: 'group.add',
189
+ ungroup: 'group.remove',
190
+ },
191
+ },
192
+ {
193
+ toolName: 'canvas_history',
194
+ description:
195
+ 'Step through canvas mutation history. Action "undo" reverses the last mutation; "redo" reapplies it. History is session-scoped (in-memory, last 200 operations).',
196
+ actionSummary: 'undo | redo',
197
+ actions: {
198
+ undo: 'canvas.undo',
199
+ redo: 'canvas.redo',
200
+ },
201
+ },
202
+ {
203
+ toolName: 'canvas_view',
204
+ description:
205
+ 'Canvas viewport and layout control. Action "arrange" auto-lays-out nodes (grid/columns/etc.); "focus" pans/zooms the viewport to a node; "fit" zooms to fit all nodes in view; "clear" removes every node and edge from the canvas; "remove-annotation" deletes a human-drawn annotation by id.',
206
+ actionSummary: 'arrange | focus | fit | clear | remove-annotation',
207
+ actions: {
208
+ arrange: 'arrange',
209
+ focus: 'node.focus',
210
+ fit: 'view.fit',
211
+ clear: 'canvas.clear',
212
+ 'remove-annotation': 'annotation.remove',
213
+ },
214
+ },
215
+ {
216
+ toolName: 'canvas_query',
217
+ description:
218
+ 'Read the board cheapest-first. Action "search" finds nodes by title/content keywords (prefer this before reading the full layout); "layout" returns the full node/edge layout; "validate" checks the board for node collisions, group-containment issues, and missing edge endpoints. Use search to locate, then layout or canvas_node get for detail.',
219
+ actionSummary: 'search | layout | validate',
220
+ actions: {
221
+ search: 'search',
222
+ layout: 'layout.get',
223
+ validate: 'validate.get',
224
+ },
225
+ },
226
+ {
227
+ toolName: 'canvas_app',
228
+ description:
229
+ 'Open external / built-content apps on the canvas. Action "open-mcp-app" connects to an external MCP server that declares a ui:// app resource, calls a tool, and opens the result inside an mcp-app node (full transport call — pass transport + toolName); "diagram" draws a hand-drawn diagram via the hosted Excalidraw preset (pass elements); "build-artifact" bundles a single-file HTML web artifact from React/Tailwind source (title + appTsx) and optionally opens it on the canvas. build-artifact can be long-running (minutes) on cold workspaces — set a long client timeout.',
230
+ actionSummary: 'open-mcp-app | diagram | build-artifact',
231
+ actions: {
232
+ 'open-mcp-app': 'mcpapp.open',
233
+ diagram: 'diagram.open',
234
+ 'build-artifact': 'webartifact.build',
235
+ },
236
+ },
237
+ {
238
+ toolName: 'canvas_webview',
239
+ description:
240
+ 'Drive the headless Bun.WebView automation session for the workbench. Action "status" reads automation status (supported, active, backend, viewport, url); "start" starts/replaces the session (backend chrome|webkit, width, height, chromePath, chromeArgv, dataStoreDir); "stop" stops the active session; "resize" sets the viewport (width, height required); "evaluate" runs JavaScript in the page (pass exactly one of expression or script; script is wrapped in an async IIFE). Capturing a screenshot is NOT folded here — use the standalone canvas_screenshot tool (it returns a binary image payload).',
241
+ actionSummary: 'status | start | stop | resize | evaluate',
242
+ actions: {
243
+ status: 'webview.status',
244
+ start: 'webview.start',
245
+ stop: 'webview.stop',
246
+ resize: 'webview.resize',
247
+ evaluate: 'webview.evaluate',
248
+ },
249
+ },
250
+ // ── AX composites (plan-007 Slice C / plan-006 §11–15) ───────────
251
+ {
252
+ toolName: 'canvas_ax_state',
253
+ description:
254
+ 'Read or set host-agnostic PMX AX state. Action "get" reads the AX state + agent-ready context (pinned/focused context, focus field); "set-focus" places node IDs in the AX focus field (nodeIds); "set-policy" patches the tool/prompt policy (tools.allowed|excluded|approvalRequired, prompt.systemAppend|mode — merges with existing); "report-capability" records a host/session capability for diagnostics. Adapters may pass a source label (e.g. codex).',
255
+ actionSummary: 'get | set-focus | set-policy | report-capability',
256
+ actions: {
257
+ get: 'ax.get',
258
+ 'set-focus': 'ax.focus.set',
259
+ 'set-policy': 'ax.policy.set',
260
+ 'report-capability': 'ax.host-capability.report',
261
+ },
262
+ },
263
+ {
264
+ toolName: 'canvas_ax_work',
265
+ description:
266
+ 'Manage canvas-bound AX work items and review annotations. Action "add" creates a work item (title, optional status todo|in-progress|blocked|done|cancelled, detail, nodeIds); "update" patches a work item by id (title/status/detail/nodeIds); "annotate" adds a review annotation (comment/finding) anchored to a node, file, or region (body, kind, severity, anchorType, nodeId/file/region). Work items and review annotations participate in snapshots and are exposed via canvas://ax-work.',
267
+ actionSummary: 'add | update | annotate',
268
+ actions: {
269
+ add: 'ax.work.create',
270
+ update: 'ax.work.update',
271
+ annotate: 'ax.review.add',
272
+ },
273
+ },
274
+ {
275
+ toolName: 'canvas_ax_gate',
276
+ description:
277
+ 'Drive the full lifecycle of a canvas-bound AX gate — request, resolve, or await — across all three gate kinds. kind: approval | elicitation | mode. action: request (open a pending gate), resolve (approval/mode take a decision approved|rejected; elicitation takes a response object), await (block until the gate leaves pending or timeoutMs elapses; 0 = immediate read). Gates are canvas-bound, snapshotted, and exposed via canvas://ax-work. NOTE: the approval machine-readable action identifier is passed as approvalAction (the action field is the lifecycle discriminator).',
278
+ actionSummary: 'request | resolve | await (× kind: approval | elicitation | mode)',
279
+ actions: {},
280
+ actionEnum: GATE_ACTIONS,
281
+ extraDiscriminatorShape: {
282
+ kind: z.enum(GATE_KINDS).describe('Gate kind: approval | elicitation | mode.'),
283
+ },
284
+ memberOps: GATE_OPS.map((entry) => entry.op),
285
+ resolveOp: resolveGateOp,
286
+ describeOp: describeGateOp,
287
+ // ax.approval.request defines its own optional `action` field (a
288
+ // machine-readable identifier the gate guards) — it collides with the gate
289
+ // lifecycle `action` discriminator. Namespace it to `approvalAction` in the
290
+ // composite schema and map it back to `action` before invoking the op, so the
291
+ // approval field stays settable AND the discriminator stays clean. (Verified:
292
+ // `action` is the ONLY field on any of the 9 gate ops that collides with a
293
+ // discriminator name.)
294
+ fieldRemap: {
295
+ approvalAction: 'action',
296
+ },
297
+ },
298
+ {
299
+ toolName: 'canvas_ax_timeline',
300
+ description:
301
+ 'Read or write the bounded PMX AX timeline. Action "read" returns recent agent-events, evidence, and steering messages plus counts (limit); "record-event" records a normalized agent-event (kind prompt|assistant-message|tool-start|tool-result|failure|approval|steering, summary, detail, data); "add-evidence" records an evidence item (kind logs|tool-result|screenshot|file|diff|test-output, title, body, ref); "send-steering" records a steering message (message). Timeline rows persist for diagnostics/continuity but are not restored by snapshots; exposed via canvas://ax-timeline.',
302
+ actionSummary: 'read | record-event | add-evidence | send-steering',
303
+ actions: {
304
+ read: 'ax.timeline.get',
305
+ 'record-event': 'ax.event.record',
306
+ 'add-evidence': 'ax.evidence.add',
307
+ 'send-steering': 'ax.steer',
308
+ },
309
+ },
310
+ {
311
+ toolName: 'canvas_ax_delivery',
312
+ description:
313
+ 'Adapterless PMX AX delivery. Action "claim" claims pending undelivered steering for a consumer (loop-safe — never returns steering the consumer originated) plus pendingActivity (open work items / approval gates / elicitations / mode requests awaiting the agent); "mark" marks a steering message delivered by id so it is not handed out again. Resolve pendingActivity via canvas_ax_gate / canvas_ax_work, not mark.',
314
+ actionSummary: 'claim | mark',
315
+ actions: {
316
+ claim: 'ax.delivery.pending',
317
+ mark: 'ax.delivery.mark',
318
+ },
319
+ },
320
+ ];
321
+
322
+ /**
323
+ * Deprecation notes for the legacy single-purpose tools, DERIVED from the
324
+ * composites: every operation a composite folds gets a `Deprecated: use
325
+ * canvas_x with action "y".` prefix on its standalone tool description, steering
326
+ * agents to the composite during the v0.2 overlap window (the legacy tools and
327
+ * these notes are removed together in v0.3). Keyed by registry operation name.
328
+ * Deriving it keeps the deprecation list in lockstep with the composites — a new
329
+ * folded action automatically deprecates the tool it replaces.
330
+ */
331
+ export function buildCompositeDeprecationNotes(
332
+ definitions: CompositeToolDefinition[] = compositeToolDefinitions,
333
+ ): Map<string, string> {
334
+ const notes = new Map<string, string>();
335
+ for (const def of definitions) {
336
+ // Single-discriminator composites: one note per (action → op).
337
+ for (const [action, opName] of Object.entries(def.actions)) {
338
+ notes.set(opName, `Deprecated: use ${def.toolName} with action "${action}". `);
339
+ }
340
+ // Two-discriminator composites (canvas_ax_gate): one note per member op,
341
+ // each mapped back to its (kind, action) so the deprecation points exactly
342
+ // at the composite invocation that replaces the legacy tool.
343
+ if (def.memberOps && def.describeOp) {
344
+ for (const opName of def.memberOps) {
345
+ const combo = def.describeOp(opName);
346
+ if (!combo) continue;
347
+ notes.set(
348
+ opName,
349
+ `Deprecated: use ${def.toolName} with kind "${combo.kind}" action "${combo.action}". `,
350
+ );
351
+ }
352
+ }
353
+ }
354
+ return notes;
355
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * HTTP dispatch for registered operations.
3
+ *
4
+ * Route matching is segment-count exact, so `/node/:id` never swallows
5
+ * `/node/:id/refresh`. server.ts calls `dispatchOperationRoute` immediately
6
+ * before its legacy `/api/canvas/*` checks; a `null` return falls through to
7
+ * the remaining legacy routes.
8
+ */
9
+ import { executeOperation, listOperations } from './registry.js';
10
+ import { OperationError } from './types.js';
11
+
12
+ function responseJson(data: unknown, status = 200): Response {
13
+ return Response.json(data, {
14
+ status,
15
+ headers: {
16
+ 'Cache-Control': 'no-store',
17
+ },
18
+ });
19
+ }
20
+
21
+ function matchPath(template: string, pathname: string): Record<string, string> | null {
22
+ const templateSegments = template.split('/');
23
+ const pathSegments = pathname.split('/');
24
+ if (templateSegments.length !== pathSegments.length) return null;
25
+ const params: Record<string, string> = {};
26
+ for (let index = 0; index < templateSegments.length; index++) {
27
+ const expected = templateSegments[index]!;
28
+ const actual = pathSegments[index]!;
29
+ if (expected.startsWith(':')) {
30
+ params[expected.slice(1)] = decodeURIComponent(actual);
31
+ } else if (expected !== actual) {
32
+ return null;
33
+ }
34
+ }
35
+ return params;
36
+ }
37
+
38
+ /**
39
+ * Shared body reader: preserves the parsed JSON value as-is (object, array,
40
+ * or primitive) — per-op `readInput` decides what to do with non-object
41
+ * bodies; the shared reader never coerces.
42
+ */
43
+ export async function readJsonValue(req: Request): Promise<unknown> {
44
+ let text = '';
45
+ try {
46
+ text = await req.text();
47
+ } catch {
48
+ return undefined;
49
+ }
50
+ if (!text.trim()) return undefined;
51
+ try {
52
+ return JSON.parse(text) as unknown;
53
+ } catch {
54
+ return undefined;
55
+ }
56
+ }
57
+
58
+ async function defaultReadInput(
59
+ req: Request,
60
+ params: Record<string, string>,
61
+ url: URL,
62
+ ): Promise<Record<string, unknown>> {
63
+ const query: Record<string, string> = {};
64
+ url.searchParams.forEach((value, key) => {
65
+ query[key] = value;
66
+ });
67
+ if (req.method === 'GET' || req.method === 'DELETE') {
68
+ return { ...query, ...params };
69
+ }
70
+ const body = await readJsonValue(req);
71
+ const record = body !== null && typeof body === 'object' && !Array.isArray(body)
72
+ ? body as Record<string, unknown>
73
+ : {};
74
+ return { ...query, ...record, ...params };
75
+ }
76
+
77
+ export async function dispatchOperationRoute(req: Request, url: URL): Promise<Response | null> {
78
+ for (const op of listOperations()) {
79
+ const route = op.http;
80
+ if (!route || route.method !== req.method) continue;
81
+ const params = matchPath(route.path, url.pathname);
82
+ if (!params) continue;
83
+ try {
84
+ const input = route.readInput
85
+ ? await route.readInput(req, params, url)
86
+ : await defaultReadInput(req, params, url);
87
+ const result = await executeOperation(op.name, input);
88
+ return responseJson(result, route.status ? route.status(result) : 200);
89
+ } catch (error) {
90
+ if (error instanceof OperationError) {
91
+ return responseJson({ ok: false, error: error.message, ...(error.details ?? {}) }, error.status);
92
+ }
93
+ // An unexpected (non-OperationError) throw from a handler MUST NOT escape the
94
+ // dispatcher: Bun.serve has no per-request boundary, so an escaped throw renders
95
+ // its dev error overlay (HTTP 500 text/html leaking the absolute server source
96
+ // path + stack). Return a clean JSON 500 with a generic message instead (the
97
+ // real error is logged server-side, never echoed to the client).
98
+ console.error(`[operation] unhandled error dispatching ${op.name}:`, error);
99
+ return responseJson({ ok: false, error: 'Internal error processing the request.' }, 500);
100
+ }
101
+ }
102
+ return null;
103
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Operation registry entrypoint: imports all ops/* files and registers them
3
+ * (single registration site), and re-exports the transport surfaces.
4
+ */
5
+ import { registerOperation } from './registry.js';
6
+ import { nodeOperations } from './ops/nodes.js';
7
+ import { edgeOperations } from './ops/edges.js';
8
+ import { viewportOperations } from './ops/viewport.js';
9
+ import { groupOperations } from './ops/groups.js';
10
+ import { queryOperations } from './ops/query.js';
11
+ import { validateOperations } from './ops/validate.js';
12
+ import { annotationOperations } from './ops/annotation.js';
13
+ import { snapshotOperations } from './ops/snapshots.js';
14
+ import { jsonRenderOperations } from './ops/json-render.js';
15
+ import { axStateOperations } from './ops/ax-state.js';
16
+ import { axWorkOperations } from './ops/ax-work.js';
17
+ import { axTimelineOperations } from './ops/ax-timeline.js';
18
+ import { axAwaitOperations } from './ops/ax-await.js';
19
+ import { batchOperations } from './ops/batch.js';
20
+ import { webviewOperations } from './ops/webview.js';
21
+ import { appOperations } from './ops/app.js';
22
+
23
+ for (const op of [
24
+ ...nodeOperations,
25
+ ...edgeOperations,
26
+ ...viewportOperations,
27
+ ...groupOperations,
28
+ ...queryOperations,
29
+ ...validateOperations,
30
+ ...annotationOperations,
31
+ ...snapshotOperations,
32
+ ...jsonRenderOperations,
33
+ ...axStateOperations,
34
+ ...axWorkOperations,
35
+ ...axTimelineOperations,
36
+ ...axAwaitOperations,
37
+ ...batchOperations,
38
+ ...webviewOperations,
39
+ ...appOperations,
40
+ ]) {
41
+ registerOperation(op);
42
+ }
43
+
44
+ export {
45
+ executeOperation,
46
+ getOperation,
47
+ listOperations,
48
+ registerOperation,
49
+ setOperationEventEmitter,
50
+ } from './registry.js';
51
+ export { dispatchOperationRoute } from './http.js';
52
+ export { runCanvasBatchOperation, type BatchEnvelope } from './ops/batch.js';
53
+ // OpenMcpAppCoreResult is the SDK's cast target for executeOperation('mcpapp.open').
54
+ export { type OpenMcpAppCoreResult } from './ops/app.js';
55
+ export { LocalOperationInvoker, HttpOperationInvoker, type OperationInvoker } from './invoker.js';
56
+ export { registerOperationTools, registerCompositeTools, type OperationToolHost } from './mcp.js';
57
+ export { compositeToolDefinitions, type CompositeToolDefinition } from './composites.js';
58
+ export {
59
+ OperationError,
60
+ defineOperation,
61
+ type Operation,
62
+ type OperationContext,
63
+ type OperationDefinition,
64
+ type OperationErrorStatus,
65
+ } from './types.js';
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Operation invokers: both return the canonical wire-shaped result (the same
3
+ * JSON body the HTTP route serves), so MCP/CLI callers format one shape
4
+ * regardless of transport. Failures throw `OperationError`.
5
+ */
6
+ import { executeOperation, getOperation } from './registry.js';
7
+ import { OperationError, type OperationErrorStatus } from './types.js';
8
+
9
+ export interface OperationInvoker {
10
+ invoke(name: string, input: Record<string, unknown>): Promise<unknown>;
11
+ }
12
+
13
+ /** Runs operations in-process against the shared canvasState singleton. */
14
+ export class LocalOperationInvoker implements OperationInvoker {
15
+ async invoke(name: string, input: Record<string, unknown>): Promise<unknown> {
16
+ return await executeOperation(name, input);
17
+ }
18
+ }
19
+
20
+ function toOperationErrorStatus(status: number): OperationErrorStatus {
21
+ return status === 404 ? 404 : status === 409 ? 409 : 400;
22
+ }
23
+
24
+ /** Builds the HTTP request from the op's route template (`:id` from input, GET flags to query). */
25
+ export class HttpOperationInvoker implements OperationInvoker {
26
+ private readonly baseUrl: string;
27
+
28
+ constructor(baseUrl: string) {
29
+ this.baseUrl = baseUrl.replace(/\/$/, '');
30
+ }
31
+
32
+ async invoke(name: string, input: Record<string, unknown>): Promise<unknown> {
33
+ const op = getOperation(name);
34
+ const route = op.http;
35
+ if (!route) throw new OperationError(`Operation "${name}" has no HTTP route.`, 400);
36
+
37
+ const consumed = new Set<string>();
38
+ const path = route.path
39
+ .split('/')
40
+ .map((segment) => {
41
+ if (!segment.startsWith(':')) return segment;
42
+ const key = segment.slice(1);
43
+ consumed.add(key);
44
+ const value = input[key];
45
+ return encodeURIComponent(value === undefined || value === null ? '' : String(value));
46
+ })
47
+ .join('/');
48
+
49
+ const rest: Record<string, unknown> = {};
50
+ for (const [key, value] of Object.entries(input)) {
51
+ if (!consumed.has(key) && value !== undefined) rest[key] = value;
52
+ }
53
+
54
+ let url = `${this.baseUrl}${path}`;
55
+ const init: RequestInit = { method: route.method };
56
+ if (route.method === 'GET' || route.method === 'DELETE') {
57
+ const params = new URLSearchParams();
58
+ for (const [key, value] of Object.entries(rest)) {
59
+ params.set(key, typeof value === 'string' ? value : JSON.stringify(value));
60
+ }
61
+ const query = params.toString();
62
+ if (query) url += `?${query}`;
63
+ } else {
64
+ init.headers = { 'Content-Type': 'application/json' };
65
+ init.body = JSON.stringify(rest);
66
+ }
67
+
68
+ const response = await fetch(url, init);
69
+ const text = await response.text();
70
+ let parsed: unknown = null;
71
+ if (text.length > 0) {
72
+ try {
73
+ parsed = JSON.parse(text) as unknown;
74
+ } catch {
75
+ parsed = { error: text };
76
+ }
77
+ }
78
+ if (!response.ok) {
79
+ if (route.errorBodyAsResult) return parsed;
80
+ const message = parsed !== null && typeof parsed === 'object' && 'error' in parsed
81
+ ? String((parsed as { error?: unknown }).error)
82
+ : `HTTP ${response.status}`;
83
+ throw new OperationError(message, toOperationErrorStatus(response.status));
84
+ }
85
+ return parsed;
86
+ }
87
+ }