pmx-canvas 0.1.11 → 0.1.12

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,17 @@ 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, 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 resourceNotificationsStarted = false;
43
38
 
44
39
  const jsonRenderSpecSchema = z.union([
45
40
  z.object({
@@ -80,31 +75,121 @@ function safeWorkspacePath(pathLike: string): string {
80
75
  return resolved;
81
76
  }
82
77
 
83
- async function ensureCanvas(): Promise<PmxCanvas> {
78
+ async function ensureCanvas(): Promise<CanvasAccess> {
84
79
  if (!canvas) {
85
- const port = parseInt(process.env.PMX_CANVAS_PORT ?? '4313');
86
- canvas = createCanvas({ port });
87
- await canvas.start({ open: true });
80
+ canvas = await createCanvasAccess();
88
81
  }
82
+ startResourceNotifications(canvas);
89
83
  return canvas;
90
84
  }
91
85
 
86
+ function sleep(ms: number): Promise<void> {
87
+ return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
88
+ }
89
+
90
+ function sendCanvasResourceNotifications(type: 'nodes' | 'pins' = 'nodes'): void {
91
+ const server = resourceNotificationServer;
92
+ if (!server) return;
93
+ try {
94
+ if (type === 'pins') {
95
+ server.server.sendResourceUpdated({ uri: 'canvas://pinned-context' });
96
+ }
97
+ server.server.sendResourceUpdated({ uri: 'canvas://layout' });
98
+ server.server.sendResourceUpdated({ uri: 'canvas://summary' });
99
+ server.server.sendResourceUpdated({ uri: 'canvas://spatial-context' });
100
+ server.server.sendResourceUpdated({ uri: 'canvas://history' });
101
+ server.server.sendResourceUpdated({ uri: 'canvas://code-graph' });
102
+ } catch (error) {
103
+ console.debug('[mcp] resource notification failed', error);
104
+ }
105
+ }
106
+
107
+ function handleRemoteSseFrame(frame: string): void {
108
+ const eventLine = frame.split('\n').find((line) => line.startsWith('event: '));
109
+ const event = eventLine?.slice('event: '.length).trim() ?? '';
110
+ if (!event || event === 'connected' || event === 'ping') return;
111
+ sendCanvasResourceNotifications(event === 'context-pins-changed' ? 'pins' : 'nodes');
112
+ }
113
+
114
+ async function watchRemoteCanvasEvents(baseUrl: string): Promise<void> {
115
+ const decoder = new TextDecoder();
116
+ while (true) {
117
+ try {
118
+ const response = await fetch(`${baseUrl}/api/workbench/events`);
119
+ if (!response.ok || !response.body) {
120
+ await sleep(1_000);
121
+ continue;
122
+ }
123
+
124
+ const reader = response.body.getReader();
125
+ let buffer = '';
126
+ while (true) {
127
+ const { done, value } = await reader.read();
128
+ if (done) break;
129
+ buffer += decoder.decode(value, { stream: true });
130
+ const frames = buffer.split('\n\n');
131
+ buffer = frames.pop() ?? '';
132
+ for (const frame of frames) handleRemoteSseFrame(frame);
133
+ }
134
+ } catch (error) {
135
+ console.debug('[mcp] remote canvas event stream failed', error);
136
+ }
137
+ await sleep(1_000);
138
+ }
139
+ }
140
+
141
+ function startResourceNotifications(c: CanvasAccess): void {
142
+ if (resourceNotificationsStarted) return;
143
+ const server = resourceNotificationServer;
144
+ if (!server) return;
145
+ resourceNotificationsStarted = true;
146
+
147
+ if (c.remoteBaseUrl) {
148
+ void watchRemoteCanvasEvents(c.remoteBaseUrl);
149
+ return;
150
+ }
151
+
152
+ canvasState.onChange((type) => {
153
+ sendCanvasResourceNotifications(type);
154
+ });
155
+ }
156
+
92
157
  function encodeBase64(bytes: Uint8Array): string {
93
158
  return Buffer.from(bytes).toString('base64');
94
159
  }
95
160
 
96
- function createdNodePayload(c: PmxCanvas, id: string): Record<string, unknown> {
97
- const node = c.getNode(id);
161
+ async function createdNodePayload(c: CanvasAccess, id: string): Promise<Record<string, unknown>> {
162
+ const node = await c.getNode(id);
98
163
  if (!node) return { ok: true, id };
99
164
  const serialized = serializeCanvasNode(node);
100
165
  return { ok: true, node: serialized, ...serialized };
101
166
  }
102
167
 
168
+ function buildSummaryFromLayout(layout: Awaited<ReturnType<CanvasAccess['getLayout']>>, pinnedIds: string[]): Record<string, unknown> {
169
+ const pinned = new Set(pinnedIds);
170
+ const nodesByType: Record<string, number> = {};
171
+ const pinnedTitles: string[] = [];
172
+ for (const node of layout.nodes) {
173
+ const serialized = serializeCanvasNode(node);
174
+ nodesByType[serialized.kind] = (nodesByType[serialized.kind] ?? 0) + 1;
175
+ if (pinned.has(node.id)) pinnedTitles.push(getCanvasNodeTitle(node) ?? node.id);
176
+ }
177
+ return {
178
+ totalNodes: layout.nodes.length,
179
+ totalEdges: layout.edges.length,
180
+ nodesByType,
181
+ pinnedCount: pinned.size,
182
+ pinnedTitles,
183
+ viewport: layout.viewport,
184
+ };
185
+ }
186
+
103
187
  export async function startMcpServer(): Promise<void> {
104
188
  const server = new McpServer({
105
189
  name: 'pmx-canvas',
106
190
  version: '0.1.0',
107
191
  });
192
+ resourceNotificationServer = server;
108
193
 
109
194
  // ── canvas_get_layout ──────────────────────────────────────────
110
195
  server.tool(
@@ -113,7 +198,7 @@ export async function startMcpServer(): Promise<void> {
113
198
  {},
114
199
  async () => {
115
200
  const c = await ensureCanvas();
116
- const layout = serializeCanvasLayout(c.getLayout());
201
+ const layout = serializeCanvasLayout(await c.getLayout());
117
202
  return {
118
203
  content: [{ type: 'text', text: JSON.stringify(layout, null, 2) }],
119
204
  };
@@ -127,7 +212,7 @@ export async function startMcpServer(): Promise<void> {
127
212
  { id: z.string().describe('The node ID to retrieve') },
128
213
  async ({ id }) => {
129
214
  const c = await ensureCanvas();
130
- const node = c.getNode(id);
215
+ const node = await c.getNode(id);
131
216
  if (!node) {
132
217
  return {
133
218
  content: [{ type: 'text', text: `Node "${id}" not found.` }],
@@ -184,9 +269,9 @@ export async function startMcpServer(): Promise<void> {
184
269
  const nodeInput = input.type === 'image' && input.path && !input.content
185
270
  ? { ...input, content: input.path }
186
271
  : input;
187
- const id = c.addNode(nodeInput);
272
+ const id = await c.addNode(nodeInput);
188
273
  return {
189
- content: [{ type: 'text', text: JSON.stringify(createdNodePayload(c, id), null, 2) }],
274
+ content: [{ type: 'text', text: JSON.stringify(await createdNodePayload(c, id), null, 2) }],
190
275
  };
191
276
  },
192
277
  );
@@ -468,7 +553,7 @@ export async function startMcpServer(): Promise<void> {
468
553
  async (input) => {
469
554
  const c = await ensureCanvas();
470
555
  try {
471
- const result = c.addJsonRenderNode({
556
+ const result = await c.addJsonRenderNode({
472
557
  ...(typeof input.title === 'string' ? { title: input.title } : {}),
473
558
  spec: input.spec,
474
559
  ...(typeof input.x === 'number' ? { x: input.x } : {}),
@@ -481,7 +566,7 @@ export async function startMcpServer(): Promise<void> {
481
566
  content: [{
482
567
  type: 'text',
483
568
  text: JSON.stringify({
484
- ...createdNodePayload(c, result.id),
569
+ ...await createdNodePayload(c, result.id),
485
570
  url: result.url,
486
571
  spec: result.spec,
487
572
  }, null, 2),
@@ -530,7 +615,7 @@ export async function startMcpServer(): Promise<void> {
530
615
  async (input) => {
531
616
  const c = await ensureCanvas();
532
617
  try {
533
- const result = c.addGraphNode({
618
+ const result = await c.addGraphNode({
534
619
  graphType: input.graphType,
535
620
  data: input.data,
536
621
  ...(typeof input.title === 'string' ? { title: input.title } : {}),
@@ -561,7 +646,7 @@ export async function startMcpServer(): Promise<void> {
561
646
  content: [{
562
647
  type: 'text',
563
648
  text: JSON.stringify({
564
- ...createdNodePayload(c, result.id),
649
+ ...await createdNodePayload(c, result.id),
565
650
  url: result.url,
566
651
  spec: result.spec,
567
652
  }, null, 2),
@@ -599,7 +684,7 @@ export async function startMcpServer(): Promise<void> {
599
684
  },
600
685
  async ({ id, title, content, x, y, width, height, spec, graphType, data, xKey, yKey, chartHeight, collapsed, arrangeLocked }) => {
601
686
  const c = await ensureCanvas();
602
- const node = c.getNode(id);
687
+ const node = await c.getNode(id);
603
688
  if (!node) {
604
689
  return {
605
690
  content: [{ type: 'text', text: `Node "${id}" not found.` }],
@@ -627,10 +712,10 @@ export async function startMcpServer(): Promise<void> {
627
712
  if (arrangeLocked !== undefined) {
628
713
  patch.arrangeLocked = arrangeLocked;
629
714
  }
630
- c.updateNode(id, patch);
631
- const updated = c.getNode(id);
715
+ await c.updateNode(id, patch);
716
+ const updated = await c.getNode(id);
632
717
  return {
633
- content: [{ type: 'text', text: JSON.stringify(updated ? createdNodePayload(c, id) : { ok: true, id }, null, 2) }],
718
+ content: [{ type: 'text', text: JSON.stringify(updated ? await createdNodePayload(c, id) : { ok: true, id }, null, 2) }],
634
719
  };
635
720
  },
636
721
  );
@@ -642,7 +727,7 @@ export async function startMcpServer(): Promise<void> {
642
727
  { id: z.string().describe('Node ID to remove') },
643
728
  async ({ id }) => {
644
729
  const c = await ensureCanvas();
645
- c.removeNode(id);
730
+ await c.removeNode(id);
646
731
  return {
647
732
  content: [{ type: 'text', text: JSON.stringify({ ok: true, removed: id }) }],
648
733
  };
@@ -678,8 +763,8 @@ export async function startMcpServer(): Promise<void> {
678
763
  };
679
764
  }
680
765
  try {
681
- const id = c.addEdge(input);
682
- const edge = c.getLayout().edges.find((entry) => entry.id === id);
766
+ const id = await c.addEdge(input);
767
+ const edge = (await c.getLayout()).edges.find((entry) => entry.id === id);
683
768
  return {
684
769
  content: [{
685
770
  type: 'text',
@@ -702,7 +787,7 @@ export async function startMcpServer(): Promise<void> {
702
787
  { id: z.string().describe('Edge ID to remove') },
703
788
  async ({ id }) => {
704
789
  const c = await ensureCanvas();
705
- c.removeEdge(id);
790
+ await c.removeEdge(id);
706
791
  return {
707
792
  content: [{ type: 'text', text: JSON.stringify({ ok: true, removed: id }) }],
708
793
  };
@@ -718,7 +803,7 @@ export async function startMcpServer(): Promise<void> {
718
803
  },
719
804
  async ({ layout }) => {
720
805
  const c = await ensureCanvas();
721
- c.arrange(layout ?? 'grid');
806
+ await c.arrange(layout ?? 'grid');
722
807
  return {
723
808
  content: [{ type: 'text', text: JSON.stringify({ ok: true, layout: layout ?? 'grid' }) }],
724
809
  };
@@ -738,7 +823,7 @@ export async function startMcpServer(): Promise<void> {
738
823
  },
739
824
  async ({ id, noPan }) => {
740
825
  const c = await ensureCanvas();
741
- const result = c.focusNode(id, { ...(noPan === true ? { noPan: true } : {}) });
826
+ const result = await c.focusNode(id, { ...(noPan === true ? { noPan: true } : {}) });
742
827
  if (!result) {
743
828
  return {
744
829
  content: [
@@ -772,7 +857,7 @@ export async function startMcpServer(): Promise<void> {
772
857
  },
773
858
  async (input) => {
774
859
  const c = await ensureCanvas();
775
- const result = c.fitView({
860
+ const result = await c.fitView({
776
861
  ...(typeof input.width === 'number' ? { width: input.width } : {}),
777
862
  ...(typeof input.height === 'number' ? { height: input.height } : {}),
778
863
  ...(typeof input.padding === 'number' ? { padding: input.padding } : {}),
@@ -792,7 +877,7 @@ export async function startMcpServer(): Promise<void> {
792
877
  {},
793
878
  async () => {
794
879
  const c = await ensureCanvas();
795
- c.clear();
880
+ await c.clear();
796
881
  return {
797
882
  content: [{ type: 'text', text: JSON.stringify({ ok: true, cleared: true }) }],
798
883
  };
@@ -808,8 +893,8 @@ export async function startMcpServer(): Promise<void> {
808
893
  limit: z.number().optional().describe('Max results to return (default: 10)'),
809
894
  },
810
895
  async ({ query, limit }) => {
811
- await ensureCanvas();
812
- const results = searchNodes(canvasState.getLayout().nodes, query);
896
+ const c = await ensureCanvas();
897
+ const results = await c.search(query);
813
898
  const capped = results.slice(0, limit ?? 10);
814
899
  return {
815
900
  content: [{
@@ -828,8 +913,9 @@ export async function startMcpServer(): Promise<void> {
828
913
  async () => {
829
914
  const c = await ensureCanvas();
830
915
  const result = await c.undo();
916
+ const history = await c.getHistory();
831
917
  return {
832
- content: [{ type: 'text', text: JSON.stringify({ ...result, canUndo: mutationHistory.canUndo(), canRedo: mutationHistory.canRedo() }) }],
918
+ content: [{ type: 'text', text: JSON.stringify({ ...result, canUndo: history.canUndo, canRedo: history.canRedo }) }],
833
919
  };
834
920
  },
835
921
  );
@@ -842,8 +928,9 @@ export async function startMcpServer(): Promise<void> {
842
928
  async () => {
843
929
  const c = await ensureCanvas();
844
930
  const result = await c.redo();
931
+ const history = await c.getHistory();
845
932
  return {
846
- content: [{ type: 'text', text: JSON.stringify({ ...result, canUndo: mutationHistory.canUndo(), canRedo: mutationHistory.canRedo() }) }],
933
+ content: [{ type: 'text', text: JSON.stringify({ ...result, canUndo: history.canUndo, canRedo: history.canRedo }) }],
847
934
  };
848
935
  },
849
936
  );
@@ -856,15 +943,13 @@ export async function startMcpServer(): Promise<void> {
856
943
  snapshot: z.string().describe('Snapshot name or ID to compare against'),
857
944
  },
858
945
  async ({ snapshot }) => {
859
- await ensureCanvas();
860
- const snapData = canvasState.getSnapshotData(snapshot);
861
- if (!snapData) {
946
+ const c = await ensureCanvas();
947
+ const result = await c.diffSnapshot(snapshot);
948
+ if (!result.ok) {
862
949
  return { content: [{ type: 'text', text: `Snapshot "${snapshot}" not found. Use canvas_snapshot to save one first.` }], isError: true };
863
950
  }
864
- const current = canvasState.getLayout();
865
- const diff = diffLayouts(snapData.name, snapData, current);
866
951
  return {
867
- content: [{ type: 'text', text: formatDiff(diff) }],
952
+ content: [{ type: 'text', text: result.text ?? '' }],
868
953
  };
869
954
  },
870
955
  );
@@ -877,7 +962,7 @@ export async function startMcpServer(): Promise<void> {
877
962
  async () => {
878
963
  const c = await ensureCanvas();
879
964
  return {
880
- content: [{ type: 'text', text: JSON.stringify(c.getAutomationWebViewStatus(), null, 2) }],
965
+ content: [{ type: 'text', text: JSON.stringify(await c.getAutomationWebViewStatus(), null, 2) }],
881
966
  };
882
967
  },
883
968
  );
@@ -927,13 +1012,14 @@ export async function startMcpServer(): Promise<void> {
927
1012
  const c = await ensureCanvas();
928
1013
  try {
929
1014
  const stopped = await c.stopAutomationWebView();
1015
+ const webview = await c.getAutomationWebViewStatus();
930
1016
  return {
931
1017
  content: [{
932
1018
  type: 'text',
933
1019
  text: JSON.stringify({
934
1020
  ok: true,
935
1021
  stopped,
936
- webview: c.getAutomationWebViewStatus(),
1022
+ webview,
937
1023
  }, null, 2),
938
1024
  }],
939
1025
  };
@@ -1017,7 +1103,7 @@ export async function startMcpServer(): Promise<void> {
1017
1103
  ...(format ? { format } : {}),
1018
1104
  ...(typeof quality === 'number' ? { quality } : {}),
1019
1105
  });
1020
- const status = c.getAutomationWebViewStatus();
1106
+ const status = await c.getAutomationWebViewStatus();
1021
1107
  return {
1022
1108
  content: [
1023
1109
  {
@@ -1092,8 +1178,8 @@ export async function startMcpServer(): Promise<void> {
1092
1178
  },
1093
1179
  async () => {
1094
1180
  const c = await ensureCanvas();
1095
- const pinnedIds = canvasState.contextPinnedNodeIds;
1096
- const layout = c.getLayout();
1181
+ const pinnedIds = new Set(await c.getPinnedNodeIds());
1182
+ const layout = await c.getLayout();
1097
1183
 
1098
1184
  const pinnedNodes = layout.nodes.filter((n) => pinnedIds.has(n.id));
1099
1185
  const pinnedEdges = layout.edges.filter(
@@ -1147,7 +1233,7 @@ export async function startMcpServer(): Promise<void> {
1147
1233
  },
1148
1234
  async () => {
1149
1235
  const c = await ensureCanvas();
1150
- const layout = serializeCanvasLayout(c.getLayout());
1236
+ const layout = serializeCanvasLayout(await c.getLayout());
1151
1237
  return {
1152
1238
  contents: [
1153
1239
  {
@@ -1170,13 +1256,13 @@ export async function startMcpServer(): Promise<void> {
1170
1256
  mimeType: 'application/json',
1171
1257
  },
1172
1258
  async () => {
1173
- await ensureCanvas();
1259
+ const c = await ensureCanvas();
1174
1260
  return {
1175
1261
  contents: [
1176
1262
  {
1177
1263
  uri: 'canvas://summary',
1178
1264
  mimeType: 'application/json',
1179
- text: JSON.stringify(buildCanvasSummary(), null, 2),
1265
+ text: JSON.stringify(buildSummaryFromLayout(await c.getLayout(), await c.getPinnedNodeIds()), null, 2),
1180
1266
  },
1181
1267
  ],
1182
1268
  };
@@ -1196,9 +1282,9 @@ export async function startMcpServer(): Promise<void> {
1196
1282
  mimeType: 'application/json',
1197
1283
  },
1198
1284
  async () => {
1199
- await ensureCanvas();
1200
- const layout = canvasState.getLayout();
1201
- const spatial = buildSpatialContext(layout.nodes, layout.edges, canvasState.contextPinnedNodeIds);
1285
+ const c = await ensureCanvas();
1286
+ const layout = await c.getLayout();
1287
+ const spatial = buildSpatialContext(layout.nodes, layout.edges, new Set(await c.getPinnedNodeIds()));
1202
1288
  return {
1203
1289
  contents: [
1204
1290
  {
@@ -1222,13 +1308,13 @@ export async function startMcpServer(): Promise<void> {
1222
1308
  mimeType: 'text/plain',
1223
1309
  },
1224
1310
  async () => {
1225
- await ensureCanvas();
1311
+ const c = await ensureCanvas();
1226
1312
  return {
1227
1313
  contents: [
1228
1314
  {
1229
1315
  uri: 'canvas://history',
1230
1316
  mimeType: 'text/plain',
1231
- text: mutationHistory.toHumanReadable(),
1317
+ text: (await c.getHistory()).text,
1232
1318
  },
1233
1319
  ],
1234
1320
  };
@@ -1247,8 +1333,8 @@ export async function startMcpServer(): Promise<void> {
1247
1333
  mimeType: 'application/json',
1248
1334
  },
1249
1335
  async () => {
1250
- await ensureCanvas();
1251
- const summary = buildCodeGraphSummary();
1336
+ const c = await ensureCanvas();
1337
+ const summary = (await c.getCodeGraph()).summary;
1252
1338
  return {
1253
1339
  contents: [
1254
1340
  {
@@ -1339,9 +1425,9 @@ export async function startMcpServer(): Promise<void> {
1339
1425
  },
1340
1426
  async (input) => {
1341
1427
  const c = await ensureCanvas();
1342
- const id = c.createGroup(input);
1428
+ const id = await c.createGroup(input);
1343
1429
  return {
1344
- content: [{ type: 'text', text: JSON.stringify(createdNodePayload(c, id), null, 2) }],
1430
+ content: [{ type: 'text', text: JSON.stringify(await createdNodePayload(c, id), null, 2) }],
1345
1431
  };
1346
1432
  },
1347
1433
  );
@@ -1357,7 +1443,7 @@ export async function startMcpServer(): Promise<void> {
1357
1443
  },
1358
1444
  async ({ groupId, childIds, childLayout }) => {
1359
1445
  const c = await ensureCanvas();
1360
- const ok = c.groupNodes(groupId, childIds, childLayout ? { childLayout } : undefined);
1446
+ const ok = await c.groupNodes(groupId, childIds, childLayout ? { childLayout } : undefined);
1361
1447
  if (!ok) {
1362
1448
  return { content: [{ type: 'text', text: 'Group not found or no valid children.' }], isError: true };
1363
1449
  }
@@ -1392,7 +1478,7 @@ export async function startMcpServer(): Promise<void> {
1392
1478
  async () => {
1393
1479
  const c = await ensureCanvas();
1394
1480
  return {
1395
- content: [{ type: 'text', text: JSON.stringify(c.validate(), null, 2) }],
1481
+ content: [{ type: 'text', text: JSON.stringify(await c.validate(), null, 2) }],
1396
1482
  };
1397
1483
  },
1398
1484
  );
@@ -1406,7 +1492,7 @@ export async function startMcpServer(): Promise<void> {
1406
1492
  },
1407
1493
  async ({ groupId }) => {
1408
1494
  const c = await ensureCanvas();
1409
- const ok = c.ungroupNodes(groupId);
1495
+ const ok = await c.ungroupNodes(groupId);
1410
1496
  if (!ok) {
1411
1497
  return { content: [{ type: 'text', text: 'Group not found or already empty.' }], isError: true };
1412
1498
  }
@@ -1425,9 +1511,7 @@ export async function startMcpServer(): Promise<void> {
1425
1511
  },
1426
1512
  async ({ nodeIds, mode }) => {
1427
1513
  const c = await ensureCanvas();
1428
- const result = c.setContextPins(nodeIds, mode ?? 'set');
1429
-
1430
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1514
+ const result = await c.setContextPins(nodeIds, mode ?? 'set');
1431
1515
 
1432
1516
  return {
1433
1517
  content: [{
@@ -1450,7 +1534,7 @@ export async function startMcpServer(): Promise<void> {
1450
1534
  },
1451
1535
  async (input) => {
1452
1536
  const c = await ensureCanvas();
1453
- const snapshot = c.saveSnapshot(input.name);
1537
+ const snapshot = await c.saveSnapshot(input.name);
1454
1538
  if (!snapshot) {
1455
1539
  return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'Failed to save snapshot' }) }] };
1456
1540
  }
@@ -1466,7 +1550,7 @@ export async function startMcpServer(): Promise<void> {
1466
1550
  async () => {
1467
1551
  const c = await ensureCanvas();
1468
1552
  return {
1469
- content: [{ type: 'text', text: JSON.stringify({ snapshots: c.listSnapshots() }, null, 2) }],
1553
+ content: [{ type: 'text', text: JSON.stringify({ snapshots: await c.listSnapshots() }, null, 2) }],
1470
1554
  };
1471
1555
  },
1472
1556
  );
@@ -1484,9 +1568,8 @@ export async function startMcpServer(): Promise<void> {
1484
1568
  if (!result.ok) {
1485
1569
  return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'Snapshot not found' }) }] };
1486
1570
  }
1487
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1488
1571
  return {
1489
- content: [{ type: 'text', text: JSON.stringify({ ok: true, layout: serializeCanvasLayout(canvasState.getLayout()) }) }],
1572
+ content: [{ type: 'text', text: JSON.stringify({ ok: true, layout: serializeCanvasLayout(await c.getLayout()) }) }],
1490
1573
  };
1491
1574
  },
1492
1575
  );
@@ -1500,7 +1583,7 @@ export async function startMcpServer(): Promise<void> {
1500
1583
  },
1501
1584
  async ({ id }) => {
1502
1585
  const c = await ensureCanvas();
1503
- const result = c.deleteSnapshot(id);
1586
+ const result = await c.deleteSnapshot(id);
1504
1587
  if (!result.ok) {
1505
1588
  return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'Snapshot not found' }) }], isError: true };
1506
1589
  }
@@ -1510,24 +1593,6 @@ export async function startMcpServer(): Promise<void> {
1510
1593
  },
1511
1594
  );
1512
1595
 
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
1596
  // Connect via stdio
1532
1597
  const transport = new StdioServerTransport();
1533
1598
  await server.connect(transport);
@@ -97,7 +97,7 @@ export class PmxCanvas extends EventEmitter {
97
97
  open?: boolean;
98
98
  automationWebView?: boolean | CanvasAutomationWebViewOptions;
99
99
  }): Promise<void> {
100
- const base = startCanvasServer({ port: this._port });
100
+ const base = startCanvasServer({ port: this._port, allowPortFallback: false });
101
101
  if (!base) {
102
102
  throw new Error(`Failed to start canvas server on port ${this._port}`);
103
103
  }
@@ -605,7 +605,7 @@ export class PmxCanvas extends EventEmitter {
605
605
  async startAutomationWebView(
606
606
  options: CanvasAutomationWebViewOptions = {},
607
607
  ): Promise<CanvasAutomationWebViewStatus> {
608
- const base = this._server ?? startCanvasServer({ port: this._port });
608
+ const base = this._server ?? startCanvasServer({ port: this._port, allowPortFallback: false });
609
609
  if (!base) {
610
610
  throw new Error(`Failed to start canvas server on port ${this._port}`);
611
611
  }
@@ -3727,6 +3727,7 @@ export interface CanvasServerOptions {
3727
3727
  port?: number;
3728
3728
  workspaceRoot?: string;
3729
3729
  autoOpenBrowser?: boolean;
3730
+ allowPortFallback?: boolean;
3730
3731
  }
3731
3732
 
3732
3733
  export function startCanvasServer(options: CanvasServerOptions = {}): string | null {
@@ -3764,7 +3765,9 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3764
3765
  rotatePrimaryWorkbenchSessionIfNeeded();
3765
3766
 
3766
3767
  const preferredPort = options.port ?? Number(process.env.PMX_WEB_CANVAS_PORT ?? DEFAULT_PORT);
3767
- const portCandidates = buildPortCandidates(preferredPort);
3768
+ const portCandidates = options.allowPortFallback === false
3769
+ ? [preferredPort > 0 ? Math.floor(preferredPort) : DEFAULT_PORT]
3770
+ : buildPortCandidates(preferredPort);
3768
3771
 
3769
3772
  for (const portCandidate of portCandidates) {
3770
3773
  try {