pmx-canvas 0.1.12 → 0.1.14

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 (32) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/dist/canvas/index.js +42 -42
  3. package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -3
  4. package/dist/types/client/nodes/McpAppNode.d.ts +2 -1
  5. package/dist/types/client/nodes/trace-model.d.ts +9 -0
  6. package/dist/types/client/state/canvas-store.d.ts +2 -0
  7. package/dist/types/mcp/canvas-access.d.ts +1 -0
  8. package/dist/types/server/canvas-operations.d.ts +8 -0
  9. package/dist/types/server/diagram-presets.d.ts +4 -0
  10. package/dist/types/server/index.d.ts +8 -0
  11. package/dist/types/server/mcp-app-runtime.d.ts +1 -0
  12. package/dist/types/server/web-artifacts.d.ts +1 -0
  13. package/package.json +1 -1
  14. package/skills/web-artifacts-builder/scripts/init-artifact.sh +9 -8
  15. package/src/cli/agent.ts +15 -1
  16. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -3
  17. package/src/client/nodes/ExtAppFrame.tsx +10 -35
  18. package/src/client/nodes/McpAppNode.tsx +2 -2
  19. package/src/client/nodes/TraceNode.tsx +2 -6
  20. package/src/client/nodes/trace-model.ts +19 -0
  21. package/src/client/state/canvas-store.ts +5 -2
  22. package/src/client/state/sse-bridge.ts +3 -1
  23. package/src/mcp/canvas-access.ts +28 -3
  24. package/src/mcp/server.ts +51 -9
  25. package/src/server/canvas-operations.ts +36 -0
  26. package/src/server/canvas-schema.ts +11 -0
  27. package/src/server/diagram-presets.ts +44 -46
  28. package/src/server/index.ts +31 -4
  29. package/src/server/mcp-app-runtime.ts +15 -5
  30. package/src/server/server.ts +96 -50
  31. package/src/server/web-artifacts/scripts/init-artifact.sh +9 -8
  32. package/src/server/web-artifacts.ts +14 -1
package/src/mcp/server.ts CHANGED
@@ -25,7 +25,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
25
25
  import { isAbsolute, relative, resolve } from 'node:path';
26
26
  import { z } from 'zod';
27
27
  import { canvasState, describeCanvasSchema, validateStructuredCanvasPayload } from '../server/index.js';
28
- import { createCanvasAccess, type CanvasAccess } from './canvas-access.js';
28
+ import { createCanvasAccess, refreshCanvasAccess, type CanvasAccess } from './canvas-access.js';
29
29
  import { serializeNodeForAgentContext } from '../server/agent-context.js';
30
30
  import { wrapCanvasAutomationScript } from '../server/server.js';
31
31
  import { buildSpatialContext, findNeighborhoods } from '../server/spatial-analysis.js';
@@ -34,7 +34,8 @@ import { listBundledSkills, readBundledSkill } from '../server/bundled-skills.js
34
34
 
35
35
  let canvas: CanvasAccess | null = null;
36
36
  let resourceNotificationServer: McpServer | null = null;
37
- let resourceNotificationsStarted = false;
37
+ let localResourceNotificationsStarted = false;
38
+ let remoteResourceNotificationsBaseUrl: string | null = null;
38
39
 
