pmx-canvas 0.1.4 → 0.1.6

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.
@@ -22,6 +22,7 @@ export interface CanvasCreateTypeSchema {
22
22
  kind: 'node' | 'virtual-node';
23
23
  description: string;
24
24
  endpoint: string;
25
+ mcpTool?: string;
25
26
  fields: CanvasCreateField[];
26
27
  example: Record<string, unknown>;
27
28
  notes?: string[];
@@ -63,6 +64,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
63
64
  kind: 'node',
64
65
  description: 'Freeform markdown note.',
65
66
  endpoint: '/api/canvas/node',
67
+ mcpTool: 'canvas_add_node',
66
68
  fields: [
67
69
  { name: 'title', type: 'string', required: false, description: 'Optional node title.' },
68
70
  { name: 'content', type: 'string', required: false, description: 'Markdown body.' },
@@ -85,6 +87,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
85
87
  kind: 'node',
86
88
  description: 'Compact status indicator.',
87
89
  endpoint: '/api/canvas/node',
90
+ mcpTool: 'canvas_add_node',
88
91
  fields: [
89
92
  { name: 'title', type: 'string', required: false, description: 'Status label.' },
90
93
  { name: 'content', type: 'string', required: false, description: 'Rendered status text.' },
@@ -100,6 +103,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
100
103
  kind: 'node',
101
104
  description: 'Agent context card container.',
102
105
  endpoint: '/api/canvas/node',
106
+ mcpTool: 'canvas_add_node',
103
107
  fields: [
104
108
  { name: 'title', type: 'string', required: false, description: 'Optional title override.' },
105
109
  { name: 'content', type: 'string', required: false, description: 'Optional context body.' },
@@ -115,6 +119,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
115
119
  kind: 'node',
116
120
  description: 'Structured ledger/log node.',
117
121
  endpoint: '/api/canvas/node',
122
+ mcpTool: 'canvas_add_node',
118
123
  fields: [
119
124
  { name: 'title', type: 'string', required: false, description: 'Optional title.' },
120
125
  { name: 'content', type: 'string', required: false, description: 'Ledger body text.' },
@@ -130,6 +135,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
130
135
  kind: 'node',
131
136
  description: 'Execution trace viewer.',
132
137
  endpoint: '/api/canvas/node',
138
+ mcpTool: 'canvas_add_node',
133
139
  fields: [
134
140
  { name: 'title', type: 'string', required: false, description: 'Optional title.' },
135
141
  { name: 'content', type: 'string', required: false, description: 'Trace summary.' },
@@ -145,6 +151,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
145
151
  kind: 'node',
146
152
  description: 'Workspace file viewer.',
147
153
  endpoint: '/api/canvas/node',
154
+ mcpTool: 'canvas_add_node',
148
155
  fields: [
149
156
  { name: 'content', type: 'string', required: true, description: 'Workspace-relative or absolute file path.' },
150
157
  { name: 'title', type: 'string', required: false, description: 'Optional title override.' },
@@ -162,6 +169,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
162
169
  kind: 'node',
163
170
  description: 'Image node backed by a path, URL, or data URI.',
164
171
  endpoint: '/api/canvas/node',
172
+ mcpTool: 'canvas_add_node',
165
173
  fields: [
166
174
  { name: 'content', type: 'string', required: true, description: 'Image path, URL, or data URI.' },
167
175
  { name: 'title', type: 'string', required: false, description: 'Optional title override.' },
@@ -187,6 +195,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
187
195
  kind: 'node',
188
196
  description: 'Persisted webpage snapshot with server-side fetch and refresh.',
189
197
  endpoint: '/api/canvas/node',
198
+ mcpTool: 'canvas_add_node',
190
199
  fields: [
191
200
  { name: 'url', type: 'string', required: true, description: 'HTTP(S) URL to fetch and cache.', aliases: ['content'] },
192
201
  { name: 'title', type: 'string', required: false, description: 'Optional title override.' },
@@ -210,6 +219,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
210
219
  kind: 'node',
211
220
  description: 'Hosted iframe/app node.',
212
221
  endpoint: '/api/canvas/node',
222
+ mcpTool: 'canvas_open_mcp_app',
213
223
  fields: [
214
224
  { name: 'title', type: 'string', required: false, description: 'App title.' },
215
225
  { name: 'content', type: 'string', required: false, description: 'Optional inline content.' },
@@ -223,11 +233,35 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
223
233
  'Tool-backed MCP app nodes and hosted artifact nodes persist `data.provenance` when the server can infer a reopen or rehydrate path.',
224
234
  ],
225
235
  },
236
+ {
237
+ type: 'external-app',
238
+ kind: 'virtual-node',
239
+ description: 'Tool-backed hosted app opened from an external MCP server, such as Excalidraw.',
240
+ endpoint: '/api/canvas/mcp-app/open',
241
+ mcpTool: 'canvas_open_mcp_app',
242
+ fields: [
243
+ { name: 'toolName', type: 'string', required: true, description: 'Tool name on the external MCP server.' },
244
+ { name: 'transport', type: '{ type: "stdio", command, args? } | { type: "http", url, headers? }', required: true, description: 'External MCP transport definition.' },
245
+ { name: 'toolArguments', type: 'record<string, unknown>', required: false, description: 'Arguments passed to the external MCP tool.' },
246
+ { name: 'title', type: 'string', required: false, description: 'Optional canvas node title override.' },
247
+ ],
248
+ example: {
249
+ toolName: 'create_view',
250
+ transport: { type: 'http', url: 'https://mcp.excalidraw.com/mcp' },
251
+ toolArguments: { elements: '[]' },
252
+ title: 'Excalidraw Diagram',
253
+ },
254
+ notes: [
255
+ 'For Excalidraw specifically, prefer canvas_add_diagram because it fills in the built-in transport, toolName, and checkpoint wiring.',
256
+ 'The CLI convenience command `external-app add --kind excalidraw` maps to this built-in Excalidraw preset; MCP canvas_open_mcp_app is the lower-level transport form.',
257
+ ],
258
+ },
226
259
  {
227
260
  type: 'group',
228
261
  kind: 'node',
229
262
  description: 'Canvas group frame.',
230
263
  endpoint: '/api/canvas/group',
264
+ mcpTool: 'canvas_create_group',
231
265
  fields: [
232
266
  { name: 'title', type: 'string', required: false, description: 'Group title.' },
233
267
  { name: 'childIds', type: 'string[]', required: false, description: 'Initial child node IDs.' },
@@ -244,6 +278,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
244
278
  kind: 'virtual-node',
245
279
  description: 'Native structured UI panel rendered from a validated json-render spec.',
246
280
  endpoint: '/api/canvas/json-render',
281
+ mcpTool: 'canvas_add_json_render_node',
247
282
  fields: [
248
283
  { name: 'title', type: 'string', required: true, description: 'Rendered node title.' },
249
284
  { name: 'spec', type: 'JsonRenderSpec', required: true, description: 'Complete json-render spec.' },
@@ -276,6 +311,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
276
311
  kind: 'virtual-node',
277
312
  description: 'Native chart node backed by the json-render chart catalog.',
278
313
  endpoint: '/api/canvas/graph',
314
+ mcpTool: 'canvas_add_graph_node',
279
315
  fields: [
280
316
  {
281
317
  name: 'graphType',
@@ -325,6 +361,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
325
361
  kind: 'virtual-node',
326
362
  description: 'Bundled single-file HTML artifact that can open as an embedded canvas node.',
327
363
  endpoint: '/api/canvas/web-artifact',
364
+ mcpTool: 'canvas_build_web_artifact',
328
365
  fields: [
329
366
  { name: 'title', type: 'string', required: true, description: 'Artifact title used for default paths.' },
330
367
  { name: 'appTsx', type: 'string', required: true, description: 'Contents for src/App.tsx. The CLI also accepts piped contents via --stdin.', aliases: ['app-file', 'app-tsx'] },
@@ -349,6 +386,16 @@ function clone<T>(value: T): T {
349
386
  return JSON.parse(JSON.stringify(value)) as T;
350
387
  }
351
388
 
389
+ function buildMcpNodeTypeRouting(nodeTypes: CanvasCreateTypeSchema[]): Record<string, string> {
390
+ const routing: Record<string, string> = {};
391
+ for (const entry of nodeTypes) {
392
+ if (typeof entry.mcpTool === 'string') {
393
+ routing[entry.type] = entry.mcpTool;
394
+ }
395
+ }
396
+ return routing;
397
+ }
398
+
352
399
  export function describeCanvasSchema(): {
353
400
  ok: true;
354
401
  source: 'running-server';
@@ -364,13 +411,15 @@ export function describeCanvasSchema(): {
364
411
  mcp: {
365
412
  tools: string[];
366
413
  resources: string[];
414
+ nodeTypeRouting: Record<string, string>;
367
415
  };
368
416
  } {
417
+ const nodeTypes = clone(CANVAS_CREATE_TYPES);
369
418
  return {
370
419
  ok: true,
371
420
  source: 'running-server',
372
421
  version: readPackageVersion(),
373
- nodeTypes: clone(CANVAS_CREATE_TYPES),
422
+ nodeTypes,
374
423
  jsonRender: {
375
424
  rootShape: {
376
425
  root: 'string',
@@ -388,10 +437,13 @@ export function describeCanvasSchema(): {
388
437
  'canvas_add_json_render_node',
389
438
  'canvas_add_graph_node',
390
439
  'canvas_build_web_artifact',
440
+ 'canvas_open_mcp_app',
441
+ 'canvas_create_group',
391
442
  'canvas_describe_schema',
392
443
  'canvas_validate_spec',
393
444
  ],
394
445
  resources: ['canvas://schema'],
446
+ nodeTypeRouting: buildMcpNodeTypeRouting(nodeTypes),
395
447
  },
396
448
  };
397
449
  }
@@ -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
+ }
@@ -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 {
@@ -67,6 +68,7 @@ import { diffLayouts, formatDiff, mutationHistory } from './mutation-history.js'
67
68
  import { buildCanvasSummary, serializeCanvasLayout, serializeCanvasNode } from './canvas-serialization.js';
68
69
  import { buildCodeGraphSummary, formatCodeGraph } from './code-graph.js';
69
70
  import { buildAgentContextPreamble, serializeNodeForAgentContext } from './agent-context.js';
71
+ import { validateLocalImageFile } from './image-source.js';
70
72
  import {
71
73
  addCanvasNode,
72
74
  addCanvasEdge,
@@ -1091,7 +1093,7 @@ async function handleCanvasViewport(req: Request): Promise<Response> {
1091
1093
  }
1092
1094
 
1093
1095
  // ── Serve image file for image nodes ─────────────────────────
1094
- function handleCanvasImage(pathname: string): Response {
1096
+ async function handleCanvasImage(pathname: string): Promise<Response> {
1095
1097
  const nodeId = pathname.replace('/api/canvas/image/', '');
1096
1098
  const node = canvasState.getNode(nodeId);
1097
1099
  if (!node || node.type !== 'image') {
@@ -1105,9 +1107,13 @@ function handleCanvasImage(pathname: string): Response {
1105
1107
  if (!existsSync(safePath)) {
1106
1108
  return responseText('Image file not found', 404);
1107
1109
  }
1108
- const ext = safePath.split('.').pop()?.toLowerCase() ?? '';
1109
- const contentType = IMAGE_MIME_MAP[ext] || 'application/octet-stream';
1110
- 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);
1111
1117
  return new Response(data, {
1112
1118
  headers: {
1113
1119
  'Content-Type': contentType,
@@ -1495,6 +1501,12 @@ async function handleCanvasBuildWebArtifact(req: Request): Promise<Response> {
1495
1501
  bytes: result.fileSize,
1496
1502
  projectPath: result.projectPath,
1497
1503
  openedInCanvas: result.openedInCanvas,
1504
+ // `id` is the canvas node id alias used by every other add-style
1505
+ // response. It is only present when a canvas node was actually
1506
+ // created (i.e. openInCanvas was not explicitly disabled). When
1507
+ // there is no canvas node, the alias is intentionally omitted so
1508
+ // consumers can `'id' in response` to detect the build-only case.
1509
+ ...(typeof result.nodeId === 'string' ? { id: result.nodeId } : {}),
1498
1510
  nodeId: result.nodeId,
1499
1511
  url: result.url,
1500
1512
  metadata: result.metadata,
@@ -3773,7 +3785,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3773
3785
  }
3774
3786
 
3775
3787
  if (url.pathname.startsWith('/api/canvas/image/') && req.method === 'GET') {
3776
- return handleCanvasImage(url.pathname);
3788
+ return await handleCanvasImage(url.pathname);
3777
3789
  }
3778
3790
 
3779
3791
  if (url.pathname === '/api/canvas/edge' && req.method === 'POST') {