pmx-canvas 0.1.13 → 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.
@@ -1,7 +1,6 @@
1
1
  import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
2
2
  import { AppBridge } from '@modelcontextprotocol/ext-apps/app-bridge';
3
3
  import type { CanvasNodeState } from '../types';
4
- type IframeLoadTarget = Pick<HTMLIFrameElement, 'addEventListener' | 'removeEventListener' | 'contentDocument'>;
5
4
  type ExtAppBridgeNotifications = Pick<AppBridge, 'sendToolInput' | 'sendToolResult'>;
6
5
  type DisplayMode = 'inline' | 'fullscreen' | 'pip';
7
6
  interface ExtAppHostDimensionsTarget {
@@ -9,7 +8,6 @@ interface ExtAppHostDimensionsTarget {
9
8
  clientHeight?: number;
10
9
  getBoundingClientRect(): Pick<DOMRectReadOnly, 'width' | 'height'>;
11
10
  }
12
- export declare function waitForExtAppFrameLoad(target: IframeLoadTarget): Promise<void>;
13
11
  export declare function getExtAppBridgeInitKey(node: CanvasNodeState, retryKey: number): string;
14
12
  export declare function resolveExtAppDisplayModeRequest(requestedMode: DisplayMode, isExpanded: boolean): {
15
13
  nextMode: DisplayMode;
@@ -26,7 +24,8 @@ export declare function resolveExtAppContainerDimensions(target: ExtAppHostDimen
26
24
  height: number;
27
25
  };
28
26
  export declare function shouldApplyExtAppSizeChange(height: unknown, isExpanded: boolean): height is number;
29
- export declare function ExtAppFrame({ node }: {
27
+ export declare function ExtAppFrame({ node, expanded }: {
30
28
  node: CanvasNodeState;
29
+ expanded?: boolean;
31
30
  }): import("preact/jsx-runtime").JSX.Element;
32
31
  export {};
@@ -1,4 +1,5 @@
1
1
  import type { CanvasNodeState } from '../types';
2
- export declare function McpAppNode({ node }: {
2
+ export declare function McpAppNode({ node, expanded }: {
3
3
  node: CanvasNodeState;
4
+ expanded?: boolean;
4
5
  }): import("preact/src").JSX.Element;
@@ -40,6 +40,12 @@ interface CanvasAddNodeInput {
40
40
  title?: string;
41
41
  content?: string;
42
42
  data?: Record<string, unknown>;
43
+ toolName?: string;
44
+ category?: string;
45
+ status?: string;
46
+ duration?: string;
47
+ resultSummary?: string;
48
+ error?: string;
43
49
  x?: number;
44
50
  y?: number;
45
51
  width?: number;
@@ -106,6 +112,8 @@ export declare function validateCanvasNodePatch(patch: {
106
112
  height: number;
107
113
  };
108
114
  }): string | null;
115
+ export declare function mergeTraceNodeDataFields(base: Record<string, unknown>, input: Record<string, unknown>): Record<string, unknown>;
116
+ export declare function hasTraceNodeDataFields(input: Record<string, unknown>): boolean;
109
117
  export declare function scheduleCodeGraphRecompute(onComplete?: () => void): void;
110
118
  export declare function addCanvasNode(input: CanvasAddNodeInput): {
111
119
  id: string;
@@ -9,11 +9,13 @@ export declare const DEFAULT_EXCALIDRAW_ELEMENTS: ReadonlyArray<Record<string, u
9
9
  export declare const EXCALIDRAW_MCP_TRANSPORT: ExternalMcpTransportConfig;
10
10
  export interface DiagramPresetOpenInput {
11
11
  elements: unknown;
12
+ nodeId?: string;
12
13
  title?: string;
13
14
  x?: number;
14
15
  y?: number;
15
16
  width?: number;
16
17
  height?: number;
18
+ timeoutMs?: number;
17
19
  }
18
20
  export interface ExcalidrawOpenMcpAppInput {
19
21
  transport: ExternalMcpTransportConfig;
@@ -22,11 +24,13 @@ export interface ExcalidrawOpenMcpAppInput {
22
24
  toolArguments: {
23
25
  elements: string;
24
26
  };
27
+ nodeId?: string;
25
28
  title?: string;
26
29
  x?: number;
27
30
  y?: number;
28
31
  width?: number;
29
32
  height?: number;
33
+ timeoutMs?: number;
30
34
  }
31
35
  export declare function inferExcalidrawCameraUpdate(elements: Array<Record<string, unknown>>): Record<string, unknown> | null;
32
36
  export declare function normalizeExcalidrawElements(elements: unknown): string;
@@ -23,6 +23,12 @@ export declare class PmxCanvas extends EventEmitter {
23
23
  type: CanvasNodeState['type'];
24
24
  title?: string;
25
25
  content?: string;
26
+ toolName?: string;
27
+ category?: string;
28
+ status?: string;
29
+ duration?: string;
30
+ resultSummary?: string;
31
+ error?: string;
26
32
  x?: number;
27
33
  y?: number;
28
34
  width?: number;
@@ -198,12 +204,14 @@ export declare class PmxCanvas extends EventEmitter {
198
204
  transport: ExternalMcpTransportConfig;
199
205
  toolName: string;
200
206
  toolArguments?: Record<string, unknown>;
207
+ nodeId?: string;
201
208
  serverName?: string;
202
209
  title?: string;
203
210
  x?: number;
204
211
  y?: number;
205
212
  width?: number;
206
213
  height?: number;
214
+ timeoutMs?: number;
207
215
  }): Promise<{
208
216
  ok: true;
209
217
  id?: string;
@@ -18,6 +18,7 @@ export interface OpenMcpAppInput {
18
18
  toolName: string;
19
19
  toolArguments?: Record<string, unknown>;
20
20
  serverName?: string;
21
+ timeoutMs?: number;
21
22
  }
22
23
  export interface OpenMcpAppResult {
23
24
  sessionId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "Spatial canvas workbench for coding agents — infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
5
5
  "type": "module",
6
6
  "main": "./src/server/index.ts",
package/src/cli/agent.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
 
27
27
  const DEFAULT_PORT = 4313;
28
28
  const defaultConsoleLog = console.log;
29
+ const TRACE_NODE_FIELDS = ['toolName', 'category', 'status', 'duration', 'resultSummary', 'error'] as const;
29
30
 
30
31
  interface CanvasSchemaField {
31
32
  name: string;
@@ -1078,6 +1079,12 @@ cmd('node add', 'Add a node to the canvas', [
1078
1079
  height: 'Use a positive number, e.g. --height 280',
1079
1080
  });
1080
1081
  applyStrictSizeFlags(body, flags);
1082
+ if (type === 'trace') {
1083
+ for (const field of TRACE_NODE_FIELDS) {
1084
+ const value = getStringFlag(flags, field);
1085
+ if (value !== undefined) body[field] = value;
1086
+ }
1087
+ }
1081
1088
 
1082
1089
  // Support --stdin for piping content
1083
1090
  if (flags.stdin) {
@@ -1565,7 +1572,9 @@ cmd('external-app add', 'Create a hosted external app node', [
1565
1572
  title: typeof flags.title === 'string' ? flags.title : 'Excalidraw Diagram',
1566
1573
  elements: DEFAULT_EXCALIDRAW_ELEMENTS,
1567
1574
  };
1568
- const elementsJson = getStringFlag(flags, 'elements-json');
1575
+ const nodeId = getStringFlag(flags, 'node-id', 'nodeId', 'id');
1576
+ if (nodeId) body.nodeId = nodeId;
1577
+ const elementsJson = getStringFlag(flags, 'elements-json', 'elements');
1569
1578
  if (elementsJson !== undefined) body.elements = parseJsonValue(elementsJson, 'Excalidraw elements', 'Use --elements-json \'[{"type":"rectangle","id":"r1","x":0,"y":0,"width":120,"height":80}]\'');
1570
1579
  const elementsFile = getStringFlag(flags, 'elements-file', 'initial-file');
1571
1580
  if (elementsFile) body.elements = parseJsonValue(readFileSync(elementsFile, 'utf-8'), 'Excalidraw elements file', 'Use --elements-file ./scene.excalidraw');
@@ -1575,6 +1584,8 @@ cmd('external-app add', 'Create a hosted external app node', [
1575
1584
  width: 'Use a positive number, e.g. --width 960',
1576
1585
  height: 'Use a positive number, e.g. --height 720',
1577
1586
  });
1587
+ const timeoutMs = optionalPositiveFiniteFlag(flags, 'timeout-ms', 'Use a positive number, e.g. --timeout-ms 120000');
1588
+ if (timeoutMs !== undefined) body.timeoutMs = timeoutMs;
1578
1589
 
1579
1590
  const result = await api('POST', '/api/canvas/diagram', body);
1580
1591
  output(result && typeof result === 'object' && !Array.isArray(result) && 'nodeId' in result && !('id' in result)
@@ -2303,9 +2314,12 @@ function showCommandHelp(name: string): void {
2303
2314
  console.log('\nOptions:');
2304
2315
  console.log(' --kind excalidraw External app kind to create');
2305
2316
  console.log(' --title <title> Node title');
2317
+ console.log(' --node-id <id> Existing Excalidraw app node to update in place');
2318
+ console.log(' --elements <json> Optional Excalidraw elements array JSON');
2306
2319
  console.log(' --elements-json <json> Optional Excalidraw elements array JSON');
2307
2320
  console.log(' --elements-file <path> Optional file containing Excalidraw elements JSON');
2308
2321
  console.log(' --initial-file <path> Alias for --elements-file');
2322
+ console.log(' --timeout-ms <number> Optional downstream MCP timeout for cold starts');
2309
2323
  }
2310
2324
  console.log('');
2311
2325
  }
@@ -26,13 +26,13 @@ function renderContent(node: CanvasNodeState, expanded: boolean) {
26
26
  case 'markdown':
27
27
  return <MarkdownNode node={node} expanded={expanded} />;
28
28
  case 'mcp-app':
29
- return <McpAppNode node={node} />;
29
+ return <McpAppNode node={node} expanded={expanded} />;
30
30
  case 'webpage':
31
31
  return <WebpageNode node={node} expanded={expanded} />;
32
32
  case 'json-render':
33
- return <McpAppNode node={node} />;
33
+ return <McpAppNode node={node} expanded={expanded} />;
34
34
  case 'graph':
35
- return <McpAppNode node={node} />;
35
+ return <McpAppNode node={node} expanded={expanded} />;
36
36
  case 'prompt':
37
37
  return <PromptNode node={node} />;
38
38
  case 'response':
@@ -13,11 +13,6 @@ import type { CanvasNodeState } from '../types';
13
13
 
14
14
  type McpUiTheme = 'light' | 'dark';
15
15
 
16
- type IframeLoadTarget = Pick<
17
- HTMLIFrameElement,
18
- 'addEventListener' | 'removeEventListener' | 'contentDocument'
19
- >;
20
-
21
16
  type ExtAppBridgeNotifications = Pick<AppBridge, 'sendToolInput' | 'sendToolResult'>;
22
17
  type DisplayMode = 'inline' | 'fullscreen' | 'pip';
23
18
  const DEFAULT_EXT_APP_SANDBOX = 'allow-scripts allow-popups allow-popups-to-escape-sandbox';
@@ -43,21 +38,6 @@ async function postJson<T>(url: string, body: Record<string, unknown>): Promise<
43
38
  return json.result as T;
44
39
  }
45
40
 
46
- export function waitForExtAppFrameLoad(target: IframeLoadTarget): Promise<void> {
47
- const readyState = target.contentDocument?.readyState;
48
- if (readyState === 'interactive' || readyState === 'complete') {
49
- return Promise.resolve();
50
- }
51
-
52
- return new Promise<void>((resolve) => {
53
- const onLoad = () => {
54
- target.removeEventListener('load', onLoad);
55
- resolve();
56
- };
57
- target.addEventListener('load', onLoad, { once: true });
58
- });
59
- }
60
-
61
41
  export function getExtAppBridgeInitKey(node: CanvasNodeState, retryKey: number): string {
62
42
  const html = typeof node.data.html === 'string' ? node.data.html : '';
63
43
  const serverName = typeof node.data.serverName === 'string' ? node.data.serverName : '';
@@ -131,7 +111,7 @@ export function shouldApplyExtAppSizeChange(height: unknown, isExpanded: boolean
131
111
  return typeof height === 'number' && Number.isFinite(height) && height > 0 && !isExpanded;
132
112
  }
133
113
 
134
- export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
114
+ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
135
115
  const iframeRef = useRef<HTMLIFrameElement>(null);
136
116
  const bridgeRef = useRef<AppBridge | null>(null);
137
117
  const transportRef = useRef<PostMessageTransport | null>(null);
@@ -164,10 +144,9 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
164
144
  const sessionError = node.data.sessionError as string | undefined;
165
145
  const maxHeight = node.size.height;
166
146
  const nodeId = node.id;
167
- const frameKey = `${node.id}:${retryKey}`;
168
- const bridgeInitKey = getExtAppBridgeInitKey(node, retryKey);
147
+ const frameKey = getExtAppBridgeInitKey(node, retryKey);
169
148
  const toMcpTheme = (theme: string): McpUiTheme => (theme === 'light' ? 'light' : 'dark');
170
- const isExpanded = expandedNodeId.value === nodeId;
149
+ const isExpanded = expanded || expandedNodeId.value === nodeId;
171
150
 
172
151
  latestToolInputRef.current = toolInput;
173
152
  latestToolResultRef.current = toolResult;
@@ -221,9 +200,9 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
221
200
  return sendPromise;
222
201
  };
223
202
 
224
- // Initialize bridge when iframe loads and HTML is available
203
+ // Initialize as soon as HTML is mounted; some apps send initialize before iframe load fires.
225
204
  useEffect(() => {
226
- if (!html) return; // Wait for HTML to arrive
205
+ if (!html) return;
227
206
  const iframe = iframeRef.current;
228
207
  if (!iframe) return;
229
208
  let disposed = false;
@@ -242,12 +221,8 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
242
221
  };
243
222
 
244
223
  const init = async () => {
245
- let contentWindow = iframe.contentWindow;
246
- if (!contentWindow) {
247
- await waitForExtAppFrameLoad(iframe);
248
- if (disposed) return;
249
- contentWindow = iframe.contentWindow;
250
- }
224
+ if (!html) return;
225
+ const contentWindow = iframe.contentWindow;
251
226
  if (!contentWindow) {
252
227
  throw new Error('Ext-app iframe window is unavailable');
253
228
  }
@@ -397,8 +372,8 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
397
372
  bridgeReadyRef.current = true;
398
373
  setStatus('ready');
399
374
  setError(null);
400
- scheduleHostContextUpdate();
401
- void sendExtAppBootstrapState(bridge, latestToolInputRef.current, undefined)
375
+ void Promise.resolve(bridge.sendHostContextChange(buildHostContext(isExpanded ? 'fullscreen' : 'inline')))
376
+ .then(() => sendExtAppBootstrapState(bridge, latestToolInputRef.current, undefined))
402
377
  .then(() => flushToolResult(bridge))
403
378
  .catch((err) => {
404
379
  const msg = err instanceof Error ? err.message : String(err);
@@ -481,7 +456,7 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
481
456
  transportRef.current = null;
482
457
  }
483
458
  };
484
- }, [bridgeInitKey]);
459
+ }, [frameKey]);
485
460
 
486
461
  // Forward tool result when it arrives after bridge is ready
487
462
  useEffect(() => {
@@ -13,9 +13,9 @@ function withTheme(url: string): string {
13
13
  }
14
14
  }
15
15
 
16
- export function McpAppNode({ node }: { node: CanvasNodeState }) {
16
+ export function McpAppNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
17
17
  if (node.data.mode === 'ext-app') {
18
- return <ExtAppFrame node={node} />;
18
+ return <ExtAppFrame node={node} expanded={expanded} />;
19
19
  }
20
20
 
21
21
  const url = withTheme((node.data.url as string) || '');
@@ -529,6 +529,7 @@ function handleExtAppOpen(data: Record<string, unknown>): void {
529
529
  if (typeof data.toolCallId !== 'string' || !data.toolCallId) return;
530
530
  ensureExtAppNode({
531
531
  toolCallId: data.toolCallId,
532
+ ...(typeof data.nodeId === 'string' && data.nodeId.length > 0 ? { nodeId: data.nodeId } : {}),
532
533
  title: data.title,
533
534
  html: data.html,
534
535
  toolInput: data.toolInput,
package/src/mcp/server.ts CHANGED
@@ -261,6 +261,12 @@ export async function startMcpServer(): Promise<void> {
261
261
  width: z.number().optional().describe('Width in pixels (default: 720)'),
262
262
  height: z.number().optional().describe('Height in pixels (default: 600)'),
263
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'),
264
270
  },
265
271
  async (input) => {
266
272
  const c = await ensureCanvas();
@@ -303,11 +309,13 @@ export async function startMcpServer(): Promise<void> {
303
309
  toolName: z.string().describe('Tool name on the external MCP server'),
304
310
  serverName: z.string().optional().describe('Optional display name for the external MCP server'),
305
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.'),
306
313
  title: z.string().optional().describe('Optional canvas node title override'),
307
314
  x: z.number().optional().describe('X position (auto-placed if omitted)'),
308
315
  y: z.number().optional().describe('Y position (auto-placed if omitted)'),
309
316
  width: z.number().optional().describe('Width in pixels (default: 720)'),
310
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'),
311
319
  transport: z.union([
312
320
  z.object({
313
321
  type: z.literal('stdio'),
@@ -331,11 +339,13 @@ export async function startMcpServer(): Promise<void> {
331
339
  toolName: input.toolName,
332
340
  ...(typeof input.serverName === 'string' ? { serverName: input.serverName } : {}),
333
341
  ...(input.toolArguments ? { toolArguments: input.toolArguments } : {}),
342
+ ...(typeof input.nodeId === 'string' ? { nodeId: input.nodeId } : {}),
334
343
  ...(typeof input.title === 'string' ? { title: input.title } : {}),
335
344
  ...(typeof input.x === 'number' ? { x: input.x } : {}),
336
345
  ...(typeof input.y === 'number' ? { y: input.y } : {}),
337
346
  ...(typeof input.width === 'number' ? { width: input.width } : {}),
338
347
  ...(typeof input.height === 'number' ? { height: input.height } : {}),
348
+ ...(typeof input.timeoutMs === 'number' ? { timeoutMs: input.timeoutMs } : {}),
339
349
  });
340
350
  return {
341
351
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
@@ -357,27 +367,37 @@ export async function startMcpServer(): Promise<void> {
357
367
  z.string().describe('JSON array string of Excalidraw elements'),
358
368
  z.array(z.record(z.string(), z.unknown())).describe('Array of Excalidraw elements'),
359
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.'),
360
371
  title: z.string().optional().describe('Optional canvas node title override'),
361
372
  x: z.number().optional().describe('X position (auto-placed if omitted)'),
362
373
  y: z.number().optional().describe('Y position (auto-placed if omitted)'),
363
374
  width: z.number().optional().describe('Width in pixels (default: 720)'),
364
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.'),
365
377
  },
366
- async (input) => {
378
+ async (input, extra) => {
367
379
  const c = await ensureCanvas();
368
380
  try {
369
381
  const result = await c.addDiagram({
370
382
  elements: input.elements,
383
+ ...(typeof input.nodeId === 'string' ? { nodeId: input.nodeId } : {}),
371
384
  ...(typeof input.title === 'string' ? { title: input.title } : {}),
372
385
  ...(typeof input.x === 'number' ? { x: input.x } : {}),
373
386
  ...(typeof input.y === 'number' ? { y: input.y } : {}),
374
387
  ...(typeof input.width === 'number' ? { width: input.width } : {}),
375
388
  ...(typeof input.height === 'number' ? { height: input.height } : {}),
389
+ ...(typeof input.timeoutMs === 'number' ? { timeoutMs: input.timeoutMs } : {}),
376
390
  });
377
391
  return {
378
392
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
379
393
  };
380
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
+ }
381
401
  return {
382
402
  content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
383
403
  isError: true,
@@ -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(() => {
@@ -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,39 +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>>();
156
+ const labelsByContainer = new Map<string, Record<string, unknown>>();
138
157
 
139
158
  for (const element of elements) {
140
159
  if (element.type !== 'text' || typeof element.id !== 'string' || typeof element.containerId !== 'string') continue;
141
160
  const container = elementsById.get(element.containerId);
142
- if (!container) continue;
143
- const ids = boundElementIdsByContainer.get(element.containerId) ?? new Set<string>();
144
- ids.add(element.id);
145
- boundElementIdsByContainer.set(element.containerId, ids);
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);
146
165
  }
147
166
 
148
- const normalized = elements.map((element) => {
149
- if (typeof element.id !== 'string') return element;
150
- const boundTextIds = boundElementIdsByContainer.get(element.id);
151
- if (!boundTextIds || boundTextIds.size === 0) return element;
152
-
153
- const existing = Array.isArray(element.boundElements)
154
- ? element.boundElements.filter(isRecord)
155
- : [];
156
- const existingTextIds = new Set(
157
- existing
158
- .filter((boundElement) => boundElement.type === 'text' && typeof boundElement.id === 'string')
159
- .map((boundElement) => boundElement.id as string),
160
- );
161
- const missing = [...boundTextIds].filter((id) => !existingTextIds.has(id));
162
- if (missing.length === 0) return element;
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;
179
+ }
163
180
 
164
181
  changed = true;
165
- return {
166
- ...element,
167
- boundElements: [...existing, ...missing.map((id) => ({ type: 'text', id }))],
168
- };
169
- });
182
+ const { boundElements: _boundElements, ...container } = element;
183
+ normalized.push({
184
+ ...container,
185
+ label: labelsByContainer.get(element.id),
186
+ });
187
+ }
170
188
 
171
189
  return changed ? normalized : elements;
172
190
  }
@@ -333,10 +351,12 @@ export function buildExcalidrawOpenMcpAppInput(input: DiagramPresetOpenInput): E
333
351
  serverName: EXCALIDRAW_SERVER_NAME,
334
352
  toolArguments: { elements },
335
353
  };
354
+ if (typeof input.nodeId === 'string' && input.nodeId.trim().length > 0) out.nodeId = input.nodeId.trim();
336
355
  if (typeof input.title === 'string' && input.title.trim().length > 0) out.title = input.title.trim();
337
356
  if (typeof input.x === 'number' && Number.isFinite(input.x)) out.x = input.x;
338
357
  if (typeof input.y === 'number' && Number.isFinite(input.y)) out.y = input.y;
339
358
  if (typeof input.width === 'number' && Number.isFinite(input.width)) out.width = input.width;
340
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;
341
361
  return out;
342
362
  }