39
40
  const jsonRenderSpecSchema = z.union([
40
41
  z.object({
@@ -78,6 +79,8 @@ function safeWorkspacePath(pathLike: string): string {
78
79
  async function ensureCanvas(): Promise<CanvasAccess> {
79
80
  if (!canvas) {
80
81
  canvas = await createCanvasAccess();
82
+ } else {
83
+ canvas = await refreshCanvasAccess(canvas);
81
84
  }
82
85
  startResourceNotifications(canvas);
83
86
  return canvas;
@@ -139,16 +142,20 @@ async function watchRemoteCanvasEvents(baseUrl: string): Promise<void> {
139
142
  }
140
143
 
141
144
  function startResourceNotifications(c: CanvasAccess): void {
142
- if (resourceNotificationsStarted) return;
143
145
  const server = resourceNotificationServer;
144
146
  if (!server) return;
145
- resourceNotificationsStarted = true;
146
147
 
147
148
  if (c.remoteBaseUrl) {
148
- void watchRemoteCanvasEvents(c.remoteBaseUrl);
149
+ if (remoteResourceNotificationsBaseUrl !== c.remoteBaseUrl) {
150
+ remoteResourceNotificationsBaseUrl = c.remoteBaseUrl;
151
+ void watchRemoteCanvasEvents(c.remoteBaseUrl);
152
+ }
149
153
  return;
150
154
  }
151
155
 
156
+ if (localResourceNotificationsStarted) return;
157
+ localResourceNotificationsStarted = true;
158
+
152
159
  canvasState.onChange((type) => {
153
160
  sendCanvasResourceNotifications(type);
154
161
  });
@@ -184,6 +191,19 @@ function buildSummaryFromLayout(layout: Awaited<ReturnType<CanvasAccess['getLayo
184
191
  };
185
192
  }
186
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
+
187
207
  export async function startMcpServer(): Promise<void> {
188
208
  const server = new McpServer({
189
209
  name: 'pmx-canvas',
@@ -241,6 +261,12 @@ export async function startMcpServer(): Promise<void> {
241
261
  width: z.number().optional().describe('Width in pixels (default: 720)'),
242
262
  height: z.number().optional().describe('Height in pixels (default: 600)'),
243
263
  strictSize: z.boolean().optional().describe('Keep explicit width/height fixed and scroll overflowing content instead of browser auto-fitting'),
264
+ toolName: z.string().optional().describe('Trace node tool or operation label'),
265
+ category: z.string().optional().describe('Trace node category: mcp, file, subagent, or other'),
266
+ status: z.string().optional().describe('Trace node status: running, success, or failed'),
267
+ duration: z.string().optional().describe('Trace node duration badge text'),
268
+ resultSummary: z.string().optional().describe('Trace node result summary'),
269
+ error: z.string().optional().describe('Trace node error message'),
244
270
  },
245
271
  async (input) => {
246
272
  const c = await ensureCanvas();
@@ -283,11 +309,13 @@ export async function startMcpServer(): Promise<void> {
283
309
  toolName: z.string().describe('Tool name on the external MCP server'),
284
310
  serverName: z.string().optional().describe('Optional display name for the external MCP server'),
285
311
  toolArguments: z.record(z.string(), z.unknown()).optional().describe('Arguments passed to the external tool call'),
312
+ nodeId: z.string().optional().describe('Existing mcp-app node ID to update in place instead of creating a new node.'),
286
313
  title: z.string().optional().describe('Optional canvas node title override'),
287
314
  x: z.number().optional().describe('X position (auto-placed if omitted)'),
288
315
  y: z.number().optional().describe('Y position (auto-placed if omitted)'),
289
316
  width: z.number().optional().describe('Width in pixels (default: 720)'),
290
317
  height: z.number().optional().describe('Height in pixels (default: 500)'),
318
+ timeoutMs: z.number().optional().describe('Optional MCP request timeout in milliseconds for cold external app servers'),
291
319
  transport: z.union([
292
320
  z.object({
293
321
  type: z.literal('stdio'),
@@ -311,11 +339,13 @@ export async function startMcpServer(): Promise<void> {
311
339
  toolName: input.toolName,
312
340
  ...(typeof input.serverName === 'string' ? { serverName: input.serverName } : {}),
313
341
  ...(input.toolArguments ? { toolArguments: input.toolArguments } : {}),
342
+ ...(typeof input.nodeId === 'string' ? { nodeId: input.nodeId } : {}),
314
343
  ...(typeof input.title === 'string' ? { title: input.title } : {}),
315
344
  ...(typeof input.x === 'number' ? { x: input.x } : {}),
316
345
  ...(typeof input.y === 'number' ? { y: input.y } : {}),
317
346
  ...(typeof input.width === 'number' ? { width: input.width } : {}),
318
347
  ...(typeof input.height === 'number' ? { height: input.height } : {}),
348
+ ...(typeof input.timeoutMs === 'number' ? { timeoutMs: input.timeoutMs } : {}),
319
349
  });
320
350
  return {
321
351
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
@@ -337,27 +367,37 @@ export async function startMcpServer(): Promise<void> {
337
367
  z.string().describe('JSON array string of Excalidraw elements'),
338
368
  z.array(z.record(z.string(), z.unknown())).describe('Array of Excalidraw elements'),
339
369
  ]).describe('Excalidraw elements to render. See https://github.com/excalidraw/excalidraw-mcp for the element format.'),
370
+ nodeId: z.string().optional().describe('Existing Excalidraw mcp-app node ID to update in place instead of creating a new node.'),
340
371
  title: z.string().optional().describe('Optional canvas node title override'),
341
372
  x: z.number().optional().describe('X position (auto-placed if omitted)'),
342
373
  y: z.number().optional().describe('Y position (auto-placed if omitted)'),
343
374
  width: z.number().optional().describe('Width in pixels (default: 720)'),
344
375
  height: z.number().optional().describe('Height in pixels (default: 500)'),
376
+ timeoutMs: z.number().optional().describe('Optional MCP request timeout in milliseconds for Excalidraw cold starts. Client-side MCP hosts may still enforce their own total request timeout.'),
345
377
  },
346
- async (input) => {
378
+ async (input, extra) => {
347
379
  const c = await ensureCanvas();
348
380
  try {
349
381
  const result = await c.addDiagram({
350
382
  elements: input.elements,
383
+ ...(typeof input.nodeId === 'string' ? { nodeId: input.nodeId } : {}),
351
384
  ...(typeof input.title === 'string' ? { title: input.title } : {}),
352
385
  ...(typeof input.x === 'number' ? { x: input.x } : {}),
353
386
  ...(typeof input.y === 'number' ? { y: input.y } : {}),
354
387
  ...(typeof input.width === 'number' ? { width: input.width } : {}),
355
388
  ...(typeof input.height === 'number' ? { height: input.height } : {}),
389
+ ...(typeof input.timeoutMs === 'number' ? { timeoutMs: input.timeoutMs } : {}),
356
390
  });
357
391
  return {
358
392
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
359
393
  };
360
394
  } catch (error) {
395
+ if (extra.signal.aborted) {
396
+ return {
397
+ content: [{ type: 'text', text: 'canvas_add_diagram was cancelled by the MCP client before Excalidraw finished. Retry with a higher client request timeout and pass timeoutMs to PMX Canvas for the downstream Excalidraw call.' }],
398
+ isError: true,
399
+ };
400
+ }
361
401
  return {
362
402
  content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
363
403
  isError: true,
@@ -463,7 +503,7 @@ export async function startMcpServer(): Promise<void> {
463
503
  // ── canvas_build_web_artifact ───────────────────────────────
464
504
  server.tool(
465
505
  'canvas_build_web_artifact',
466
- '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.',
506
+ '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.',
467
507
  {
468
508
  title: z.string().describe('Artifact title used for default project and output paths'),
469
509
  appTsx: z.string().describe('Contents for src/App.tsx'),
@@ -514,6 +554,7 @@ export async function startMcpServer(): Promise<void> {
514
554
  bytes: result.fileSize,
515
555
  projectPath: result.projectPath,
516
556
  openedInCanvas: result.openedInCanvas,
557
+ completedAt: result.completedAt,
517
558
  // `id` only present when a canvas node was actually created.
518
559
  // See the matching block in src/server/server.ts handleCanvasBuildWebArtifact.
519
560
  ...(typeof result.nodeId === 'string' ? { id: result.nodeId } : {}),
@@ -1453,7 +1494,7 @@ export async function startMcpServer(): Promise<void> {
1453
1494
 
1454
1495
  server.tool(
1455
1496
  'canvas_batch',
1456
- '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.',
1497
+ '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.',
1457
1498
  {
1458
1499
  operations: z.array(z.object({
1459
1500
  op: z.string().describe('Operation name, e.g. "node.add" or "edge.add"'),
@@ -1568,8 +1609,9 @@ export async function startMcpServer(): Promise<void> {
1568
1609
  if (!result.ok) {
1569
1610
  return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'Snapshot not found' }) }] };
1570
1611
  }
1612
+ const layout = await c.getLayout();
1571
1613
  return {
1572
- content: [{ type: 'text', text: JSON.stringify({ ok: true, layout: serializeCanvasLayout(await c.getLayout()) }) }],
1614
+ content: [{ type: 'text', text: JSON.stringify({ ok: true, restored: input.id, summary: buildSnapshotRestoreSummary(layout) }, null, 2) }],
1573
1615
  };
1574
1616
  },
1575
1617
  );
@@ -76,6 +76,12 @@ interface CanvasAddNodeInput {
76
76
  title?: string;
77
77
  content?: string;
78
78
  data?: Record<string, unknown>;
79
+ toolName?: string;
80
+ category?: string;
81
+ status?: string;
82
+ duration?: string;
83
+ resultSummary?: string;
84
+ error?: string;
79
85
  x?: number;
80
86
  y?: number;
81
87
  width?: number;
@@ -109,6 +115,7 @@ interface CanvasNodeLookupInput {
109
115
  }
110
116
 
111
117
  const MAX_CONTEXT_PINS = 20;
118
+ const TRACE_DATA_FIELDS = ['toolName', 'category', 'status', 'duration', 'resultSummary', 'error'] as const;
112
119
 
113
120
  function isRecord(value: unknown): value is Record<string, unknown> {
114
121
  return value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -757,10 +764,23 @@ function buildWebpageNodeData(input: CanvasAddNodeInput): Record<string, unknown
757
764
  };
758
765
  }
759
766
 
767
+ function normalizeTraceNodeData(input: CanvasAddNodeInput): Record<string, unknown> {
768
+ const data: Record<string, unknown> = { ...(input.data ?? {}) };
769
+ for (const field of TRACE_DATA_FIELDS) {
770
+ const value = input[field];
771
+ if (typeof value === 'string') data[field] = value;
772
+ }
773
+ if (input.title) data.title = input.title;
774
+ if (input.content) data.content = input.content;
775
+ if (input.strictSize) data.strictSize = true;
776
+ return data;
777
+ }
778
+
760
779
  function buildNodeData(input: CanvasAddNodeInput): Record<string, unknown> {
761
780
  if (input.type === 'file') return buildFileNodeData(input);
762
781
  if (input.type === 'image') return buildImageNodeData(input);
763
782
  if (input.type === 'webpage') return buildWebpageNodeData(input);
783
+ if (input.type === 'trace') return normalizeTraceNodeData(input);
764
784
  return {
765
785
  ...(input.data ?? {}),
766
786
  ...(input.title ? { title: input.title } : {}),
@@ -769,6 +789,21 @@ function buildNodeData(input: CanvasAddNodeInput): Record<string, unknown> {
769
789
  };
770
790
  }
771
791
 
792
+ export function mergeTraceNodeDataFields(
793
+ base: Record<string, unknown>,
794
+ input: Record<string, unknown>,
795
+ ): Record<string, unknown> {
796
+ const next: Record<string, unknown> = { ...base };
797
+ for (const field of TRACE_DATA_FIELDS) {
798
+ if (typeof input[field] === 'string') next[field] = input[field];
799
+ }
800
+ return next;
801
+ }
802
+
803
+ export function hasTraceNodeDataFields(input: Record<string, unknown>): boolean {
804
+ return TRACE_DATA_FIELDS.some((field) => typeof input[field] === 'string');
805
+ }
806
+
772
807
  export function scheduleCodeGraphRecompute(onComplete?: () => void): void {
773
808
  if (codeGraphTimer) clearTimeout(codeGraphTimer);
774
809
  codeGraphTimer = setTimeout(() => {
@@ -1373,6 +1408,7 @@ function resolveBatchRefs(value: unknown, refs: Record<string, unknown>): unknow
1373
1408
  if (typeof value === 'string' && value.startsWith('$')) {
1374
1409
  const path = value.slice(1).split('.');
1375
1410
  let current: unknown = refs[path[0] ?? ''];
1411
+ if (path.length === 1 && isPlainRecord(current) && typeof current.id === 'string') return current.id;
1376
1412
  for (const segment of path.slice(1)) {
1377
1413
  if (!isPlainRecord(current) && !Array.isArray(current)) return undefined;
1378
1414
  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
 
@@ -43,11 +43,13 @@ export const EXCALIDRAW_MCP_TRANSPORT: ExternalMcpTransportConfig = {
43
43
 
44
44
  export interface DiagramPresetOpenInput {
45
45
  elements: unknown;
46
+ nodeId?: string;
46
47
  title?: string;
47
48
  x?: number;
48
49
  y?: number;
49
50
  width?: number;
50
51
  height?: number;
52
+ timeoutMs?: number;
51
53
  }
52
54
 
53
55
  export interface ExcalidrawOpenMcpAppInput {
@@ -55,11 +57,13 @@ export interface ExcalidrawOpenMcpAppInput {
55
57
  toolName: string;
56
58
  serverName: string;
57
59
  toolArguments: { elements: string };
60
+ nodeId?: string;
58
61
  title?: string;
59
62
  x?: number;
60
63
  y?: number;
61
64
  width?: number;
62
65
  height?: number;
66
+ timeoutMs?: number;
63
67
  }
64
68
 
65
69
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -111,6 +115,21 @@ function finiteNumber(value: unknown): number | null {
111
115
  return typeof value === 'number' && Number.isFinite(value) ? value : null;
112
116
  }
113
117
 
118
+ function positiveFiniteNumber(value: unknown): number | null {
119
+ const num = finiteNumber(value);
120
+ return num !== null && num > 0 ? num : null;
121
+ }
122
+
123
+ function labelFromBoundText(element: Record<string, unknown>): Record<string, unknown> | null {
124
+ const text = typeof element.text === 'string' ? element.text : '';
125
+ if (text.trim().length === 0) return null;
126
+ const fontSize = positiveFiniteNumber(element.fontSize);
127
+ return {
128
+ text,
129
+ ...(fontSize ? { fontSize } : {}),
130
+ };
131
+ }
132
+
114
133
  function elementHasCameraUpdate(elements: Array<Record<string, unknown>>): boolean {
115
134
  return elements.some((element) => element.type === 'cameraUpdate');
116
135
  }
@@ -134,61 +153,38 @@ function normalizeExcalidrawBoundText(elements: Array<Record<string, unknown>>):
134
153
  }
135
154
 
136
155
  let changed = false;
137
- const boundElementIdsByContainer = new Map<string, Set<string>>();
138
- const labelByContainer = new Map<string, Record<string, unknown>>();
139
- const textIdsConvertedToLabels = new Set<string>();
156
+ const labelsByContainer = new Map<string, Record<string, unknown>>();
140
157
 
141
158
  for (const element of elements) {
142
159
  if (element.type !== 'text' || typeof element.id !== 'string' || typeof element.containerId !== 'string') continue;
143
160
  const container = elementsById.get(element.containerId);
144
- if (!container) continue;
145
- const ids = boundElementIdsByContainer.get(element.containerId) ?? new Set<string>();
146
- ids.add(element.id);
147
- boundElementIdsByContainer.set(element.containerId, ids);
148
- const text = typeof element.text === 'string' ? element.text.trim() : '';
149
- if (!isRecord(container.label) && text.length > 0) {
150
- labelByContainer.set(element.containerId, {
151
- text,
152
- ...(typeof element.fontSize === 'number' && Number.isFinite(element.fontSize) ? { fontSize: element.fontSize } : {}),
153
- });
154
- textIdsConvertedToLabels.add(element.id);
155
- }
161
+ if (!container || (container.type !== 'rectangle' && container.type !== 'ellipse' && container.type !== 'diamond')) continue;
162
+ const label = labelFromBoundText(element);
163
+ if (!label) continue;
164
+ labelsByContainer.set(element.containerId, label);
156
165
  }
157
166
 
158
- const normalized = elements.flatMap<Record<string, unknown>>((element) => {
159
- if (typeof element.id === 'string' && textIdsConvertedToLabels.has(element.id)) {
160
- changed = true;
161
- return [];
167
+ const normalized: Array<Record<string, unknown>> = [];
168
+ for (const element of elements) {
169
+ if (element.type === 'text' && typeof element.containerId === 'string') {
170
+ if (labelsByContainer.has(element.containerId)) {
171
+ changed = true;
172
+ continue;
173
+ }
174
+ }
175
+
176
+ if (typeof element.id !== 'string' || !labelsByContainer.has(element.id)) {
177
+ normalized.push(element);
178
+ continue;
162
179
  }
163
- if (typeof element.id !== 'string') return element;
164
- const boundTextIds = boundElementIdsByContainer.get(element.id);
165
- const label = labelByContainer.get(element.id);
166
- if ((!boundTextIds || boundTextIds.size === 0) && !label) return element;
167
-
168
- const existing = Array.isArray(element.boundElements)
169
- ? element.boundElements.filter(isRecord)
170
- : [];
171
- const remainingExisting = existing.filter((boundElement) => {
172
- return !(boundElement.type === 'text' && typeof boundElement.id === 'string' && textIdsConvertedToLabels.has(boundElement.id));
173
- });
174
- const existingTextIds = new Set(
175
- remainingExisting
176
- .filter((boundElement) => boundElement.type === 'text' && typeof boundElement.id === 'string')
177
- .map((boundElement) => boundElement.id as string),
178
- );
179
- const missing = [...(boundTextIds ?? [])]
180
- .filter((id) => !textIdsConvertedToLabels.has(id) && !existingTextIds.has(id));
181
- if (missing.length === 0 && !label && remainingExisting.length === existing.length) return element;
182
180
 
183
181
  changed = true;
184
- return {
185
- ...element,
186
- ...(label ? { label } : {}),
187
- ...(remainingExisting.length > 0 || missing.length > 0
188
- ? { boundElements: [...remainingExisting, ...missing.map((id) => ({ type: 'text', id }))] }
189
- : {}),
190
- };
191
- });
182
+ const { boundElements: _boundElements, ...container } = element;
183
+ normalized.push({
184
+ ...container,
185
+ label: labelsByContainer.get(element.id),
186
+ });
187
+ }
192
188
 
193
189
  return changed ? normalized : elements;
194
190
  }
@@ -355,10 +351,12 @@ export function buildExcalidrawOpenMcpAppInput(input: DiagramPresetOpenInput): E
355
351
  serverName: EXCALIDRAW_SERVER_NAME,
356
352
  toolArguments: { elements },
357
353
  };
354
+ if (typeof input.nodeId === 'string' && input.nodeId.trim().length > 0) out.nodeId = input.nodeId.trim();
358
355
  if (typeof input.title === 'string' && input.title.trim().length > 0) out.title = input.title.trim();
359
356
  if (typeof input.x === 'number' && Number.isFinite(input.x)) out.x = input.x;
360
357
  if (typeof input.y === 'number' && Number.isFinite(input.y)) out.y = input.y;
361
358
  if (typeof input.width === 'number' && Number.isFinite(input.width)) out.width = input.width;
362
359
  if (typeof input.height === 'number' && Number.isFinite(input.height)) out.height = input.height;
360
+ if (typeof input.timeoutMs === 'number' && Number.isFinite(input.timeoutMs) && input.timeoutMs > 0) out.timeoutMs = input.timeoutMs;
363
361
  return out;
364
362
  }
@@ -33,6 +33,8 @@ import {
33
33
  ungroupCanvasNodes,
34
34
  validateCanvasNodePatch,
35
35
  hasStructuredNodeUpdateFields,
36
+ hasTraceNodeDataFields,
37
+ mergeTraceNodeDataFields,
36
38
  } from './canvas-operations.js';
37
39
  import { validateCanvasLayout } from './canvas-validation.js';
38
40
  import { describeCanvasSchema, validateStructuredCanvasPayload } from './canvas-schema.js';
@@ -158,6 +160,12 @@ export class PmxCanvas extends EventEmitter {
158
160
  type: CanvasNodeState['type'];
159
161
  title?: string;
160
162
  content?: string;
163
+ toolName?: string;
164
+ category?: string;
165
+ status?: string;
166
+ duration?: string;
167
+ resultSummary?: string;
168
+ error?: string;
161
169
  x?: number;
162
170
  y?: number;
163
171
  width?: number;
@@ -243,9 +251,10 @@ export class PmxCanvas extends EventEmitter {
243
251
  patch.title !== undefined ||
244
252
  patch.content !== undefined ||
245
253
  typeof patch.arrangeLocked === 'boolean' ||
246
- typeof patch.strictSize === 'boolean'
254
+ typeof patch.strictSize === 'boolean' ||
255
+ (existing.type === 'trace' && hasTraceNodeDataFields(patch))
247
256
  ) {
248
- resolvedPatch.data = {
257
+ const nextData = {
249
258
  ...existing.data,
250
259
  ...(patch.data && typeof patch.data === 'object' && !Array.isArray(patch.data) ? patch.data : {}),
251
260
  ...(typeof patch.title === 'string' ? { title: patch.title } : {}),
@@ -253,6 +262,9 @@ export class PmxCanvas extends EventEmitter {
253
262
  ...(typeof patch.arrangeLocked === 'boolean' ? { arrangeLocked: patch.arrangeLocked } : {}),
254
263
  ...(typeof patch.strictSize === 'boolean' ? { strictSize: patch.strictSize } : {}),
255
264
  };
265
+ resolvedPatch.data = existing.type === 'trace'
266
+ ? mergeTraceNodeDataFields(nextData, patch)
267
+ : nextData;
256
268
  }
257
269
 
258
270
  const error = validateCanvasNodePatch({
@@ -520,21 +532,36 @@ export class PmxCanvas extends EventEmitter {
520
532
  transport: ExternalMcpTransportConfig;
521
533
  toolName: string;
522
534
  toolArguments?: Record<string, unknown>;
535
+ nodeId?: string;
523
536
  serverName?: string;
524
537
  title?: string;
525
538
  x?: number;
526
539
  y?: number;
527
540
  width?: number;
528
541
  height?: number;
542
+ timeoutMs?: number;
529
543
  }): Promise<{ ok: true; id?: string; nodeId: string | null; toolCallId: string; sessionId: string; resourceUri: string }> {
544
+ const targetNode = input.nodeId ? canvasState.getNode(input.nodeId) : undefined;
545
+ if (input.nodeId && !targetNode) {
546
+ throw new Error(`Node "${input.nodeId}" not found.`);
547
+ }
548
+ if (targetNode && (targetNode.type !== 'mcp-app' || targetNode.data.mode !== 'ext-app')) {
549
+ throw new Error(`Node "${input.nodeId}" is not an external app node.`);
550
+ }
551
+
530
552
  const opened = await openExternalMcpApp({
531
553
  transport: input.transport,
532
554
  toolName: input.toolName,
533
555
  ...(input.toolArguments ? { toolArguments: input.toolArguments } : {}),
534
556
  ...(input.serverName ? { serverName: input.serverName } : {}),
557
+ ...(typeof input.timeoutMs === 'number' ? { timeoutMs: input.timeoutMs } : {}),
535
558
  });
536
559
  const toolCallId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
537
- const nodeIdSeed = `ext-app-${toolCallId}`;
560
+ const previousSessionId = targetNode?.data.appSessionId;
561
+ if (typeof previousSessionId === 'string' && previousSessionId.trim().length > 0) {
562
+ closeMcpAppSession(previousSessionId);
563
+ }
564
+ const nodeIdSeed = input.nodeId ?? `ext-app-${toolCallId}`;
538
565
  const toolResult = isExcalidrawCreateView(opened.serverName, opened.toolName)
539
566
  ? ensureExcalidrawCheckpointId(opened.toolResult, nodeIdSeed)
540
567
  : opened.toolResult;
@@ -566,7 +593,7 @@ export class PmxCanvas extends EventEmitter {
566
593
  success: toolResult.isError !== true,
567
594
  result: toolResult,
568
595
  });
569
- const nodeId = this.findCanvasExtAppNodeId(toolCallId);
596
+ const nodeId = input.nodeId ?? this.findCanvasExtAppNodeId(toolCallId);
570
597
  return {
571
598
  ok: true,
572
599
  ...(nodeId ? { id: nodeId } : {}),
@@ -12,6 +12,7 @@ import type {
12
12
  TextResourceContents,
13
13
  Tool,
14
14
  } from '@modelcontextprotocol/sdk/types.js';
15
+ import type { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js';
15
16
  import {
16
17
  EXTENSION_ID,
17
18
  RESOURCE_MIME_TYPE,
@@ -47,6 +48,7 @@ export interface OpenMcpAppInput {
47
48
  toolName: string;
48
49
  toolArguments?: Record<string, unknown>;
49
50
  serverName?: string;
51
+ timeoutMs?: number;
50
52
  }
51
53
 
52
54
  export interface OpenMcpAppResult {
@@ -184,6 +186,12 @@ function normalizeServerName(raw: string | undefined, transport: ExternalMcpTran
184
186
  return trimmed.length > 0 ? trimmed : defaultServerName(transport);
185
187
  }
186
188
 
189
+ function requestOptions(timeoutMs: number | undefined): RequestOptions | undefined {
190
+ return typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs > 0
191
+ ? { timeout: timeoutMs }
192
+ : undefined;
193
+ }
194
+
187
195
  function buildTransport(config: ExternalMcpTransportConfig): RuntimeTransport {
188
196
  if (config.type === 'http') {
189
197
  return new StreamableHTTPClientTransport(new URL(config.url), {
@@ -209,15 +217,16 @@ function buildTransport(config: ExternalMcpTransportConfig): RuntimeTransport {
209
217
  async function createSession(
210
218
  transportConfig: ExternalMcpTransportConfig,
211
219
  serverName?: string,
220
+ timeoutMs?: number,
212
221
  ): Promise<McpAppSession> {
213
222
  const transport = buildTransport(transportConfig);
214
223
  const client = new Client(
215
224
  { name: 'pmx-canvas-app-host', version: '0.1.0' },
216
225
  { capabilities: clientCapabilities },
217
226
  );
218
- await client.connect(transport);
227
+ await client.connect(transport, requestOptions(timeoutMs));
219
228
 
220
- const toolList = await client.listTools();
229
+ const toolList = await client.listTools(undefined, requestOptions(timeoutMs));
221
230
  const session: McpAppSession = {
222
231
  id: randomId('mcp-app-session'),
223
232
  serverName: normalizeServerName(serverName, transportConfig),
@@ -350,7 +359,8 @@ function prepareResourceHtml(html: string, meta: McpUiResourceMeta | undefined):
350
359
  }
351
360
 
352
361
  export async function openMcpApp(input: OpenMcpAppInput): Promise<OpenMcpAppResult> {
353
- const session = await createSession(input.transport, input.serverName);
362
+ const options = requestOptions(input.timeoutMs);
363
+ const session = await createSession(input.transport, input.serverName, input.timeoutMs);
354
364
  try {
355
365
  const tool = await findTool(session, input.toolName);
356
366
  const resourceUri = getToolUiResourceUri(tool);
@@ -362,9 +372,9 @@ export async function openMcpApp(input: OpenMcpAppInput): Promise<OpenMcpAppResu
362
372
  const rawToolResult = await session.client.callTool({
363
373
  name: tool.name,
364
374
  arguments: toolInput,
365
- });
375
+ }, undefined, options);
366
376
  const toolResult = normalizeExtAppToolResult({ result: rawToolResult });
367
- const readResult = await session.client.readResource({ uri: resourceUri });
377
+ const readResult = await session.client.readResource({ uri: resourceUri }, options);
368
378
  const resourceMeta = resourceMetaFromReadResult(readResult);
369
379
  const html = prepareResourceHtml(htmlContentFromReadResult(readResult, resourceUri), resourceMeta);
370
380