pmx-canvas 0.1.35 → 0.2.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 (100) hide show
  1. package/CHANGELOG.md +461 -0
  2. package/Readme.md +14 -2
  3. package/dist/canvas/index.js +82 -41
  4. package/dist/json-render/index.js +89 -334
  5. package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -0
  6. package/dist/types/mcp/canvas-access.d.ts +12 -159
  7. package/dist/types/server/ax-context.d.ts +1 -1
  8. package/dist/types/server/ax-state-manager.d.ts +256 -0
  9. package/dist/types/server/ax-state.d.ts +29 -1
  10. package/dist/types/server/ax-wait.d.ts +23 -0
  11. package/dist/types/server/canvas-operations.d.ts +1 -12
  12. package/dist/types/server/canvas-state.d.ts +46 -14
  13. package/dist/types/server/html-surface.d.ts +7 -0
  14. package/dist/types/server/index.d.ts +66 -26
  15. package/dist/types/server/operations/composites.d.ts +121 -0
  16. package/dist/types/server/operations/http.d.ts +7 -0
  17. package/dist/types/server/operations/index.d.ts +8 -0
  18. package/dist/types/server/operations/invoker.d.ts +13 -0
  19. package/dist/types/server/operations/mcp.d.ts +15 -0
  20. package/dist/types/server/operations/ops/annotation.d.ts +2 -0
  21. package/dist/types/server/operations/ops/app.d.ts +33 -0
  22. package/dist/types/server/operations/ops/ax-await.d.ts +2 -0
  23. package/dist/types/server/operations/ops/ax-shared.d.ts +31 -0
  24. package/dist/types/server/operations/ops/ax-state.d.ts +2 -0
  25. package/dist/types/server/operations/ops/ax-timeline.d.ts +2 -0
  26. package/dist/types/server/operations/ops/ax-work.d.ts +2 -0
  27. package/dist/types/server/operations/ops/batch.d.ts +19 -0
  28. package/dist/types/server/operations/ops/edges.d.ts +2 -0
  29. package/dist/types/server/operations/ops/groups.d.ts +2 -0
  30. package/dist/types/server/operations/ops/json-render.d.ts +31 -0
  31. package/dist/types/server/operations/ops/nodes.d.ts +62 -0
  32. package/dist/types/server/operations/ops/query.d.ts +2 -0
  33. package/dist/types/server/operations/ops/snapshots.d.ts +2 -0
  34. package/dist/types/server/operations/ops/validate.d.ts +2 -0
  35. package/dist/types/server/operations/ops/viewport.d.ts +2 -0
  36. package/dist/types/server/operations/ops/webview.d.ts +2 -0
  37. package/dist/types/server/operations/registry.d.ts +15 -0
  38. package/dist/types/server/operations/types.d.ts +116 -0
  39. package/dist/types/server/operations/webview-runner.d.ts +69 -0
  40. package/docs/RELEASE.md +5 -0
  41. package/docs/adr-001-bun-only-runtime.md +46 -0
  42. package/docs/api-stability.md +57 -0
  43. package/docs/ax-host-adapter-contract.md +65 -0
  44. package/docs/ax-state-contract.md +72 -0
  45. package/docs/http-api.md +34 -2
  46. package/docs/mcp.md +64 -11
  47. package/docs/plans/plan-005-operation-registry.md +84 -0
  48. package/docs/plans/plan-006-mcp-tool-consolidation.md +109 -0
  49. package/docs/plans/plan-007-ax-domain.md +99 -0
  50. package/docs/plans/plan-008-registry-finish.md +91 -0
  51. package/docs/screenshot.png +0 -0
  52. package/docs/tech-debt-assessment-2026-06.md +90 -0
  53. package/package.json +3 -3
  54. package/skills/pmx-canvas/SKILL.md +233 -185
  55. package/skills/pmx-canvas/evals/evals.json +3 -3
  56. package/skills/pmx-canvas/references/codex-app-adapter.md +24 -11
  57. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +31 -1
  58. package/src/cli/agent.ts +52 -31
  59. package/src/client/nodes/ExtAppFrame.tsx +73 -5
  60. package/src/client/nodes/HtmlNode.tsx +12 -3
  61. package/src/client/nodes/McpAppNode.tsx +12 -3
  62. package/src/json-render/renderer/index.tsx +3 -0
  63. package/src/mcp/canvas-access.ts +43 -774
  64. package/src/mcp/server.ts +190 -2001
  65. package/src/server/ax-context.ts +7 -1
  66. package/src/server/ax-state-manager.ts +808 -0
  67. package/src/server/ax-state.ts +89 -2
  68. package/src/server/ax-wait.ts +56 -0
  69. package/src/server/canvas-operations.ts +2 -328
  70. package/src/server/canvas-schema.ts +2 -2
  71. package/src/server/canvas-state.ts +140 -382
  72. package/src/server/html-surface.ts +49 -11
  73. package/src/server/index.ts +136 -192
  74. package/src/server/operations/composites.ts +355 -0
  75. package/src/server/operations/http.ts +103 -0
  76. package/src/server/operations/index.ts +65 -0
  77. package/src/server/operations/invoker.ts +87 -0
  78. package/src/server/operations/mcp.ts +221 -0
  79. package/src/server/operations/ops/annotation.ts +60 -0
  80. package/src/server/operations/ops/app.ts +447 -0
  81. package/src/server/operations/ops/ax-await.ts +216 -0
  82. package/src/server/operations/ops/ax-shared.ts +38 -0
  83. package/src/server/operations/ops/ax-state.ts +249 -0
  84. package/src/server/operations/ops/ax-timeline.ts +381 -0
  85. package/src/server/operations/ops/ax-work.ts +635 -0
  86. package/src/server/operations/ops/batch.ts +365 -0
  87. package/src/server/operations/ops/edges.ts +166 -0
  88. package/src/server/operations/ops/groups.ts +176 -0
  89. package/src/server/operations/ops/json-render.ts +691 -0
  90. package/src/server/operations/ops/nodes.ts +1047 -0
  91. package/src/server/operations/ops/query.ts +281 -0
  92. package/src/server/operations/ops/snapshots.ts +366 -0
  93. package/src/server/operations/ops/validate.ts +37 -0
  94. package/src/server/operations/ops/viewport.ts +219 -0
  95. package/src/server/operations/ops/webview.ts +339 -0
  96. package/src/server/operations/registry.ts +79 -0
  97. package/src/server/operations/types.ts +150 -0
  98. package/src/server/operations/webview-runner.ts +77 -0
  99. package/src/server/server.ts +253 -2170
  100. package/src/server/web-artifacts.ts +6 -2
package/src/mcp/server.ts CHANGED
@@ -22,15 +22,15 @@
22
22
 
23
23
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
24
24
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
25
- import { isAbsolute, relative, resolve } from 'node:path';
26
25
  import { z } from 'zod';
27
- import { canvasState, describeCanvasSchema, validateStructuredCanvasPayload } from '../server/index.js';
26
+ import { canvasState, describeCanvasSchema } from '../server/index.js';
28
27
  import { AX_INTERACTION_TYPES } from '../server/ax-interaction.js';
28
+ import { buildPendingAxActivity } from '../server/ax-state.js';
29
29
  import { isHtmlPrimitiveKind } from '../server/html-primitives.js';
30
30
  import type { HtmlPrimitiveKind } from '../server/html-primitives.js';
31
+ import { registerOperationTools, registerCompositeTools } from '../server/operations/index.js';
31
32
  import { createCanvasAccess, refreshCanvasAccess, type CanvasAccess } from './canvas-access.js';
32
33
  import { serializeNodeForAgentContext } from '../server/agent-context.js';
33
- import { wrapCanvasAutomationScript } from '../server/server.js';
34
34
  import { buildSpatialContext, findNeighborhoods } from '../server/spatial-analysis.js';
