pmx-canvas 0.1.11 → 0.1.13

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.
package/src/mcp/server.ts CHANGED
@@ -24,22 +24,18 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
24
24
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
25
25
  import { isAbsolute, relative, resolve } from 'node:path';
26
26
  import { z } from 'zod';
27
- import {
28
- createCanvas,
29
- canvasState,
30
- describeCanvasSchema,
31
- validateStructuredCanvasPayload,
32
- type PmxCanvas,
33
- } from '../server/index.js';
27
+ import { canvasState, describeCanvasSchema, validateStructuredCanvasPayload } from '../server/index.js';
28
+ import { createCanvasAccess, refreshCanvasAccess, type CanvasAccess } from './canvas-access.js';
34
29
  import { serializeNodeForAgentContext } from '../server/agent-context.js';
35
- import { emitPrimaryWorkbenchEvent, wrapCanvasAutomationScript } from '../server/server.js';
36
- import { searchNodes, buildSpatialContext, findNeighborhoods } from '../server/spatial-analysis.js';
37
- import { mutationHistory, diffLayouts, formatDiff } from '../server/mutation-history.js';
38
- import { buildCodeGraphSummary, formatCodeGraph } from '../server/code-graph.js';
39
- import { buildCanvasSummary, serializeCanvasLayout, serializeCanvasNode } from '../server/canvas-serialization.js';
30
+ import { wrapCanvasAutomationScript } from '../server/server.js';
31
+ import { buildSpatialContext, findNeighborhoods } from '../server/spatial-analysis.js';
32
+ import { getCanvasNodeTitle, serializeCanvasLayout, serializeCanvasNode } from '../server/canvas-serialization.js';
40
33
  import { listBundledSkills, readBundledSkill } from '../server/bundled-skills.js';
41
34
 
42
- let canvas: PmxCanvas | null = null;
35
+ let canvas: CanvasAccess | null = null;
36
+ let resourceNotificationServer: McpServer | null = null;
37
+ let localResourceNotificationsStarted = false;
38
+ let remoteResourceNotificationsBaseUrl: string | null = null;
43
39
 
