pmx-canvas 0.3.4 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/Readme.md +16 -3
  3. package/dist/canvas/index.js +80 -80
  4. package/dist/types/client/index.d.ts +1 -1
  5. package/dist/types/client/nodes/ExtAppFrame.d.ts +10 -0
  6. package/dist/types/client/nodes/iframe-document-url.d.ts +1 -0
  7. package/dist/types/client/nodes/use-surface-frame.d.ts +13 -0
  8. package/dist/types/client/state/canvas-store.d.ts +1 -0
  9. package/dist/types/client/state/iframe-mode.d.ts +25 -0
  10. package/dist/types/client/state/node-factory.d.ts +29 -0
  11. package/dist/types/client/uuid-polyfill.d.ts +0 -0
  12. package/dist/types/server/canvas-db.d.ts +0 -1
  13. package/dist/types/server/canvas-state.d.ts +14 -15
  14. package/dist/types/server/operations/composites.d.ts +6 -21
  15. package/dist/types/server/server.d.ts +1 -0
  16. package/docs/RELEASE.md +10 -4
  17. package/docs/cli.md +5 -0
  18. package/docs/environment.md +2 -2
  19. package/docs/http-api.md +47 -1
  20. package/docs/mcp.md +21 -30
  21. package/docs/plans/plan-009-tech-debt-backlog.md +22 -13
  22. package/docs/screenshot.png +0 -0
  23. package/docs/tech-debt-assessment-2026-06.md +5 -5
  24. package/package.json +7 -7
  25. package/skills/pmx-canvas/SKILL.md +36 -10
  26. package/skills/pmx-canvas/evals/evals.json +2 -2
  27. package/skills/pmx-canvas/references/full-reference.md +17 -15
  28. package/skills/pmx-canvas-testing/SKILL.md +3 -0
  29. package/src/cli/commands/history.ts +1 -1
  30. package/src/cli/commands/nodes.ts +31 -1
  31. package/src/cli/index.ts +8 -2
  32. package/src/cli/shared.ts +1 -0
  33. package/src/client/index.tsx +7 -0
  34. package/src/client/nodes/ExtAppFrame.tsx +50 -2
  35. package/src/client/nodes/HtmlNode.tsx +5 -1
  36. package/src/client/nodes/McpAppNode.tsx +5 -1
  37. package/src/client/nodes/iframe-document-url.ts +62 -9
  38. package/src/client/nodes/use-surface-frame.ts +53 -0
  39. package/src/client/state/canvas-store.ts +5 -0
  40. package/src/client/state/iframe-mode.ts +103 -0
  41. package/src/client/state/node-factory.ts +72 -0
  42. package/src/client/state/sse-bridge.ts +212 -86
  43. package/src/client/uuid-polyfill.ts +14 -0
  44. package/src/server/canvas-db.ts +0 -8
  45. package/src/server/canvas-schema.ts +2 -8
  46. package/src/server/canvas-state.ts +51 -151
  47. package/src/server/index.ts +3 -9
  48. package/src/server/operations/composites.ts +24 -21
  49. package/src/server/operations/ops/snapshots.ts +6 -7
  50. package/src/server/operations/registry.ts +4 -1
  51. package/src/server/server.ts +194 -75
@@ -22,10 +22,11 @@ import {
22
22
  restoreLayout,
23
23
  sessionId,
24
24
  traceEnabled,
25
- updateNode,
26
25
  updateNodeData,
26
+ workbenchConnectionEpoch,
27
27
  } from './canvas-store';
28
28
  import { fetchAxSurfaceState } from './intent-bridge';
29
+ import { DEFAULT_POSITIONS, makeNodeState } from './node-factory';
29
30
  import { invalidateTokenCache } from '../theme/tokens';
30
31
  import { resetAttentionBridge, syncAttentionFromSse } from './attention-bridge';
31
32
  import { dissolveIntent, resetIntents, settleIntent, upsertIntent } from './intent-store';
@@ -36,10 +37,36 @@ let savedLayout: Map<string, Partial<CanvasNodeState>> | null = null;
36
37
  let reconnectAttempts = 0;
37
38
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
38
39
 