35
35
  import {
36
36
  getCanvasNodeTitle,
@@ -46,19 +46,6 @@ let resourceNotificationServer: McpServer | null = null;
46
46
  let localResourceNotificationsStarted = false;
47
47
  let remoteResourceNotificationsBaseUrl: string | null = null;
48
48
 
49
- const jsonRenderSpecSchema = z.union([
50
- z.object({
51
- root: z.string(),
52
- elements: z.record(z.string(), z.unknown()),
53
- state: z.record(z.string(), z.unknown()).optional(),
54
- }).passthrough(),
55
- z.object({
56
- type: z.string(),
57
- props: z.record(z.string(), z.unknown()).optional(),
58
- children: z.array(z.string()).optional(),
59
- }).passthrough(),
60
- ]);
61
-
62
49
  const htmlPrimitiveKindSchema = z.string().refine(isHtmlPrimitiveKind, 'Unknown HTML primitive kind');
63
50
 
64
51
  function structuredSchemaDescription(): string {
@@ -68,24 +55,9 @@ function structuredSchemaDescription(): string {
68
55
  .join(', ');
69
56
  }
70
57
 
71
- function workspaceRoot(): string {
72
- return resolve(process.cwd());
73
- }
74
-
75
- function isPathInside(base: string, candidate: string): boolean {
76
- const rel = relative(base, candidate);
77
- if (rel === '') return true;
78
- return !rel.startsWith('..') && rel !== '..' && !isAbsolute(rel);
79
- }
80
-
81
- function safeWorkspacePath(pathLike: string): string {
82
- const workspace = workspaceRoot();
83
- const resolved = resolve(workspace, pathLike);
84
- if (!isPathInside(workspace, resolved)) {
85
- throw new Error(`Path "${pathLike}" resolves outside workspace.`);
86
- }
87
- return resolved;
88
- }
58
+ // workspaceRoot / isPathInside / safeWorkspacePath removed with the
59
+ // canvas_build_web_artifact MCP tool (plan-008 Wave 4). The webartifact.build op
60
+ // sandboxes projectPath/outputPath via web-artifacts.ts resolveWorkspacePath.
89
61
 
90
62
  async function ensureCanvas(): Promise<CanvasAccess> {
91
63
  if (!canvas) {
@@ -200,56 +172,6 @@ function wantsFullPayload(input: { full?: boolean; verbose?: boolean; includeDat
200
172
  return input.full === true || input.verbose === true || input.includeData === true;
201
173
  }
202
174
 
203
- interface PendingAxActivityItem {
204
- kind: 'work-item' | 'approval-gate' | 'elicitation' | 'mode-request';
205
- id: string;
206
- title: string;
207
- status: string;
208
- nodeIds: string[];
209
- source: string | null;
210
- }
211
-
212
- const OPEN_AX_WORK_STATUSES = new Set(['todo', 'in-progress', 'blocked']);
213
-
214
- /**
215
- * Open, agent-actionable canvas-bound AX items (open work items + pending approval
216
- * gates / elicitations / mode requests). Unlike steering (a directive routed through
217
- * the claim/ack delivery queue), these are STATE the human curates in the browser —
218
- * they fire `ax-state-changed` (so resource-subscribers are pushed canvas://ax-work),
219
- * but an adapterless client that only POLLS the delivery surface never saw them.
220
- * Surfacing this digest there closes report #43 without conflating state with steering.
221
- * Optionally excludes items the consumer itself originated (loop prevention), mirroring
222
- * getPendingSteering.
223
- */
224
- function buildPendingAxActivity(
225
- state: Awaited<ReturnType<CanvasAccess['getAxState']>>,
226
- consumer?: string,
227
- ): PendingAxActivityItem[] {
228
- const notMine = (source: string | null) => !consumer || source !== consumer;
229
- const out: PendingAxActivityItem[] = [];
230
- for (const w of state.workItems ?? []) {
231
- if (OPEN_AX_WORK_STATUSES.has(w.status) && notMine(w.source)) {
232
- out.push({ kind: 'work-item', id: w.id, title: w.title, status: w.status, nodeIds: w.nodeIds ?? [], source: w.source });
233
- }
234
- }
235
- for (const g of state.approvalGates ?? []) {
236
- if (g.status === 'pending' && notMine(g.source)) {
237
- out.push({ kind: 'approval-gate', id: g.id, title: g.title, status: g.status, nodeIds: g.nodeIds ?? [], source: g.source });
238
- }
239
- }
240
- for (const e of state.elicitations ?? []) {
241
- if (e.status === 'pending' && notMine(e.source)) {
242
- out.push({ kind: 'elicitation', id: e.id, title: e.prompt, status: e.status, nodeIds: e.nodeIds ?? [], source: e.source });
243
- }
244
- }
245
- for (const m of state.modeRequests ?? []) {
246
- if (m.status === 'pending' && notMine(m.source)) {
247
- out.push({ kind: 'mode-request', id: m.id, title: m.reason ? `${m.mode}: ${m.reason}` : `mode: ${m.mode}`, status: m.status, nodeIds: m.nodeIds ?? [], source: m.source });
248
- }
249
- }
250
- return out;
251
- }
252
-
253
175
  function compactNodePayload(node: Awaited<ReturnType<CanvasAccess['getNode']>>): Record<string, unknown> | null {
254
176
  if (!node) return null;
255
177
  const serialized = serializeCanvasNode(node);
@@ -293,28 +215,6 @@ function agentSafeFullLayoutPayload(layout: Awaited<ReturnType<CanvasAccess['get
293
215
  };
294
216
  }
295
217
 
296
- function compactBatchValue(value: unknown): unknown {
297
- if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
298
- const record = value as Record<string, unknown>;
299
- const nodeLike = typeof record.id === 'string' && typeof record.type === 'string';
300
- const compact: Record<string, unknown> = {};
301
- for (const key of ['ok', 'id', 'type', 'kind', 'title', 'content', 'position', 'size', 'fetch', 'error', 'from', 'to', 'groupId', 'nodeIds', 'snapshot', 'arranged', 'layout']) {
302
- if (record[key] !== undefined) compact[key] = record[key];
303
- }
304
- if (nodeLike) return compact;
305
- return record;
306
- }
307
-
308
- function compactBatchResult(result: { ok: boolean; results: Array<Record<string, unknown>>; refs: Record<string, unknown>; failedIndex?: number; error?: string }): Record<string, unknown> {
309
- return {
310
- ok: result.ok,
311
- ...(result.failedIndex !== undefined ? { failedIndex: result.failedIndex } : {}),
312
- ...(result.error ? { error: result.error } : {}),
313
- results: result.results.map((entry) => compactBatchValue(entry)),
314
- refs: Object.fromEntries(Object.entries(result.refs).map(([key, value]) => [key, compactBatchValue(value)])),
315
- };
316
- }
317
-
318
218
  async function createdNodePayload(c: CanvasAccess, id: string, options: { full?: boolean; verbose?: boolean; includeData?: boolean } = {}): Promise<Record<string, unknown>> {
319
219
  // Expose both `id` and a `nodeId` alias on every node-create response so
320
220
  // agents using either key (or a cached schema) work — matching the
@@ -349,20 +249,6 @@ function buildSummaryFromLayout(layout: Awaited<ReturnType<CanvasAccess['getLayo
349
249
  };
350
250
  }
351
251
 
352
- function buildSnapshotRestoreSummary(layout: Awaited<ReturnType<CanvasAccess['getLayout']>>): Record<string, unknown> {
353
- const nodesByType: Record<string, number> = {};
354
- for (const node of layout.nodes) {
355
- nodesByType[node.type] = (nodesByType[node.type] ?? 0) + 1;
356
- }
357
- return {
358
- nodeCount: layout.nodes.length,
359
- edgeCount: layout.edges.length,
360
- annotationCount: (layout.annotations ?? []).length,
361
- nodesByType,
362
- viewport: layout.viewport,
363
- };
364
- }
365
-
366
252
  export async function startMcpServer(): Promise<void> {
367
253
  const server = new McpServer({
368
254
  name: 'pmx-canvas',
@@ -370,118 +256,27 @@ export async function startMcpServer(): Promise<void> {
370
256
  });
371
257
  resourceNotificationServer = server;
372
258
 
373
- // ── canvas_get_layout ──────────────────────────────────────────
374
- server.tool(
375
- 'canvas_get_layout',
376
- 'Get the canvas layout. Defaults to a compact agent-safe projection; pass full:true for full node data.',
377
- {
378
- full: z.boolean().optional().describe('Return the full layout including node data. Default false keeps responses compact.'),
379
- verbose: z.boolean().optional().describe('Alias for full:true.'),
380
- },
381
- async (input) => {
382
- const c = await ensureCanvas();
383
- const layout = await c.getLayout();
384
- const payload = wantsFullPayload(input)
385
- ? agentSafeFullLayoutPayload(layout)
386
- : compactLayoutPayload(layout, await c.getPinnedNodeIds());
387
- return {
388
- content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
389
- };
390
- },
391
- );
392
-
393
- // ── canvas_get_node ────────────────────────────────────────────
394
- server.tool(
395
- 'canvas_get_node',
396
- 'Get a single node by ID. Defaults to compact metadata; pass full:true to include full data/tool results.',
397
- {
398
- id: z.string().describe('The node ID to retrieve'),
399
- full: z.boolean().optional().describe('Include full node data, including mcp-app tool results. Default false.'),
400
- verbose: z.boolean().optional().describe('Alias for full:true.'),
401
- },
402
- async (input) => {
403
- const c = await ensureCanvas();
404
- const node = await c.getNode(input.id);
405
- if (!node) {
406
- return {
407
- content: [{ type: 'text', text: `Node "${input.id}" not found.` }],
408
- isError: true,
409
- };
410
- }
411
- const payload = wantsFullPayload(input) ? serializeCanvasNodeForAgent(node) : compactNodePayload(node);
412
- return {
413
- content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
414
- };
415
- },
416
- );
417
-
418
- // ── canvas_add_node ────────────────────────────────────────────
419
- server.tool(
420
- 'canvas_add_node',
421
- 'Add a basic node to the canvas. Returns the created node with normalized title/content and rendered geometry. Supported here: markdown, status, context, ledger, trace, file, image, webpage, mcp-app, html, group. Dedicated node tools: json-render -> canvas_add_json_render_node, graph -> canvas_add_graph_node, web-artifact -> canvas_build_web_artifact, external apps -> canvas_open_mcp_app, html (preferred) -> canvas_add_html_node, groups -> canvas_create_group. Call canvas_describe_schema for the full nodeTypeRouting table.',
422
- {
423
- type: z.enum(['markdown', 'status', 'context', 'ledger', 'trace', 'file', 'image', 'webpage', 'mcp-app', 'html', 'group'])
424
- .describe('Node type (prefer canvas_create_group for groups)'),
425
- title: z.string().optional().describe('Node title'),
426
- content: z.string().optional().describe('Node content (markdown for markdown nodes, file path for file nodes, image path/URL/data-URI for image nodes, URL for webpage nodes)'),
427
- path: z.string().optional().describe('Compatibility alias for image node content. Prefer content for image paths.'),
428
- url: z.string().optional().describe('Canonical webpage URL field for webpage nodes. Overrides content when both are provided.'),
429
- x: z.number().optional().describe('X position (auto-placed if omitted)'),
430
- y: z.number().optional().describe('Y position (auto-placed if omitted)'),
431
- width: z.number().optional().describe('Width in pixels (default: 720)'),
432
- height: z.number().optional().describe('Height in pixels (default: 600)'),
433
- strictSize: z.boolean().optional().describe('Keep explicit width/height fixed and scroll overflowing content instead of browser auto-fitting'),
434
- children: z.array(z.string()).optional().describe('Group-only alias for childIds. Node IDs to include in a generic group node.'),
435
- childIds: z.array(z.string()).optional().describe('Group-only field. Node IDs to include in a generic group node. Prefer canvas_create_group for groups.'),
436
- childLayout: z.enum(['grid', 'column', 'flow']).optional().describe('Group-only optional layout for grouped children.'),
437
- color: z.string().optional().describe('Group-only frame accent color.'),
438
- toolName: z.string().optional().describe('Trace node tool or operation label'),
439
- category: z.string().optional().describe('Trace node category: mcp, file, subagent, or other'),
440
- status: z.string().optional().describe('Trace node status: running, success, or failed'),
441
- duration: z.string().optional().describe('Trace node duration badge text'),
442
- resultSummary: z.string().optional().describe('Trace node result summary'),
443
- error: z.string().optional().describe('Trace node error message'),
444
- full: z.boolean().optional().describe('Return the full created node payload. Default false returns compact metadata.'),
445
- verbose: z.boolean().optional().describe('Alias for full:true.'),
446
- },
447
- async (input) => {
448
- const c = await ensureCanvas();
449
- if (input.type === 'webpage') {
450
- const url = input.url ?? input.content;
451
- if (!url) {
452
- return {
453
- content: [{ type: 'text', text: 'Webpage nodes require a page URL via "url" (preferred) or "content".' }],
454
- isError: true,
455
- };
456
- }
457
- const result = await c.addWebpageNode({
458
- ...(typeof input.title === 'string' ? { title: input.title } : {}),
459
- url,
460
- ...(typeof input.x === 'number' ? { x: input.x } : {}),
461
- ...(typeof input.y === 'number' ? { y: input.y } : {}),
462
- ...(typeof input.width === 'number' ? { width: input.width } : {}),
463
- ...(typeof input.height === 'number' ? { height: input.height } : {}),
464
- ...(input.strictSize === true ? { strictSize: true } : {}),
465
- });
466
- return {
467
- content: [{ type: 'text', text: JSON.stringify(result) }],
468
- ...(result.ok ? {} : { isError: true }),
469
- };
470
- }
471
- const nodeInput = input.type === 'image' && input.path && !input.content
472
- ? { ...input, content: input.path }
473
- : input;
474
- const id = await c.addNode(nodeInput);
475
- return {
476
- content: [{ type: 'text', text: JSON.stringify(await createdNodePayload(c, id, input), null, 2) }],
477
- };
478
- },
479
- );
259
+ // ── Operation-registry tools (plan-005) ────────────────────────
260
+ // canvas_get_layout / canvas_get_node / canvas_add_node /
261
+ // canvas_update_node / canvas_remove_node are registered from the shared
262
+ // operation registry. Tool names and compact/full payload behavior are
263
+ // frozen (tests/unit/mcp-tool-freeze.test.ts, operation-parity.test.ts).
264
+ registerOperationTools(server, ensureCanvas);
265
+
266
+ // ── Composite (action-discriminated) tools (plan-006) ───────────
267
+ // Consolidate single-purpose tools into action-routed composites
268
+ // (canvas_node, canvas_render, canvas_edge, canvas_group, canvas_history,
269
+ // canvas_view, canvas_query, plus the AX composites). Each action dispatches
270
+ // to the same registered operation as its standalone tool, so behavior is
271
+ // identical. Additive in v0.2 (legacy tools still registered below); legacy
272
+ // removed in v0.3 per docs/api-stability.md. (canvas_snapshot composite is
273
+ // deferred to v0.3 — its name is still held by the legacy save-snapshot tool.)
274
+ registerCompositeTools(server, ensureCanvas);
480
275
 
481
276
  // ── canvas_add_html_node ────────────────────────────────────────
482
277
  server.tool(
483
278
  'canvas_add_html_node',
484
- 'Add a normal html node: a self-contained HTML document (with optional inline <script> and CDN <script src="...">) rendered inside a sandboxed iframe (sandbox="allow-scripts"). This is the default HTML surface for reports, widgets, and bespoke visualizations. Presentation mode is opt-in: only pass presentation:true when the user explicitly asks for a deck/fullscreen presentation, or use canvas_add_html_primitive with kind="presentation". The iframe inherits live canvas theme tokens via injected CSS custom properties (both --c-* and common --color-* aliases) so authored HTML using var(--color-text-secondary), var(--color-bg), etc. renders cohesively. No same-origin access; no top-navigation; no forms. For declarative-only views with zero JS, prefer canvas_add_json_render_node. For React + shadcn + routing or multi-component apps, use canvas_build_web_artifact.',
279
+ 'Deprecated: use canvas_node with action "add" and type:"html". Add a normal html node: a self-contained HTML document (with optional inline <script> and CDN <script src="...">) rendered inside a sandboxed iframe (sandbox="allow-scripts"). This is the default HTML surface for reports, widgets, and bespoke visualizations. Presentation mode is opt-in: only pass presentation:true when the user explicitly asks for a deck/fullscreen presentation, or use canvas_add_html_primitive with kind="presentation". The iframe inherits live canvas theme tokens via injected CSS custom properties (both --c-* and common --color-* aliases) so authored HTML using var(--color-text-secondary), var(--color-bg), etc. renders cohesively. No same-origin access; no top-navigation; no forms. For declarative-only views with zero JS, prefer canvas_add_json_render_node. For React + shadcn + routing or multi-component apps, use canvas_build_web_artifact.',
485
280
  {
486
281
  html: z.string().describe('HTML document or fragment. Full <html>...</html> documents are passed through with theme styles injected into <head>; bare fragments are wrapped in a minimal document. Inline <script> and remote CDN <script src="..."> are allowed. If this is a bare path to an existing local .html/.htm file, the file contents are read and used as the HTML.'),
487
282
  title: z.string().optional().describe('Node title shown in the canvas titlebar.'),
@@ -520,1602 +315,204 @@ export async function startMcpServer(): Promise<void> {
520
315
  ...(typeof input.x === 'number' ? { x: input.x } : {}),
521
316
  ...(typeof input.y === 'number' ? { y: input.y } : {}),
522
317
  ...(typeof input.width === 'number' ? { width: input.width } : {}),
523
- ...(typeof input.height === 'number' ? { height: input.height } : {}),
524
- ...(input.strictSize === true ? { strictSize: true } : {}),
525
- });
526
- return {
527
- content: [{ type: 'text', text: JSON.stringify(await createdNodePayload(c, id, input), null, 2) }],
528
- };
529
- },
530
- );
531
-
532
- server.tool(
533
- 'canvas_add_html_primitive',
534
- 'Create a reusable HTML communication primitive as a normal sandboxed html node. Use this instead of long markdown for side-by-side choices, implementation plans, PR review sheets, module maps, design sheets, component galleries, flowcharts, explainers, status reports, and throwaway editors with export/copy paths. Use kind="presentation" only when the user explicitly asks for a PowerPoint-like deck, pitch, briefing, workshop walkthrough, or fullscreen story.',
535
- {
536
- kind: htmlPrimitiveKindSchema.describe('Primitive kind. Call canvas_describe_schema and read htmlPrimitives for data shapes and examples.'),
537
- title: z.string().optional().describe('Node title shown in the canvas titlebar.'),
538
- data: z.record(z.string(), z.unknown()).optional().describe('Primitive-specific data payload. For kind="presentation", data may include theme:"canvas"|"midnight"|"paper"|"aurora" or a custom color object. See canvas_describe_schema.htmlPrimitives for each shape.'),
539
- x: z.number().optional().describe('X position (auto-placed if omitted).'),
540
- y: z.number().optional().describe('Y position (auto-placed if omitted).'),
541
- width: z.number().optional().describe('Width in pixels (defaults per primitive).'),
542
- height: z.number().optional().describe('Height in pixels (defaults per primitive).'),
543
- strictSize: z.boolean().optional().describe('Keep explicit width/height fixed; iframe scrolls overflow internally.'),
544
- full: z.boolean().optional().describe('Return the full created node payload. Default false returns compact metadata.'),
545
- verbose: z.boolean().optional().describe('Alias for full:true.'),
546
- },
547
- async (input) => {
548
- const c = await ensureCanvas();
549
- const kind = input.kind as HtmlPrimitiveKind;
550
- const result = await c.addHtmlPrimitive({
551
- kind,
552
- ...(typeof input.title === 'string' ? { title: input.title } : {}),
553
- ...(input.data ? { data: input.data } : {}),
554
- ...(typeof input.x === 'number' ? { x: input.x } : {}),
555
- ...(typeof input.y === 'number' ? { y: input.y } : {}),
556
- ...(typeof input.width === 'number' ? { width: input.width } : {}),
557
- ...(typeof input.height === 'number' ? { height: input.height } : {}),
558
- ...(input.strictSize === true ? { strictSize: true } : {}),
559
- });
560
- return {
561
- content: [{
562
- type: 'text',
563
- text: JSON.stringify({
564
- ...(await createdNodePayload(c, result.id, input)),
565
- primitive: { kind: result.kind, title: result.title, htmlBytes: result.htmlBytes },
566
- }, null, 2),
567
- }],
568
- };
569
- },
570
- );
571
-
572
- server.tool(
573
- 'canvas_open_mcp_app',
574
- 'Connect to an external MCP server that declares a ui:// app resource, call the specified tool, and open the resulting MCP App inside a canvas mcp-app node. This is a full external-MCP transport call, not the CLI kind shortcut; use canvas_add_diagram for the built-in Excalidraw preset.',
575
- {
576
- toolName: z.string().describe('Tool name on the external MCP server'),
577
- serverName: z.string().optional().describe('Optional display name for the external MCP server'),
578
- toolArguments: z.record(z.string(), z.unknown()).optional().describe('Arguments passed to the external tool call'),
579
- nodeId: z.string().optional().describe('Existing mcp-app node ID to update in place instead of creating a new node.'),
580
- title: z.string().optional().describe('Optional canvas node title override'),
581
- x: z.number().optional().describe('X position (auto-placed if omitted)'),
582
- y: z.number().optional().describe('Y position (auto-placed if omitted)'),
583
- width: z.number().optional().describe('Width in pixels (default: 720)'),
584
- height: z.number().optional().describe('Height in pixels (default: 500)'),
585
- timeoutMs: z.number().optional().describe('Optional MCP request timeout in milliseconds for cold external app servers'),
586
- transport: z.union([
587
- z.object({
588
- type: z.literal('stdio'),
589
- command: z.string().describe('Executable used to start the external MCP server'),
590
- args: z.array(z.string()).optional().describe('Arguments for the executable'),
591
- cwd: z.string().optional().describe('Optional working directory'),
592
- env: z.record(z.string(), z.string()).optional().describe('Optional environment overrides'),
593
- }),
594
- z.object({
595
- type: z.literal('http'),
596
- url: z.string().describe('Streamable HTTP MCP endpoint URL'),
597
- headers: z.record(z.string(), z.string()).optional().describe('Optional HTTP headers'),
598
- }),
599
- ]).describe('How PMX Canvas should connect to the external MCP server'),
600
- },
601
- async (input) => {
602
- const c = await ensureCanvas();
603
- try {
604
- const result = await c.openMcpApp({
605
- transport: input.transport,
606
- toolName: input.toolName,
607
- ...(typeof input.serverName === 'string' ? { serverName: input.serverName } : {}),
608
- ...(input.toolArguments ? { toolArguments: input.toolArguments } : {}),
609
- ...(typeof input.nodeId === 'string' ? { nodeId: input.nodeId } : {}),
610
- ...(typeof input.title === 'string' ? { title: input.title } : {}),
611
- ...(typeof input.x === 'number' ? { x: input.x } : {}),
612
- ...(typeof input.y === 'number' ? { y: input.y } : {}),
613
- ...(typeof input.width === 'number' ? { width: input.width } : {}),
614
- ...(typeof input.height === 'number' ? { height: input.height } : {}),
615
- ...(typeof input.timeoutMs === 'number' ? { timeoutMs: input.timeoutMs } : {}),
616
- });
617
- return {
618
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
619
- };
620
- } catch (error) {
621
- return {
622
- content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
623
- isError: true,
624
- };
625
- }
626
- },
627
- );
628
-
629
- server.tool(
630
- 'canvas_add_diagram',
631
- 'Draw a hand-drawn diagram on the canvas via the hosted Excalidraw MCP app. Pass an array of Excalidraw elements (rectangles, ellipses, diamonds, arrows, text). The diagram opens inside an mcp-app node that supports fullscreen editing. For other MCP apps, use canvas_open_mcp_app.',
632
- {
633
- elements: z.union([
634
- z.string().describe('JSON array string of Excalidraw elements'),
635
- z.array(z.record(z.string(), z.unknown())).describe('Array of Excalidraw elements'),
636
- ]).describe('Excalidraw elements to render. See https://github.com/excalidraw/excalidraw-mcp for the element format.'),
637
- nodeId: z.string().optional().describe('Existing Excalidraw mcp-app node ID to update in place instead of creating a new node.'),
638
- title: z.string().optional().describe('Optional canvas node title override'),
639
- x: z.number().optional().describe('X position (auto-placed if omitted)'),
640
- y: z.number().optional().describe('Y position (auto-placed if omitted)'),
641
- width: z.number().optional().describe('Width in pixels (default: 720)'),
642
- height: z.number().optional().describe('Height in pixels (default: 500)'),
643
- timeoutMs: z.number().optional().describe('Optional MCP request timeout in milliseconds for Excalidraw cold starts. Client-side MCP hosts may still enforce their own total request timeout.'),
644
- },
645
- async (input, extra) => {
646
- const c = await ensureCanvas();
647
- try {
648
- const result = await c.addDiagram({
649
- elements: input.elements,
650
- ...(typeof input.nodeId === 'string' ? { nodeId: input.nodeId } : {}),
651
- ...(typeof input.title === 'string' ? { title: input.title } : {}),
652
- ...(typeof input.x === 'number' ? { x: input.x } : {}),
653
- ...(typeof input.y === 'number' ? { y: input.y } : {}),
654
- ...(typeof input.width === 'number' ? { width: input.width } : {}),
655
- ...(typeof input.height === 'number' ? { height: input.height } : {}),
656
- ...(typeof input.timeoutMs === 'number' ? { timeoutMs: input.timeoutMs } : {}),
657
- });
658
- return {
659
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
660
- };
661
- } catch (error) {
662
- if (extra.signal.aborted) {
663
- return {
664
- content: [{ type: 'text', text: 'canvas_add_diagram was cancelled by the MCP client before Excalidraw finished. Retry with a higher client request timeout and pass timeoutMs to PMX Canvas for the downstream Excalidraw call.' }],
665
- isError: true,
666
- };
667
- }
668
- return {
669
- content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
670
- isError: true,
671
- };
672
- }
673
- },
674
- );
675
-
676
- server.tool(
677
- 'canvas_describe_schema',
678
- 'Describe the current server-supported canvas create schemas, json-render component catalog, canonical examples, and related MCP entry points. Includes mcp.nodeTypeRouting, the authoritative map from node type to MCP creation tool.',
679
- {},
680
- async () => ({
681
- content: [{ type: 'text', text: JSON.stringify(describeCanvasSchema(), null, 2) }],
682
- }),
683
- );
684
-
685
- server.tool(
686
- 'canvas_validate_spec',
687
- 'Validate a json-render spec, graph payload, or HTML primitive payload without creating a node. Returns normalized metadata the server would accept.',
688
- {
689
- type: z.enum(['json-render', 'graph', 'html-primitive']).describe('Structured payload type to validate'),
690
- spec: jsonRenderSpecSchema.optional().describe('json-render spec to validate when type="json-render"'),
691
- kind: htmlPrimitiveKindSchema.optional().describe('HTML primitive kind when type="html-primitive"'),
692
- primitive: htmlPrimitiveKindSchema.optional().describe('Alias for kind when type="html-primitive"'),
693
- primitiveData: z.record(z.string(), z.unknown()).optional().describe('HTML primitive data payload when type="html-primitive"'),
694
- title: z.string().optional().describe('Optional graph title'),
695
- graphType: z.string().optional().describe('Graph type when type="graph"'),
696
- data: z.array(z.record(z.string(), z.unknown())).optional().describe('Graph dataset when type="graph"'),
697
- xKey: z.string().optional().describe('X-axis key for line/bar graphs'),
698
- yKey: z.string().optional().describe('Y-axis key for line/bar graphs'),
699
- zKey: z.string().optional().describe('Optional bubble-size key for scatter charts'),
700
- nameKey: z.string().optional().describe('Slice name key for pie graphs'),
701
- valueKey: z.string().optional().describe('Value key for pie slices, sparkline, dot-plot, and the bullet measure'),
702
- axisKey: z.string().optional().describe('Category key for radar charts'),
703
- metrics: z.array(z.string()).optional().describe('Series keys to plot as radar polygons'),
704
- series: z.array(z.string()).optional().describe('Series keys for stacked-bar segments'),
705
- barKey: z.string().optional().describe('Bar series key for composed charts'),
706
- lineKey: z.string().optional().describe('Line series key for composed charts'),
707
- aggregate: z.enum(['sum', 'count', 'avg']).optional().describe('Optional aggregation for repeated keys'),
708
- color: z.string().optional().describe('Optional graph color'),
709
- colorBy: z.enum(['series', 'category', 'value', 'none']).optional().describe("Bar charts only: how bars are colored (default 'series')"),
710
- highlight: z.union([z.number(), z.enum(['max', 'min'])]).nullable().optional().describe("Bar charts only, colorBy='series': which bar gets the accent"),
711
- barColor: z.string().optional().describe('Optional bar color for composed charts'),
712
- lineColor: z.string().optional().describe('Optional line color for composed charts'),
713
- labelKey: z.string().optional().describe('Category label key for dot-plot / bullet / slopegraph rows'),
714
- targetKey: z.string().optional().describe('Per-row target value key for bullet charts'),
715
- rangesKey: z.string().optional().describe('Per-row qualitative band thresholds key (number[]) for bullet charts'),
716
- beforeKey: z.string().optional().describe('Left-column value key for slopegraph'),
717
- afterKey: z.string().optional().describe('Right-column value key for slopegraph'),
718
- beforeLabel: z.string().optional().describe('Header label for the slopegraph left column'),
719
- afterLabel: z.string().optional().describe('Header label for the slopegraph right column'),
720
- sort: z.enum(['asc', 'desc', 'none']).optional().describe('Row sort order for dot-plot (defaults to desc)'),
721
- fill: z.boolean().optional().describe('Sparkline: draw a light area fill under the line'),
722
- showEndDot: z.boolean().optional().describe('Sparkline: draw a dot at the last point (default true)'),
723
- showMinMax: z.boolean().optional().describe('Sparkline: mark the min and max points'),
724
- showValue: z.boolean().optional().describe('Sparkline: print the last value inline'),
725
- colorByDirection: z.boolean().optional().describe('Slopegraph: accent rising lines and mute falling ones (default off)'),
726
- height: z.number().optional().describe('Optional graph content height'),
727
- },
728
- async (input) => {
729
- try {
730
- const result = input.type === 'json-render'
731
- ? validateStructuredCanvasPayload({
732
- type: 'json-render',
733
- spec: input.spec,
734
- })
735
- : input.type === 'html-primitive'
736
- ? validateStructuredCanvasPayload({
737
- type: 'html-primitive',
738
- primitive: {
739
- kind: (input.kind ?? input.primitive ?? '') as HtmlPrimitiveKind | '',
740
- ...(typeof input.title === 'string' ? { title: input.title } : {}),
741
- ...(input.primitiveData ? { data: input.primitiveData } : {}),
742
- },
743
- })
744
- : validateStructuredCanvasPayload({
745
- type: 'graph',
746
- graph: {
747
- title: input.title,
748
- graphType: input.graphType ?? 'line',
749
- data: input.data ?? [],
750
- ...(typeof input.xKey === 'string' ? { xKey: input.xKey } : {}),
751
- ...(typeof input.yKey === 'string' ? { yKey: input.yKey } : {}),
752
- ...(typeof input.zKey === 'string' ? { zKey: input.zKey } : {}),
753
- ...(typeof input.nameKey === 'string' ? { nameKey: input.nameKey } : {}),
754
- ...(typeof input.valueKey === 'string' ? { valueKey: input.valueKey } : {}),
755
- ...(typeof input.axisKey === 'string' ? { axisKey: input.axisKey } : {}),
756
- ...(Array.isArray(input.metrics) ? { metrics: input.metrics } : {}),
757
- ...(Array.isArray(input.series) ? { series: input.series } : {}),
758
- ...(typeof input.barKey === 'string' ? { barKey: input.barKey } : {}),
759
- ...(typeof input.lineKey === 'string' ? { lineKey: input.lineKey } : {}),
760
- ...(typeof input.aggregate === 'string' ? { aggregate: input.aggregate } : {}),
761
- ...(typeof input.color === 'string' ? { color: input.color } : {}),
762
- ...(typeof input.colorBy === 'string' ? { colorBy: input.colorBy } : {}),
763
- ...(input.highlight !== undefined ? { highlight: input.highlight } : {}),
764
- ...(typeof input.barColor === 'string' ? { barColor: input.barColor } : {}),
765
- ...(typeof input.lineColor === 'string' ? { lineColor: input.lineColor } : {}),
766
- ...(typeof input.labelKey === 'string' ? { labelKey: input.labelKey } : {}),
767
- ...(typeof input.targetKey === 'string' ? { targetKey: input.targetKey } : {}),
768
- ...(typeof input.rangesKey === 'string' ? { rangesKey: input.rangesKey } : {}),
769
- ...(typeof input.beforeKey === 'string' ? { beforeKey: input.beforeKey } : {}),
770
- ...(typeof input.afterKey === 'string' ? { afterKey: input.afterKey } : {}),
771
- ...(typeof input.beforeLabel === 'string' ? { beforeLabel: input.beforeLabel } : {}),
772
- ...(typeof input.afterLabel === 'string' ? { afterLabel: input.afterLabel } : {}),
773
- ...(typeof input.sort === 'string' ? { sort: input.sort } : {}),
774
- ...(typeof input.fill === 'boolean' ? { fill: input.fill } : {}),
775
- ...(typeof input.showEndDot === 'boolean' ? { showEndDot: input.showEndDot } : {}),
776
- ...(typeof input.showMinMax === 'boolean' ? { showMinMax: input.showMinMax } : {}),
777
- ...(typeof input.showValue === 'boolean' ? { showValue: input.showValue } : {}),
778
- ...(typeof input.colorByDirection === 'boolean' ? { colorByDirection: input.colorByDirection } : {}),
779
- ...(typeof input.height === 'number' ? { height: input.height } : {}),
780
- },
781
- });
782
-
783
- return {
784
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
785
- };
786
- } catch (error) {
787
- return {
788
- content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
789
- isError: true,
790
- };
791
- }
792
- },
793
- );
794
-
795
- server.tool(
796
- 'canvas_refresh_webpage_node',
797
- 'Refresh a webpage node from its persisted URL so the server re-fetches and caches the latest page text and metadata.',
798
- {
799
- id: z.string().describe('Webpage node ID to refresh'),
800
- url: z.string().optional().describe('Optional replacement URL before refresh'),
801
- },
802
- async ({ id, url }) => {
803
- const c = await ensureCanvas();
804
- const result = await c.refreshWebpageNode(id, url);
805
- return {
806
- content: [{ type: 'text', text: JSON.stringify(result) }],
807
- ...(result.ok ? {} : { isError: true }),
808
- };
809
- },
810
- );
811
-
812
- // ── canvas_build_web_artifact ───────────────────────────────
813
- server.tool(
814
- 'canvas_build_web_artifact',
815
- 'Build a bundled single-file HTML web artifact from React/Tailwind source files using the bundled web-artifacts-builder skill scripts. MCP callers pass source content in appTsx (the CLI app-file flag reads a file before calling this path). Builds can exceed default 60s MCP client timeouts on cold workspaces; set a long client timeout or retry with the same projectPath/outputPath if the client times out. Optionally opens the generated artifact as an embedded node on the canvas. Read canvas://skills/web-artifacts-builder for the full workflow, stack, and anti-slop design guidelines before calling.',
816
- {
817
- title: z.string().describe('Artifact title used for default project and output paths'),
818
- appTsx: z.string().describe('Contents for src/App.tsx'),
819
- indexCss: z.string().optional().describe('Optional contents for src/index.css'),
820
- mainTsx: z.string().optional().describe('Optional contents for src/main.tsx'),
821
- indexHtml: z.string().optional().describe('Optional contents for index.html'),
822
- files: z.record(z.string(), z.string()).optional().describe('Optional map of additional project-relative file paths to file contents'),
823
- deps: z.array(z.string()).optional().describe('Optional npm dependencies to install before bundling (e.g. ["recharts", "framer-motion@^11"]). Validated against npm-name format; flags and shell metacharacters are rejected.'),
824
- projectPath: z.string().optional().describe('Optional workspace-relative reusable project path. Defaults to .pmx-canvas/artifacts/.web-artifacts/<slug>'),
825
- outputPath: z.string().optional().describe('Optional workspace-relative HTML output path. Defaults to .pmx-canvas/artifacts/<slug>.html'),
826
- openInCanvas: z.boolean().optional().describe('Open the generated artifact in canvas after build (default true)'),
827
- includeLogs: z.boolean().optional().describe('Include raw build stdout/stderr in the response (default false)'),
828
- initScriptPath: z.string().optional().describe('Optional script path override for tests/debugging. Must resolve inside the workspace.'),
829
- bundleScriptPath: z.string().optional().describe('Optional script path override for tests/debugging. Must resolve inside the workspace.'),
830
- timeoutMs: z.number().optional().describe('Optional timeout in milliseconds for init and bundle commands'),
831
- },
832
- async (input) => {
833
- const c = await ensureCanvas();
834
- try {
835
- const result = await c.buildWebArtifact({
836
- title: input.title,
837
- appTsx: input.appTsx,
838
- ...(typeof input.indexCss === 'string' ? { indexCss: input.indexCss } : {}),
839
- ...(typeof input.mainTsx === 'string' ? { mainTsx: input.mainTsx } : {}),
840
- ...(typeof input.indexHtml === 'string' ? { indexHtml: input.indexHtml } : {}),
841
- ...(input.files ? { files: input.files } : {}),
842
- ...(Array.isArray(input.deps) ? { deps: input.deps } : {}),
843
- ...(typeof input.projectPath === 'string'
844
- ? { projectPath: safeWorkspacePath(input.projectPath) }
845
- : {}),
846
- ...(typeof input.outputPath === 'string'
847
- ? { outputPath: safeWorkspacePath(input.outputPath) }
848
- : {}),
849
- ...(typeof input.initScriptPath === 'string'
850
- ? { initScriptPath: input.initScriptPath }
851
- : {}),
852
- ...(typeof input.bundleScriptPath === 'string'
853
- ? { bundleScriptPath: input.bundleScriptPath }
854
- : {}),
855
- ...(typeof input.timeoutMs === 'number' ? { timeoutMs: input.timeoutMs } : {}),
856
- ...(typeof input.openInCanvas === 'boolean' ? { openInCanvas: input.openInCanvas } : {}),
857
- });
858
- return {
859
- content: [{
860
- type: 'text',
861
- text: JSON.stringify({
862
- path: result.filePath,
863
- bytes: result.fileSize,
864
- projectPath: result.projectPath,
865
- openedInCanvas: result.openedInCanvas,
866
- startedAt: result.startedAt,
867
- completedAt: result.completedAt,
868
- durationMs: result.durationMs,
869
- timeoutMs: result.timeoutMs,
870
- // `id` only present when a canvas node was actually created.
871
- // See the matching block in src/server/server.ts handleCanvasBuildWebArtifact.
872
- ...(typeof result.nodeId === 'string' ? { id: result.nodeId } : {}),
873
- nodeId: result.nodeId,
874
- url: result.url,
875
- metadata: result.metadata,
876
- logs: result.logs,
877
- ...(input.includeLogs === true ? {
878
- stdout: result.stdout,
879
- stderr: result.stderr,
880
- } : {}),
881
- }, null, 2),
882
- }],
883
- };
884
- } catch (error) {
885
- return {
886
- content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
887
- isError: true,
888
- };
889
- }
890
- },
891
- );
892
-
893
- // ── canvas_add_json_render_node ───────────────────────────
894
- server.tool(
895
- 'canvas_add_json_render_node',
896
- 'Create a native json-render canvas node from a complete spec. Use this for structured dashboards, forms, tables, and other interactive UI panels that should render directly inside PMX Canvas.',
897
- {
898
- title: z.string().optional().describe('Optional node title. If omitted, PMX Canvas infers one from the root element.'),
899
- spec: z.unknown().describe('json-render spec. Prefer a complete {root, elements, state?} document; a single bare component object is accepted for legacy callers.'),
900
- x: z.number().optional().describe('Optional X position'),
901
- y: z.number().optional().describe('Optional Y position'),
902
- width: z.number().optional().describe('Optional node width'),
903
- height: z.number().optional().describe('Optional node height'),
904
- strictSize: z.boolean().optional().describe('Keep explicit width/height fixed and scroll overflowing content instead of browser auto-fitting'),
905
- },
906
- async (input) => {
907
- const c = await ensureCanvas();
908
- try {
909
- const result = await c.addJsonRenderNode({
910
- ...(typeof input.title === 'string' ? { title: input.title } : {}),
911
- spec: input.spec,
912
- ...(typeof input.x === 'number' ? { x: input.x } : {}),
913
- ...(typeof input.y === 'number' ? { y: input.y } : {}),
914
- ...(typeof input.width === 'number' ? { width: input.width } : {}),
915
- ...(typeof input.height === 'number' ? { height: input.height } : {}),
916
- ...(input.strictSize === true ? { strictSize: true } : {}),
917
- });
918
- return {
919
- content: [{
920
- type: 'text',
921
- text: JSON.stringify({
922
- ...(await createdNodePayload(c, result.id)),
923
- url: result.url,
924
- spec: result.spec,
925
- }, null, 2),
926
- }],
927
- };
928
- } catch (error) {
929
- return {
930
- content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
931
- isError: true,
932
- };
933
- }
934
- },
935
- );
936
-
937
- // ── canvas_stream_json_render_node ────────────────────────
938
- server.tool(
939
- 'canvas_stream_json_render_node',
940
- 'Progressively build a json-render node by streaming SpecStream patches, so a panel fills in live as you generate it. Omit nodeId on the first call to create a new streaming node (returns its id); pass that same nodeId on later calls to append more patches; set done=true on the final call. Each call updates the live node. Patches are JSON-Patch operations, e.g. {"op":"add","path":"/elements/card","value":{"type":"Card","props":{"title":"Live"},"children":[]}}, {"op":"replace","path":"/root","value":"card"}, {"op":"add","path":"/elements/card/children/-","value":"row1"}. Build the spec incrementally: set /root, add container elements, then append children. The server accumulates the spec (it is the source of truth); partial specs render what they can.',
941
- {
942
- nodeId: z.string().optional().describe('Existing streaming node id to append to; omit to create a new streaming node'),
943
- title: z.string().optional().describe('Title when creating a new streaming node'),
944
- patches: z
945
- .array(z.union([z.string(), z.record(z.string(), z.unknown())]))
946
- .optional()
947
- .describe('SpecStream patches to apply this call: JSON-Patch objects ({op,path,value}) or raw JSONL patch lines'),
948
- done: z.boolean().optional().describe('Set true on the final call to mark the stream complete'),
949
- x: z.number().optional().describe('Optional X position (new node)'),
950
- y: z.number().optional().describe('Optional Y position (new node)'),
951
- width: z.number().optional().describe('Optional node width (new node)'),
952
- nodeHeight: z.number().optional().describe('Optional node height (new node)'),
953
- strictSize: z.boolean().optional().describe('Keep explicit node size fixed and scroll overflowing content (new node)'),
954
- },
955
- async (input) => {
956
- const c = await ensureCanvas();
957
- try {
958
- const result = await c.streamJsonRenderNode({
959
- ...(typeof input.nodeId === 'string' ? { nodeId: input.nodeId } : {}),
960
- ...(typeof input.title === 'string' ? { title: input.title } : {}),
961
- ...(Array.isArray(input.patches) ? { patches: input.patches } : {}),
962
- ...(input.done === true ? { done: true } : {}),
963
- ...(typeof input.x === 'number' ? { x: input.x } : {}),
964
- ...(typeof input.y === 'number' ? { y: input.y } : {}),
965
- ...(typeof input.width === 'number' ? { width: input.width } : {}),
966
- ...(typeof input.nodeHeight === 'number' ? { height: input.nodeHeight } : {}),
967
- ...(input.strictSize === true ? { strictSize: true } : {}),
968
- });
969
- return {
970
- content: [{
971
- type: 'text',
972
- text: JSON.stringify({
973
- ...(await createdNodePayload(c, result.id)),
974
- url: result.url,
975
- applied: result.applied,
976
- skipped: result.skipped,
977
- specVersion: result.specVersion,
978
- elementCount: result.elementCount,
979
- streamStatus: result.streamStatus,
980
- }, null, 2),
981
- }],
982
- };
983
- } catch (error) {
984
- return {
985
- content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
986
- isError: true,
987
- };
988
- }
989
- },
990
- );
991
-
992
- // ── canvas_add_graph_node ─────────────────────────────────
993
- server.tool(
994
- 'canvas_add_graph_node',
995
- 'Create a native graph node backed by the json-render chart catalog. Supports line, bar, pie, area, scatter, radar, stacked-bar, composed (bar+line), sparkline, dot-plot (Cleveland), bullet (Few KPI vs target), and slopegraph (paired before/after) graphs rendered directly inside PMX Canvas.',
996
- {
997
- title: z.string().optional().describe('Optional node title'),
998
- graphType: z.string().describe('Graph type: line, bar, pie, area, scatter, radar, stacked-bar (or "stack"), composed (or "combo"), sparkline, dot-plot (or "dot"), bullet, slopegraph (or "slope")'),
999
- data: z.array(z.record(z.string(), z.unknown())).describe('Array of chart data objects'),
1000
- xKey: z.string().optional().describe('X-axis key (line/bar/area/scatter/stacked/composed)'),
1001
- yKey: z.string().optional().describe('Y-axis key (line/bar/area/scatter); falls back to barKey for composed'),
1002
- zKey: z.string().optional().describe('Optional bubble-size key for scatter charts'),
1003
- nameKey: z.string().optional().describe('Name key for pie graphs'),
1004
- valueKey: z.string().optional().describe('Value key for pie slices, sparkline, dot-plot, and the bullet measure'),
1005
- axisKey: z.string().optional().describe('Category key for radar charts'),
1006
- metrics: z.array(z.string()).optional().describe('Series keys to plot as radar polygons (defaults to non-axis numeric columns)'),
1007
- series: z.array(z.string()).optional().describe('Series keys for stacked-bar segments (defaults to non-x numeric columns)'),
1008
- barKey: z.string().optional().describe('Bar series key for composed charts'),
1009
- lineKey: z.string().optional().describe('Line series key for composed charts'),
1010
- aggregate: z.enum(['sum', 'count', 'avg']).optional().describe('Optional aggregation for repeated x-axis values (line/bar/area/stacked)'),
1011
- color: z.string().optional().describe('Optional series color (line/bar/area/scatter)'),
1012
- colorBy: z
1013
- .enum(['series', 'category', 'value', 'none'])
1014
- .optional()
1015
- .describe("Bar charts only: how bars are colored. 'series' (default) = single accent with one highlighted bar; 'category' = rotate palette per bar; 'value' = shade by magnitude; 'none' = flat. Prefer 'series' — color should encode data, not decorate."),
1016
- highlight: z
1017
- .union([z.number(), z.enum(['max', 'min'])])
1018
- .nullable()
1019
- .optional()
1020
- .describe("Bar charts only, for colorBy='series': which bar gets the accent — 'max' (default), 'min', a 0-based index, or null for no emphasis."),
1021
- barColor: z.string().optional().describe('Optional bar color for composed charts'),
1022
- lineColor: z.string().optional().describe('Optional line color for composed charts'),
1023
- labelKey: z.string().optional().describe('Category label key for dot-plot / bullet / slopegraph rows'),
1024
- targetKey: z.string().optional().describe('Per-row target value key for bullet charts'),
1025
- rangesKey: z.string().optional().describe('Per-row qualitative band thresholds key (number[]) for bullet charts'),
1026
- beforeKey: z.string().optional().describe('Left-column value key for slopegraph'),
1027
- afterKey: z.string().optional().describe('Right-column value key for slopegraph'),
1028
- beforeLabel: z.string().optional().describe('Header label for the slopegraph left column'),
1029
- afterLabel: z.string().optional().describe('Header label for the slopegraph right column'),
1030
- sort: z.enum(['asc', 'desc', 'none']).optional().describe('Row sort order for dot-plot (defaults to desc)'),
1031
- fill: z.boolean().optional().describe('Sparkline: draw a light area fill under the line'),
1032
- showEndDot: z.boolean().optional().describe('Sparkline: draw a dot at the last point (default true)'),
1033
- showMinMax: z.boolean().optional().describe('Sparkline: mark the min and max points'),
1034
- showValue: z.boolean().optional().describe('Sparkline: print the last value inline'),
1035
- colorByDirection: z.boolean().optional().describe('Slopegraph: accent rising lines and mute falling ones (default off — lines use one neutral ink)'),
1036
- height: z.number().optional().describe('Optional chart content height'),
1037
- showLegend: z.boolean().optional().describe('Show chart legend when supported; pass false for compact node layouts'),
1038
- showLabels: z.boolean().optional().describe('Show direct labels when supported, such as pie slice labels (defaults to true)'),
1039
- x: z.number().optional().describe('Optional X position'),
1040
- y: z.number().optional().describe('Optional Y position'),
1041
- width: z.number().optional().describe('Optional node width'),
1042
- nodeHeight: z.number().optional().describe('Optional node height'),
1043
- strictSize: z.boolean().optional().describe('Keep explicit node size fixed and scroll overflowing content instead of browser auto-fitting'),
1044
- },
1045
- async (input) => {
1046
- const c = await ensureCanvas();
1047
- try {
1048
- const result = await c.addGraphNode({
1049
- graphType: input.graphType,
1050
- data: input.data,
1051
- ...(typeof input.title === 'string' ? { title: input.title } : {}),
1052
- ...(typeof input.xKey === 'string' ? { xKey: input.xKey } : {}),
1053
- ...(typeof input.yKey === 'string' ? { yKey: input.yKey } : {}),
1054
- ...(typeof input.zKey === 'string' ? { zKey: input.zKey } : {}),
1055
- ...(typeof input.nameKey === 'string' ? { nameKey: input.nameKey } : {}),
1056
- ...(typeof input.valueKey === 'string' ? { valueKey: input.valueKey } : {}),
1057
- ...(typeof input.axisKey === 'string' ? { axisKey: input.axisKey } : {}),
1058
- ...(Array.isArray(input.metrics) ? { metrics: input.metrics } : {}),
1059
- ...(Array.isArray(input.series) ? { series: input.series } : {}),
1060
- ...(typeof input.barKey === 'string' ? { barKey: input.barKey } : {}),
1061
- ...(typeof input.lineKey === 'string' ? { lineKey: input.lineKey } : {}),
1062
- ...(typeof input.aggregate === 'string' ? { aggregate: input.aggregate } : {}),
1063
- ...(typeof input.color === 'string' ? { color: input.color } : {}),
1064
- ...(typeof input.colorBy === 'string' ? { colorBy: input.colorBy } : {}),
1065
- ...(input.highlight !== undefined ? { highlight: input.highlight } : {}),
1066
- ...(typeof input.barColor === 'string' ? { barColor: input.barColor } : {}),
1067
- ...(typeof input.lineColor === 'string' ? { lineColor: input.lineColor } : {}),
1068
- ...(typeof input.labelKey === 'string' ? { labelKey: input.labelKey } : {}),
1069
- ...(typeof input.targetKey === 'string' ? { targetKey: input.targetKey } : {}),
1070
- ...(typeof input.rangesKey === 'string' ? { rangesKey: input.rangesKey } : {}),
1071
- ...(typeof input.beforeKey === 'string' ? { beforeKey: input.beforeKey } : {}),
1072
- ...(typeof input.afterKey === 'string' ? { afterKey: input.afterKey } : {}),
1073
- ...(typeof input.beforeLabel === 'string' ? { beforeLabel: input.beforeLabel } : {}),
1074
- ...(typeof input.afterLabel === 'string' ? { afterLabel: input.afterLabel } : {}),
1075
- ...(typeof input.sort === 'string' ? { sort: input.sort } : {}),
1076
- ...(typeof input.fill === 'boolean' ? { fill: input.fill } : {}),
1077
- ...(typeof input.showEndDot === 'boolean' ? { showEndDot: input.showEndDot } : {}),
1078
- ...(typeof input.showMinMax === 'boolean' ? { showMinMax: input.showMinMax } : {}),
1079
- ...(typeof input.showValue === 'boolean' ? { showValue: input.showValue } : {}),
1080
- ...(typeof input.colorByDirection === 'boolean' ? { colorByDirection: input.colorByDirection } : {}),
1081
- ...(typeof input.height === 'number' ? { height: input.height } : {}),
1082
- ...(typeof input.showLegend === 'boolean' ? { showLegend: input.showLegend } : {}),
1083
- ...(typeof input.showLabels === 'boolean' ? { showLabels: input.showLabels } : {}),
1084
- ...(typeof input.x === 'number' ? { x: input.x } : {}),
1085
- ...(typeof input.y === 'number' ? { y: input.y } : {}),
1086
- ...(typeof input.width === 'number' ? { width: input.width } : {}),
1087
- ...(typeof input.nodeHeight === 'number' ? { heightPx: input.nodeHeight } : {}),
1088
- ...(input.strictSize === true ? { strictSize: true } : {}),
1089
- });
1090
- return {
1091
- content: [{
1092
- type: 'text',
1093
- text: JSON.stringify({
1094
- ...(await createdNodePayload(c, result.id)),
1095
- url: result.url,
1096
- spec: result.spec,
1097
- }, null, 2),
1098
- }],
1099
- };
1100
- } catch (error) {
1101
- return {
1102
- content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
1103
- isError: true,
1104
- };
1105
- }
1106
- },
1107
- );
1108
-
1109
- // ── canvas_update_node ─────────────────────────────────────────
1110
- server.tool(
1111
- 'canvas_update_node',
1112
- 'Update an existing node. You can change its content, title, position, size, dock placement, or data.',
1113
- {
1114
- id: z.string().describe('Node ID to update'),
1115
- title: z.string().optional().describe('New title'),
1116
- content: z.string().optional().describe('New content'),
1117
- x: z.number().optional().describe('New X position'),
1118
- y: z.number().optional().describe('New Y position'),
1119
- width: z.number().optional().describe('New width'),
1120
- height: z.number().optional().describe('New height'),
1121
- spec: z.record(z.string(), z.unknown()).optional().describe('New json-render spec, or a graph payload with graphType/data for graph nodes'),
1122
- graphType: z.string().optional().describe('Graph type when updating a graph node'),
1123
- data: z.array(z.record(z.string(), z.unknown())).optional().describe('Graph dataset when updating a graph node'),
1124
- xKey: z.string().optional().describe('Graph x/category key'),
1125
- yKey: z.string().optional().describe('Graph y/value key'),
1126
- chartHeight: z.number().optional().describe('Graph chart content height, distinct from node height'),
1127
- toolName: z.string().optional().describe('Trace node tool or operation label'),
1128
- category: z.string().optional().describe('Trace node category: mcp, file, subagent, or other'),
1129
- status: z.string().optional().describe('Trace node status: running, success, or failed'),
1130
- duration: z.string().optional().describe('Trace node duration badge text'),
1131
- resultSummary: z.string().optional().describe('Trace node result summary'),
1132
- error: z.string().optional().describe('Trace node error message'),
1133
- collapsed: z.boolean().optional().describe('Collapse or expand the node'),
1134
- dockPosition: z.enum(['left', 'right']).nullable().optional().describe('Dock the node to the left/right HUD column, or pass null to return it to the canvas'),
1135
- pinned: z.boolean().optional().describe('Pin or unpin the node to exclude it from auto-arrange'),
1136
- arrangeLocked: z.boolean().optional().describe('Prevent auto-arrange from moving this node. Pinned nodes are also skipped.'),
1137
- axCapabilities: z.object({
1138
- enabled: z.boolean().optional(),
1139
- allowed: z.array(z.string()).optional(),
1140
- }).optional().describe('Enable/disable AX interactions on an existing node (e.g. flip an html node on with { enabled: true, allowed: ["ax.work.create"] }). Merged into the node data; clamped to the node-type ceiling server-side.'),
1141
- full: z.boolean().optional().describe('Return the full updated node payload. Default false returns compact metadata.'),
1142
- verbose: z.boolean().optional().describe('Alias for full:true.'),
1143
- },
1144
- async (input) => {
1145
- const { id, title, content, x, y, width, height, spec, graphType, data, xKey, yKey, chartHeight, collapsed, dockPosition, pinned, arrangeLocked, axCapabilities, toolName, category, status, duration, resultSummary, error } = input;
1146
- const c = await ensureCanvas();
1147
- const node = await c.getNode(id);
1148
- if (!node) {
1149
- return {
1150
- content: [{ type: 'text', text: `Node "${id}" not found.` }],
1151
- isError: true,
1152
- };
1153
- }
1154
- const patch: Record<string, unknown> = {};
1155
- if (x !== undefined || y !== undefined) {
1156
- patch.position = { x: x ?? node.position.x, y: y ?? node.position.y };
1157
- }
1158
- if (width !== undefined || height !== undefined) {
1159
- patch.size = { width: width ?? node.size.width, height: height ?? node.size.height };
1160
- }
1161
- if (collapsed !== undefined) {
1162
- patch.collapsed = collapsed;
1163
- }
1164
- if (dockPosition !== undefined) {
1165
- patch.dockPosition = dockPosition;
1166
- }
1167
- if (pinned !== undefined) {
1168
- patch.pinned = pinned;
1169
- }
1170
- if (title !== undefined) patch.title = title;
1171
- if (content !== undefined) patch.content = content;
1172
- if (spec !== undefined) patch.spec = spec;
1173
- if (graphType !== undefined) patch.graphType = graphType;
1174
- if (data !== undefined) patch.data = data;
1175
- if (xKey !== undefined) patch.xKey = xKey;
1176
- if (yKey !== undefined) patch.yKey = yKey;
1177
- if (chartHeight !== undefined) patch.chartHeight = chartHeight;
1178
- if (toolName !== undefined) patch.toolName = toolName;
1179
- if (category !== undefined) patch.category = category;
1180
- if (status !== undefined) patch.status = status;
1181
- if (duration !== undefined) patch.duration = duration;
1182
- if (resultSummary !== undefined) patch.resultSummary = resultSummary;
1183
- if (error !== undefined) patch.error = error;
1184
- if (arrangeLocked !== undefined) {
1185
- patch.arrangeLocked = arrangeLocked;
1186
- }
1187
- if (axCapabilities !== undefined) {
1188
- // A graph dataset update (`data` array) and an axCapabilities toggle collide
1189
- // on patch.data (array vs object) — reject rather than silently dropping the
1190
- // dataset. Otherwise merge into existing node data so enabling AX doesn't
1191
- // clobber html/spec/etc. The server re-clamps axCapabilities to the ceiling.
1192
- if (Array.isArray(patch.data)) {
1193
- return {
1194
- content: [{ type: 'text', text: 'Update the graph dataset and axCapabilities in separate canvas_update_node calls.' }],
1195
- isError: true,
1196
- };
1197
- }
1198
- patch.data = { ...(node.data as Record<string, unknown>), axCapabilities };
1199
- }
1200
- await c.updateNode(id, patch);
1201
- const updated = await c.getNode(id);
1202
- return {
1203
- content: [{ type: 'text', text: JSON.stringify(updated ? await createdNodePayload(c, id, input) : { ok: true, id }, null, 2) }],
1204
- };
1205
- },
1206
- );
1207
-
1208
- // ── canvas_remove_node ─────────────────────────────────────────
1209
- server.tool(
1210
- 'canvas_remove_node',
1211
- 'Remove a node from the canvas. Also removes all edges connected to it.',
1212
- { id: z.string().describe('Node ID to remove') },
1213
- async ({ id }) => {
1214
- const c = await ensureCanvas();
1215
- await c.removeNode(id);
1216
- return {
1217
- content: [{ type: 'text', text: JSON.stringify({ ok: true, removed: id }) }],
1218
- };
1219
- },
1220
- );
1221
-
1222
- // ── canvas_remove_annotation ─────────────────────────────────────
1223
- server.tool(
1224
- 'canvas_remove_annotation',
1225
- 'Remove a human-drawn canvas annotation by ID.',
1226
- { id: z.string().describe('Annotation ID to remove') },
1227
- async ({ id }) => {
1228
- const c = await ensureCanvas();
1229
- const removed = await c.removeAnnotation(id);
1230
- if (!removed) {
1231
- return {
1232
- content: [{ type: 'text', text: `Annotation "${id}" not found.` }],
1233
- isError: true,
1234
- };
1235
- }
1236
- return {
1237
- content: [{ type: 'text', text: JSON.stringify({ ok: true, removed: id }) }],
1238
- };
1239
- },
1240
- );
1241
-
1242
- // ── canvas_add_edge ────────────────────────────────────────────
1243
- server.tool(
1244
- 'canvas_add_edge',
1245
- 'Add an edge (connection) between two nodes. Edge types: flow (sequential), depends-on (dependency), relation (general), references (cross-reference).',
1246
- {
1247
- from: z.string().optional().describe('Source node ID'),
1248
- to: z.string().optional().describe('Target node ID'),
1249
- fromSearch: z.string().optional().describe('Resolve the source node by exact or fuzzy title/content search'),
1250
- toSearch: z.string().optional().describe('Resolve the target node by exact or fuzzy title/content search'),
1251
- type: z.enum(['flow', 'depends-on', 'relation', 'references']).describe('Edge type'),
1252
- label: z.string().optional().describe('Edge label text'),
1253
- style: z.enum(['solid', 'dashed', 'dotted']).optional().describe('Optional edge stroke style'),
1254
- animated: z.boolean().optional().describe('Animate the edge stroke'),
1255
- },
1256
- async (input) => {
1257
- const c = await ensureCanvas();
1258
- if (!input.from && !input.fromSearch) {
1259
- return {
1260
- content: [{ type: 'text', text: 'Provide either "from" or "fromSearch".' }],
1261
- isError: true,
1262
- };
1263
- }
1264
- if (!input.to && !input.toSearch) {
1265
- return {
1266
- content: [{ type: 'text', text: 'Provide either "to" or "toSearch".' }],
1267
- isError: true,
1268
- };
1269
- }
1270
- try {
1271
- const id = await c.addEdge(input);
1272
- const edge = (await c.getLayout()).edges.find((entry) => entry.id === id);
1273
- return {
1274
- content: [{
1275
- type: 'text',
1276
- text: JSON.stringify(edge ? { id, from: edge.from, to: edge.to, type: edge.type, label: edge.label, style: edge.style, animated: edge.animated } : { id }, null, 2),
1277
- }],
1278
- };
1279
- } catch (error) {
1280
- return {
1281
- content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
1282
- isError: true,
1283
- };
1284
- }
1285
- },
1286
- );
1287
-
1288
- // ── canvas_remove_edge ─────────────────────────────────────────
1289
- server.tool(
1290
- 'canvas_remove_edge',
1291
- 'Remove an edge from the canvas.',
1292
- { id: z.string().describe('Edge ID to remove') },
1293
- async ({ id }) => {
1294
- const c = await ensureCanvas();
1295
- await c.removeEdge(id);
1296
- return {
1297
- content: [{ type: 'text', text: JSON.stringify({ ok: true, removed: id }) }],
1298
- };
1299
- },
1300
- );
1301
-
1302
- // ── canvas_arrange ─────────────────────────────────────────────
1303
- server.tool(
1304
- 'canvas_arrange',
1305
- 'Auto-arrange all nodes on the canvas. Layouts: grid (default), column (vertical stack), flow (horizontal row).',
1306
- {
1307
- layout: z.enum(['grid', 'column', 'flow']).optional().describe('Arrangement layout (default: grid)'),
1308
- },
1309
- async ({ layout }) => {
1310
- const c = await ensureCanvas();
1311
- await c.arrange(layout ?? 'grid');
1312
- return {
1313
- content: [{ type: 'text', text: JSON.stringify({ ok: true, layout: layout ?? 'grid' }) }],
1314
- };
1315
- },
1316
- );
1317
-
1318
- // ── canvas_focus_node ──────────────────────────────────────────
1319
- server.tool(
1320
- 'canvas_focus_node',
1321
- 'Bring a node into focus. By default the viewport pans so the node is centered. Pass noPan=true to raise/select the node without moving the human\'s camera (useful when reacting to background events without disrupting the human\'s current view).',
1322
- {
1323
- id: z.string().describe('Node ID to focus on'),
1324
- noPan: z
1325
- .boolean()
1326
- .optional()
1327
- .describe('If true, raise/select the node without panning the viewport. Default false.'),
1328
- },
1329
- async ({ id, noPan }) => {
1330
- const c = await ensureCanvas();
1331
- const result = await c.focusNode(id, { ...(noPan === true ? { noPan: true } : {}) });
1332
- if (!result) {
1333
- return {
1334
- content: [
1335
- {
1336
- type: 'text',
1337
- text: JSON.stringify({ ok: false, error: `Node "${id}" not found.` }),
1338
- },
1339
- ],
1340
- };
1341
- }
1342
- return {
1343
- content: [
1344
- {
1345
- type: 'text',
1346
- text: JSON.stringify({ ok: true, focused: result.focused, panned: result.panned }),
1347
- },
1348
- ],
1349
- };
1350
- },
1351
- );
1352
-
1353
- // ── AX context and focus ───────────────────────────────────────
1354
- server.tool(
1355
- 'canvas_get_ax',
1356
- 'Read the host-agnostic PMX AX state and agent-ready AX context. Use this when you need pinned context plus the current focus field.',
1357
- {
1358
- includeContext: z.boolean().optional().describe('Include serialized agent-ready AX context. Default true.'),
1359
- },
1360
- async ({ includeContext }) => {
1361
- const c = await ensureCanvas();
1362
- const state = await c.getAxState();
1363
- const host = await c.getHostCapability();
1364
- const context = includeContext === false ? undefined : await c.getAxContext();
1365
- return {
1366
- content: [
1367
- {
1368
- type: 'text',
1369
- text: JSON.stringify({
1370
- ok: true,
1371
- state,
1372
- host,
1373
- ...(context ? { context } : {}),
1374
- }),
1375
- },
1376
- ],
1377
- };
1378
- },
1379
- );
1380
-
1381
- server.tool(
1382
- 'canvas_set_ax_focus',
1383
- 'Set the PMX AX focus field without requiring viewport movement. Focus is persisted and available through canvas://ax-context.',
1384
- {
1385
- nodeIds: z.array(z.string()).describe('Node IDs to place in the AX focus field. Missing nodes are ignored.'),
1386
- source: z.enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system'])
1387
- .optional()
1388
- .describe('Optional host/source label for adapter-originated focus. Defaults to mcp. Use codex from the Codex app adapter.'),
1389
- },
1390
- async ({ nodeIds, source }) => {
1391
- const c = await ensureCanvas();
1392
- const focus = await c.setAxFocus(nodeIds, { source: source ?? 'mcp' });
1393
- return {
1394
- content: [
1395
- {
1396
- type: 'text',
1397
- text: JSON.stringify({ ok: true, focus }),
1398
- },
1399
- ],
1400
- };
1401
- },
1402
- );
1403
-
1404
- server.tool(
1405
- 'canvas_record_ax_event',
1406
- 'Record a normalized AX timeline event (prompt/assistant-message/tool-start/tool-result/failure/approval/steering). Timeline events persist for diagnostics and continuity but are not restored by snapshots.',
1407
- {
1408
- kind: z.enum(['prompt', 'assistant-message', 'tool-start', 'tool-result', 'failure', 'approval', 'steering'])
1409
- .describe('Normalized event kind.'),
1410
- summary: z.string().describe('Short human-readable summary of the event.'),
1411
- detail: z.string().optional().describe('Optional longer detail or payload text.'),
1412
- nodeIds: z.array(z.string()).optional().describe('Optional node IDs this event relates to.'),
1413
- data: z.record(z.string(), z.unknown()).optional().describe('Optional structured data payload.'),
1414
- source: z.enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system'])
1415
- .optional()
1416
- .describe('Optional host/source label. Defaults to mcp.'),
1417
- },
1418
- async ({ kind, summary, detail, nodeIds, data, source }) => {
1419
- const c = await ensureCanvas();
1420
- const event = await c.recordAxEvent(
1421
- {
1422
- kind,
1423
- summary,
1424
- ...(typeof detail === 'string' ? { detail } : {}),
1425
- ...(Array.isArray(nodeIds) ? { nodeIds } : {}),
1426
- ...(data ? { data } : {}),
1427
- },
1428
- { source: source ?? 'mcp' },
1429
- );
1430
- return {
1431
- content: [
1432
- {
1433
- type: 'text',
1434
- text: JSON.stringify({ ok: true, event }),
1435
- },
1436
- ],
1437
- };
1438
- },
1439
- );
1440
-
1441
- server.tool(
1442
- 'canvas_send_steering',
1443
- 'Record a steering message: a user instruction from the surface to the active agent session. Persisted on the AX timeline and exposed via canvas://ax-timeline.',
1444
- {
1445
- message: z.string().describe('The steering instruction to deliver to the active agent session.'),
1446
- source: z.enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system'])
1447
- .optional()
1448
- .describe('Optional host/source label. Defaults to mcp.'),
1449
- },
1450
- async ({ message, source }) => {
1451
- const c = await ensureCanvas();
1452
- const steering = await c.sendSteering(message, { source: source ?? 'mcp' });
1453
- return {
1454
- content: [
1455
- {
1456
- type: 'text',
1457
- text: JSON.stringify({ ok: true, steering }),
1458
- },
1459
- ],
1460
- };
1461
- },
1462
- );
1463
-
1464
- server.tool(
1465
- 'canvas_get_ax_timeline',
1466
- 'Read the bounded AX timeline: recent agent-events, evidence, and steering messages plus counts. Use this for diagnostics and session continuity.',
1467
- {
1468
- limit: z.number().optional().describe('Max rows per timeline table (default 50, max 200).'),
1469
- },
1470
- async ({ limit }) => {
1471
- const c = await ensureCanvas();
1472
- const timeline = await c.getAxTimeline(
1473
- typeof limit === 'number' && limit > 0 ? { limit } : undefined,
1474
- );
1475
- return {
1476
- content: [
1477
- {
1478
- type: 'text',
1479
- text: JSON.stringify({ ok: true, ...timeline }),
1480
- },
1481
- ],
1482
- };
1483
- },
1484
- );
1485
-
1486
- server.tool(
1487
- 'canvas_add_work_item',
1488
- 'Add a canvas-bound AX work item: a visible task/plan/status tied to nodes and agent work. Work items participate in snapshots and are exposed via canvas://ax-work.',
1489
- {
1490
- title: z.string().describe('Short title of the work item.'),
1491
- status: z.enum(['todo', 'in-progress', 'blocked', 'done', 'cancelled'])
1492
- .optional()
1493
- .describe('Work item status. Defaults to todo.'),
1494
- detail: z.string().optional().describe('Optional longer description.'),
1495
- nodeIds: z.array(z.string()).optional().describe('Optional node IDs this work item is tied to.'),
1496
- source: z.enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system'])
1497
- .optional()
1498
- .describe('Optional host/source label. Defaults to mcp.'),
1499
- },
1500
- async ({ title, status, detail, nodeIds, source }) => {
1501
- const c = await ensureCanvas();
1502
- const workItem = await c.addWorkItem(
1503
- {
1504
- title,
1505
- ...(status ? { status } : {}),
1506
- ...(typeof detail === 'string' ? { detail } : {}),
1507
- ...(Array.isArray(nodeIds) ? { nodeIds } : {}),
1508
- },
1509
- { source: source ?? 'mcp' },
1510
- );
1511
- return {
1512
- content: [{ type: 'text', text: JSON.stringify({ ok: true, workItem }) }],
1513
- };
1514
- },
1515
- );
1516
-
1517
- server.tool(
1518
- 'canvas_update_work_item',
1519
- 'Update a canvas-bound AX work item by ID (title/status/detail/nodeIds). Returns null if the work item does not exist.',
1520
- {
1521
- id: z.string().describe('Work item ID to update.'),
1522
- title: z.string().optional().describe('New title.'),
1523
- status: z.enum(['todo', 'in-progress', 'blocked', 'done', 'cancelled'])
1524
- .optional()
1525
- .describe('New status.'),
1526
- detail: z.string().optional().describe('New detail text.'),
1527
- nodeIds: z.array(z.string()).optional().describe('Replacement node IDs.'),
1528
- source: z.enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system'])
1529
- .optional()
1530
- .describe('Optional host/source label. Defaults to mcp.'),
1531
- },
1532
- async ({ id, title, status, detail, nodeIds, source }) => {
1533
- const c = await ensureCanvas();
1534
- const workItem = await c.updateWorkItem(
1535
- id,
1536
- {
1537
- ...(typeof title === 'string' ? { title } : {}),
1538
- ...(status ? { status } : {}),
1539
- ...(typeof detail === 'string' ? { detail } : {}),
1540
- ...(Array.isArray(nodeIds) ? { nodeIds } : {}),
1541
- },
1542
- { source: source ?? 'mcp' },
1543
- );
1544
- return {
1545
- content: [{ type: 'text', text: JSON.stringify({ ok: workItem !== null, workItem }) }],
1546
- };
1547
- },
1548
- );
1549
-
1550
- server.tool(
1551
- 'canvas_request_approval',
1552
- 'Request human approval before a high-impact AX action: creates a pending approval gate tied to nodes. Canvas-bound and snapshotted; exposed via canvas://ax-work.',
1553
- {
1554
- title: z.string().describe('Short title of what needs approval.'),
1555
- detail: z.string().optional().describe('Optional explanation of the action and its impact.'),
1556
- action: z.string().optional().describe('Optional machine-readable action identifier the approval gates.'),
1557
- nodeIds: z.array(z.string()).optional().describe('Optional node IDs this approval relates to.'),
1558
- source: z.enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system'])
1559
- .optional()
1560
- .describe('Optional host/source label. Defaults to mcp.'),
1561
- },
1562
- async ({ title, detail, action, nodeIds, source }) => {
1563
- const c = await ensureCanvas();
1564
- const approvalGate = await c.requestApproval(
1565
- {
1566
- title,
1567
- ...(typeof detail === 'string' ? { detail } : {}),
1568
- ...(typeof action === 'string' ? { action } : {}),
1569
- ...(Array.isArray(nodeIds) ? { nodeIds } : {}),
1570
- },
1571
- { source: source ?? 'mcp' },
1572
- );
1573
- return {
1574
- content: [{ type: 'text', text: JSON.stringify({ ok: true, approvalGate }) }],
1575
- };
1576
- },
1577
- );
1578
-
1579
- server.tool(
1580
- 'canvas_resolve_approval',
1581
- 'Resolve a pending approval gate by ID with approved or rejected. Returns null if the gate does not exist or is already resolved.',
1582
- {
1583
- id: z.string().describe('Approval gate ID to resolve.'),
1584
- decision: z.enum(['approved', 'rejected']).describe('Approval decision.'),
1585
- resolution: z.string().optional().describe('Optional human-readable resolution note.'),
1586
- source: z.enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system'])
1587
- .optional()
1588
- .describe('Optional host/source label. Defaults to mcp.'),
1589
- },
1590
- async ({ id, decision, resolution, source }) => {
1591
- const c = await ensureCanvas();
1592
- const approvalGate = await c.resolveApproval(id, decision, {
1593
- ...(typeof resolution === 'string' ? { resolution } : {}),
1594
- source: source ?? 'mcp',
1595
- });
1596
- return {
1597
- content: [{ type: 'text', text: JSON.stringify({ ok: approvalGate !== null, approvalGate }) }],
1598
- };
1599
- },
1600
- );
1601
-
1602
- server.tool(
1603
- 'canvas_add_evidence',
1604
- 'Record an AX evidence item (logs/tool-result/screenshot/file/diff/test-output) on the timeline. Evidence persists for diagnostics and continuity but is not restored by snapshots; exposed via canvas://ax-timeline.',
1605
- {
1606
- kind: z.enum(['logs', 'tool-result', 'screenshot', 'file', 'diff', 'test-output'])
1607
- .describe('Evidence kind.'),
1608
- title: z.string().describe('Short human-readable title for the evidence.'),
1609
- body: z.string().optional().describe('Optional inline body/content.'),
1610
- ref: z.string().optional().describe('Optional reference (path, URL, or external locator).'),
1611
- nodeIds: z.array(z.string()).optional().describe('Optional node IDs this evidence relates to.'),
1612
- data: z.record(z.string(), z.unknown()).optional().describe('Optional structured data payload.'),
1613
- source: z.enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system'])
1614
- .optional()
1615
- .describe('Optional host/source label. Defaults to mcp.'),
1616
- },
1617
- async ({ kind, title, body, ref, nodeIds, data, source }) => {
1618
- const c = await ensureCanvas();
1619
- const evidence = await c.addEvidence(
1620
- {
1621
- kind,
1622
- title,
1623
- ...(typeof body === 'string' ? { body } : {}),
1624
- ...(typeof ref === 'string' ? { ref } : {}),
1625
- ...(Array.isArray(nodeIds) ? { nodeIds } : {}),
1626
- ...(data ? { data } : {}),
1627
- },
1628
- { source: source ?? 'mcp' },
1629
- );
1630
- return {
1631
- content: [{ type: 'text', text: JSON.stringify({ ok: true, evidence }) }],
1632
- };
1633
- },
1634
- );
1635
-
1636
- server.tool(
1637
- 'canvas_add_review_annotation',
1638
- 'Add a canvas-bound review annotation: a comment or finding anchored to a node, file, or region. Review annotations participate in snapshots and are exposed via canvas://ax-work.',
1639
- {
1640
- body: z.string().describe('Annotation body text.'),
1641
- kind: z.enum(['comment', 'finding']).optional().describe('Annotation kind. Default comment.'),
1642
- severity: z.enum(['info', 'warning', 'error']).optional().describe('Severity. Default info.'),
1643
- anchorType: z.enum(['node', 'file', 'region']).optional().describe('Anchor type. Default node.'),
1644
- nodeId: z.string().optional().describe('Node ID when anchorType is node.'),
1645
- file: z.string().optional().describe('File path when anchorType is file.'),
1646
- region: z.object({
1647
- line: z.number().optional(),
1648
- endLine: z.number().optional(),
1649
- label: z.string().optional(),
1650
- }).optional().describe('Region descriptor when anchorType is region.'),
1651
- author: z.string().optional().describe('Optional author label.'),
1652
- source: z.enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system'])
1653
- .optional()
1654
- .describe('Optional host/source label. Defaults to mcp.'),
1655
- },
1656
- async ({ body, kind, severity, anchorType, nodeId, file, region, author, source }) => {
1657
- const c = await ensureCanvas();
1658
- const reviewAnnotation = await c.addReviewAnnotation(
1659
- {
1660
- body,
1661
- ...(kind ? { kind } : {}),
1662
- ...(severity ? { severity } : {}),
1663
- ...(anchorType ? { anchorType } : {}),
1664
- ...(typeof nodeId === 'string' ? { nodeId } : {}),
1665
- ...(typeof file === 'string' ? { file } : {}),
1666
- ...(region ? { region } : {}),
1667
- ...(typeof author === 'string' ? { author } : {}),
1668
- },
1669
- { source: source ?? 'mcp' },
1670
- );
1671
- if (!reviewAnnotation) {
1672
- return {
1673
- content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'node-anchored review annotation requires a nodeId that exists on the canvas.' }) }],
1674
- isError: true,
1675
- };
1676
- }
1677
- return {
1678
- content: [{ type: 'text', text: JSON.stringify({ ok: true, reviewAnnotation }) }],
1679
- };
1680
- },
1681
- );
1682
-
1683
- server.tool(
1684
- 'canvas_report_host_capability',
1685
- 'Report host/session capability from an adapter: what the host can do (canvas/hooks/tools/sessionMessaging/permissions/files/uiPrompts). Stored for diagnostics; core does not depend on a host.',
1686
- {
1687
- host: z.string().optional().describe('Host identifier (e.g. copilot, codex).'),
1688
- canvas: z.boolean().optional(),
1689
- hooks: z.boolean().optional(),
1690
- tools: z.boolean().optional(),
1691
- sessionMessaging: z.boolean().optional(),
1692
- permissions: z.boolean().optional(),
1693
- files: z.boolean().optional(),
1694
- uiPrompts: z.boolean().optional(),
1695
- raw: z.record(z.string(), z.unknown()).optional().describe('Optional raw capability payload for diagnostics.'),
1696
- source: z.enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system'])
1697
- .optional()
1698
- .describe('Optional host/source label. Defaults to mcp.'),
1699
- },
1700
- async (input) => {
1701
- const c = await ensureCanvas();
1702
- const { source, ...capability } = input;
1703
- const host = await c.reportHostCapability(capability, { source: source ?? 'mcp' });
1704
- return {
1705
- content: [{ type: 'text', text: JSON.stringify({ ok: true, host }) }],
1706
- };
1707
- },
1708
- );
1709
-
1710
- server.tool(
1711
- 'canvas_ax_interaction',
1712
- 'Submit a node-originated AX interaction: a capability-gated, validated event from an eligible node that maps onto an AX operation (work item, evidence, approval, review, focus, steering, event). Returns { ok: false, code } if the node type/metadata does not allow the interaction type or the payload is invalid.',
1713
- {
1714
- type: z.enum(AX_INTERACTION_TYPES).describe('Interaction type, e.g. ax.work.create, ax.evidence.add, ax.focus.set.'),
1715
- sourceNodeId: z.string().describe('The node emitting the interaction.'),
1716
- payload: z.record(z.string(), z.unknown()).optional().describe('Type-specific payload, e.g. {"title":"..."} for ax.work.create.'),
1717
- sourceSurface: z.enum(['native-node', 'json-render', 'html-node', 'mcp-app', 'adapter']).optional(),
1718
- correlationId: z.string().optional(),
1719
- source: z.enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system'])
1720
- .optional()
1721
- .describe('Optional host/source label. Defaults to mcp.'),
1722
- },
1723
- async ({ type, sourceNodeId, payload, sourceSurface, correlationId, source }) => {
1724
- const c = await ensureCanvas();
1725
- const result = await c.submitAxInteraction(
1726
- {
1727
- type,
1728
- sourceNodeId,
1729
- ...(payload ? { payload } : {}),
1730
- ...(sourceSurface ? { sourceSurface } : {}),
1731
- ...(correlationId ? { correlationId } : {}),
1732
- },
1733
- { source: source ?? 'mcp' },
1734
- );
1735
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
1736
- },
1737
- );
1738
-
1739
- server.tool(
1740
- 'canvas_claim_ax_delivery',
1741
- 'Claim pending PMX AX deliveries for a consumer (adapterless delivery). Returns `pending` undelivered steering (mark each with canvas_mark_ax_delivery after acting) AND `pendingActivity`: open canvas-bound AX items awaiting the agent (open work items, pending approval gates / elicitations / mode requests) — typically created by the human in the browser. Both exclude items the consumer itself originated (loop prevention). pendingActivity is read-only here: resolve each via its own tool (canvas_resolve_approval / canvas_respond_elicitation / canvas_resolve_mode / canvas_update_work_item), not canvas_mark_ax_delivery.',
1742
- {
1743
- consumer: z.string().optional().describe('Consumer/source label to exclude from results (e.g. copilot, mcp).'),
1744
- limit: z.number().optional().describe('Max steering messages to return.'),
1745
- },
1746
- async ({ consumer, limit }) => {
1747
- const c = await ensureCanvas();
1748
- const pending = await c.getPendingSteering({
1749
- ...(consumer ? { consumer } : {}),
1750
- ...(typeof limit === 'number' ? { limit } : {}),
1751
- });
1752
- const pendingActivity = buildPendingAxActivity(await c.getAxState(), consumer);
1753
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, pending, pendingActivity }) }] };
1754
- },
1755
- );
1756
-
1757
- server.tool(
1758
- 'canvas_mark_ax_delivery',
1759
- 'Mark a PMX AX steering message as delivered so it is not handed out again.',
1760
- {
1761
- id: z.string().describe('The steering message id to mark delivered.'),
1762
- },
1763
- async ({ id }) => {
1764
- const c = await ensureCanvas();
1765
- const delivered = await c.markSteeringDelivered(id);
1766
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, delivered }) }] };
1767
- },
1768
- );
1769
-
1770
- server.tool(
1771
- 'canvas_request_elicitation',
1772
- 'Request structured human input (an elicitation): a pending question/form tied to nodes. Canvas-bound and snapshotted; exposed via canvas://ax-work. Answer it with canvas_respond_elicitation.',
1773
- {
1774
- prompt: z.string().describe('The question or instruction for the human.'),
1775
- fields: z.array(z.string()).optional().describe('Optional field names to request (a simple structured form).'),
1776
- nodeIds: z.array(z.string()).optional(),
1777
- source: z.enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system']).optional(),
1778
- },
1779
- async ({ prompt, fields, nodeIds, source }) => {
1780
- const c = await ensureCanvas();
1781
- const elicitation = await c.requestElicitation(
1782
- { prompt, ...(fields ? { fields } : {}), ...(Array.isArray(nodeIds) ? { nodeIds } : {}) },
1783
- { source: source ?? 'mcp' },
1784
- );
1785
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, elicitation }) }] };
1786
- },
1787
- );
1788
-
1789
- server.tool(
1790
- 'canvas_respond_elicitation',
1791
- 'Answer a pending elicitation with a structured response.',
1792
- {
1793
- id: z.string().describe('The elicitation id.'),
1794
- response: z.record(z.string(), z.unknown()).describe('The structured answer.'),
1795
- source: z.enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system']).optional(),
1796
- },
1797
- async ({ id, response, source }) => {
1798
- const c = await ensureCanvas();
1799
- const elicitation = await c.respondElicitation(id, response, { source: source ?? 'mcp' });
1800
- return { content: [{ type: 'text', text: JSON.stringify({ ok: Boolean(elicitation), elicitation }) }] };
1801
- },
1802
- );
1803
-
1804
- server.tool(
1805
- 'canvas_request_mode',
1806
- 'Request a workflow mode transition (plan/execute/autonomous): a pending mode request tied to nodes. Canvas-bound and snapshotted; exposed via canvas://ax-work. Resolve with canvas_resolve_mode.',
1807
- {
1808
- mode: z.enum(['plan', 'execute', 'autonomous']).describe('Requested target mode.'),
1809
- reason: z.string().optional(),
1810
- nodeIds: z.array(z.string()).optional(),
1811
- source: z.enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system']).optional(),
1812
- },
1813
- async ({ mode, reason, nodeIds, source }) => {
1814
- const c = await ensureCanvas();
1815
- const modeRequest = await c.requestMode(
1816
- { mode, ...(typeof reason === 'string' ? { reason } : {}), ...(Array.isArray(nodeIds) ? { nodeIds } : {}) },
1817
- { source: source ?? 'mcp' },
1818
- );
1819
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, modeRequest }) }] };
1820
- },
1821
- );
1822
-
1823
- server.tool(
1824
- 'canvas_resolve_mode',
1825
- 'Resolve a pending mode request (approved or rejected).',
1826
- {
1827
- id: z.string(),
1828
- decision: z.enum(['approved', 'rejected']),
1829
- resolution: z.string().optional(),
1830
- source: z.enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system']).optional(),
1831
- },
1832
- async ({ id, decision, resolution, source }) => {
1833
- const c = await ensureCanvas();
1834
- const modeRequest = await c.resolveModeRequest(id, decision, {
1835
- ...(typeof resolution === 'string' ? { resolution } : {}),
1836
- source: source ?? 'mcp',
1837
- });
1838
- return { content: [{ type: 'text', text: JSON.stringify({ ok: Boolean(modeRequest), modeRequest }) }] };
1839
- },
1840
- );
1841
-
1842
- server.tool(
1843
- 'canvas_invoke_command',
1844
- 'Invoke a registry-gated PMX command intent (pmx.plan | pmx.execute | pmx.promote-context | pmx.summarize | pmx.review). Records a timeline event a host/agent can observe — NOT arbitrary execution; unknown names are rejected.',
1845
- {
1846
- name: z.string().describe('A command name from the PMX command registry.'),
1847
- args: z.record(z.string(), z.unknown()).optional(),
1848
- source: z.enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system']).optional(),
1849
- },
1850
- async ({ name, args, source }) => {
1851
- const c = await ensureCanvas();
1852
- const event = await c.invokeCommand(name, args ?? null, { source: source ?? 'mcp' });
1853
- return { content: [{ type: 'text', text: JSON.stringify({ ok: Boolean(event), event }) }] };
1854
- },
1855
- );
1856
-
1857
- server.tool(
1858
- 'canvas_set_ax_policy',
1859
- 'Set the declarative AX policy (allowed/excluded/approval-required tools; prompt mode/append). PMX stores it and exposes it via canvas://ax-context; host adapters READ and enforce it. Merges with the existing policy.',
1860
- {
1861
- tools: z.object({
1862
- allowed: z.array(z.string()).optional(),
1863
- excluded: z.array(z.string()).optional(),
1864
- approvalRequired: z.array(z.string()).optional(),
1865
- }).optional(),
1866
- prompt: z.object({ systemAppend: z.string().optional(), mode: z.string().optional() }).optional(),
1867
- source: z.enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system']).optional(),
1868
- },
1869
- async ({ tools, prompt, source }) => {
1870
- const c = await ensureCanvas();
1871
- const policy = await c.setPolicy({ ...(tools ? { tools } : {}), ...(prompt ? { prompt } : {}) }, { source: source ?? 'mcp' });
1872
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, policy }) }] };
1873
- },
1874
- );
1875
-
1876
- server.tool(
1877
- 'canvas_fit_view',
1878
- 'Fit the canvas viewport to all nodes or a selected subset. Useful before screenshots and whole-board review.',
1879
- {
1880
- width: z.number().optional().describe('Viewport width used for fit math (default 1440)'),
1881
- height: z.number().optional().describe('Viewport height used for fit math (default 900)'),
1882
- padding: z.number().optional().describe('World-space padding around fitted nodes (default 60)'),
1883
- maxScale: z.number().optional().describe('Maximum zoom scale (default 1)'),
1884
- nodeIds: z.array(z.string()).optional().describe('Optional node IDs to fit instead of the whole canvas'),
1885
- },
1886
- async (input) => {
1887
- const c = await ensureCanvas();
1888
- const result = await c.fitView({
1889
- ...(typeof input.width === 'number' ? { width: input.width } : {}),
1890
- ...(typeof input.height === 'number' ? { height: input.height } : {}),
1891
- ...(typeof input.padding === 'number' ? { padding: input.padding } : {}),
1892
- ...(typeof input.maxScale === 'number' ? { maxScale: input.maxScale } : {}),
1893
- ...(Array.isArray(input.nodeIds) ? { nodeIds: input.nodeIds } : {}),
1894
- });
1895
- return {
1896
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
1897
- };
1898
- },
1899
- );
1900
-
1901
- // ── canvas_clear ───────────────────────────────────────────────
1902
- server.tool(
1903
- 'canvas_clear',
1904
- 'Remove all nodes and edges from the canvas. Use with caution.',
1905
- {},
1906
- async () => {
1907
- const c = await ensureCanvas();
1908
- await c.clear();
1909
- return {
1910
- content: [{ type: 'text', text: JSON.stringify({ ok: true, cleared: true }) }],
1911
- };
1912
- },
1913
- );
1914
-
1915
- // ── canvas_search ───────────────────────────────────────────
1916
- server.tool(
1917
- 'canvas_search',
1918
- 'Search for nodes by title or content keywords. Returns matching nodes ranked by relevance with snippets. Much faster than reading the full layout when you need to find specific nodes.',
1919
- {
1920
- query: z.string().describe('Search query — matches against node titles, content, and file paths'),
1921
- limit: z.number().optional().describe('Max results to return (default: 10)'),
1922
- },
1923
- async ({ query, limit }) => {
1924
- const c = await ensureCanvas();
1925
- const results = await c.search(query);
1926
- const capped = results.slice(0, limit ?? 10);
1927
- return {
1928
- content: [{
1929
- type: 'text',
1930
- text: JSON.stringify({ query, resultCount: results.length, results: capped }, null, 2),
1931
- }],
1932
- };
1933
- },
1934
- );
1935
-
1936
- // ── canvas_undo ────────────────────────────────────────────────
1937
- server.tool(
1938
- 'canvas_undo',
1939
- 'Undo the last canvas mutation. Returns a description of what was undone. Use this to backtrack when an approach is wrong — explore without fear.',
1940
- {},
1941
- async () => {
1942
- const c = await ensureCanvas();
1943
- const result = await c.undo();
1944
- const history = await c.getHistory();
1945
- return {
1946
- content: [{ type: 'text', text: JSON.stringify({ ...result, canUndo: history.canUndo, canRedo: history.canRedo }) }],
1947
- };
1948
- },
1949
- );
1950
-
1951
- // ── canvas_redo ────────────────────────────────────────────────
1952
- server.tool(
1953
- 'canvas_redo',
1954
- 'Redo the last undone canvas mutation. Use after undo to re-apply a change.',
1955
- {},
1956
- async () => {
1957
- const c = await ensureCanvas();
1958
- const result = await c.redo();
1959
- const history = await c.getHistory();
318
+ ...(typeof input.height === 'number' ? { height: input.height } : {}),
319
+ ...(input.strictSize === true ? { strictSize: true } : {}),
320
+ });
1960
321
  return {
1961
- content: [{ type: 'text', text: JSON.stringify({ ...result, canUndo: history.canUndo, canRedo: history.canRedo }) }],
322
+ content: [{ type: 'text', text: JSON.stringify(await createdNodePayload(c, id, input), null, 2) }],
1962
323
  };
