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.
- package/CHANGELOG.md +138 -0
- package/dist/canvas/index.js +25 -25
- package/dist/json-render/index.css +1 -1
- package/dist/json-render/index.js +1 -1
- package/dist/types/json-render/charts/components.d.ts +2 -1
- package/dist/types/server/canvas-serialization.d.ts +1 -0
- package/dist/types/server/ext-app-lookup.d.ts +22 -0
- package/dist/types/server/image-source.d.ts +3 -0
- package/dist/types/server/index.d.ts +2 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +9 -5
- package/src/cli/agent.ts +78 -21
- package/src/client/state/sse-bridge.ts +14 -4
- package/src/json-render/charts/components.tsx +6 -4
- package/src/json-render/charts/extra-components.tsx +5 -5
- package/src/json-render/renderer/index.css +14 -0
- package/src/mcp/server.ts +16 -0
- package/src/server/canvas-operations.ts +11 -11
- package/src/server/canvas-schema.ts +15 -14
- package/src/server/canvas-serialization.ts +19 -1
- package/src/server/ext-app-lookup.ts +49 -0
- package/src/server/image-source.ts +206 -0
- package/src/server/index.ts +11 -15
- package/src/server/server.ts +65 -25
- package/src/server/web-artifacts.ts +1 -0
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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 =
|
|
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
|
}
|
package/src/server/server.ts
CHANGED
|
@@ -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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
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
|
|
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 =
|
|
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') {
|