40
+ // ── Proxy-safe polling transport ──────────────────────────────
41
+ // Some proxies (e.g. the Amp orb portal) buffer streaming responses and only
42
+ // flush them on close, so the SSE stream delivers NOTHING — the connection
43
+ // "opens" (headers pass through) but no event ever arrives. Fallback: if the
44
+ // stream produces no event at all within the watchdog window, switch to
45
+ // polling GET /api/workbench/poll, which returns buffered events (or a full
46
+ // connect snapshot) as a normal response every couple of seconds. The same
47
+ // EVENT_HANDLERS map serves both transports. `?transport=poll` forces polling
48
+ // from the start; `?transport=sse` disables the fallback.
49
+ const SSE_FIRST_EVENT_WATCHDOG_MS = 3000;
50
+ const POLL_INTERVAL_MS = 2000;
51
+ const POLL_ERROR_INTERVAL_MS = 5000;
52
+ let pollingMode = false;
53
+ let pollTimer: ReturnType<typeof setTimeout> | null = null;
54
+ let pollSeq: number | null = null;
55
+ let pollGeneration = 0;
56
+ let sseFirstEventWatchdog: ReturnType<typeof setTimeout> | null = null;
57
+
58
+ function forcedTransport(): 'poll' | 'sse' | null {
59
+ if (typeof location === 'undefined') return null;
60
+ const value = new URLSearchParams(location.search).get('transport');
61
+ return value === 'poll' || value === 'sse' ? value : null;
62
+ }
63
+
39
64
  // Maps responseNodeId → thread prompt node ID so response deltas/completions
40
65
  // are routed into the thread's turns array instead of creating separate nodes.
41
- // Entries are added on response-start and removed on response-complete.
42
- // Not cleaned on SSE reconnect — orphaned entries are benign (small, bounded by active streams).
66
+ // Entries are added on response-start and removed on response-complete. Streams
67
+ // still in flight when the connection drops would otherwise leak entries, so
68
+ // connectSSE() — the shared entry path for both transports — clears the map
69
+ // alongside the rest of the per-connection state.
43
70
  const responseToThreadMap = new Map<string, string>();
44
71
 
45
72
  // ── Helpers ───────────────────────────────────────────────────
@@ -71,44 +98,15 @@ function applyLayoutOverrides(node: CanvasNodeState): CanvasNodeState {
71
98
  };
72
99
  }
73
100
 
74
- // ── Default positions by type ─────────────────────────────────
75
- const DEFAULT_POSITIONS: Record<CanvasNodeState['type'], { x: number; y: number; w: number; h: number }> &
76
- Record<'prompt' | 'response', { x: number; y: number; w: number; h: number }> = {
77
- status: { x: 40, y: 80, w: 300, h: 120 },
78
- markdown: { x: 380, y: 80, w: 720, h: 600 },
79
- context: { x: 1130, y: 80, w: 320, h: 400 },
80
- 'mcp-app': { x: 380, y: 720, w: 960, h: 600 },
81
- webpage: { x: 380, y: 80, w: 520, h: 420 },
82
- 'json-render': { x: 380, y: 720, w: 840, h: 620 },
83
- graph: { x: 380, y: 720, w: 760, h: 520 },
84
- ledger: { x: 1130, y: 520, w: 320, h: 280 },
85
- trace: { x: 40, y: 900, w: 200, h: 56 },
86
- file: { x: 380, y: 80, w: 720, h: 600 },
87
- image: { x: 380, y: 80, w: 720, h: 520 },
88
- html: { x: 380, y: 80, w: 720, h: 640 },
89
- group: { x: 220, y: 60, w: 840, h: 560 },
90
- prompt: { x: 380, y: 1260, w: 520, h: 400 },
91
- response: { x: 380, y: 1480, w: 720, h: 400 },
92
- };
93
-
101
+ // Default geometry lives in the shared node factory (node-factory.ts); this
102
+ // wrapper just layers the connection-local saved-layout overrides on top.
94
103
  function makeNode(
95
104
  id: string,
96
105
  type: CanvasNodeState['type'],
97
106
  data: Record<string, unknown>,
98
107
  dockPosition: 'left' | 'right' | null = null,
99
108
  ): CanvasNodeState {
100
- const pos = DEFAULT_POSITIONS[type];
101
- return applyLayoutOverrides({
102
- id,
103
- type,
104
- position: { x: pos.x, y: pos.y },
105
- size: { width: pos.w, height: pos.h },
106
- zIndex: type === 'status' ? 0 : 1,
107
- collapsed: false,
108
- pinned: false,
109
- dockPosition,
110
- data,
111
- });
109
+ return applyLayoutOverrides(makeNodeState(id, type, data, { dockPosition }));
112
110
  }