1963
324
  },
1964
325
  );
1965
326
 
1966
- // ── canvas_diff ────────────────────────────────────────────────
1967
327
  server.tool(
1968
- 'canvas_diff',
1969
- 'Compare the current canvas state against a saved snapshot. Shows added/removed/modified nodes and edges. Pass either a snapshot name or ID.',
328
+ 'canvas_add_html_primitive',
329
+ 'Deprecated: use canvas_node with action "add", type:"html", primitive:"<kind>" (and data). Create a reusable HTML communication primitive as a normal sandboxed html node. Use this instead of long markdown for side-by-side choices, implementation plans, PR review sheets, module maps, design sheets, component galleries, flowcharts, explainers, status reports, and throwaway editors with export/copy paths. Use kind="presentation" only when the user explicitly asks for a PowerPoint-like deck, pitch, briefing, workshop walkthrough, or fullscreen story.',
1970
330
  {
1971
- snapshot: z.string().describe('Snapshot name or ID to compare against'),
331
+ kind: htmlPrimitiveKindSchema.describe('Primitive kind. Call canvas_describe_schema and read htmlPrimitives for data shapes and examples.'),
332
+ title: z.string().optional().describe('Node title shown in the canvas titlebar.'),
333
+ data: z.record(z.string(), z.unknown()).optional().describe('Primitive-specific data payload. For kind="presentation", data may include theme:"canvas"|"midnight"|"paper"|"aurora" or a custom color object. See canvas_describe_schema.htmlPrimitives for each shape.'),
334
+ x: z.number().optional().describe('X position (auto-placed if omitted).'),
335
+ y: z.number().optional().describe('Y position (auto-placed if omitted).'),
336
+ width: z.number().optional().describe('Width in pixels (defaults per primitive).'),
337
+ height: z.number().optional().describe('Height in pixels (defaults per primitive).'),
338
+ strictSize: z.boolean().optional().describe('Keep explicit width/height fixed; iframe scrolls overflow internally.'),
339
+ full: z.boolean().optional().describe('Return the full created node payload. Default false returns compact metadata.'),
340
+ verbose: z.boolean().optional().describe('Alias for full:true.'),
1972
341
  },
1973
- async ({ snapshot }) => {
342
+ async (input) => {
1974
343
  const c = await ensureCanvas();
1975
- const result = await c.diffSnapshot(snapshot);
1976
- if (!result.ok) {
1977
- return { content: [{ type: 'text', text: `Snapshot "${snapshot}" not found. Use canvas_snapshot to save one first.` }], isError: true };
1978
- }
344
+ const kind = input.kind as HtmlPrimitiveKind;
345
+ const result = await c.addHtmlPrimitive({
346
+ kind,
347
+ ...(typeof input.title === 'string' ? { title: input.title } : {}),
348
+ ...(input.data ? { data: input.data } : {}),
349
+ ...(typeof input.x === 'number' ? { x: input.x } : {}),
350
+ ...(typeof input.y === 'number' ? { y: input.y } : {}),
351
+ ...(typeof input.width === 'number' ? { width: input.width } : {}),
352
+ ...(typeof input.height === 'number' ? { height: input.height } : {}),
353
+ ...(input.strictSize === true ? { strictSize: true } : {}),
354
+ });
1979
355
  return {
1980
- content: [{ type: 'text', text: result.text ?? '' }],
356
+ content: [{
357
+ type: 'text',
358
+ text: JSON.stringify({
359
+ ...(await createdNodePayload(c, result.id, input)),
360
+ primitive: { kind: result.kind, title: result.title, htmlBytes: result.htmlBytes },
361
+ }, null, 2),
362
+ }],
1981
363
  };
1982
364
  },
1983
365
  );
1984
366
 
1985
- // ── canvas_webview_status ─────────────────────────────────────
1986
- server.tool(
1987
- 'canvas_webview_status',
1988
- 'Get the current Bun.WebView automation status for the PMX Canvas workbench. Returns whether Bun.WebView is supported, whether an automation session is active, backend, viewport size, and the current workbench URL if active.',
1989
- {},
1990
- async () => {
1991
- const c = await ensureCanvas();
1992
- return {
1993
- content: [{ type: 'text', text: JSON.stringify(await c.getAutomationWebViewStatus(), null, 2) }],
1994
- };
1995
- },
1996
- );
367
+ // canvas_open_mcp_app + canvas_add_diagram migrated to the operation registry
368
+ // (plan-008 Wave 4): src/server/operations/ops/app.ts (mcpapp.open /
369
+ // diagram.open). Folded into the canvas_app composite.
1997
370
 
1998
- // ── canvas_webview_start ──────────────────────────────────────
1999
371
  server.tool(
2000
- 'canvas_webview_start',
2001
- 'Start or replace the headless Bun.WebView automation session for the current PMX Canvas workbench. Use this before screenshot, evaluate, or resize when no automation session is active.',
372
+ 'canvas_refresh_webpage_node',
373
+ 'Deprecated: use canvas_node with action "update" and refresh:true. Refresh a webpage node from its persisted URL so the server re-fetches and caches the latest page text and metadata.',
2002
374
  {
2003
- backend: z.enum(['chrome', 'webkit']).optional()
2004
- .describe('Automation backend. Default: webkit on macOS, chrome elsewhere.'),
2005
- width: z.number().optional().describe('Viewport width in pixels (default: 1280)'),
2006
- height: z.number().optional().describe('Viewport height in pixels (default: 800)'),
2007
- chromePath: z.string().optional().describe('Optional Chrome/Chromium executable path'),
2008
- chromeArgv: z.array(z.string()).optional().describe('Optional extra Chrome launch args'),
2009
- dataStoreDir: z.string().optional().describe('Optional persistent data store directory'),
375
+ id: z.string().describe('Webpage node ID to refresh'),
376
+ url: z.string().optional().describe('Optional replacement URL before refresh'),
2010
377
  },
2011
- async ({ backend, width, height, chromePath, chromeArgv, dataStoreDir }) => {
378
+ async ({ id, url }) => {
2012
379
  const c = await ensureCanvas();
2013
- try {
2014
- const status = await c.startAutomationWebView({
2015
- ...(backend ? { backend } : {}),
2016
- ...(typeof width === 'number' ? { width } : {}),
2017
- ...(typeof height === 'number' ? { height } : {}),
2018
- ...(typeof chromePath === 'string' ? { chromePath } : {}),
2019
- ...(Array.isArray(chromeArgv) ? { chromeArgv } : {}),
2020
- ...(typeof dataStoreDir === 'string' ? { dataStoreDir: safeWorkspacePath(dataStoreDir) } : {}),
2021
- });
2022
- return {
2023
- content: [{ type: 'text', text: JSON.stringify(status, null, 2) }],
2024
- };
2025
- } catch (error) {
2026
- return {
2027
- content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
2028
- isError: true,
2029
- };
2030
- }
380
+ const result = await c.refreshWebpageNode(id, url);
381
+ return {
382
+ content: [{ type: 'text', text: JSON.stringify(result) }],
383
+ ...(result.ok ? {} : { isError: true }),
384
+ };
2031
385
  },
2032
386
  );
2033
387
 
2034
- // ── canvas_webview_stop ───────────────────────────────────────
2035
- server.tool(
2036
- 'canvas_webview_stop',
2037
- 'Stop the current Bun.WebView automation session if one is active.',
2038
- {},
2039
- async () => {
2040
- const c = await ensureCanvas();
2041
- try {
2042
- const stopped = await c.stopAutomationWebView();
2043
- const webview = await c.getAutomationWebViewStatus();
2044
- return {
2045
- content: [{
2046
- type: 'text',
2047
- text: JSON.stringify({
2048
- ok: true,
2049
- stopped,
2050
- webview,
2051
- }, null, 2),
2052
- }],
2053
- };
2054
- } catch (error) {
2055
- return {
2056
- content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
2057
- isError: true,
2058
- };
2059
- }
2060
- },
2061
- );
388
+ // canvas_build_web_artifact migrated to the operation registry (plan-008
389
+ // Wave 4): src/server/operations/ops/app.ts (webartifact.build). Folded into
390
+ // the canvas_app composite.
391
+
392
+ // canvas_remove_annotation migrated to the operation registry (plan-008
393
+ // Wave 1): src/server/operations/ops/annotation.ts.
394
+
395
+ // ── AX context and focus ───────────────────────────────────────
396
+ // canvas_get_ax + canvas_set_ax_focus migrated to the operation registry
397
+ // (plan-007 Slice B.1): src/server/operations/ops/ax-state.ts.
398
+
399
+ // canvas_record_ax_event / canvas_send_steering / canvas_get_ax_timeline
400
+ // migrated to the operation registry (plan-007 Slice B wave 3):
401
+ // src/server/operations/ops/ax-timeline.ts.
402
+
403
+ // canvas_add_work_item / canvas_update_work_item / canvas_request_approval /
404
+ // canvas_resolve_approval migrated to the operation registry (plan-007 Slice B
405
+ // wave 2): src/server/operations/ops/ax-work.ts.
406
+
407
+ // canvas_add_evidence migrated to the operation registry (plan-007 Slice B
408
+ // wave 3): src/server/operations/ops/ax-timeline.ts.
409
+
410
+ // canvas_add_review_annotation migrated to the operation registry (plan-007
411
+ // Slice B wave 2): src/server/operations/ops/ax-work.ts.
412
+
413
+ // canvas_report_host_capability migrated to the operation registry
414
+ // (plan-007 Slice B.1): src/server/operations/ops/ax-state.ts.
2062
415
 
2063
- // ── canvas_evaluate ───────────────────────────────────────────
2064
416
  server.tool(
2065
- 'canvas_evaluate',
2066
- 'Evaluate JavaScript in the active Bun.WebView automation session for the workbench page. Use this to inspect rendered browser state. Requires an active automation session started via canvas_webview_start.',
417
+ 'canvas_ax_interaction',
418
+ 'Submit a node-originated AX interaction: a capability-gated, validated event from an eligible node that maps onto an AX operation (work item, evidence, approval, review, focus, steering, event). Returns { ok: false, code } if the node type/metadata does not allow the interaction type or the payload is invalid.',
2067
419
  {
2068
- expression: z.string().optional().describe('JavaScript expression to evaluate in the page context'),
2069
- script: z.string().optional().describe('Multi-statement JavaScript body. The MCP server wraps it in an async IIFE and evaluates the resolved return value.'),
420
+ type: z.enum(AX_INTERACTION_TYPES).describe('Interaction type, e.g. ax.work.create, ax.evidence.add, ax.focus.set.'),
421
+ sourceNodeId: z.string().describe('The node emitting the interaction.'),
422
+ payload: z.record(z.string(), z.unknown()).optional().describe('Type-specific payload, e.g. {"title":"..."} for ax.work.create.'),
423
+ sourceSurface: z.enum(['native-node', 'json-render', 'html-node', 'mcp-app', 'adapter']).optional(),
424
+ correlationId: z.string().optional(),
425
+ source: z.enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system'])
426
+ .optional()
427
+ .describe('Optional host/source label. Defaults to mcp.'),
2070
428
  },
2071
- async ({ expression, script }) => {
429
+ async ({ type, sourceNodeId, payload, sourceSurface, correlationId, source }) => {
2072
430
  const c = await ensureCanvas();
2073
- if ((expression ? 1 : 0) + (script ? 1 : 0) !== 1) {
2074
- return {
2075
- content: [{ type: 'text', text: 'Pass exactly one of "expression" or "script".' }],
2076
- isError: true,
2077
- };
2078
- }
2079
-
2080
- const source = script ? wrapCanvasAutomationScript(script) : expression!;
2081
- try {
2082
- const value = await c.evaluateAutomationWebView(source);
2083
- return {
2084
- content: [{ type: 'text', text: JSON.stringify({ value }, null, 2) }],
2085
- };
2086
- } catch (error) {
2087
- return {
2088
- content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
2089
- isError: true,
2090
- };
2091
- }
431
+ const result = await c.submitAxInteraction(
432
+ {
433
+ type,
434
+ sourceNodeId,
435
+ ...(payload ? { payload } : {}),
436
+ ...(sourceSurface ? { sourceSurface } : {}),
437
+ ...(correlationId ? { correlationId } : {}),
438
+ },
439
+ { source: source ?? 'mcp' },
440
+ );
441
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
2092
442
  },
2093
443
  );
2094
444
 
2095
- // ── canvas_resize ─────────────────────────────────────────────
445
+ // canvas_claim_ax_delivery / canvas_mark_ax_delivery migrated to the operation
446
+ // registry (plan-007 Slice B wave 3): src/server/operations/ops/ax-timeline.ts.
447
+
448
+ // canvas_request_elicitation / canvas_respond_elicitation / canvas_request_mode /
449
+ // canvas_resolve_mode migrated to the operation registry (plan-007 Slice B
450
+ // wave 2): src/server/operations/ops/ax-work.ts.
451
+
2096
452
  server.tool(
2097
- 'canvas_resize',
2098
- 'Resize the active Bun.WebView automation viewport. Requires an active automation session started via canvas_webview_start.',
453
+ 'canvas_ingest_activity',
454
+ 'Ingest a normalized agent activity (a tool/session event your harness forwards) so the board reacts automatically — primitive A, makes AX bidirectional. Always records a timeline event; kind-driven default reactions (overridable per call via `reactions`): failure/error → work item (blocked) + review finding + evidence (logs); tool-result + outcome:"success" → evidence (tool-result); everything else (tool-start, session-*, command, note) → event only. Set any reaction to false to suppress it, or to an object to override its fields. Returns { event, workItem, evidence, review }.',
2099
455
  {
2100
- width: z.number().describe('Viewport width in pixels'),
2101
- height: z.number().describe('Viewport height in pixels'),
456
+ kind: z.enum(['tool-start', 'tool-result', 'failure', 'error', 'session-start', 'session-end', 'command', 'note']),
457
+ title: z.string(),
458
+ summary: z.string().optional(),
459
+ outcome: z.enum(['success', 'failure']).optional(),
460
+ ref: z.string().optional().describe('A file path, URL, or commit the activity refers to (used as the review file anchor for failures).'),
461
+ nodeIds: z.array(z.string()).optional(),
462
+ data: z.record(z.string(), z.unknown()).optional(),
463
+ reactions: z.object({
464
+ workItem: z.union([z.literal(false), z.object({
465
+ status: z.enum(['todo', 'in-progress', 'blocked', 'done', 'cancelled']).optional(),
466
+ detail: z.string().nullable().optional(),
467
+ })]).optional(),
468
+ evidence: z.union([z.literal(false), z.object({
469
+ kind: z.enum(['logs', 'tool-result', 'screenshot', 'file', 'diff', 'test-output']).optional(),
470
+ body: z.string().nullable().optional(),
471
+ })]).optional(),
472
+ review: z.union([z.literal(false), z.object({
473
+ severity: z.enum(['info', 'warning', 'error']).optional(),
474
+ kind: z.enum(['comment', 'finding']).optional(),
475
+ anchorType: z.enum(['node', 'file', 'region']).optional(),
476
+ nodeId: z.string().nullable().optional(),
477
+ })]).optional(),
478
+ }).optional().describe('Override or suppress the kind-driven default reactions.'),
479
+ source: z.enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system']).optional(),
2102
480
  },
2103
- async ({ width, height }) => {
481
+ async ({ kind, title, summary, outcome, ref, nodeIds, data, reactions, source }) => {
2104
482
  const c = await ensureCanvas();
2105
- try {
2106
- const status = await c.resizeAutomationWebView(width, height);
2107
- return {
2108
- content: [{ type: 'text', text: JSON.stringify(status, null, 2) }],
2109
- };
2110
- } catch (error) {
2111
- return {
2112
- content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
2113
- isError: true,
2114
- };
2115
- }
483
+ const result = await c.ingestActivity(
484
+ {
485
+ kind,
486
+ title,
487
+ ...(summary !== undefined ? { summary } : {}),
488
+ ...(outcome !== undefined ? { outcome } : {}),
489
+ ...(ref !== undefined ? { ref } : {}),
490
+ ...(nodeIds !== undefined ? { nodeIds } : {}),
491
+ ...(data !== undefined ? { data } : {}),
492
+ ...(reactions !== undefined ? { reactions } : {}),
493
+ },
494
+ { source: source ?? 'mcp' },
495
+ );
496
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true, ...result }) }] };
2116
497
  },
