pmx-canvas 0.6.4 → 0.7.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 (204) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +67 -3
  2. package/CHANGELOG.md +73 -0
  3. package/Readme.md +35 -5
  4. package/dist/canvas/global.css +498 -28
  5. package/dist/canvas/index.js +110 -95
  6. package/dist/canvas/mermaid-entry.js +258 -258
  7. package/dist/types/cli/agent.d.ts +4 -0
  8. package/dist/types/cli/commands/backup.d.ts +1 -0
  9. package/dist/types/cli/commands/boards.d.ts +1 -0
  10. package/dist/types/cli/commands/export.d.ts +1 -0
  11. package/dist/types/cli/commands/record.d.ts +12 -0
  12. package/dist/types/client/canvas/BoardCopyDialog.d.ts +5 -0
  13. package/dist/types/client/canvas/BoardFolderDialog.d.ts +7 -0
  14. package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -0
  15. package/dist/types/client/canvas/BoardSwitcher.d.ts +8 -0
  16. package/dist/types/client/canvas/ExportDialog.d.ts +7 -0
  17. package/dist/types/client/canvas/HomeView.d.ts +9 -0
  18. package/dist/types/client/canvas/Presentation.d.ts +13 -0
  19. package/dist/types/client/canvas/use-node-drag.d.ts +2 -1
  20. package/dist/types/client/nodes/AttachmentNode.d.ts +6 -0
  21. package/dist/types/client/nodes/BoardNode.d.ts +5 -0
  22. package/dist/types/client/nodes/McpAppNode.d.ts +24 -1
  23. package/dist/types/client/state/boards-store.d.ts +79 -0
  24. package/dist/types/client/state/canvas-store.d.ts +2 -0
  25. package/dist/types/client/state/intent-bridge.d.ts +1 -0
  26. package/dist/types/client/state/presentation.d.ts +3 -0
  27. package/dist/types/client/state/session-store.d.ts +7 -0
  28. package/dist/types/client/types.d.ts +2 -2
  29. package/dist/types/json-render/catalog.d.ts +3 -3
  30. package/dist/types/json-render/charts/definitions.d.ts +1 -1
  31. package/dist/types/json-render/server.d.ts +8 -0
  32. package/dist/types/mcp/canvas-access.d.ts +10 -3
  33. package/dist/types/mcp/context-uri-template.d.ts +6 -0
  34. package/dist/types/server/agent-presence.d.ts +1 -1
  35. package/dist/types/server/attribution.d.ts +11 -0
  36. package/dist/types/server/ax-context.d.ts +3 -1
  37. package/dist/types/server/ax-interaction.d.ts +2 -2
  38. package/dist/types/server/ax-state.d.ts +13 -3
  39. package/dist/types/server/backup.d.ts +36 -0
  40. package/dist/types/server/board-export.d.ts +45 -0
  41. package/dist/types/server/canvas-db.d.ts +65 -9
  42. package/dist/types/server/canvas-operations.d.ts +45 -0
  43. package/dist/types/server/canvas-provenance.d.ts +1 -1
  44. package/dist/types/server/canvas-schema.d.ts +1 -0
  45. package/dist/types/server/canvas-state.d.ts +146 -3
  46. package/dist/types/server/canvas-validation.d.ts +3 -0
  47. package/dist/types/server/context-brief.d.ts +111 -0
  48. package/dist/types/server/context-reads.d.ts +55 -0
  49. package/dist/types/server/document-import.d.ts +66 -0
  50. package/dist/types/server/html-surface.d.ts +1 -1
  51. package/dist/types/server/index.d.ts +107 -4
  52. package/dist/types/server/mutation-history.d.ts +1 -0
  53. package/dist/types/server/operations/invoker.d.ts +2 -0
  54. package/dist/types/server/operations/mcp.d.ts +7 -1
  55. package/dist/types/server/operations/ops/app.d.ts +2 -0
  56. package/dist/types/server/operations/ops/ax-shared.d.ts +2 -2
  57. package/dist/types/server/operations/ops/backup.d.ts +2 -0
  58. package/dist/types/server/operations/ops/boards.d.ts +10 -0
  59. package/dist/types/server/operations/ops/export.d.ts +2 -0
  60. package/dist/types/server/operations/ops/imports.d.ts +2 -0
  61. package/dist/types/server/operations/ops/json-render.d.ts +2 -0
  62. package/dist/types/server/operations/ops/nodes.d.ts +4 -2
  63. package/dist/types/server/operations/ops/query.d.ts +2 -0
  64. package/dist/types/server/operations/registry.d.ts +9 -1
  65. package/dist/types/server/operations/types.d.ts +1 -1
  66. package/dist/types/server/server.d.ts +1 -0
  67. package/dist/types/server/trace-manager.d.ts +7 -0
  68. package/dist/types/server/web-artifacts.d.ts +3 -0
  69. package/dist/types/server/webpage-node.d.ts +22 -1
  70. package/dist/types/server/workbench-auth.d.ts +3 -0
  71. package/dist/types/shared/boards.d.ts +6 -0
  72. package/dist/types/shared/tour.d.ts +72 -0
  73. package/docs/RELEASE.md +5 -0
  74. package/docs/cli.md +157 -0
  75. package/docs/http-api.md +277 -6
  76. package/docs/mcp.md +96 -7
  77. package/docs/node-types.md +106 -0
  78. package/docs/plans/plan-010-attributed-ask-answers.md +39 -0
  79. package/docs/plans/plan-011-context-read-instrumentation.md +54 -0
  80. package/docs/plans/plan-012-many-boards-stage-1.md +98 -0
  81. package/docs/plans/plan-013-static-board-export.md +47 -0
  82. package/docs/plans/plan-014-connected-board-memory.md +140 -0
  83. package/docs/product-vision-2026-09.md +118 -18
  84. package/docs/sdk.md +50 -0
  85. package/package.json +1 -1
  86. package/skills/pmx-canvas/SKILL.md +62 -5
  87. package/skills/pmx-canvas/references/full-reference.md +44 -8
  88. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +3 -0
  89. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  90. package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
  91. package/skills/pmx-canvas-testing/SKILL.md +4 -0
  92. package/src/cli/agent.ts +9 -0
  93. package/src/cli/commands/ax.ts +13 -0
  94. package/src/cli/commands/backup.ts +66 -0
  95. package/src/cli/commands/boards.ts +71 -0
  96. package/src/cli/commands/export.ts +30 -0
  97. package/src/cli/commands/nodes.ts +14 -1
  98. package/src/cli/commands/pins.ts +6 -2
  99. package/src/cli/commands/pump.ts +3 -0
  100. package/src/cli/commands/query.ts +33 -7
  101. package/src/cli/commands/record.ts +218 -0
  102. package/src/cli/commands/view.ts +7 -5
  103. package/src/cli/index.ts +21 -9
  104. package/src/client/App.tsx +28 -4
  105. package/src/client/canvas/AgentPresenceLayer.tsx +1 -1
  106. package/src/client/canvas/BoardCopyDialog.tsx +153 -0
  107. package/src/client/canvas/BoardFolderDialog.tsx +120 -0
  108. package/src/client/canvas/BoardLinkDialog.tsx +93 -0
  109. package/src/client/canvas/BoardSwitcher.tsx +149 -0
  110. package/src/client/canvas/CanvasNode.tsx +8 -0
  111. package/src/client/canvas/CanvasViewport.tsx +3 -0
  112. package/src/client/canvas/CommandPalette.tsx +107 -4
  113. package/src/client/canvas/ContextMenu.tsx +4 -0
  114. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
  115. package/src/client/canvas/ExportDialog.tsx +197 -0
  116. package/src/client/canvas/HomeView.tsx +277 -0
  117. package/src/client/canvas/IntentLayer.tsx +9 -2
  118. package/src/client/canvas/Presentation.tsx +108 -0
  119. package/src/client/canvas/SessionPanel.tsx +28 -1
  120. package/src/client/canvas/ToolRail.tsx +38 -3
  121. package/src/client/canvas/TopBar.tsx +49 -7
  122. package/src/client/canvas/import-files.ts +19 -1
  123. package/src/client/canvas/kind-colors.ts +1 -0
  124. package/src/client/canvas/use-node-drag.ts +8 -3
  125. package/src/client/icons.tsx +2 -0
  126. package/src/client/mermaid-entry.ts +39 -0
  127. package/src/client/nodes/AttachmentNode.tsx +148 -0
  128. package/src/client/nodes/BoardNode.tsx +35 -0
  129. package/src/client/nodes/FileNode.tsx +6 -0
  130. package/src/client/nodes/HtmlNode.tsx +12 -13
  131. package/src/client/nodes/MarkdownNode.tsx +47 -19
  132. package/src/client/nodes/McpAppNode.tsx +105 -13
  133. package/src/client/nodes/MermaidNode.tsx +3 -2
  134. package/src/client/nodes/ax-node-actions.ts +2 -5
  135. package/src/client/nodes/use-ax-surface-bridge.ts +1 -1
  136. package/src/client/state/boards-store.ts +167 -0
  137. package/src/client/state/canvas-store.ts +22 -0
  138. package/src/client/state/intent-bridge.ts +30 -2
  139. package/src/client/state/intent-store.ts +5 -1
  140. package/src/client/state/node-factory.ts +1 -0
  141. package/src/client/state/presentation.ts +7 -0
  142. package/src/client/state/session-store.ts +8 -0
  143. package/src/client/state/sse-bridge.ts +52 -5
  144. package/src/client/theme/global.css +498 -28
  145. package/src/client/types.ts +2 -0
  146. package/src/json-render/server.ts +45 -9
  147. package/src/mcp/canvas-access.ts +59 -22
  148. package/src/mcp/context-uri-template.ts +28 -0
  149. package/src/mcp/server.ts +122 -15
  150. package/src/server/agent-context.ts +116 -29
  151. package/src/server/agent-presence.ts +15 -9
  152. package/src/server/attribution.ts +29 -0
  153. package/src/server/ax-context.ts +4 -2
  154. package/src/server/ax-interaction.ts +1 -0
  155. package/src/server/ax-state-manager.ts +72 -3
  156. package/src/server/ax-state.ts +41 -0
  157. package/src/server/backup.ts +178 -0
  158. package/src/server/board-export.ts +686 -0
  159. package/src/server/canvas-db.ts +612 -105
  160. package/src/server/canvas-operations.ts +183 -31
  161. package/src/server/canvas-provenance.ts +1 -0
  162. package/src/server/canvas-schema.ts +22 -0
  163. package/src/server/canvas-state.ts +812 -48
  164. package/src/server/canvas-validation.ts +9 -1
  165. package/src/server/context-brief.ts +372 -0
  166. package/src/server/context-reads.ts +238 -0
  167. package/src/server/demo-state.json +670 -9
  168. package/src/server/document-import.ts +223 -0
  169. package/src/server/html-primitives.ts +199 -91
  170. package/src/server/html-surface.ts +8 -3
  171. package/src/server/index.ts +243 -5
  172. package/src/server/mutation-history.ts +10 -13
  173. package/src/server/operations/composites.ts +29 -5
  174. package/src/server/operations/http.ts +9 -3
  175. package/src/server/operations/index.ts +8 -0
  176. package/src/server/operations/invoker.ts +14 -3
  177. package/src/server/operations/mcp.ts +50 -11
  178. package/src/server/operations/ops/app.ts +15 -0
  179. package/src/server/operations/ops/ax-read.ts +10 -6
  180. package/src/server/operations/ops/ax-timeline.ts +75 -0
  181. package/src/server/operations/ops/backup.ts +134 -0
  182. package/src/server/operations/ops/batch.ts +20 -4
  183. package/src/server/operations/ops/boards.ts +351 -0
  184. package/src/server/operations/ops/export.ts +74 -0
  185. package/src/server/operations/ops/ext-app.ts +4 -0
  186. package/src/server/operations/ops/groups.ts +14 -7
  187. package/src/server/operations/ops/imports.ts +220 -0
  188. package/src/server/operations/ops/json-render.ts +37 -5
  189. package/src/server/operations/ops/nodes.ts +99 -21
  190. package/src/server/operations/ops/query.ts +206 -9
  191. package/src/server/operations/ops/viewport.ts +32 -0
  192. package/src/server/operations/registry.ts +129 -31
  193. package/src/server/operations/types.ts +1 -1
  194. package/src/server/scope-fence.ts +1 -1
  195. package/src/server/server.ts +155 -8
  196. package/src/server/spatial-analysis.ts +9 -17
  197. package/src/server/trace-manager.ts +19 -0
  198. package/src/server/web-artifacts.ts +11 -3
  199. package/src/server/webpage-node.ts +173 -13
  200. package/src/server/workbench-auth.ts +11 -0
  201. package/src/server/workboard.ts +3 -3
  202. package/src/shared/boards.ts +18 -0
  203. package/src/shared/node-sizes.ts +1 -0
  204. package/src/shared/tour.ts +87 -0