113
111
 
114
112
  function getMarkdownPlacement(): { x: number; y: number } {
@@ -205,26 +203,20 @@ function ensureExtAppNode(data: Record<string, unknown>): void {
205
203
  customX === undefined || customY === undefined
206
204
  ? findOpenCanvasPosition([...nodes.value.values()], width, height)
207
205
  : null;
208
- const node = applyLayoutOverrides({
209
- id,
210
- type: 'mcp-app' as const,
211
- position: {
212
- x: customX ?? autoPos?.x ?? pos.x,
213
- y: customY ?? autoPos?.y ?? pos.y,
214
- },
215
- size: {
216
- width,
217
- height,
218
- },
219
- zIndex: 1,
220
- collapsed: false,
221
- pinned: false,
222
- dockPosition: null,
223
- data: {
224
- mode: 'ext-app',
225
- ...data,
226
- },
227
- });
206
+ const node = applyLayoutOverrides(
207
+ makeNodeState(
208
+ id,
209
+ 'mcp-app',
210
+ { mode: 'ext-app', ...data },
211
+ {
212
+ position: {
213
+ x: customX ?? autoPos?.x ?? pos.x,
214
+ y: customY ?? autoPos?.y ?? pos.y,
215
+ },
216
+ size: { width, height },
217
+ },
218
+ ),
219
+ );
228
220
  addNode(node);
229
221
  if (!node.dockPosition) {
230
222
  focusNode(id, { recordHistory: false });
@@ -402,6 +394,8 @@ function parseCanvasAnnotation(raw: Record<string, unknown>): CanvasAnnotation |
402
394
  function handleConnected(data: Record<string, unknown>): void {
403
395
  sessionId.value = (data.sessionId as string) || '';
404
396
  connectionStatus.value = 'connected';
397
+ // Reconnect marker for holders of server-minted URLs (Finding S).
398
+ workbenchConnectionEpoch.value += 1;
405
399
  if (typeof data.theme === 'string') {
406
400
  applyCanvasTheme(data.theme);
407
401
  }
@@ -637,24 +631,21 @@ function handleCanvasPromptCreated(data: Record<string, unknown>): void {
637
631
  if (!nodes.value.has(nodeId)) {
638
632
  const pos = position ?? DEFAULT_POSITIONS.prompt;
639
633
  addNode(
640
- applyLayoutOverrides({
641
- id: nodeId,
642
- type: 'prompt' as const,
643
- position: { x: pos.x, y: pos.y },
644
- size: { width: DEFAULT_POSITIONS.prompt.w, height: 400 },
645
- zIndex: 1,
646
- collapsed: false,
647
- pinned: false,
648
- dockPosition: null,
649
- data: {
650
- text,
651
- turns: text ? [{ role: 'user', text, status: 'pending' }] : [],
652
- threadStatus: text ? 'pending' : 'draft',
653
- status: text ? 'pending' : 'draft',
654
- parentNodeId,
655
- contextNodeIds,
656
- },
657
- }),
634
+ applyLayoutOverrides(
635
+ makeNodeState(
636
+ nodeId,
637
+ 'prompt',
638
+ {
639
+ text,
640
+ turns: text ? [{ role: 'user', text, status: 'pending' }] : [],
641
+ threadStatus: text ? 'pending' : 'draft',
642
+ status: text ? 'pending' : 'draft',
643
+ parentNodeId,
644
+ contextNodeIds,
645
+ },
646
+ { position: { x: pos.x, y: pos.y } },
647
+ ),
648
+ ),
658
649
  );
659
650
  focusNode(nodeId);
660
651
  }
@@ -706,17 +697,14 @@ function handleCanvasResponseStart(data: Record<string, unknown>): void {
706
697
 
707
698
  if (!nodes.value.has(responseNodeId)) {
708
699
  addNode(
709
- applyLayoutOverrides({
710
- id: responseNodeId,
711
- type: 'response' as const,
712
- position: pos,
713
- size: { width: DEFAULT_POSITIONS.response.w, height: DEFAULT_POSITIONS.response.h },
714
- zIndex: 1,
715
- collapsed: false,
716
- pinned: false,
717
- dockPosition: null,
718
- data: { content: '', status: 'streaming', promptNodeId },
719
- }),
700
+ applyLayoutOverrides(
701
+ makeNodeState(
702
+ responseNodeId,
703
+ 'response',
704
+ { content: '', status: 'streaming', promptNodeId },
705
+ { position: pos },
706
+ ),
707
+ ),
720
708
  );
721
709
  }
722
710
 
@@ -991,17 +979,124 @@ export const EVENT_HANDLERS: Record<string, (data: Record<string, unknown>) => v
991
979
  'ax-intent-clear': handleAxIntentClear,
992
980
  };
993
981
 
982
+ /** Dispatch one event (from either transport) through the shared handler map. */
983
+ function dispatchWorkbenchEvent(event: string, payload: unknown): void {
984
+ const handler = EVENT_HANDLERS[event as keyof typeof EVENT_HANDLERS];
985
+ if (!handler) return;
986
+ // The map's handlers each declare their own payload type; over the wire the
987
+ // payload is untyped JSON either way (SSE previously passed JSON.parse output
988
+ // straight in). The double cast states that honestly without `any`.
989
+ const invoke = handler as unknown as (value: unknown) => void;
990
+ try {
991
+ invoke(payload);
992
+ } catch (err) {
993
+ console.warn(`[sse-bridge] Handler for "${event}" failed:`, err);
994
+ }
995
+ }
996
+
997
+ /**
998
+ * Polling transport loop. Each response is short-lived, so it flushes through
999
+ * any buffering proxy. Snapshot responses (fresh client / server restart /
1000
+ * ring eviction) carry the same event list an SSE connect sends, so replaying
1001
+ * them through the handlers matches existing reconnect semantics.
1002
+ */
1003
+ function startPollingTransport(): () => void {
1004
+ pollingMode = true;
1005
+ pollGeneration += 1;
1006
+ const generation = pollGeneration;
1007
+ connectionStatus.value = 'connecting';
1008
+ // Server-run identity from the last poll response. The event seq counter
1009
+ // restarts at 0 on every server boot, so a cursor from a previous run can
1010
+ // look "usable" to a new run and silently skip its earlier events.
1011
+ let lastPollSessionId: string | null = null;
1012
+ let dispatchedOnce = false;
1013
+
1014
+ const pollOnce = async (): Promise<void> => {
1015
+ if (generation !== pollGeneration) return;
1016
+ try {
1017
+ const params = new URLSearchParams();
1018
+ if (pollSeq !== null) params.set('since', String(pollSeq));
1019
+ const sid = sessionId.value;
1020
+ if (sid) params.set('session', sid);
1021
+ const query = params.toString();
1022
+ const response = await fetch(`/api/workbench/poll${query ? `?${query}` : ''}`);
1023
+ if (generation !== pollGeneration) return;
1024
+ if (!response.ok) throw new Error(`poll failed with HTTP ${response.status}`);
1025
+ const body = (await response.json()) as {
1026
+ ok: boolean;
1027
+ seq: number;
1028
+ snapshot: boolean;
1029
+ sessionId?: string;
1030
+ events: Array<{ event: string; payload: unknown }>;
1031
+ };
1032
+ if (generation !== pollGeneration) return;
1033
+ if (!body.ok) throw new Error('poll returned ok: false');
1034
+ const priorSession = lastPollSessionId;
1035
+ lastPollSessionId = body.sessionId ?? null;
1036
+ if (priorSession !== null && body.sessionId && priorSession !== body.sessionId && !body.snapshot) {
1037
+ // The server identity changed under our cursor (restart): a non-snapshot
1038
+ // batch may have silently skipped the new run's earlier events. Discard
1039
+ // it, drop the cursor, and re-poll immediately for a full snapshot.
1040
+ pollSeq = null;
1041
+ pollTimer = setTimeout(() => void pollOnce(), 0);
1042
+ return;
1043
+ }
1044
+ if (body.snapshot && dispatchedOnce) {
1045
+ // A mid-polling snapshot (ring eviction or server restart) replaces
1046
+ // client state wholesale — run the same per-connection resets an SSE
1047
+ // reconnect performs so in-flight stream routes and transient state
1048
+ // from the previous window can't leak (H5 orphan class).
1049
+ savedLayout = restoreLayout();
1050
+ ensureStatusNode();
1051
+ hasInitialServerLayout.value = false;
1052
+ resetAttentionBridge();
1053
+ resetIntents();
1054
+ responseToThreadMap.clear();
1055
+ }
1056
+ for (const entry of body.events) dispatchWorkbenchEvent(entry.event, entry.payload);
1057
+ dispatchedOnce = true;
1058
+ pollSeq = body.seq;
1059
+ connectionStatus.value = 'connected';
1060
+ pollTimer = setTimeout(() => void pollOnce(), POLL_INTERVAL_MS);
1061
+ } catch (err) {
1062
+ if (generation !== pollGeneration) return;
1063
+ console.warn('[sse-bridge] poll transport error:', err);
1064
+ connectionStatus.value = 'disconnected';
1065
+ pollTimer = setTimeout(() => void pollOnce(), POLL_ERROR_INTERVAL_MS);
1066
+ }
1067
+ };
1068
+ void pollOnce();
1069
+
1070
+ return () => {
1071
+ pollGeneration += 1;
1072
+ pollingMode = false;
1073
+ pollSeq = null;
1074
+ if (pollTimer) {
1075
+ clearTimeout(pollTimer);
1076
+ pollTimer = null;
1077
+ }
1078
+ };
1079
+ }
1080
+
994
1081
  export function connectSSE(): () => void {
995
1082
  savedLayout = restoreLayout();
996
1083
  ensureStatusNode();
997
1084
  hasInitialServerLayout.value = false;
998
1085
  resetAttentionBridge();
999
1086
  resetIntents();
1087
+ // Response→thread routes belong to the dropped connection's in-flight
1088
+ // streams; clear them so orphans can't accumulate across reconnect cycles.
1089
+ responseToThreadMap.clear();
1000
1090
  if (reconnectTimer) {
1001
1091
  clearTimeout(reconnectTimer);
1002
1092
  reconnectTimer = null;
1003
1093
  }
1004
1094
 
1095
+ const transport = forcedTransport();
1096
+ if (transport === 'poll' || pollingMode) {
1097
+ return startPollingTransport();
1098
+ }
1099
+
1005
1100
  const sid = sessionId.value;
1006
1101
  const url = sid ? `/api/workbench/events?session=${sid}` : '/api/workbench/events';
1007
1102
  connectionStatus.value = 'connecting';
@@ -1009,10 +1104,28 @@ export function connectSSE(): () => void {
1009
1104
  const source = new EventSource(url);
1010
1105
  eventSource = source;
1011
1106
 
1012
- for (const [event, handler] of Object.entries(EVENT_HANDLERS)) {
1107
+ // Proxy detection: a buffered stream "opens" (headers pass through) but never
1108
+ // delivers a single event — not even the immediate `connected` frame. Key the
1109
+ // watchdog on the FIRST EVENT, not onopen.
1110
+ let gotFirstEvent = false;
1111
+ if (sseFirstEventWatchdog) clearTimeout(sseFirstEventWatchdog);
1112
+ sseFirstEventWatchdog = null;
1113
+ if (transport !== 'sse') {
1114
+ sseFirstEventWatchdog = setTimeout(() => {
1115
+ sseFirstEventWatchdog = null;
1116
+ if (gotFirstEvent || eventSource !== source) return;
1117
+ console.warn('[sse-bridge] No SSE event within watchdog window — switching to polling transport.');
1118
+ source.close();
1119
+ eventSource = null;
1120
+ startPollingTransport();
1121
+ }, SSE_FIRST_EVENT_WATCHDOG_MS);
1122
+ }
1123
+
1124
+ for (const [event] of Object.entries(EVENT_HANDLERS)) {
1013
1125
  source.addEventListener(event, (e) => {
1126
+ gotFirstEvent = true;
1014
1127
  try {
1015
- handler(JSON.parse((e as MessageEvent).data));
1128
+ dispatchWorkbenchEvent(event, JSON.parse((e as MessageEvent).data));
1016
1129
  } catch (err) {
1017
1130
  // H5: Surface malformed SSE data during debugging instead of silently swallowing
1018
1131
  console.warn(`[sse-bridge] Failed to parse "${event}" event:`, err);
@@ -1043,6 +1156,19 @@ export function connectSSE(): () => void {
1043
1156
  clearTimeout(reconnectTimer);
1044
1157
  reconnectTimer = null;
1045
1158
  }
1159
+ if (sseFirstEventWatchdog) {
1160
+ clearTimeout(sseFirstEventWatchdog);
1161
+ sseFirstEventWatchdog = null;
1162
+ }
1163
+ if (pollingMode) {
1164
+ pollGeneration += 1;
1165
+ pollingMode = false;
1166
+ pollSeq = null;
1167
+ if (pollTimer) {
1168
+ clearTimeout(pollTimer);
1169
+ pollTimer = null;
1170
+ }
1171
+ }
1046
1172
  source.close();
1047
1173
  eventSource = null;
1048
1174
  };
@@ -0,0 +1,14 @@
1
+ // crypto.randomUUID is only defined in SECURE CONTEXTS. Embedded hosts that
2
+ // serve the canvas over plain http inside an iframe (portal/proxy setups) get
3
+ // `crypto` without `randomUUID`, which would throw on every per-mount nonce
4
+ // (AX tokens, frame tokens, session ids). Polyfill with the standard v4
5
+ // fallback; a strict no-op wherever the native implementation exists. These
6
+ // ids are nonces/correlation ids, not cryptographic secrets, so Math.random
7
+ // quality is acceptable for the fallback path.
8
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID !== 'function') {
9
+ (crypto as { randomUUID?: () => string }).randomUUID = () =>
10
+ 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
11
+ const r = (Math.random() * 16) | 0;
12
+ return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
13
+ });
14
+ }
@@ -823,14 +823,6 @@ export function readBlobFromDB(db: Database, sha256: string): string | null {
823
823
  return gunzipSync(row.data).toString('utf-8');
824
824
  }
825
825
 
826
- export function hasBlobInDB(db: Database, sha256: string): boolean {
827
- interface CountRow {
828
- c: number;
829
- }
830
- const row = db.query<CountRow, [string]>('SELECT COUNT(*) as c FROM blobs WHERE sha256 = ?').get(sha256);
831
- return (row?.c ?? 0) > 0;
832
- }
833
-
834
826
  // ── AX Timeline Persistence (NOT snapshotted; bounded by retention) ──
835
827
 
836
828
  function safeParseJson(raw: string | null | undefined): unknown {
@@ -937,7 +937,7 @@ export function describeCanvasSchema(): {
937
937
  htmlPrimitives: listHtmlPrimitiveDescriptors(),
938
938
  mcp: {
939
939
  tools: [
940
- // 15 composites
940
+ // 16 composites (canvas_snapshot folded the legacy snapshot standalones in v0.4.0)
941
941
  'canvas_node',
942
942
  'canvas_render',
943
943
  'canvas_edge',
@@ -947,6 +947,7 @@ export function describeCanvasSchema(): {
947
947
  'canvas_query',
948
948
  'canvas_webview',
949
949
  'canvas_app',
950
+ 'canvas_snapshot',
950
951
  'canvas_ax_state',
951
952
  'canvas_ax_work',
952
953
  'canvas_ax_gate',
@@ -960,13 +961,6 @@ export function describeCanvasSchema(): {
960
961
  'canvas_ax_interaction',
961
962
  'canvas_ingest_activity',
962
963
  'canvas_screenshot',
963
- // 6 snapshot tools (deprecated; fold into canvas_snapshot composite in v0.4)
964
- 'canvas_snapshot',
965
- 'canvas_list_snapshots',
966
- 'canvas_restore',
967
- 'canvas_delete_snapshot',
968
- 'canvas_gc_snapshots',
969
- 'canvas_diff',
970
964
  ],
971
965
  resources: ['canvas://schema'],
972
966
  nodeTypeRouting: buildMcpNodeTypeRouting(nodeTypes),