2117
498
  );
2118
499
 
500
+ // canvas_await_approval / canvas_await_elicitation / canvas_await_mode migrated
501
+ // to the operation registry (plan-007 Slice B wave 4):
502
+ // src/server/operations/ops/ax-await.ts.
503
+
504
+ // canvas_invoke_command migrated to the operation registry (plan-007 Slice B
505
+ // wave 3): src/server/operations/ops/ax-timeline.ts.
506
+
507
+ // canvas_set_ax_policy migrated to the operation registry
508
+ // (plan-007 Slice B.1): src/server/operations/ops/ax-state.ts.
509
+
510
+ // canvas_webview_status / canvas_webview_start / canvas_webview_stop /
511
+ // canvas_evaluate / canvas_resize migrated to the operation registry
512
+ // (plan-008 Wave 3): src/server/operations/ops/webview.ts (via the injected
513
+ // webview runner). canvas_screenshot stays hand-written below — it returns a
514
+ // binary image payload, which the registry's JSON wire shape does not model.
515
+
2119
516
  // ── canvas_screenshot ─────────────────────────────────────────
2120
517
  server.tool(
2121
518
  'canvas_screenshot',
@@ -2283,7 +680,7 @@ export async function startMcpServer(): Promise<void> {
2283
680
  },
2284
681
  async () => {
2285
682
  const c = await ensureCanvas();
2286
- const context = await c.getAxContext();
683
+ const context = await c.getAxContext({ consumer: 'mcp' });
2287
684
  return {
2288
685
  contents: [
2289
686
  {
@@ -2394,7 +791,7 @@ export async function startMcpServer(): Promise<void> {
2394
791
  'Inject the current PMX Canvas AX context (pins, focus, work items, approvals, review, timeline) so an MCP-aware client can ground its next action without a host-native adapter.',
2395
792
  async () => {
2396
793
  const c = await ensureCanvas();
2397
- const context = await c.getAxContext();
794
+ const context = await c.getAxContext({ consumer: 'mcp' });
2398
795
  return {
2399
796
  messages: [
2400
797
  {
@@ -2593,218 +990,10 @@ export async function startMcpServer(): Promise<void> {
2593
990
  );
2594
991
  }
2595
992
 
2596
- // ── canvas_create_group ──────────────────────────────────────
2597
- server.tool(
2598
- 'canvas_create_group',
2599
- 'Create a group (frame) on the canvas that visually contains other nodes. Groups are spatial containers — they communicate "these nodes belong together." If childIds are provided, grouping preserves child positions by default; pass childLayout to auto-pack them. You can also provide an explicit frame (x/y/width/height) and auto-arrange children inside it.',
2600
- {
2601
- title: z.string().optional().describe('Group title (default: "Group")'),
2602
- childIds: z.array(z.string()).optional().describe('Node IDs to include in the group. Group auto-sizes to fit them.'),
2603
- color: z.string().optional().describe('Group accent color (CSS color string, e.g. "#4a9eff")'),
2604
- x: z.number().optional().describe('X position (auto-computed from children if omitted)'),
2605
- y: z.number().optional().describe('Y position (auto-computed from children if omitted)'),
2606
- width: z.number().optional().describe('Width (auto-computed from children if omitted)'),
2607
- height: z.number().optional().describe('Height (auto-computed from children if omitted)'),
2608
- childLayout: z.enum(['grid', 'column', 'flow']).optional().describe('Optional child auto-layout. Omit to preserve current child positions.'),
2609
- full: z.boolean().optional().describe('Return the full created group payload. Default false returns compact metadata.'),
2610
- verbose: z.boolean().optional().describe('Alias for full:true.'),
2611
- },
2612
- async (input) => {
2613
- const c = await ensureCanvas();
2614
- const id = await c.createGroup(input);
2615
- return {
2616
- content: [{ type: 'text', text: JSON.stringify(await createdNodePayload(c, id, input), null, 2) }],
2617
- };
2618
- },
2619
- );
2620
-
2621
- // ── canvas_group_nodes ──────────────────────────────────────
2622
- server.tool(
2623
- 'canvas_group_nodes',
2624
- 'Add nodes to an existing group. The nodes will be visually contained within the group frame.',
2625
- {
2626
- groupId: z.string().describe('The group node ID'),
2627
- childIds: z.array(z.string()).describe('Node IDs to add to the group'),
2628
- childLayout: z.enum(['grid', 'column', 'flow']).optional().describe('Optional child layout to apply while grouping'),
2629
- },
2630
- async ({ groupId, childIds, childLayout }) => {
2631
- const c = await ensureCanvas();
2632
- const ok = await c.groupNodes(groupId, childIds, childLayout ? { childLayout } : undefined);
2633
- if (!ok) {
2634
- return { content: [{ type: 'text', text: 'Group not found or no valid children.' }], isError: true };
2635
- }
2636
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, groupId }) }] };
2637
- },
2638
- );
2639
-
2640
- server.tool(
2641
- 'canvas_batch',
2642
- 'Run a non-atomic batch of canvas operations with optional assigned references. Use assign to name a result, then reference it later as "$name" for the created node id or "$name.id" for a specific result field. On failure, earlier successful operations remain applied and the response includes ok:false, failedIndex, error, results, and refs. Supports node.add, node.update, node.remove, graph.add, edge.add, group.create, group.add, group.remove, pin.set/add/remove, snapshot.save, and arrange.',
2643
- {
2644
- operations: z.array(z.object({
2645
- op: z.string().describe('Operation name, e.g. "node.add" or "edge.add"'),
2646
- assign: z.string().optional().describe('Optional reference name for later operations'),
2647
- args: z.record(z.string(), z.unknown()).optional().describe('Operation arguments'),
2648
- })).describe('Ordered array of batch operations'),
2649
- full: z.boolean().optional().describe('Return full batch operation results. Default false compacts node-like payloads.'),
2650
- verbose: z.boolean().optional().describe('Alias for full:true.'),
2651
- },
2652
- async (input) => {
2653
- const c = await ensureCanvas();
2654
- const result = await c.runBatch(input.operations);
2655
- const payload = wantsFullPayload(input) ? result : compactBatchResult(result);
2656
- return {
2657
- content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
2658
- ...(result.ok ? {} : { isError: true }),
2659
- };
2660
- },
2661
- );
2662
-
2663
- server.tool(
2664
- 'canvas_validate',
2665
- 'Validate the current canvas layout. Distinguishes true node collisions from expected group-child containment and reports missing edge endpoints.',
2666
- {},
2667
- async () => {
2668
- const c = await ensureCanvas();
2669
- return {
2670
- content: [{ type: 'text', text: JSON.stringify(await c.validate(), null, 2) }],
2671
- };
2672
- },
2673
- );
2674
-
2675
- // ── canvas_ungroup ──────────────────────────────────────────
2676
- server.tool(
2677
- 'canvas_ungroup',
2678
- 'Remove all children from a group, releasing them as independent nodes. The group node itself remains (delete it separately with canvas_remove_node if desired).',
2679
- {
2680
- groupId: z.string().describe('The group node ID to ungroup'),
2681
- },
2682
- async ({ groupId }) => {
2683
- const c = await ensureCanvas();
2684
- const ok = await c.ungroupNodes(groupId);
2685
- if (!ok) {
2686
- return { content: [{ type: 'text', text: 'Group not found or already empty.' }], isError: true };
2687
- }
2688
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, groupId }) }] };
2689
- },
2690
- );
2691
-
2692
- // ── canvas_pin_nodes ─────────────────────────────────────────
2693
- server.tool(
2694
- 'canvas_pin_nodes',
2695
- 'Pin nodes to include them in the agent context. Pinned nodes appear in the canvas://pinned-context resource. The human can also pin nodes by clicking in the browser.',
2696
- {
2697
- nodeIds: z.array(z.string()).describe('Array of node IDs to pin'),
2698
- mode: z.enum(['set', 'add', 'remove']).optional()
2699
- .describe('set: replace all pins, add: add to existing pins, remove: unpin these nodes (default: set)'),
2700
- },
2701
- async ({ nodeIds, mode }) => {
2702
- const c = await ensureCanvas();
2703
- const result = await c.setContextPins(nodeIds, mode ?? 'set');
2704
-
2705
- return {
2706
- content: [{
2707
- type: 'text',
2708
- text: JSON.stringify({
2709
- ok: true,
2710
- pinnedNodeIds: result.nodeIds,
2711
- }),
2712
- }],
2713
- };
2714
- },
2715
- );
2716
-
2717
- // ── canvas_snapshot ──────────────────────────────────────────
2718
- server.tool(
2719
- 'canvas_snapshot',
2720
- 'Save the current canvas state as a named snapshot. Snapshots persist to disk and can be restored later.',
2721
- {
2722
- name: z.string().describe('Name for this snapshot (e.g., "before refactor", "investigation v2")'),
2723
- },
2724
- async (input) => {
2725
- const c = await ensureCanvas();
2726
- const snapshot = await c.saveSnapshot(input.name);
2727
- if (!snapshot) {
2728
- return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'Failed to save snapshot' }) }] };
2729
- }
2730
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, id: snapshot.id, snapshot }) }] };
2731
- },
2732
- );
2733
-
2734
- // ── canvas_list_snapshots ───────────────────────────────────
2735
- server.tool(
2736
- 'canvas_list_snapshots',
2737
- 'List saved canvas snapshots with IDs, names, timestamps, and node/edge counts. Defaults to the 20 newest snapshots; pass all=true to return every snapshot.',
2738
- {
2739
- limit: z.number().optional().describe('Maximum snapshots to return (default: 20)'),
2740
- query: z.string().optional().describe('Optional case-insensitive ID/name filter'),
2741
- before: z.string().optional().describe('Only return snapshots created at or before this ISO timestamp'),
2742
- after: z.string().optional().describe('Only return snapshots created at or after this ISO timestamp'),
2743
- all: z.boolean().optional().describe('Return all snapshots instead of the default limit'),
2744
- },
2745
- async (input) => {
2746
- const c = await ensureCanvas();
2747
- return {
2748
- content: [{ type: 'text', text: JSON.stringify({ snapshots: await c.listSnapshots(input) }, null, 2) }],
2749
- };
2750
- },
2751
- );
2752
-
2753
- // ── canvas_gc_snapshots ─────────────────────────────────────
2754
- server.tool(
2755
- 'canvas_gc_snapshots',
2756
- 'Delete old saved canvas snapshots, keeping the newest N snapshots. Use dryRun=true to preview deletions.',
2757
- {
2758
- keep: z.number().optional().describe('Number of newest snapshots to keep (default: 20)'),
2759
- dryRun: z.boolean().optional().describe('Preview deletions without removing snapshot files'),
2760
- },
2761
- async (input) => {
2762
- const c = await ensureCanvas();
2763
- const result = await c.gcSnapshots(input);
2764
- return {
2765
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
2766
- };
2767
- },
2768
- );
2769
-
2770
- // ── canvas_restore ──────────────────────────────────────────
2771
- server.tool(
2772
- 'canvas_restore',
2773
- 'Restore the canvas to a previously saved snapshot. Use canvas_snapshot to save first. Pass either the snapshot ID or name to restore.',
2774
- {
2775
- id: z.string().describe('Snapshot ID or name to restore (from canvas_snapshot or snapshot list)'),
2776
- },
2777
- async (input) => {
2778
- const c = await ensureCanvas();
2779
- const result = await c.restoreSnapshot(input.id);
2780
- if (!result.ok) {
2781
- return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'Snapshot not found' }) }] };
2782
- }
2783
- const layout = await c.getLayout();
2784
- return {
2785
- content: [{ type: 'text', text: JSON.stringify({ ok: true, restored: input.id, summary: buildSnapshotRestoreSummary(layout) }, null, 2) }],
2786
- };
2787
- },
2788
- );
2789
-
2790
- // ── canvas_delete_snapshot ──────────────────────────────────
2791
- server.tool(
2792
- 'canvas_delete_snapshot',
2793
- 'Delete a saved snapshot by ID.',
2794
- {
2795
- id: z.string().describe('Snapshot ID to delete'),
2796
- },
2797
- async ({ id }) => {
2798
- const c = await ensureCanvas();
2799
- const result = await c.deleteSnapshot(id);
2800
- if (!result.ok) {
2801
- return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'Snapshot not found' }) }], isError: true };
2802
- }
2803
- return {
2804
- content: [{ type: 'text', text: JSON.stringify({ ok: true, deleted: id }) }],
2805
- };
2806
- },
2807
- );
993
+ // canvas_batch migrated to the operation registry (plan-008 Wave 2):
994
+ // src/server/operations/ops/batch.ts.
995
+ // canvas_validate migrated to the operation registry (plan-008 Wave 1):
996
+ // src/server/operations/ops/validate.ts.
2808
997
 
2809
998
  // Connect via stdio
2810
999
  const transport = new StdioServerTransport();