@@ -14,6 +14,7 @@ export interface CanvasNodeState {
14
14
  | 'webpage'
15
15
  | 'json-render'
16
16
  | 'graph'
17
+ | 'board'
17
18
  | 'prompt'
18
19
  | 'response'
19
20
  | 'status'
@@ -74,6 +75,7 @@ export const TYPE_LABELS: Record<CanvasNodeState['type'], string> = {
74
75
  webpage: 'WEB',
75
76
  'json-render': 'UI',
76
77
  graph: 'GRAPH',
78
+ board: 'BOARD',
77
79
  prompt: 'ASK',
78
80
  response: 'ANS',
79
81
  status: 'STATUS',
@@ -149,7 +149,7 @@ function bundleDir(): string {
149
149
  return candidates[candidates.length - 1];
150
150
  }
151
151
 
152
- function escapeInlineScriptSource(source: string): string {
152
+ export function escapeInlineScriptSource(source: string): string {
153
153
  return source.replace(/<\/script/gi, '<\\/script');
154
154
  }
155
155
 
@@ -542,6 +542,35 @@ export function normalizeAndValidateJsonRenderSpec(spec: unknown): JsonRenderSpe
542
542
  return propsValidation.data as JsonRenderSpec;
543
543
  }
544
544
 
545
+ /** shadcn 0.21 ignores these props unless the renderer supplies a two-way binding. */
546
+ export function jsonRenderFormWarnings(spec: JsonRenderSpec): string[] {
547
+ const bindingOnlyProps: Record<string, string> = {
548
+ Input: 'value',
549
+ Textarea: 'value',
550
+ Select: 'value',
551
+ Radio: 'value',
552
+ Slider: 'value',
553
+ ToggleGroup: 'value',
554
+ ButtonGroup: 'selected',
555
+ Tabs: 'value',
556
+ };
557
+ const warnings: string[] = [];
558
+ for (const [key, rawElement] of Object.entries(spec.elements)) {
559
+ const element = asRecord(rawElement);
560
+ const type = typeof element?.type === 'string' ? element.type : '';
561
+ const prop = bindingOnlyProps[type];
562
+ if (!prop) continue;
563
+ const value = asRecord(element?.props)?.[prop];
564
+ if (value === undefined || value === null) continue;
565
+ const expression = asRecord(value);
566
+ if (hasString(expression?.$bindState) || hasString(expression?.$bindItem)) continue;
567
+ warnings.push(
568
+ `elements.${key}.props.${prop}: ${type} ignores an unbound ${prop} (including literal values and $state expressions). Use { "$bindState": "/form/field" } and seed spec.state.form.field with the initial value; inside a repeat use $bindItem with seeded item data.${type === 'Tabs' ? ' For an uncontrolled initial tab, use defaultValue instead.' : ''}`,
569
+ );
570
+ }
571
+ return warnings;
572
+ }
573
+
545
574
  export function normalizeGraphType(value: string): GraphChartType {
546
575
  const normalized = value.toLowerCase().replace(/[-_\s]/g, '');
547
576
  return GRAPH_TYPE_ALIASES[normalized] ?? 'LineChart';
@@ -927,6 +956,20 @@ export function createJsonRenderNodeData(
927
956
  };
928
957
  }
929
958
 
959
+ /** The viewer bundle's script and stylesheet, as the viewer document inlines them. */
960
+ export async function readJsonRenderBundle(): Promise<{ js: string; css: string }> {
961
+ await ensureJsonRenderBundle();
962
+ const dir = bundleDir();
963
+ const jsPath = join(dir, 'index.js');
964
+ const cssPath = join(dir, 'index.css');
965
+ return {
966
+ js: existsSync(jsPath)
967
+ ? readFileSync(jsPath, 'utf-8')
968
+ : 'document.body.innerHTML = "<pre>json-render bundle missing</pre>";',
969
+ css: existsSync(cssPath) ? readFileSync(cssPath, 'utf-8') : '',
970
+ };
971
+ }
972
+
930
973
  export async function buildJsonRenderViewerHtml(options: {
931
974
  title: string;
932
975
  spec: JsonRenderSpec;
@@ -946,14 +989,7 @@ export async function buildJsonRenderViewerHtml(options: {
946
989
  const sanitizeAxValue = (v?: string): string =>
947
990
  typeof v === 'string' ? v.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 80) : '';
948
991
  try {
949
- await ensureJsonRenderBundle();
950
- const dir = bundleDir();
951
- const jsPath = join(dir, 'index.js');
952
- const cssPath = join(dir, 'index.css');
953
- const jsBundle = existsSync(jsPath)
954
- ? readFileSync(jsPath, 'utf-8')
955
- : 'document.body.innerHTML = "<pre>json-render bundle missing</pre>";';
956
- const cssBundle = existsSync(cssPath) ? readFileSync(cssPath, 'utf-8') : '';
992
+ const { js: jsBundle, css: cssBundle } = await readJsonRenderBundle();
957
993
  const boot = [
958
994
  `window.__PMX_CANVAS_JSON_RENDER_SPEC__ = ${JSON.stringify(options.spec)};`,
959
995
  ...(options.theme ? [`window.__PMX_CANVAS_JSON_RENDER_THEME__ = ${JSON.stringify(options.theme)};`] : []),
@@ -1,15 +1,9 @@
1
1
  import { realpathSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { basename, dirname, resolve } from 'node:path';
4
- import {
5
- createCanvas,
6
- canvasState,
7
- type CanvasEdge,
8
- type CanvasLayout,
9
- type CanvasNodeState,
10
- type PmxCanvas,
11
- } from '../server/index.js';
4
+ import { createCanvas, canvasState, type CanvasLayout, type CanvasNodeState, type PmxCanvas } from '../server/index.js';
12
5
  import type { PmxAxSource } from '../server/ax-state.js';
6
+ import type { ContextReadInput } from '../server/context-reads.js';
13
7
  import { HttpOperationInvoker, LocalOperationInvoker, type OperationInvoker } from '../server/operations/index.js';
14
8
 
15
9
  // openMcpApp / addDiagram / buildWebArtifact / refreshWebpageNode / addHtmlNode /
@@ -54,8 +48,8 @@ export interface CanvasAccess {
54
48
  readonly remoteBaseUrl: string | null;
55
49
  /** Operation-registry invoker (plan-005): local in-process or HTTP, matching the access mode. */
56
50
  invoker(): OperationInvoker;
57
- getLayout(): Promise<CanvasLayout>;
58
- getNode(id: string): Promise<CanvasNodeState | undefined>;
51
+ getLayout(board?: string): Promise<CanvasLayout>;
52
+ getNode(id: string, board?: string): Promise<CanvasNodeState | undefined>;
59
53
  getAxState(): Promise<AxStateResult>;
60
54
  getAxContext(options?: { consumer?: string }): Promise<AxContextResult>;
61
55
  getAxTimeline(query?: GetAxTimelineQuery): Promise<GetAxTimelineResult>;
@@ -72,7 +66,10 @@ export interface CanvasAccess {
72
66
  ingestActivity(input: IngestActivityInput, options?: { source?: PmxAxSource }): Promise<IngestActivityResult>;
73
67
  getPolicy(): Promise<GetPolicyResult>;
74
68
  getHistory(): Promise<HistoryResult>;
75
- getPinnedNodeIds(): Promise<string[]>;
69
+ getPinnedNodeIds(board?: string): Promise<string[]>;
70
+ prepareContextRead(board?: string): Promise<{ boardId: string | null; pinnedNodeIds: string[] }>;
71
+ /** Record a context read the agent made through this MCP server (plan-011). */
72
+ recordContextRead(read: ContextReadInput): Promise<void>;
76
73
  runBatch(operations: RunBatchInput): Promise<RunBatchResult>;
77
74
  getCodeGraph(): Promise<CodeGraphResult>;
78
75
  // canvas_screenshot (still hand-written — binary payload) is the only webview
@@ -99,11 +96,15 @@ class LocalCanvasAccess implements CanvasAccess {
99
96
  return this.operationInvoker;
100
97
  }
101
98
 
102
- async getLayout(): Promise<CanvasLayout> {
103
- return this.canvas.getLayout();
99
+ async getLayout(board?: string): Promise<CanvasLayout> {
100
+ return board
101
+ ? ((await this.operationInvoker.invoke('layout.get', { board, includeBlobs: true })) as CanvasLayout)
102
+ : this.canvas.getLayout();
104
103
  }
105
104
 
106
- async getNode(id: string): Promise<CanvasNodeState | undefined> {
105
+ async getNode(id: string, board?: string): Promise<CanvasNodeState | undefined> {
106
+ if (board)
107
+ return (await this.operationInvoker.invoke('node.get', { id, board, includeBlobs: true })) as CanvasNodeState;
107
108
  return this.canvas.getNode(id);
108
109
  }
109
110
 
@@ -162,10 +163,23 @@ class LocalCanvasAccess implements CanvasAccess {
162
163
  return this.canvas.getHistory();
163
164
  }
164
165
 
165
- async getPinnedNodeIds(): Promise<string[]> {
166
+ async getPinnedNodeIds(board?: string): Promise<string[]> {
167
+ if (board) {
168
+ const result = (await this.operationInvoker.invoke('pinned-context.get', { board })) as { nodeIds?: string[] };
169
+ return result.nodeIds ?? [];
170
+ }
166
171
  return Array.from(canvasState.contextPinnedNodeIds);
167
172
  }
168
173
 
174
+ async prepareContextRead(board?: string): Promise<{ boardId: string | null; pinnedNodeIds: string[] }> {
175
+ const boardId = board ?? canvasState.activeBoardId;
176
+ return { boardId, pinnedNodeIds: boardId ? await this.getPinnedNodeIds(boardId) : [] };
177
+ }
178
+
179
+ async recordContextRead(read: ContextReadInput): Promise<void> {
180
+ canvasState.recordContextRead(read, read.boardId === undefined ? canvasState.activeBoardId : read.boardId);
181
+ }
182
+
169
183
  async runBatch(operations: RunBatchInput): Promise<RunBatchResult> {
170
184
  return await this.canvas.runBatch(operations);
171
185
  }
@@ -202,7 +216,12 @@ class RemoteCanvasAccess implements CanvasAccess {
202
216
  private async requestJson<T>(method: string, path: string, body?: unknown): Promise<T> {
203
217
  const response = await fetch(`${this.remoteBaseUrl}${path}`, {
204
218
  method,
205
- headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
219
+ // These fetches serve the agent's MCP reads, which this server records
220
+ // itself — the daemon must not record them a second time (plan-011).
221
+ headers: {
222
+ 'x-pmx-proxied-read': '1',
223
+ ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
224
+ },
206
225
  body: body === undefined ? undefined : JSON.stringify(body),
207
226
  });
208
227
  const text = await response.text();
@@ -227,12 +246,16 @@ class RemoteCanvasAccess implements CanvasAccess {
227
246
  return parsed as T;
228
247
  }
229
248
 
230
- async getLayout(): Promise<CanvasLayout> {
231
- return await this.requestJson<CanvasLayout>('GET', '/api/canvas/state?includeBlobs=true');
249
+ async getLayout(board?: string): Promise<CanvasLayout> {
250
+ const params = new URLSearchParams({ includeBlobs: 'true' });
251
+ if (board) params.set('board', board);
252
+ return await this.requestJson<CanvasLayout>('GET', `/api/canvas/state?${params}`);
232
253
  }
233
254
 
234
- async getNode(id: string): Promise<CanvasNodeState | undefined> {
235
- const response = await fetch(`${this.remoteBaseUrl}/api/canvas/node/${encodeURIComponent(id)}?includeBlobs=true`);
255
+ async getNode(id: string, board?: string): Promise<CanvasNodeState | undefined> {
256
+ const params = new URLSearchParams({ includeBlobs: 'true' });
257
+ if (board) params.set('board', board);
258
+ const response = await fetch(`${this.remoteBaseUrl}/api/canvas/node/${encodeURIComponent(id)}?${params}`);
236
259
  if (response.status === 404) return undefined;
237
260
  const text = await response.text();
238
261
  let parsed: unknown;
@@ -346,11 +369,25 @@ class RemoteCanvasAccess implements CanvasAccess {
346
369
  return response.reviewAnnotations ?? [];
347
370
  }
348
371
 
349
- async getPinnedNodeIds(): Promise<string[]> {
350
- const response = await this.requestJson<{ nodeIds?: string[] }>('GET', '/api/canvas/pinned-context');
372
+ async getPinnedNodeIds(board?: string): Promise<string[]> {
373
+ const qs = board ? `?board=${encodeURIComponent(board)}` : '';
374
+ const response = await this.requestJson<{ nodeIds?: string[] }>('GET', `/api/canvas/pinned-context${qs}`);
351
375
  return Array.isArray(response.nodeIds) ? response.nodeIds : [];
352
376
  }
353
377
 
378
+ async prepareContextRead(board?: string): Promise<{ boardId: string | null; pinnedNodeIds: string[] }> {
379
+ let boardId = board;
380
+ if (!boardId) {
381
+ const boards = await this.requestJson<{ activeBoardId?: string | null }>('GET', '/api/canvas/boards');
382
+ boardId = boards.activeBoardId ?? undefined;
383
+ }
384
+ return { boardId: boardId ?? null, pinnedNodeIds: boardId ? await this.getPinnedNodeIds(boardId) : [] };
385
+ }
386
+
387
+ async recordContextRead(read: ContextReadInput): Promise<void> {
388
+ await this.requestJson('POST', '/api/canvas/ax/context-reads', read);
389
+ }
390
+
354
391
  async runBatch(operations: RunBatchInput): Promise<RunBatchResult> {
355
392
  return await this.requestJson<RunBatchResult>('POST', '/api/canvas/batch', { operations });
356
393
  }
@@ -0,0 +1,28 @@
1
+ import { UriTemplate } from '@modelcontextprotocol/sdk/shared/uriTemplate.js';
2
+
3
+ /** SDK v1 matches {?a,b} as two required, ordered parameters, unlike its expansion. */
4
+ export class ContextUriTemplate extends UriTemplate {
5
+ constructor() {
6
+ super('canvas://context{?budget,consumer,since}');
7
+ }
8
+
9
+ override match(uri: string): Record<string, string> | null {
10
+ if (!URL.canParse(uri)) return null;
11
+ const url = new URL(uri);
12
+ if (
13
+ url.protocol !== 'canvas:' ||
14
+ url.host !== 'context' ||
15
+ url.pathname ||
16
+ url.hash ||
17
+ url.username ||
18
+ url.password
19
+ )
20
+ return null;
21
+ const variables: Record<string, string> = {};
22
+ for (const [key, value] of url.searchParams) {
23
+ if (!['budget', 'consumer', 'since'].includes(key) || key in variables) return null;
24
+ variables[key] = value;
25
+ }
26
+ return variables;
27
+ }
28
+ }
package/src/mcp/server.ts CHANGED
@@ -23,7 +23,7 @@
23
23
  import { readFileSync } from 'node:fs';
24
24
  import { dirname, join } from 'node:path';
25
25
  import { fileURLToPath } from 'node:url';
26
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
26
+ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
27
27
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
28
28
  import { z } from 'zod';
29
29
  import { AX_SOURCES } from '../server/operations/ops/ax-shared.js';
@@ -31,6 +31,8 @@ import { canvasState, describeCanvasSchema, stopCanvasServer } from '../server/i
31
31
  import { AX_INTERACTION_TYPES } from '../server/ax-interaction.js';
32
32
  import { buildPendingAxActivity } from '../server/ax-state.js';
33
33
  import { registerOperationTools, registerCompositeTools } from '../server/operations/index.js';
34
+ import { agentSourceLabel } from '../server/operations/invoker.js';
35
+ import { contextReadFromPayload } from '../server/context-reads.js';
34
36
  import { createCanvasAccess, refreshCanvasAccess, type CanvasAccess } from './canvas-access.js';
35
37
  import { serializeNodeForAgentContext } from '../server/agent-context.js';
36
38
  import { buildSpatialContext, findNeighborhoods } from '../server/spatial-analysis.js';
@@ -42,6 +44,7 @@ import {
42
44
  } from '../server/canvas-serialization.js';
43
45
  import { listBundledSkills, readBundledSkill } from '../server/bundled-skills.js';
44
46
  import { registerSkillExtension } from './skills.js';
47
+ import { ContextUriTemplate } from './context-uri-template.js';
45
48
 
46
49
  let canvas: CanvasAccess | null = null;
47
50
  let resourceNotificationServer: McpServer | null = null;
@@ -102,7 +105,9 @@ function sendCanvasResourceNotifications(type: 'nodes' | 'pins' | 'ax' | 'ax-tim
102
105
  server.server.sendResourceUpdated({ uri: 'canvas://ax-delivery' });
103
106
  }
104
107
  server.server.sendResourceUpdated({ uri: 'canvas://layout' });
108
+ server.server.sendResourceUpdated({ uri: 'canvas://boards' });
105
109
  server.server.sendResourceUpdated({ uri: 'canvas://summary' });
110
+ server.server.sendResourceUpdated({ uri: 'canvas://context' });
106
111
  server.server.sendResourceUpdated({ uri: 'canvas://spatial-context' });
107
112
  server.server.sendResourceUpdated({ uri: 'canvas://history' });
108
113
  server.server.sendResourceUpdated({ uri: 'canvas://code-graph' });
@@ -223,6 +228,36 @@ export async function startMcpServer(): Promise<void> {
223
228
  resourceNotificationServer = server;
224
229
  registerSkillExtension(server);
225
230
 
231
+ /**
232
+ * Records what the agent actually received from a context resource or
233
+ * prompt (plan-011). Instrumentation must never fail the read itself.
234
+ */
235
+ async function recordContextRead(
236
+ c: CanvasAccess,
237
+ channel: 'mcp-resource' | 'mcp-prompt',
238
+ resource: string,
239
+ text: string,
240
+ target: { boardId: string | null; pinnedNodeIds: string[] },
241
+ ): Promise<void> {
242
+ try {
243
+ const read = contextReadFromPayload(
244
+ {
245
+ channel,
246
+ resource,
247
+ source: agentSourceLabel('mcp'),
248
+ consumer: server.server.getClientVersion()?.name ?? null,
249
+ agentId: null,
250
+ pinnedNodeIds: target.pinnedNodeIds,
251
+ boardId: target.boardId,
252
+ },
253
+ text,
254
+ );
255
+ await c.recordContextRead(read);
256
+ } catch (error) {
257
+ console.error('[pmx-canvas mcp] recording a context read failed:', error);
258
+ }
259
+ }
260
+
226
261
  // ── Operation-registry tools (plan-005) ────────────────────────
227
262
  // Standalone tools are registered from the shared operation registry for
228
263
  // every op not folded into a composite (registerOperationTools skips
@@ -478,8 +513,10 @@ export async function startMcpServer(): Promise<void> {
478
513
  },
479
514
  async () => {
480
515
  const c = await ensureCanvas();
481
- const pinnedIds = new Set(await c.getPinnedNodeIds());
482
- const layout = await c.getLayout();
516
+ const readTarget = await c.prepareContextRead();
517
+ const board = readTarget.boardId ?? undefined;
518
+ const pinnedIds = new Set(readTarget.pinnedNodeIds);
519
+ const layout = await c.getLayout(board);
483
520
 
484
521
  const pinnedNodes = layout.nodes.filter((n) => pinnedIds.has(n.id));
485
522
  const pinnedEdges = layout.edges.filter((e) => pinnedIds.has(e.from) && pinnedIds.has(e.to));
@@ -510,12 +547,41 @@ export async function startMcpServer(): Promise<void> {
510
547
  })),
511
548
  };
512
549
 
550
+ const text = JSON.stringify(context, null, 2);
551
+ await recordContextRead(c, 'mcp-resource', 'canvas://pinned-context', text, readTarget);
513
552
  return {
514
553
  contents: [
515
554
  {
516
555
  uri: 'canvas://pinned-context',
517
556
  mimeType: 'application/json',
518
- text: JSON.stringify(context, null, 2),
557
+ text,
558
+ },
559
+ ],
560
+ };
561
+ },
562
+ );
563
+
564
+ server.resource(
565
+ 'context',
566
+ new ResourceTemplate(new ContextUriTemplate(), { list: undefined }),
567
+ {
568
+ description:
569
+ 'Budgeted cross-board context brief. budget is a maximum number of UTF-16 characters; consumer selects a durable board-scoped delivery cursor, while since requests an explicit revision.',
570
+ mimeType: 'application/json',
571
+ },
572
+ async (uri) => {
573
+ const c = await ensureCanvas();
574
+ const input: Record<string, unknown> = {};
575
+ if (uri.searchParams.has('consumer')) input.consumer = uri.searchParams.get('consumer');
576
+ if (uri.searchParams.has('budget')) input.budget = uri.searchParams.get('budget');
577
+ if (uri.searchParams.has('since')) input.since = uri.searchParams.get('since');
578
+ const document = await c.invoker().invoke('context.get', input);
579
+ return {
580
+ contents: [
581
+ {
582
+ uri: uri.href,
583
+ mimeType: 'application/json',
584
+ text: JSON.stringify(document),
519
585
  },
520
586
  ],
521
587
  };
@@ -555,12 +621,17 @@ export async function startMcpServer(): Promise<void> {
555
621
  async () => {
556
622
  const c = await ensureCanvas();
557
623
  const context = await c.getAxContext({ consumer: 'mcp' });
624
+ const text = JSON.stringify(context, null, 2);
625
+ await recordContextRead(c, 'mcp-resource', 'canvas://ax-context', text, {
626
+ boardId: context.boardId,
627
+ pinnedNodeIds: context.pinned.nodeIds,
628
+ });
558
629
  return {
559
630
  contents: [
560
631
  {
561
632
  uri: 'canvas://ax-context',
562
633
  mimeType: 'application/json',
563
- text: JSON.stringify(context, null, 2),
634
+ text,
564
635
  },
565
636
  ],
566
637
  };
@@ -678,11 +749,16 @@ export async function startMcpServer(): Promise<void> {
678
749
  async () => {
679
750
  const c = await ensureCanvas();
680
751
  const context = await c.getAxContext({ consumer: 'mcp' });
752
+ const text = `Current PMX Canvas context:\n\n${JSON.stringify(context, null, 2)}`;
753
+ await recordContextRead(c, 'mcp-prompt', 'pmx-current-context', text, {
754
+ boardId: context.boardId,
755
+ pinnedNodeIds: context.pinned.nodeIds,
756
+ });
681
757
  return {
682
758
  messages: [
683
759
  {
684
760
  role: 'user',
685
- content: { type: 'text', text: `Current PMX Canvas context:\n\n${JSON.stringify(context, null, 2)}` },
761
+ content: { type: 'text', text },
686
762
  },
687
763
  ],
688
764
  };
@@ -700,19 +776,40 @@ export async function startMcpServer(): Promise<void> {
700
776
  },
701
777
  async () => {
702
778
  const c = await ensureCanvas();
703
- const layout = agentSafeFullLayoutPayload(await c.getLayout());
779
+ const readTarget = await c.prepareContextRead();
780
+ const layout = agentSafeFullLayoutPayload(await c.getLayout(readTarget.boardId ?? undefined));
781
+ const text = JSON.stringify(layout, null, 2);
782
+ await recordContextRead(c, 'mcp-resource', 'canvas://layout', text, readTarget);
704
783
  return {
705
784
  contents: [
706
785
  {
707
786
  uri: 'canvas://layout',
708
787
  mimeType: 'application/json',
709
- text: JSON.stringify(layout, null, 2),
788
+ text,
710
789
  },
711
790
  ],
712
791
  };
713
792
  },
714
793
  );
715
794
 
795
+ server.resource(
796
+ 'canvas-boards',
797
+ 'canvas://boards',
798
+ {
799
+ description:
800
+ 'The boards in this workspace with their categories, most recently opened first, and which one the ' +
801
+ 'human has open (activeBoardId; null = Home). Your writes go to the open board; ask the human to open another.',
802
+ mimeType: 'application/json',
803
+ },
804
+ async () => {
805
+ const c = await ensureCanvas();
806
+ const boards = await c.invoker().invoke('board.list', {});
807
+ return {
808
+ contents: [{ uri: 'canvas://boards', mimeType: 'application/json', text: JSON.stringify(boards, null, 2) }],
809
+ };
810
+ },
811
+ );
812
+
716
813
  server.resource(
717
814
  'canvas-summary',
718
815
  'canvas://summary',
@@ -724,12 +821,19 @@ export async function startMcpServer(): Promise<void> {
724
821
  },
725
822
  async () => {
726
823
  const c = await ensureCanvas();
824
+ const readTarget = await c.prepareContextRead();
825
+ const text = JSON.stringify(
826
+ buildSummaryFromLayout(await c.getLayout(readTarget.boardId ?? undefined), readTarget.pinnedNodeIds),
827
+ null,
828
+ 2,
829
+ );
830
+ await recordContextRead(c, 'mcp-resource', 'canvas://summary', text, readTarget);
727
831
  return {
728
832
  contents: [
729
833
  {
730
834
  uri: 'canvas://summary',
731
835
  mimeType: 'application/json',
732
- text: JSON.stringify(buildSummaryFromLayout(await c.getLayout(), await c.getPinnedNodeIds()), null, 2),
836
+ text,
733
837
  },
734
838
  ],
735
839
  };
@@ -750,19 +854,22 @@ export async function startMcpServer(): Promise<void> {
750
854
  },
751
855
  async () => {
752
856
  const c = await ensureCanvas();
753
- const layout = await c.getLayout();
857
+ const readTarget = await c.prepareContextRead();
858
+ const layout = await c.getLayout(readTarget.boardId ?? undefined);
754
859
  const spatial = buildSpatialContext(
755
860
  layout.nodes,
756
861
  layout.edges,
757
- new Set(await c.getPinnedNodeIds()),
862
+ new Set(readTarget.pinnedNodeIds),
758
863
  layout.annotations ?? [],
759
864
  );
865
+ const text = JSON.stringify(spatial, null, 2);
866
+ await recordContextRead(c, 'mcp-resource', 'canvas://spatial-context', text, readTarget);
760
867
  return {
761
868
  contents: [
762
869
  {
763
870
  uri: 'canvas://spatial-context',
764
871
  mimeType: 'application/json',
765
- text: JSON.stringify(spatial, null, 2),
872
+ text,
766
873
  },
767
874
  ],
768
875
  };
@@ -898,10 +1005,10 @@ export async function startMcpServer(): Promise<void> {
898
1005
  const shutdown = (): void => {
899
1006
  try {
900
1007
  stopCanvasServer();
901
- } catch {
902
- // Exit regardless — a failed cleanup must not resurrect the orphan.
1008
+ process.exit(0);
1009
+ } catch (error) {
1010
+ console.error('Shutdown refused: canvas changes could not be saved. Fix persistence and retry shutdown.', error);
903
1011
  }
904
- process.exit(0);
905
1012
  };
906
1013
  server.server.onclose = shutdown;
907
1014
  process.stdin.on('end', shutdown);