pmx-canvas 0.1.3 → 0.1.5

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.
@@ -6,6 +6,7 @@ import {
6
6
  } from './canvas-provenance.js';
7
7
 
8
8
  export interface SerializedCanvasNode extends CanvasNodeState {
9
+ kind: string;
9
10
  title: string | null;
10
11
  content: string | null;
11
12
  path: string | null;
@@ -26,6 +27,21 @@ function pickProvenance(value: unknown): CanvasNodeProvenance | null {
26
27
  return value as CanvasNodeProvenance;
27
28
  }
28
29
 
30
+ function getCanvasNodeKind(node: CanvasNodeState, data: Record<string, unknown>): string {
31
+ if (node.type !== 'mcp-app') return node.type;
32
+ // Authoritative discriminator added in v0.1.4. New web-artifacts always set
33
+ // it; matching here first means a future URL-only artifact (no `data.path`)
34
+ // still classifies correctly without falling through to the legacy heuristic.
35
+ if (data.viewerType === 'web-artifact') return 'web-artifact';
36
+ if (data.mode === 'ext-app') return 'external-app';
37
+ // Transitional fallback for canvas state.json files persisted before v0.1.4
38
+ // introduced `viewerType`. Web-artifacts written by older versions always
39
+ // stored a `path` to the bundled HTML file, so this heuristic is safe for
40
+ // existing data. Remove in v0.2.x once a one-shot migration runs at boot.
41
+ if (data.hostMode === 'hosted' && typeof data.path === 'string') return 'web-artifact';
42
+ return 'mcp-app';
43
+ }
44
+
29
45
  export function getCanvasNodeTitle(node: CanvasNodeState): string | null {
30
46
  return pickString(node.data.title)
31
47
  ?? (node.type === 'webpage' ? pickString(node.data.pageTitle) : null)
@@ -47,6 +63,7 @@ export function serializeCanvasNode(node: CanvasNodeState): SerializedCanvasNode
47
63
  return {
48
64
  ...node,
49
65
  data,
66
+ kind: getCanvasNodeKind(node, data),
50
67
  title: getCanvasNodeTitle(node),
51
68
  content: getCanvasNodeContent(node),
52
69
  path: pickString(data.path),
@@ -77,7 +94,8 @@ export function buildCanvasSummary(): CanvasSummary {
77
94
 
78
95
  const typeCounts: Record<string, number> = {};
79
96
  for (const n of layout.nodes) {
80
- typeCounts[n.type] = (typeCounts[n.type] ?? 0) + 1;
97
+ const kind = getCanvasNodeKind(n, normalizeCanvasNodeData(n.type, n.data));
98
+ typeCounts[kind] = (typeCounts[kind] ?? 0) + 1;
81
99
  }
82
100
 
83
101
  const pinnedTitles = layout.nodes
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Resolve the canvas node ID for a given ext-app `toolCallId`.
3
+ *
4
+ * v0.1.4 fixed a long-standing `ext-app-ext-app-…` double-prefix bug where
5
+ * both `nodeId` and `toolCallId` carried the `ext-app-` prefix. This helper
6
+ * encodes the lookup contract so it doesn't drift between the
7
+ * `PmxCanvas` SDK class and the HTTP server.
8
+ *
9
+ * Resolution order:
10
+ * 1. The direct prefixed form (`ext-app-<toolCallId>` if not already
11
+ * prefixed, otherwise `toolCallId` as-is).
12
+ * 2. The legacy `ext-app-ext-app-…` form, for canvases persisted before
13
+ * v0.1.4 and still on disk. Remove this fallback in v0.2.x.
14
+ * 3. A scan of the layout for any `mcp-app` ext-app node carrying that
15
+ * `toolCallId` in its data.
16
+ */
17
+ import type { CanvasNodeState } from './canvas-state.js';
18
+
19
+ export interface ExtAppLookupSource {
20
+ getNode(id: string): CanvasNodeState | undefined;
21
+ listNodes(): readonly CanvasNodeState[];
22
+ }
23
+
24
+ export function findCanvasExtAppNodeId(
25
+ toolCallId: string,
26
+ source: ExtAppLookupSource,
27
+ ): string | null {
28
+ const directId = toolCallId.startsWith('ext-app-')
29
+ ? toolCallId
30
+ : `ext-app-${toolCallId}`;
31
+ if (source.getNode(directId)) return directId;
32
+
33
+ const legacyDirectId = `ext-app-${toolCallId}`;
34
+ if (legacyDirectId !== directId && source.getNode(legacyDirectId)) {
35
+ return legacyDirectId;
36
+ }
37
+
38
+ for (const node of source.listNodes()) {
39
+ if (
40
+ node.type === 'mcp-app' &&
41
+ node.data.mode === 'ext-app' &&
42
+ node.data.toolCallId === toolCallId
43
+ ) {
44
+ return node.id;
45
+ }
46
+ }
47
+
48
+ return null;
49
+ }
@@ -0,0 +1,206 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { closeSync, existsSync, openSync, readSync, statSync } from 'node:fs';
3
+ import { basename } from 'node:path';
4
+ import { IMAGE_MIME_MAP } from './canvas-state.js';
5
+
6
+ const IMAGE_HEADER_BYTES = 512;
7
+ const IMAGE_HEADER_READ_TIMEOUT_MS = 5000;
8
+ // Set by `chflags hidden`, iCloud Drive, OneDrive, etc. on macOS — the
9
+ // metadata exists but the file content has not been downloaded locally.
10
+ const MACOS_DATALESS_FLAG = 0x40000000;
11
+ // Per macOS `man stat -f %Xf`, this bit is also set on iCloud Documents
12
+ // & Files for content-not-yet-downloaded entries on newer OS releases.
13
+ const MACOS_BSD_NODUMP_FLAG = 0x00000001;
14
+
15
+ function fileName(path: string): string {
16
+ return basename(path) || path;
17
+ }
18
+
19
+ function readMacosFileFlags(path: string): number | null {
20
+ if (process.platform !== 'darwin') return null;
21
+
22
+ try {
23
+ const raw = execFileSync('/usr/bin/stat', ['-f', '%Xf', path], {
24
+ encoding: 'utf8',
25
+ stdio: ['ignore', 'pipe', 'ignore'],
26
+ timeout: 1000,
27
+ }).trim();
28
+ return raw.length > 0 ? Number.parseInt(raw, 16) : null;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ function isMacosCloudPlaceholder(path: string): boolean {
35
+ const flags = readMacosFileFlags(path);
36
+ if (flags === null) return false;
37
+ // Both flags can indicate iCloud-on-demand status depending on macOS
38
+ // release; treat either as a placeholder.
39
+ return (flags & MACOS_DATALESS_FLAG) !== 0 || (flags & MACOS_BSD_NODUMP_FLAG) !== 0;
40
+ }
41
+
42
+ function assertNotCloudPlaceholder(path: string): void {
43
+ if (isMacosCloudPlaceholder(path)) {
44
+ throw new Error(
45
+ `Invalid image node: "${fileName(path)}" appears to be a cloud-on-demand placeholder. ` +
46
+ 'Ensure the file is downloaded locally before adding it as an image.',
47
+ );
48
+ }
49
+ }
50
+
51
+ function readHeaderWithDirectFs(path: string, size: number): Buffer {
52
+ const length = Math.min(IMAGE_HEADER_BYTES, size);
53
+ const buffer = Buffer.alloc(length);
54
+ const fd = openSync(path, 'r');
55
+ try {
56
+ const bytesRead = readSync(fd, buffer, 0, length, 0);
57
+ return buffer.subarray(0, bytesRead);
58
+ } finally {
59
+ closeSync(fd);
60
+ }
61
+ }
62
+
63
+ interface ProcessSpawnError {
64
+ code?: string;
65
+ signal?: NodeJS.Signals;
66
+ status?: number;
67
+ }
68
+
69
+ function isProcessSpawnError(value: unknown): value is ProcessSpawnError {
70
+ return value !== null && typeof value === 'object';
71
+ }
72
+
73
+ function readHeaderWithDd(path: string): Buffer {
74
+ return execFileSync('/bin/dd', [`if=${path}`, `bs=${IMAGE_HEADER_BYTES}`, 'count=1'], {
75
+ encoding: 'buffer',
76
+ maxBuffer: IMAGE_HEADER_BYTES,
77
+ stdio: ['ignore', 'pipe', 'ignore'],
78
+ timeout: IMAGE_HEADER_READ_TIMEOUT_MS,
79
+ });
80
+ }
81
+
82
+ function readHeaderWithTimeout(path: string, size: number): Buffer {
83
+ // Direct fs read is the fast path on every platform — no fork, no shell,
84
+ // no timeout required because the kernel either returns bytes immediately
85
+ // or fails synchronously. The `dd` escape hatch only matters for macOS
86
+ // cloud-on-demand placeholders, which `assertNotCloudPlaceholder` rejected
87
+ // at the call site, so this path is safe.
88
+ try {
89
+ return readHeaderWithDirectFs(path, size);
90
+ } catch (directError) {
91
+ // On macOS, fall through to `/bin/dd` so a kernel-level stall on a path
92
+ // we did not flag as a placeholder (e.g. an unmounted SMB share that
93
+ // still satisfies `existsSync`) cannot wedge a Bun fiber.
94
+ if (process.platform !== 'darwin' || !existsSync('/bin/dd')) {
95
+ throw directError;
96
+ }
97
+ try {
98
+ return readHeaderWithDd(path);
99
+ } catch (ddError) {
100
+ const reason = isProcessSpawnError(ddError) ? ddError : null;
101
+ const timedOut = reason?.signal === 'SIGTERM' || reason?.code === 'ETIMEDOUT';
102
+ if (timedOut) {
103
+ throw new Error(
104
+ `Invalid image node: could not read image header for "${fileName(path)}" within ${IMAGE_HEADER_READ_TIMEOUT_MS}ms. ` +
105
+ 'If this file is stored in OneDrive, iCloud Drive, or another cloud-on-demand provider, download it locally first.',
106
+ );
107
+ }
108
+ const detail = ddError instanceof Error ? ddError.message : String(ddError);
109
+ throw new Error(
110
+ `Invalid image node: could not read "${fileName(path)}" — ${detail}.`,
111
+ );
112
+ }
113
+ }
114
+ }
115
+
116
+ function hasAscii(buffer: Buffer, offset: number, value: string): boolean {
117
+ return buffer.subarray(offset, offset + value.length).toString('ascii') === value;
118
+ }
119
+
120
+ function detectImageMimeType(header: Buffer): string | null {
121
+ if (
122
+ header.length >= 8 &&
123
+ header[0] === 0x89 &&
124
+ hasAscii(header, 1, 'PNG') &&
125
+ header[4] === 0x0d &&
126
+ header[5] === 0x0a &&
127
+ header[6] === 0x1a &&
128
+ header[7] === 0x0a
129
+ ) {
130
+ return 'image/png';
131
+ }
132
+
133
+ if (header.length >= 3 && header[0] === 0xff && header[1] === 0xd8 && header[2] === 0xff) {
134
+ return 'image/jpeg';
135
+ }
136
+
137
+ if (header.length >= 6 && (hasAscii(header, 0, 'GIF87a') || hasAscii(header, 0, 'GIF89a'))) {
138
+ return 'image/gif';
139
+ }
140
+
141
+ if (header.length >= 12 && hasAscii(header, 0, 'RIFF') && hasAscii(header, 8, 'WEBP')) {
142
+ return 'image/webp';
143
+ }
144
+
145
+ if (header.length >= 2 && hasAscii(header, 0, 'BM')) {
146
+ return 'image/bmp';
147
+ }
148
+
149
+ if (
150
+ header.length >= 4 &&
151
+ header[0] === 0x00 &&
152
+ header[1] === 0x00 &&
153
+ (header[2] === 0x01 || header[2] === 0x02) &&
154
+ header[3] === 0x00
155
+ ) {
156
+ return 'image/x-icon';
157
+ }
158
+
159
+ if (header.length >= 12 && hasAscii(header, 4, 'ftyp')) {
160
+ const brandArea = header.subarray(8, Math.min(header.length, 32)).toString('ascii');
161
+ if (brandArea.includes('avif') || brandArea.includes('avis')) return 'image/avif';
162
+ }
163
+
164
+ const text = header.toString('utf8').replace(/^\uFEFF/, '').trimStart().toLowerCase();
165
+ if (text.startsWith('<svg') || (text.startsWith('<?xml') && text.includes('<svg'))) {
166
+ return 'image/svg+xml';
167
+ }
168
+
169
+ return null;
170
+ }
171
+
172
+ export function validateLocalImageFile(path: string): { mimeType: string } {
173
+ const name = fileName(path);
174
+ if (!existsSync(path)) {
175
+ throw new Error(`Invalid image node: "${name}" does not exist.`);
176
+ }
177
+
178
+ const stat = statSync(path);
179
+ if (!stat.isFile()) {
180
+ throw new Error(`Invalid image node: "${name}" is not a regular file.`);
181
+ }
182
+ if (stat.size <= 0) {
183
+ throw new Error(`Invalid image node: "${name}" is empty.`);
184
+ }
185
+
186
+ const ext = name.split('.').pop()?.toLowerCase() ?? '';
187
+ if (!IMAGE_MIME_MAP[ext]) {
188
+ throw new Error(
189
+ `Invalid image node: "${name}" has unsupported extension ".${ext}". ` +
190
+ `Accepted: ${Object.keys(IMAGE_MIME_MAP).join(', ')}. ` +
191
+ 'For non-image files use type="file" (live viewer) or type="webpage" (URL) instead.',
192
+ );
193
+ }
194
+
195
+ assertNotCloudPlaceholder(path);
196
+ const header = readHeaderWithTimeout(path, stat.size);
197
+ const mimeType = detectImageMimeType(header);
198
+ if (!mimeType) {
199
+ throw new Error(
200
+ `Invalid image node: "${name}" is not a recognized image file. ` +
201
+ 'Expected PNG, JPEG, GIF, SVG, WebP, BMP, ICO, or AVIF image bytes.',
202
+ );
203
+ }
204
+
205
+ return { mimeType };
206
+ }
@@ -1,6 +1,7 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
  import { canvasState, IMAGE_MIME_MAP } from './canvas-state.js';
3
3
  import type { CanvasNodeState, CanvasEdge, CanvasLayout, ViewportState } from './canvas-state.js';
4
+ import { findCanvasExtAppNodeId } from './ext-app-lookup.js';
4
5
  import { onFileNodeChanged } from './file-watcher.js';
5
6
  import { findOpenCanvasPosition, computeGroupBounds } from './placement.js';
6
7
  import { searchNodes, buildSpatialContext } from './spatial-analysis.js';
@@ -440,18 +441,10 @@ export class PmxCanvas extends EventEmitter {
440
441
  }
441
442
 
442
443
  private findCanvasExtAppNodeId(toolCallId: string): string | null {
443
- const directId = `ext-app-${toolCallId}`;
444
- if (canvasState.getNode(directId)) return directId;
445
- for (const node of canvasState.getLayout().nodes) {
446
- if (
447
- node.type === 'mcp-app' &&
448
- node.data.mode === 'ext-app' &&
449
- node.data.toolCallId === toolCallId
450
- ) {
451
- return node.id;
452
- }
453
- }
454
- return null;
444
+ return findCanvasExtAppNodeId(toolCallId, {
445
+ getNode: (id) => canvasState.getNode(id),
446
+ listNodes: () => canvasState.getLayout().nodes,
447
+ });
455
448
  }
456
449
 
457
450
  describeSchema() {
@@ -488,20 +481,21 @@ export class PmxCanvas extends EventEmitter {
488
481
  y?: number;
489
482
  width?: number;
490
483
  height?: number;
491
- }): Promise<{ ok: true; nodeId: string | null; toolCallId: string; sessionId: string; resourceUri: string }> {
484
+ }): Promise<{ ok: true; id?: string; nodeId: string | null; toolCallId: string; sessionId: string; resourceUri: string }> {
492
485
  const opened = await openExternalMcpApp({
493
486
  transport: input.transport,
494
487
  toolName: input.toolName,
495
488
  ...(input.toolArguments ? { toolArguments: input.toolArguments } : {}),
496
489
  ...(input.serverName ? { serverName: input.serverName } : {}),
497
490
  });
498
- const toolCallId = `ext-app-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
491
+ const toolCallId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
499
492
  const nodeIdSeed = `ext-app-${toolCallId}`;
500
493
  const toolResult = isExcalidrawCreateView(opened.serverName, opened.toolName)
501
494
  ? ensureExcalidrawCheckpointId(opened.toolResult, nodeIdSeed)
502
495
  : opened.toolResult;
503
496
  emitPrimaryWorkbenchEvent('ext-app-open', {
504
497
  toolCallId,
498
+ nodeId: nodeIdSeed,
505
499
  title: input.title ?? opened.tool.title ?? opened.tool.name,
506
500
  html: opened.html,
507
501
  toolInput: opened.toolInput,
@@ -521,6 +515,7 @@ export class PmxCanvas extends EventEmitter {
521
515
  });
522
516
  emitPrimaryWorkbenchEvent('ext-app-result', {
523
517
  toolCallId,
518
+ nodeId: nodeIdSeed,
524
519
  serverName: opened.serverName,
525
520
  toolName: opened.toolName,
526
521
  success: toolResult.isError !== true,
@@ -529,6 +524,7 @@ export class PmxCanvas extends EventEmitter {
529
524
  const nodeId = this.findCanvasExtAppNodeId(toolCallId);
530
525
  return {
531
526
  ok: true,
527
+ ...(nodeId ? { id: nodeId } : {}),
532
528
  nodeId,
533
529
  toolCallId,
534
530
  sessionId: opened.sessionId,
@@ -538,7 +534,7 @@ export class PmxCanvas extends EventEmitter {
538
534
 
539
535
  async addDiagram(
540
536
  input: DiagramPresetOpenInput,
541
- ): Promise<{ ok: true; nodeId: string | null; toolCallId: string; sessionId: string; resourceUri: string }> {
537
+ ): Promise<{ ok: true; id?: string; nodeId: string | null; toolCallId: string; sessionId: string; resourceUri: string }> {
542
538
  const built = buildExcalidrawOpenMcpAppInput(input);
543
539
  return this.openMcpApp(built);
544
540
  }
@@ -36,6 +36,7 @@
36
36
 
37
37
  import { spawnSync } from 'node:child_process';
38
38
  import { existsSync, readFileSync, statSync, writeFileSync, appendFileSync } from 'node:fs';
39
+ import { readFile } from 'node:fs/promises';
39
40
  import { basename, extname, join, relative, resolve } from 'node:path';
40
41
  import * as marked from 'marked';
41
42
  import type {
@@ -46,6 +47,7 @@ import type {
46
47
  ListToolsResult,
47
48
  } from '@modelcontextprotocol/sdk/types.js';
48
49
  import { type CanvasEdge, type CanvasNodeState, IMAGE_MIME_MAP, canvasState } from './canvas-state.js';
50
+ import { findCanvasExtAppNodeId as findCanvasExtAppNodeIdShared } from './ext-app-lookup.js';
49
51
  import { normalizeExtAppToolResult } from './ext-app-tool-result.js';
50
52
  import { getMcpAppHostSnapshot } from './mcp-app-host.js';
51
53
  import {
@@ -66,6 +68,7 @@ import { diffLayouts, formatDiff, mutationHistory } from './mutation-history.js'
66
68
  import { buildCanvasSummary, serializeCanvasLayout, serializeCanvasNode } from './canvas-serialization.js';
67
69
  import { buildCodeGraphSummary, formatCodeGraph } from './code-graph.js';
68
70
  import { buildAgentContextPreamble, serializeNodeForAgentContext } from './agent-context.js';
71
+ import { validateLocalImageFile } from './image-source.js';
69
72
  import {
70
73
  addCanvasNode,
71
74
  addCanvasEdge,
@@ -602,31 +605,42 @@ function getMarkdownPlacement(): { x: number; y: number } {
602
605
  }
603
606
 
604
607
  function findCanvasExtAppNodeId(toolCallId: string): string | null {
605
- const directId = `ext-app-${toolCallId}`;
606
- if (canvasState.getNode(directId)) return directId;
607
- for (const node of canvasState.getLayout().nodes) {
608
- if (
609
- node.type === 'mcp-app' &&
610
- node.data.mode === 'ext-app' &&
611
- node.data.toolCallId === toolCallId
612
- ) {
613
- return node.id;
614
- }
615
- }
616
- return null;
608
+ return findCanvasExtAppNodeIdShared(toolCallId, {
609
+ getNode: (id) => canvasState.getNode(id),
610
+ listNodes: () => canvasState.getLayout().nodes,
611
+ });
617
612
  }
618
613
 
619
614
  function isCheckpointToolName(toolName: string): boolean {
620
615
  return toolName === EXCALIDRAW_SAVE_CHECKPOINT_TOOL || toolName === EXCALIDRAW_READ_CHECKPOINT_TOOL;
621
616
  }
622
617
 
618
+ /**
619
+ * Decide whether a fresh `callServerTool` result should *replace* the
620
+ * canvas node's bootstrap-replay `toolResult`.
621
+ *
622
+ * The bootstrap-replay toolResult is what the server re-sends to the
623
+ * widget on reload to restore visible state. We only want to overwrite
624
+ * it when the new result genuinely carries widget state — `isError` or
625
+ * `structuredContent`. A plain-text result (e.g. `read_checkpoint`
626
+ * returning a string status, or any informational message) updates
627
+ * `appModelContext` for the agent's record but should *not* clobber the
628
+ * bootstrap entry, because doing so would replace the widget's restored
629
+ * state with conversational noise on the next reload.
630
+ *
631
+ * This separation is exercised by:
632
+ * - tests/unit/server-api.test.ts "keeps ext-app model context
633
+ * separate from the replayed tool result" (text-only result preserves
634
+ * bootstrap replay)
635
+ * - tests/unit/server-api.test.ts "app-only text tool results update
636
+ * model context without replacing bootstrap replay"
637
+ * - tests/unit/server-api.test.ts "rehydrates Excalidraw checkpoint
638
+ * replay after server restart" (structured-content result becomes
639
+ * the new bootstrap replay)
640
+ */
623
641
  function shouldReplayAppToolResult(toolName: string, result: CallToolResult): boolean {
624
642
  void toolName;
625
- return Boolean(
626
- result.isError ||
627
- result.structuredContent ||
628
- result.content.some((entry) => entry.type !== 'text' || entry.text !== 'ok'),
629
- );
643
+ return Boolean(result.isError || result.structuredContent);
630
644
  }
631
645
 
632
646
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -1079,7 +1093,7 @@ async function handleCanvasViewport(req: Request): Promise<Response> {
1079
1093
  }
1080
1094
 
1081
1095
  // ── Serve image file for image nodes ─────────────────────────
1082
- function handleCanvasImage(pathname: string): Response {
1096
+ async function handleCanvasImage(pathname: string): Promise<Response> {
1083
1097
  const nodeId = pathname.replace('/api/canvas/image/', '');
1084
1098
  const node = canvasState.getNode(nodeId);
1085
1099
  if (!node || node.type !== 'image') {
@@ -1093,9 +1107,13 @@ function handleCanvasImage(pathname: string): Response {
1093
1107
  if (!existsSync(safePath)) {
1094
1108
  return responseText('Image file not found', 404);
1095
1109
  }
1096
- const ext = safePath.split('.').pop()?.toLowerCase() ?? '';
1097
- const contentType = IMAGE_MIME_MAP[ext] || 'application/octet-stream';
1098
- const data = readFileSync(safePath);
1110
+ let contentType: string;
1111
+ try {
1112
+ contentType = validateLocalImageFile(safePath).mimeType;
1113
+ } catch (error) {
1114
+ return responseText(error instanceof Error ? error.message : 'Invalid image file', 400);
1115
+ }
1116
+ const data = await readFile(safePath);
1099
1117
  return new Response(data, {
1100
1118
  headers: {
1101
1119
  'Content-Type': contentType,
@@ -1545,10 +1563,22 @@ async function handleCanvasValidateSpec(req: Request): Promise<Response> {
1545
1563
  data,
1546
1564
  ...(typeof body.xKey === 'string' ? { xKey: body.xKey } : {}),
1547
1565
  ...(typeof body.yKey === 'string' ? { yKey: body.yKey } : {}),
1566
+ ...(typeof body.zKey === 'string' ? { zKey: body.zKey } : {}),
1548
1567
  ...(typeof body.nameKey === 'string' ? { nameKey: body.nameKey } : {}),
1549
1568
  ...(typeof body.valueKey === 'string' ? { valueKey: body.valueKey } : {}),
1569
+ ...(typeof body.axisKey === 'string' ? { axisKey: body.axisKey } : {}),
1570
+ ...(Array.isArray(body.metrics)
1571
+ ? { metrics: body.metrics.filter((m: unknown): m is string => typeof m === 'string') }
1572
+ : {}),
1573
+ ...(Array.isArray(body.series)
1574
+ ? { series: body.series.filter((s: unknown): s is string => typeof s === 'string') }
1575
+ : {}),
1576
+ ...(typeof body.barKey === 'string' ? { barKey: body.barKey } : {}),
1577
+ ...(typeof body.lineKey === 'string' ? { lineKey: body.lineKey } : {}),
1550
1578
  ...(aggregate ? { aggregate } : {}),
1551
1579
  ...(typeof body.color === 'string' ? { color: body.color } : {}),
1580
+ ...(typeof body.barColor === 'string' ? { barColor: body.barColor } : {}),
1581
+ ...(typeof body.lineColor === 'string' ? { lineColor: body.lineColor } : {}),
1552
1582
  ...(typeof body.height === 'number' ? { height: body.height } : {}),
1553
1583
  },
1554
1584
  }));
@@ -1871,7 +1901,7 @@ function handleRead(pathLike: string): Response {
1871
1901
  }
1872
1902
 
1873
1903
  function randomExtAppToolCallId(): string {
1874
- return `ext-app-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1904
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1875
1905
  }
1876
1906
 
1877
1907
  function nodeAppSessionId(node: CanvasNodeState | undefined): string | null {
@@ -1951,7 +1981,7 @@ async function runAndEmitOpenMcpApp(params: RunAndEmitOpenMcpAppParams): Promise
1951
1981
  });
1952
1982
 
1953
1983
  const toolCallId = randomExtAppToolCallId();
1954
- const nodeIdSeed = `ext-app-${toolCallId}`;
1984
+ const nodeIdSeed = toolCallId.startsWith('ext-app-') ? toolCallId : `ext-app-${toolCallId}`;
1955
1985
  const toolResult = isExcalidrawCreateView(opened.serverName, opened.toolName)
1956
1986
  ? ensureExcalidrawCheckpointId(opened.toolResult, nodeIdSeed)
1957
1987
  : opened.toolResult;
@@ -1959,6 +1989,7 @@ async function runAndEmitOpenMcpApp(params: RunAndEmitOpenMcpAppParams): Promise
1959
1989
 
1960
1990
  emitPrimaryWorkbenchEvent('ext-app-open', {
1961
1991
  toolCallId,
1992
+ nodeId: nodeIdSeed,
1962
1993
  title: nodeTitle,
1963
1994
  html: opened.html,
1964
1995
  toolInput: opened.toolInput,
@@ -1978,6 +2009,7 @@ async function runAndEmitOpenMcpApp(params: RunAndEmitOpenMcpAppParams): Promise
1978
2009
  });
1979
2010
  emitPrimaryWorkbenchEvent('ext-app-result', {
1980
2011
  toolCallId,
2012
+ nodeId: nodeIdSeed,
1981
2013
  serverName: opened.serverName,
1982
2014
  toolName: opened.toolName,
1983
2015
  success: toolResult.isError !== true,
@@ -1987,6 +2019,7 @@ async function runAndEmitOpenMcpApp(params: RunAndEmitOpenMcpAppParams): Promise
1987
2019
 
1988
2020
  return responseJson({
1989
2021
  ok: true,
2022
+ ...(nodeId ? { id: nodeId } : {}),
1990
2023
  nodeId,
1991
2024
  toolCallId,
1992
2025
  sessionId: opened.sessionId,
@@ -3151,10 +3184,13 @@ function syncEventToCanvasState(event: string, payload: PrimaryWorkbenchEventPay
3151
3184
  } else if (event === 'ext-app-open') {
3152
3185
  const toolCallId = payload.toolCallId as string;
3153
3186
  if (!toolCallId) return;
3154
- const id = `ext-app-${toolCallId}`;
3187
+ const id = typeof payload.nodeId === 'string' && payload.nodeId.length > 0
3188
+ ? payload.nodeId
3189
+ : toolCallId.startsWith('ext-app-') ? toolCallId : `ext-app-${toolCallId}`;
3155
3190
  const dataPatch = {
3156
3191
  mode: 'ext-app',
3157
3192
  toolCallId,
3193
+ nodeId: id,
3158
3194
  ...(typeof payload.title === 'string' && payload.title.trim().length > 0
3159
3195
  ? { title: payload.title.trim() }
3160
3196
  : {}),
@@ -3222,7 +3258,9 @@ function syncEventToCanvasState(event: string, payload: PrimaryWorkbenchEventPay
3222
3258
  } else if (event === 'ext-app-update') {
3223
3259
  const toolCallId = payload.toolCallId as string;
3224
3260
  if (!toolCallId) return;
3261
+ const payloadNodeId = typeof payload.nodeId === 'string' ? payload.nodeId : '';
3225
3262
  const id =
3263
+ (payloadNodeId && canvasState.getNode(payloadNodeId) ? payloadNodeId : null) ||
3226
3264
  findCanvasExtAppNodeId(toolCallId) ||
3227
3265
  (typeof payload.serverName === 'string' && typeof payload.toolName === 'string'
3228
3266
  ? findOnlyPendingCanvasExtAppNodeId(payload.serverName, payload.toolName)
@@ -3235,7 +3273,9 @@ function syncEventToCanvasState(event: string, payload: PrimaryWorkbenchEventPay
3235
3273
  } else if (event === 'ext-app-result') {
3236
3274
  const toolCallId = payload.toolCallId as string;
3237
3275
  if (!toolCallId) return;
3276
+ const payloadNodeId = typeof payload.nodeId === 'string' ? payload.nodeId : '';
3238
3277
  const id =
3278
+ (payloadNodeId && canvasState.getNode(payloadNodeId) ? payloadNodeId : null) ||
3239
3279
  findCanvasExtAppNodeId(toolCallId) ||
3240
3280
  (typeof payload.serverName === 'string' && typeof payload.toolName === 'string'
3241
3281
  ? findOnlyPendingCanvasExtAppNodeId(payload.serverName, payload.toolName)
@@ -3739,7 +3779,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3739
3779
  }
3740
3780
 
3741
3781
  if (url.pathname.startsWith('/api/canvas/image/') && req.method === 'GET') {
3742
- return handleCanvasImage(url.pathname);
3782
+ return await handleCanvasImage(url.pathname);
3743
3783
  }
3744
3784
 
3745
3785
  if (url.pathname === '/api/canvas/edge' && req.method === 'POST') {
@@ -494,6 +494,7 @@ export function openWebArtifactInCanvas(input: {
494
494
  trustedDomain: true,
495
495
  sourceServer: 'pmx-canvas',
496
496
  hostMode: 'hosted',
497
+ viewerType: 'web-artifact',
497
498
  },
498
499
  };
499
500