44
40
  const jsonRenderSpecSchema = z.union([
45
41
  z.object({
@@ -80,31 +76,140 @@ function safeWorkspacePath(pathLike: string): string {
80
76
  return resolved;
81
77
  }
82
78
 
83
- async function ensureCanvas(): Promise<PmxCanvas> {
79
+ async function ensureCanvas(): Promise<CanvasAccess> {
84
80
  if (!canvas) {
85
- const port = parseInt(process.env.PMX_CANVAS_PORT ?? '4313');
86
- canvas = createCanvas({ port });
87
- await canvas.start({ open: true });
81
+ canvas = await createCanvasAccess();
82
+ } else {
83
+ canvas = await refreshCanvasAccess(canvas);
88
84
  }
85
+ startResourceNotifications(canvas);
89
86
  return canvas;
90
87
  }
91
88
 
89
+ function sleep(ms: number): Promise<void> {
90
+ return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
91
+ }
92
+
93
+ function sendCanvasResourceNotifications(type: 'nodes' | 'pins' = 'nodes'): void {
94
+ const server = resourceNotificationServer;
95
+ if (!server) return;
96
+ try {
97
+ if (type === 'pins') {
98
+ server.server.sendResourceUpdated({ uri: 'canvas://pinned-context' });
99
+ }
100
+ server.server.sendResourceUpdated({ uri: 'canvas://layout' });
101
+ server.server.sendResourceUpdated({ uri: 'canvas://summary' });
102
+ server.server.sendResourceUpdated({ uri: 'canvas://spatial-context' });
103
+ server.server.sendResourceUpdated({ uri: 'canvas://history' });
104
+ server.server.sendResourceUpdated({ uri: 'canvas://code-graph' });
105
+ } catch (error) {
106
+ console.debug('[mcp] resource notification failed', error);
107
+ }
108
+ }
109
+
110
+ function handleRemoteSseFrame(frame: string): void {
111
+ const eventLine = frame.split('\n').find((line) => line.startsWith('event: '));
112
+ const event = eventLine?.slice('event: '.length).trim() ?? '';
113
+ if (!event || event === 'connected' || event === 'ping') return;
114
+ sendCanvasResourceNotifications(event === 'context-pins-changed' ? 'pins' : 'nodes');
115
+ }
116
+
117
+ async function watchRemoteCanvasEvents(baseUrl: string): Promise<void> {
118
+ const decoder = new TextDecoder();
119
+ while (true) {
120
+ try {
121
+ const response = await fetch(`${baseUrl}/api/workbench/events`);
122
+ if (!response.ok || !response.body) {
123
+ await sleep(1_000);
124
+ continue;
125
+ }
126
+
127
+ const reader = response.body.getReader();
128
+ let buffer = '';
129
+ while (true) {
130
+ const { done, value } = await reader.read();
131
+ if (done) break;
132
+ buffer += decoder.decode(value, { stream: true });
133
+ const frames = buffer.split('\n\n');
134
+ buffer = frames.pop() ?? '';
135
+ for (const frame of frames) handleRemoteSseFrame(frame);
136
+ }
137
+ } catch (error) {
138
+ console.debug('[mcp] remote canvas event stream failed', error);
139
+ }
140
+ await sleep(1_000);
141
+ }
142
+ }
143
+
144
+ function startResourceNotifications(c: CanvasAccess): void {
145
+ const server = resourceNotificationServer;
146
+ if (!server) return;
147
+
148
+ if (c.remoteBaseUrl) {
149
+ if (remoteResourceNotificationsBaseUrl !== c.remoteBaseUrl) {
150
+ remoteResourceNotificationsBaseUrl = c.remoteBaseUrl;
151
+ void watchRemoteCanvasEvents(c.remoteBaseUrl);
152
+ }
153
+ return;
154
+ }
155
+
156
+ if (localResourceNotificationsStarted) return;
157
+ localResourceNotificationsStarted = true;
158
+
159
+ canvasState.onChange((type) => {
160
+ sendCanvasResourceNotifications(type);
161
+ });
162
+ }
163
+
92
164
  function encodeBase64(bytes: Uint8Array): string {
93
165
  return Buffer.from(bytes).toString('base64');
94
166
  }
95
167
 
96
- function createdNodePayload(c: PmxCanvas, id: string): Record<string, unknown> {
97
- const node = c.getNode(id);
168
+ async function createdNodePayload(c: CanvasAccess, id: string): Promise<Record<string, unknown>> {
169
+ const node = await c.getNode(id);
98
170
  if (!node) return { ok: true, id };
99
171
  const serialized = serializeCanvasNode(node);
100
172
  return { ok: true, node: serialized, ...serialized };
101
173
  }
102
174
 
175
+ function buildSummaryFromLayout(layout: Awaited<ReturnType<CanvasAccess['getLayout']>>, pinnedIds: string[]): Record<string, unknown> {
176
+ const pinned = new Set(pinnedIds);
177
+ const nodesByType: Record<string, number> = {};
178
+ const pinnedTitles: string[] = [];
179
+ for (const node of layout.nodes) {
180
+ const serialized = serializeCanvasNode(node);
181
+ nodesByType[serialized.kind] = (nodesByType[serialized.kind] ?? 0) + 1;
182
+ if (pinned.has(node.id)) pinnedTitles.push(getCanvasNodeTitle(node) ?? node.id);
183
+ }
184
+ return {
185
+ totalNodes: layout.nodes.length,
186
+ totalEdges: layout.edges.length,
187
+ nodesByType,
188
+ pinnedCount: pinned.size,
189
+ pinnedTitles,
190
+ viewport: layout.viewport,
191
+ };
192
+ }
193
+
194
+ function buildSnapshotRestoreSummary(layout: Awaited<ReturnType<CanvasAccess['getLayout']>>): Record<string, unknown> {
195
+ const nodesByType: Record<string, number> = {};
196
+ for (const node of layout.nodes) {
197
+ nodesByType[node.type] = (nodesByType[node.type] ?? 0) + 1;
198
+ }
199
+ return {
200
+ nodeCount: layout.nodes.length,
201
+ edgeCount: layout.edges.length,
202
+ nodesByType,
203
+ viewport: layout.viewport,
204
+ };
205
+ }
206
+
103
207
  export async function startMcpServer(): Promise<void> {
104
208
  const server = new McpServer({
105
209
  name: 'pmx-canvas',
106
210
  version: '0.1.0',
107
211
  });
212
+ resourceNotificationServer = server;
108
213
 
109
214
  // ── canvas_get_layout ──────────────────────────────────────────
110
215
  server.tool(
@@ -113,7 +218,7 @@ export async function startMcpServer(): Promise<void> {
113
218
  {},
114
219
  async () => {
115
220
  const c = await ensureCanvas();
116
- const layout = serializeCanvasLayout(c.getLayout());
221
+ const layout = serializeCanvasLayout(await c.getLayout());
117
222
  return {
118
223
  content: [{ type: 'text', text: JSON.stringify(layout, null, 2) }],
119
224
  };
@@ -127,7 +232,7 @@ export async function startMcpServer(): Promise<void> {
127
232
  { id: z.string().describe('The node ID to retrieve') },
128
233
  async ({ id }) => {
129
234
  const c = await ensureCanvas();
130
- const node = c.getNode(id);
235
+ const node = await c.getNode(id);
131
236
  if (!node) {
132
237
  return {
133
238
  content: [{ type: 'text', text: `Node "${id}" not found.` }],
@@ -184,9 +289,9 @@ export async function startMcpServer(): Promise<void> {
184
289
  const nodeInput = input.type === 'image' && input.path && !input.content
185
290
  ? { ...input, content: input.path }
186
291
  : input;
187
- const id = c.addNode(nodeInput);
292
+ const id = await c.addNode(nodeInput);
188
293
  return {
189
- content: [{ type: 'text', text: JSON.stringify(createdNodePayload(c, id), null, 2) }],
294
+ content: [{ type: 'text', text: JSON.stringify(await createdNodePayload(c, id), null, 2) }],
190
295
  };
191
296
  },
192
297
  );
@@ -378,7 +483,7 @@ export async function startMcpServer(): Promise<void> {
378
483
  // ── canvas_build_web_artifact ───────────────────────────────
379
484
  server.tool(
380
485
  'canvas_build_web_artifact',
381
- '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 commonly take 45-60s on cold workspaces; use a long client timeout. 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.',
486
+ '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.',
382
487
  {
383
488
  title: z.string().describe('Artifact title used for default project and output paths'),
384
489
  appTsx: z.string().describe('Contents for src/App.tsx'),
@@ -429,6 +534,7 @@ export async function startMcpServer(): Promise<void> {
429
534
  bytes: result.fileSize,
430
535
  projectPath: result.projectPath,
431
536
  openedInCanvas: result.openedInCanvas,
537
+ completedAt: result.completedAt,
432
538
  // `id` only present when a canvas node was actually created.
433
539
  // See the matching block in src/server/server.ts handleCanvasBuildWebArtifact.
434
540
  ...(typeof result.nodeId === 'string' ? { id: result.nodeId } : {}),
@@ -468,7 +574,7 @@ export async function startMcpServer(): Promise<void> {
468
574
  async (input) => {
469
575
  const c = await ensureCanvas();
470
576
  try {
471
- const result = c.addJsonRenderNode({
577
+ const result = await c.addJsonRenderNode({
472
578
  ...(typeof input.title === 'string' ? { title: input.title } : {}),
473
579
  spec: input.spec,
474
580
  ...(typeof input.x === 'number' ? { x: input.x } : {}),
@@ -481,7 +587,7 @@ export async function startMcpServer(): Promise<void> {
481
587
  content: [{
482
588
  type: 'text',
483
589
  text: JSON.stringify({
484
- ...createdNodePayload(c, result.id),
590
+ ...await createdNodePayload(c, result.id),
485
591
  url: result.url,
486
592
  spec: result.spec,
487
593
  }, null, 2),
@@ -530,7 +636,7 @@ export async function startMcpServer(): Promise<void> {
530
636
  async (input) => {
531
637
  const c = await ensureCanvas();
532
638
  try {
533
- const result = c.addGraphNode({
639
+ const result = await c.addGraphNode({
534
640
  graphType: input.graphType,
535
641
  data: input.data,
536
642
  ...(typeof input.title === 'string' ? { title: input.title } : {}),
@@ -561,7 +667,7 @@ export async function startMcpServer(): Promise<void> {
561
667
  content: [{
562
668
  type: 'text',
563
669
  text: JSON.stringify({
564
- ...createdNodePayload(c, result.id),
670
+ ...await createdNodePayload(c, result.id),
565
671
  url: result.url,
566
672
  spec: result.spec,
567
673
  }, null, 2),
@@ -599,7 +705,7 @@ export async function startMcpServer(): Promise<void> {
599
705
  },
600
706
  async ({ id, title, content, x, y, width, height, spec, graphType, data, xKey, yKey, chartHeight, collapsed, arrangeLocked }) => {
601
707
  const c = await ensureCanvas();
602
- const node = c.getNode(id);
708
+ const node = await c.getNode(id);
603
709
  if (!node) {
604
710
  return {
605
711
  content: [{ type: 'text', text: `Node "${id}" not found.` }],
@@ -627,10 +733,10 @@ export async function startMcpServer(): Promise<void> {
627
733
  if (arrangeLocked !== undefined) {
628
734
  patch.arrangeLocked = arrangeLocked;
629
735
  }
630
- c.updateNode(id, patch);
631
- const updated = c.getNode(id);
736
+ await c.updateNode(id, patch);
737
+ const updated = await c.getNode(id);
632
738
  return {
633
- content: [{ type: 'text', text: JSON.stringify(updated ? createdNodePayload(c, id) : { ok: true, id }, null, 2) }],
739
+ content: [{ type: 'text', text: JSON.stringify(updated ? await createdNodePayload(c, id) : { ok: true, id }, null, 2) }],
634
740
  };
635
741
  },
636
742
  );
@@ -642,7 +748,7 @@ export async function startMcpServer(): Promise<void> {
642
748
  { id: z.string().describe('Node ID to remove') },
643
749
  async ({ id }) => {
644
750
  const c = await ensureCanvas();
645
- c.removeNode(id);
751
+ await c.removeNode(id);
646
752
  return {
647
753
  content: [{ type: 'text', text: JSON.stringify({ ok: true, removed: id }) }],
648
754
  };
@@ -678,8 +784,8 @@ export async function startMcpServer(): Promise<void> {
678
784
  };
679
785
  }
680
786
  try {
681
- const id = c.addEdge(input);
682
- const edge = c.getLayout().edges.find((entry) => entry.id === id);
787
+ const id = await c.addEdge(input);
788
+ const edge = (await c.getLayout()).edges.find((entry) => entry.id === id);
683
789
  return {
684
790
  content: [{
685
791
  type: 'text',
@@ -702,7 +808,7 @@ export async function startMcpServer(): Promise<void> {
702
808
  { id: z.string().describe('Edge ID to remove') },
703
809
  async ({ id }) => {
704
810
  const c = await ensureCanvas();
705
- c.removeEdge(id);
811
+ await c.removeEdge(id);
706
812
  return {
707
813
  content: [{ type: 'text', text: JSON.stringify({ ok: true, removed: id }) }],
708
814
  };
@@ -718,7 +824,7 @@ export async function startMcpServer(): Promise<void> {
718
824
  },
719
825
  async ({ layout }) => {
720
826
  const c = await ensureCanvas();
721
- c.arrange(layout ?? 'grid');
827
+ await c.arrange(layout ?? 'grid');
722
828
  return {
723
829
  content: [{ type: 'text', text: JSON.stringify({ ok: true, layout: layout ?? 'grid' }) }],
724
830
  };
@@ -738,7 +844,7 @@ export async function startMcpServer(): Promise<void> {
738
844
  },
739
845
  async ({ id, noPan }) => {
740
846
  const c = await ensureCanvas();
741
- const result = c.focusNode(id, { ...(noPan === true ? { noPan: true } : {}) });
847
+ const result = await c.focusNode(id, { ...(noPan === true ? { noPan: true } : {}) });
742
848
  if (!result) {
743
849
  return {
744
850
  content: [
@@ -772,7 +878,7 @@ export async function startMcpServer(): Promise<void> {
772
878
  },
773
879
  async (input) => {
774
880
  const c = await ensureCanvas();
775
- const result = c.fitView({
881
+ const result = await c.fitView({
776
882
  ...(typeof input.width === 'number' ? { width: input.width } : {}),
777
883
  ...(typeof input.height === 'number' ? { height: input.height } : {}),
778
884
  ...(typeof input.padding === 'number' ? { padding: input.padding } : {}),
@@ -792,7 +898,7 @@ export async function startMcpServer(): Promise<void> {
792
898
  {},
793
899
  async () => {
794
900
  const c = await ensureCanvas();
795
- c.clear();
901
+ await c.clear();
796
902
  return {
797
903
  content: [{ type: 'text', text: JSON.stringify({ ok: true, cleared: true }) }],
798
904
  };
@@ -808,8 +914,8 @@ export async function startMcpServer(): Promise<void> {
808
914
  limit: z.number().optional().describe('Max results to return (default: 10)'),
809
915
  },
810
916
  async ({ query, limit }) => {
811
- await ensureCanvas();
812
- const results = searchNodes(canvasState.getLayout().nodes, query);
917
+ const c = await ensureCanvas();
918
+ const results = await c.search(query);
813
919
  const capped = results.slice(0, limit ?? 10);
814
920
  return {
815
921
  content: [{
@@ -828,8 +934,9 @@ export async function startMcpServer(): Promise<void> {
828
934
  async () => {
829
935
  const c = await ensureCanvas();
830
936
  const result = await c.undo();
937
+ const history = await c.getHistory();
831
938
  return {
832
- content: [{ type: 'text', text: JSON.stringify({ ...result, canUndo: mutationHistory.canUndo(), canRedo: mutationHistory.canRedo() }) }],
939
+ content: [{ type: 'text', text: JSON.stringify({ ...result, canUndo: history.canUndo, canRedo: history.canRedo }) }],
833
940
  };
834
941
  },
835
942
  );
@@ -842,8 +949,9 @@ export async function startMcpServer(): Promise<void> {
842
949
  async () => {
843
950
  const c = await ensureCanvas();
844
951
  const result = await c.redo();
952
+ const history = await c.getHistory();
845
953
  return {
846
- content: [{ type: 'text', text: JSON.stringify({ ...result, canUndo: mutationHistory.canUndo(), canRedo: mutationHistory.canRedo() }) }],
954
+ content: [{ type: 'text', text: JSON.stringify({ ...result, canUndo: history.canUndo, canRedo: history.canRedo }) }],
847
955
  };
848
956
  },
849
957
  );
@@ -856,15 +964,13 @@ export async function startMcpServer(): Promise<void> {
856
964
  snapshot: z.string().describe('Snapshot name or ID to compare against'),
857
965
  },
858
966
  async ({ snapshot }) => {
859
- await ensureCanvas();
860
- const snapData = canvasState.getSnapshotData(snapshot);
861
- if (!snapData) {
967
+ const c = await ensureCanvas();
968
+ const result = await c.diffSnapshot(snapshot);
969
+ if (!result.ok) {
862
970
  return { content: [{ type: 'text', text: `Snapshot "${snapshot}" not found. Use canvas_snapshot to save one first.` }], isError: true };
863
971
  }
864
- const current = canvasState.getLayout();
865
- const diff = diffLayouts(snapData.name, snapData, current);
866
972
  return {
867
- content: [{ type: 'text', text: formatDiff(diff) }],
973
+ content: [{ type: 'text', text: result.text ?? '' }],
868
974
  };
869
975
  },
870
976
  );
@@ -877,7 +983,7 @@ export async function startMcpServer(): Promise<void> {
877
983
  async () => {
878
984
  const c = await ensureCanvas();
879
985
  return {
880
- content: [{ type: 'text', text: JSON.stringify(c.getAutomationWebViewStatus(), null, 2) }],
986
+ content: [{ type: 'text', text: JSON.stringify(await c.getAutomationWebViewStatus(), null, 2) }],
881
987
  };
882
988
  },
883
989
  );
@@ -927,13 +1033,14 @@ export async function startMcpServer(): Promise<void> {
927
1033
  const c = await ensureCanvas();
928
1034
  try {
929
1035
  const stopped = await c.stopAutomationWebView();
1036
+ const webview = await c.getAutomationWebViewStatus();
930
1037
  return {
931
1038
  content: [{
932
1039
  type: 'text',
933
1040
  text: JSON.stringify({
934
1041
  ok: true,
935
1042
  stopped,
936
- webview: c.getAutomationWebViewStatus(),
1043
+ webview,
937
1044
  }, null, 2),
938
1045
  }],
939
1046
  };
@@ -1017,7 +1124,7 @@ export async function startMcpServer(): Promise<void> {
1017
1124
  ...(format ? { format } : {}),
1018
1125
  ...(typeof quality === 'number' ? { quality } : {}),
1019
1126
  });
1020
- const status = c.getAutomationWebViewStatus();
1127
+ const status = await c.getAutomationWebViewStatus();
1021
1128
  return {
1022
1129
  content: [
1023
1130
  {
@@ -1092,8 +1199,8 @@ export async function startMcpServer(): Promise<void> {
1092
1199
  },
1093
1200
  async () => {
1094
1201
  const c = await ensureCanvas();
1095
- const pinnedIds = canvasState.contextPinnedNodeIds;
1096
- const layout = c.getLayout();
1202
+ const pinnedIds = new Set(await c.getPinnedNodeIds());
1203
+ const layout = await c.getLayout();
1097
1204
 
1098
1205
  const pinnedNodes = layout.nodes.filter((n) => pinnedIds.has(n.id));
1099
1206
  const pinnedEdges = layout.edges.filter(
@@ -1147,7 +1254,7 @@ export async function startMcpServer(): Promise<void> {
1147
1254
  },
1148
1255
  async () => {
1149
1256
  const c = await ensureCanvas();
1150
- const layout = serializeCanvasLayout(c.getLayout());
1257
+ const layout = serializeCanvasLayout(await c.getLayout());
1151
1258
  return {
1152
1259
  contents: [
1153
1260
  {
@@ -1170,13 +1277,13 @@ export async function startMcpServer(): Promise<void> {
1170
1277
  mimeType: 'application/json',
1171
1278
  },
1172
1279
  async () => {
1173
- await ensureCanvas();
1280
+ const c = await ensureCanvas();
1174
1281
  return {
1175
1282
  contents: [
1176
1283
  {
1177
1284
  uri: 'canvas://summary',
1178
1285
  mimeType: 'application/json',
1179
- text: JSON.stringify(buildCanvasSummary(), null, 2),
1286
+ text: JSON.stringify(buildSummaryFromLayout(await c.getLayout(), await c.getPinnedNodeIds()), null, 2),
1180
1287
  },
1181
1288
  ],
1182
1289
  };
@@ -1196,9 +1303,9 @@ export async function startMcpServer(): Promise<void> {
1196
1303
  mimeType: 'application/json',
1197
1304
  },
1198
1305
  async () => {
1199
- await ensureCanvas();
1200
- const layout = canvasState.getLayout();
1201
- const spatial = buildSpatialContext(layout.nodes, layout.edges, canvasState.contextPinnedNodeIds);
1306
+ const c = await ensureCanvas();
1307
+ const layout = await c.getLayout();
1308
+ const spatial = buildSpatialContext(layout.nodes, layout.edges, new Set(await c.getPinnedNodeIds()));
1202
1309
  return {
1203
1310
  contents: [
1204
1311
  {
@@ -1222,13 +1329,13 @@ export async function startMcpServer(): Promise<void> {
1222
1329
  mimeType: 'text/plain',
1223
1330
  },
1224
1331
  async () => {
1225
- await ensureCanvas();
1332
+ const c = await ensureCanvas();
1226
1333
  return {
1227
1334
  contents: [
1228
1335
  {
1229
1336
  uri: 'canvas://history',
1230
1337
  mimeType: 'text/plain',
1231
- text: mutationHistory.toHumanReadable(),
1338
+ text: (await c.getHistory()).text,
1232
1339
  },
1233
1340
  ],
1234
1341
  };
@@ -1247,8 +1354,8 @@ export async function startMcpServer(): Promise<void> {
1247
1354
  mimeType: 'application/json',
1248
1355
  },
1249
1356
  async () => {
1250
- await ensureCanvas();
1251
- const summary = buildCodeGraphSummary();
1357
+ const c = await ensureCanvas();
1358
+ const summary = (await c.getCodeGraph()).summary;
1252
1359
  return {
1253
1360
  contents: [
1254
1361
  {
@@ -1339,9 +1446,9 @@ export async function startMcpServer(): Promise<void> {
1339
1446
  },
1340
1447
  async (input) => {
1341
1448
  const c = await ensureCanvas();
1342
- const id = c.createGroup(input);
1449
+ const id = await c.createGroup(input);
1343
1450
  return {
1344
- content: [{ type: 'text', text: JSON.stringify(createdNodePayload(c, id), null, 2) }],
1451
+ content: [{ type: 'text', text: JSON.stringify(await createdNodePayload(c, id), null, 2) }],
1345
1452
  };
1346
1453
  },
1347
1454
  );
@@ -1357,7 +1464,7 @@ export async function startMcpServer(): Promise<void> {
1357
1464
  },
1358
1465
  async ({ groupId, childIds, childLayout }) => {
1359
1466
  const c = await ensureCanvas();
1360
- const ok = c.groupNodes(groupId, childIds, childLayout ? { childLayout } : undefined);
1467
+ const ok = await c.groupNodes(groupId, childIds, childLayout ? { childLayout } : undefined);
1361
1468
  if (!ok) {
1362
1469
  return { content: [{ type: 'text', text: 'Group not found or no valid children.' }], isError: true };
1363
1470
  }
@@ -1367,7 +1474,7 @@ export async function startMcpServer(): Promise<void> {
1367
1474
 
1368
1475
  server.tool(
1369
1476
  'canvas_batch',
1370
- 'Run a batch of canvas operations with optional assigned references. Supports node.add, node.update, graph.add, edge.add, group.create, group.add, group.remove, pin.set/add/remove, snapshot.save, and arrange.',
1477
+ '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, graph.add, edge.add, group.create, group.add, group.remove, pin.set/add/remove, snapshot.save, and arrange.',
1371
1478
  {
1372
1479
  operations: z.array(z.object({
1373
1480
  op: z.string().describe('Operation name, e.g. "node.add" or "edge.add"'),
@@ -1392,7 +1499,7 @@ export async function startMcpServer(): Promise<void> {
1392
1499
  async () => {
1393
1500
  const c = await ensureCanvas();
1394
1501
  return {
1395
- content: [{ type: 'text', text: JSON.stringify(c.validate(), null, 2) }],
1502
+ content: [{ type: 'text', text: JSON.stringify(await c.validate(), null, 2) }],
1396
1503
  };
1397
1504
  },
1398
1505
  );
@@ -1406,7 +1513,7 @@ export async function startMcpServer(): Promise<void> {
1406
1513
  },
1407
1514
  async ({ groupId }) => {
1408
1515
  const c = await ensureCanvas();
1409
- const ok = c.ungroupNodes(groupId);
1516
+ const ok = await c.ungroupNodes(groupId);
1410
1517
  if (!ok) {
1411
1518
  return { content: [{ type: 'text', text: 'Group not found or already empty.' }], isError: true };
1412
1519
  }
@@ -1425,9 +1532,7 @@ export async function startMcpServer(): Promise<void> {
1425
1532
  },
1426
1533
  async ({ nodeIds, mode }) => {
1427
1534
  const c = await ensureCanvas();
1428
- const result = c.setContextPins(nodeIds, mode ?? 'set');
1429
-
1430
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1535
+ const result = await c.setContextPins(nodeIds, mode ?? 'set');
1431
1536
 
1432
1537
  return {
1433
1538
  content: [{
@@ -1450,7 +1555,7 @@ export async function startMcpServer(): Promise<void> {
1450
1555
  },
1451
1556
  async (input) => {
1452
1557
  const c = await ensureCanvas();
1453
- const snapshot = c.saveSnapshot(input.name);
1558
+ const snapshot = await c.saveSnapshot(input.name);
1454
1559
  if (!snapshot) {
1455
1560
  return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'Failed to save snapshot' }) }] };
1456
1561
  }
@@ -1466,7 +1571,7 @@ export async function startMcpServer(): Promise<void> {
1466
1571
  async () => {
1467
1572
  const c = await ensureCanvas();
1468
1573
  return {
1469
- content: [{ type: 'text', text: JSON.stringify({ snapshots: c.listSnapshots() }, null, 2) }],
1574
+ content: [{ type: 'text', text: JSON.stringify({ snapshots: await c.listSnapshots() }, null, 2) }],
1470
1575
  };
1471
1576
  },
1472
1577
  );
@@ -1484,9 +1589,9 @@ export async function startMcpServer(): Promise<void> {
1484
1589
  if (!result.ok) {
1485
1590
  return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'Snapshot not found' }) }] };
1486
1591
  }
1487
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1592
+ const layout = await c.getLayout();
1488
1593
  return {
1489
- content: [{ type: 'text', text: JSON.stringify({ ok: true, layout: serializeCanvasLayout(canvasState.getLayout()) }) }],
1594
+ content: [{ type: 'text', text: JSON.stringify({ ok: true, restored: input.id, summary: buildSnapshotRestoreSummary(layout) }, null, 2) }],
1490
1595
  };
1491
1596
  },
1492
1597
  );
@@ -1500,7 +1605,7 @@ export async function startMcpServer(): Promise<void> {
1500
1605
  },
1501
1606
  async ({ id }) => {
1502
1607
  const c = await ensureCanvas();
1503
- const result = c.deleteSnapshot(id);
1608
+ const result = await c.deleteSnapshot(id);
1504
1609
  if (!result.ok) {
1505
1610
  return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'Snapshot not found' }) }], isError: true };
1506
1611
  }
@@ -1510,24 +1615,6 @@ export async function startMcpServer(): Promise<void> {
1510
1615
  },
1511
1616
  );
1512
1617
 
1513
- // ── Resource change notifications ──────────────────────────
1514
- // When canvas state changes (nodes, edges, pins), notify MCP clients
1515
- // so they can re-read resources like canvas://pinned-context.
1516
- canvasState.onChange((type) => {
1517
- try {
1518
- if (type === 'pins') {
1519
- server.server.sendResourceUpdated({ uri: 'canvas://pinned-context' });
1520
- }
1521
- server.server.sendResourceUpdated({ uri: 'canvas://layout' });
1522
- server.server.sendResourceUpdated({ uri: 'canvas://summary' });
1523
- server.server.sendResourceUpdated({ uri: 'canvas://spatial-context' });
1524
- server.server.sendResourceUpdated({ uri: 'canvas://history' });
1525
- server.server.sendResourceUpdated({ uri: 'canvas://code-graph' });
1526
- } catch (error) {
1527
- console.debug('[mcp] resource notification failed', error);
1528
- }
1529
- });
1530
-
1531
1618
  // Connect via stdio
1532
1619
  const transport = new StdioServerTransport();
1533
1620
  await server.connect(transport);
@@ -1373,6 +1373,7 @@ function resolveBatchRefs(value: unknown, refs: Record<string, unknown>): unknow
1373
1373
  if (typeof value === 'string' && value.startsWith('$')) {
1374
1374
  const path = value.slice(1).split('.');
1375
1375
  let current: unknown = refs[path[0] ?? ''];
1376
+ if (path.length === 1 && isPlainRecord(current) && typeof current.id === 'string') return current.id;
1376
1377
  for (const segment of path.slice(1)) {
1377
1378
  if (!isPlainRecord(current) && !Array.isArray(current)) return undefined;
1378
1379
  current = (current as Record<string, unknown>)[segment];
@@ -140,11 +140,18 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
140
140
  fields: [
141
141
  { name: 'title', type: 'string', required: false, description: 'Optional title.' },
142
142
  { name: 'content', type: 'string', required: false, description: 'Trace summary.' },
143
+ { name: 'toolName', type: 'string', required: false, description: 'Tool or operation label shown in the trace pill; defaults to title.' },
144
+ { name: 'category', type: 'string', required: false, description: 'Trace category color key: mcp, file, subagent, or other.' },
145
+ { name: 'status', type: 'string', required: false, description: 'Trace status: running, success, or failed.' },
146
+ { name: 'duration', type: 'string', required: false, description: 'Optional duration badge text.' },
147
+ { name: 'resultSummary', type: 'string', required: false, description: 'Short trace result summary; defaults to content.' },
148
+ { name: 'error', type: 'string', required: false, description: 'Short error message shown in failed traces.' },
143
149
  ],
144
150
  example: {
145
151
  type: 'trace',
146
152
  title: 'Execution Trace',
147
153
  content: 'Canvas actions and tool events.',
154
+ status: 'success',
148
155
  },
149
156
  },
150
157
  {
@@ -378,12 +385,16 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
378
385
  { name: 'openInCanvas', type: 'boolean', required: false, description: 'Open the built artifact on the canvas (default true).' },
379
386
  { name: 'includeLogs', type: 'boolean', required: false, description: 'Include raw build stdout/stderr in the response (default false).' },
380
387
  { name: 'deps', type: 'string[]', required: false, description: 'Optional npm dependencies to add before bundling, e.g. recharts.', aliases: ['deps'] },
388
+ { name: 'timeoutMs', type: 'number', required: false, description: 'Build command timeout in milliseconds. This controls subprocess timeout, not the MCP client request timeout.' },
381
389
  ],
382
390
  example: {
383
391
  title: 'Dashboard Artifact',
384
392
  appTsx: 'export default function App() { return <main>Artifact</main>; }',
385
393
  indexCss: 'body { background: #123456; color: white; }',
386
394
  },
395
+ notes: [
396
+ 'Cold builds can exceed default 60s MCP client timeouts; configure a longer MCP call timeout or retry with the same projectPath/outputPath if the first call times out.',
397
+ ],
387
398
  },
388
399
  ];
389
400