pmx-canvas 0.1.2 → 0.1.3

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,8 +1,40 @@
1
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
1
2
  import type { ExternalMcpTransportConfig } from './mcp-app-runtime.js';
2
3
 
3
4
  export const EXCALIDRAW_MCP_URL = 'https://mcp.excalidraw.com/mcp';
4
5
  export const EXCALIDRAW_SERVER_NAME = 'Excalidraw';
5
6
  export const EXCALIDRAW_CREATE_VIEW_TOOL = 'create_view';
7
+ export const EXCALIDRAW_SAVE_CHECKPOINT_TOOL = 'save_checkpoint';
8
+ export const EXCALIDRAW_READ_CHECKPOINT_TOOL = 'read_checkpoint';
9
+ const EXCALIDRAW_CAMERA_PADDING = 80;
10
+ const EXCALIDRAW_MIN_CAMERA_WIDTH = 320;
11
+ const EXCALIDRAW_MIN_CAMERA_HEIGHT = 240;
12
+ const EXCALIDRAW_CAMERA_ASPECT_RATIO = 4 / 3;
13
+ const EXCALIDRAW_CAMERA_SIZES = [
14
+ { width: 400, height: 300 },
15
+ { width: 600, height: 450 },
16
+ { width: 800, height: 600 },
17
+ { width: 1200, height: 900 },
18
+ { width: 1600, height: 1200 },
19
+ ];
20
+
21
+ export const DEFAULT_EXCALIDRAW_ELEMENTS: ReadonlyArray<Record<string, unknown>> = [
22
+ {
23
+ type: 'rectangle',
24
+ id: 'pmx-start',
25
+ x: 80,
26
+ y: 80,
27
+ width: 280,
28
+ height: 120,
29
+ roundness: { type: 3 },
30
+ backgroundColor: '#a5d8ff',
31
+ fillStyle: 'solid',
32
+ label: {
33
+ text: 'PMX Canvas',
34
+ fontSize: 24,
35
+ },
36
+ },
37
+ ];
6
38
 
7
39
  export const EXCALIDRAW_MCP_TRANSPORT: ExternalMcpTransportConfig = {
8
40
  type: 'http',
@@ -30,7 +62,11 @@ export interface ExcalidrawOpenMcpAppInput {
30
62
  height?: number;
31
63
  }
32
64
 
33
- export function normalizeExcalidrawElements(elements: unknown): string {
65
+ function isRecord(value: unknown): value is Record<string, unknown> {
66
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
67
+ }
68
+
69
+ function parseExcalidrawElements(elements: unknown): Array<Record<string, unknown>> {
34
70
  if (typeof elements === 'string') {
35
71
  const trimmed = elements.trim();
36
72
  if (!trimmed) {
@@ -46,16 +82,195 @@ export function normalizeExcalidrawElements(elements: unknown): string {
46
82
  if (!Array.isArray(parsed)) {
47
83
  throw new Error('diagram.elements string must encode a JSON array.');
48
84
  }
49
- return JSON.stringify(parsed);
85
+ return parsed.filter(isRecord);
50
86
  }
87
+
51
88
  if (Array.isArray(elements)) {
52
- return JSON.stringify(elements);
89
+ return elements.filter(isRecord);
53
90
  }
91
+
54
92
  throw new Error('diagram.elements must be a JSON array string or an array of Excalidraw elements.');
55
93
  }
56
94
 
95
+ function parseExcalidrawCheckpointElements(data: unknown): Array<Record<string, unknown>> | null {
96
+ let parsed: unknown = data;
97
+ if (typeof data === 'string') {
98
+ try {
99
+ parsed = JSON.parse(data);
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ if (Array.isArray(parsed)) return parsed.filter(isRecord);
106
+ if (isRecord(parsed) && Array.isArray(parsed.elements)) return parsed.elements.filter(isRecord);
107
+ return null;
108
+ }
109
+
110
+ function finiteNumber(value: unknown): number | null {
111
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
112
+ }
113
+
114
+ function elementHasCameraUpdate(elements: Array<Record<string, unknown>>): boolean {
115
+ return elements.some((element) => element.type === 'cameraUpdate');
116
+ }
117
+
118
+ function resolveExcalidrawCameraSize(width: number, height: number): { width: number; height: number } {
119
+ const requiredWidth = Math.max(EXCALIDRAW_MIN_CAMERA_WIDTH, width);
120
+ const requiredHeight = Math.max(EXCALIDRAW_MIN_CAMERA_HEIGHT, height);
121
+ const standard = EXCALIDRAW_CAMERA_SIZES.find(
122
+ (size) => size.width >= requiredWidth && size.height >= requiredHeight,
123
+ );
124
+ if (standard) return standard;
125
+
126
+ const heightFromWidth = requiredWidth / EXCALIDRAW_CAMERA_ASPECT_RATIO;
127
+ const widthFromHeight = requiredHeight * EXCALIDRAW_CAMERA_ASPECT_RATIO;
128
+ const cameraWidth = Math.ceil(Math.max(requiredWidth, widthFromHeight));
129
+ return {
130
+ width: cameraWidth,
131
+ height: Math.ceil(cameraWidth / EXCALIDRAW_CAMERA_ASPECT_RATIO),
132
+ };
133
+ }
134
+
135
+ export function inferExcalidrawCameraUpdate(
136
+ elements: Array<Record<string, unknown>>,
137
+ ): Record<string, unknown> | null {
138
+ let minX = Number.POSITIVE_INFINITY;
139
+ let minY = Number.POSITIVE_INFINITY;
140
+ let maxX = Number.NEGATIVE_INFINITY;
141
+ let maxY = Number.NEGATIVE_INFINITY;
142
+
143
+ const includePoint = (x: number, y: number) => {
144
+ minX = Math.min(minX, x);
145
+ minY = Math.min(minY, y);
146
+ maxX = Math.max(maxX, x);
147
+ maxY = Math.max(maxY, y);
148
+ };
149
+
150
+ for (const element of elements) {
151
+ if (element.isDeleted === true || element.type === 'cameraUpdate' || element.type === 'restoreCheckpoint' || element.type === 'delete') {
152
+ continue;
153
+ }
154
+
155
+ const x = finiteNumber(element.x);
156
+ const y = finiteNumber(element.y);
157
+ if (x === null || y === null) continue;
158
+
159
+ includePoint(x, y);
160
+ const width = finiteNumber(element.width) ?? 0;
161
+ const height = finiteNumber(element.height) ?? 0;
162
+ includePoint(x + width, y + height);
163
+
164
+ if (Array.isArray(element.points)) {
165
+ for (const point of element.points) {
166
+ if (!Array.isArray(point)) continue;
167
+ const pointX = finiteNumber(point[0]);
168
+ const pointY = finiteNumber(point[1]);
169
+ if (pointX === null || pointY === null) continue;
170
+ includePoint(x + pointX, y + pointY);
171
+ }
172
+ }
173
+ }
174
+
175
+ if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) {
176
+ return null;
177
+ }
178
+
179
+ const contentWidth = Math.max(1, maxX - minX);
180
+ const contentHeight = Math.max(1, maxY - minY);
181
+ const padding = Math.max(
182
+ EXCALIDRAW_CAMERA_PADDING,
183
+ Math.round(Math.max(contentWidth, contentHeight) * 0.18),
184
+ );
185
+ const camera = resolveExcalidrawCameraSize(contentWidth + padding * 2, contentHeight + padding * 2);
186
+ const centerX = minX + contentWidth / 2;
187
+ const centerY = minY + contentHeight / 2;
188
+
189
+ return {
190
+ type: 'cameraUpdate',
191
+ x: Math.round(centerX - camera.width / 2),
192
+ y: Math.round(centerY - camera.height / 2),
193
+ width: camera.width,
194
+ height: camera.height,
195
+ };
196
+ }
197
+
198
+ function withInferredCameraUpdate(
199
+ elements: Array<Record<string, unknown>>,
200
+ ): Array<Record<string, unknown>> {
201
+ if (elementHasCameraUpdate(elements)) return elements;
202
+ const camera = inferExcalidrawCameraUpdate(elements);
203
+ return camera ? [camera, ...elements] : elements;
204
+ }
205
+
206
+ export function normalizeExcalidrawElements(elements: unknown): string {
207
+ const parsed = parseExcalidrawElements(elements);
208
+ return JSON.stringify(parsed.length > 0 ? parsed : DEFAULT_EXCALIDRAW_ELEMENTS);
209
+ }
210
+
211
+ export function normalizeExcalidrawElementsForToolInput(elements: unknown): string {
212
+ const parsed = parseExcalidrawElements(elements);
213
+ const seeded = parsed.length > 0 ? parsed : [...DEFAULT_EXCALIDRAW_ELEMENTS];
214
+ return JSON.stringify(withInferredCameraUpdate(seeded));
215
+ }
216
+
217
+ export function normalizeExcalidrawCheckpointDataForToolInput(data: unknown): string | null {
218
+ const elements = parseExcalidrawCheckpointElements(data);
219
+
220
+ return elements ? JSON.stringify(withInferredCameraUpdate(elements)) : null;
221
+ }
222
+
223
+ export function buildExcalidrawRestoreCheckpointToolInput(checkpointId: string, data?: unknown): string {
224
+ const elements = parseExcalidrawCheckpointElements(data);
225
+ const camera = elements ? inferExcalidrawCameraUpdate(elements) : null;
226
+ return JSON.stringify([
227
+ { type: 'restoreCheckpoint', id: checkpointId },
228
+ ...(camera ? [camera] : []),
229
+ ]);
230
+ }
231
+
232
+ export function isExcalidrawCreateView(serverName: unknown, toolName: unknown): boolean {
233
+ return serverName === EXCALIDRAW_SERVER_NAME && toolName === EXCALIDRAW_CREATE_VIEW_TOOL;
234
+ }
235
+
236
+ export function buildExcalidrawCheckpointId(seed: string): string {
237
+ const safe = seed.replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 96);
238
+ return `pmx-${safe || 'checkpoint'}`;
239
+ }
240
+
241
+ export function getExcalidrawCheckpointIdFromToolResult(result: unknown): string | null {
242
+ if (!isRecord(result) || !isRecord(result.structuredContent)) return null;
243
+ const checkpointId = result.structuredContent.checkpointId;
244
+ return typeof checkpointId === 'string' && checkpointId.trim().length > 0 ? checkpointId.trim() : null;
245
+ }
246
+
247
+ export function withExcalidrawCheckpointId(
248
+ result: CallToolResult,
249
+ checkpointId: string,
250
+ ): CallToolResult {
251
+ const structuredContent = isRecord(result.structuredContent) ? result.structuredContent : {};
252
+ return {
253
+ ...result,
254
+ structuredContent: {
255
+ ...structuredContent,
256
+ checkpointId,
257
+ },
258
+ };
259
+ }
260
+
261
+ export function ensureExcalidrawCheckpointId(
262
+ result: CallToolResult,
263
+ seed: string,
264
+ checkpointId?: string | null,
265
+ ): CallToolResult {
266
+ return withExcalidrawCheckpointId(
267
+ result,
268
+ checkpointId ?? getExcalidrawCheckpointIdFromToolResult(result) ?? buildExcalidrawCheckpointId(seed),
269
+ );
270
+ }
271
+
57
272
  export function buildExcalidrawOpenMcpAppInput(input: DiagramPresetOpenInput): ExcalidrawOpenMcpAppInput {
58
- const elements = normalizeExcalidrawElements(input.elements);
273
+ const elements = normalizeExcalidrawElementsForToolInput(input.elements);
59
274
  const out: ExcalidrawOpenMcpAppInput = {
60
275
  transport: EXCALIDRAW_MCP_TRANSPORT,
61
276
  toolName: EXCALIDRAW_CREATE_VIEW_TOOL,
@@ -44,6 +44,8 @@ import {
44
44
  } from './mcp-app-runtime.js';
45
45
  import {
46
46
  buildExcalidrawOpenMcpAppInput,
47
+ ensureExcalidrawCheckpointId,
48
+ isExcalidrawCreateView,
47
49
  type DiagramPresetOpenInput,
48
50
  } from './diagram-presets.js';
49
51
  import {
@@ -323,16 +325,22 @@ export class PmxCanvas extends EventEmitter {
323
325
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
324
326
  }
325
327
 
326
- focusNode(id: string): void {
328
+ focusNode(id: string, options?: { noPan?: boolean }): { focused: string; panned: boolean } | null {
327
329
  const node = canvasState.getNode(id);
328
- if (!node) return;
329
- canvasState.setViewport({
330
- x: node.position.x - 100,
331
- y: node.position.y - 100,
332
- });
333
- emitPrimaryWorkbenchEvent('canvas-focus-node', { nodeId: id });
334
- emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
330
+ if (!node) return null;
331
+ const noPan = options?.noPan === true;
332
+ if (!noPan) {
333
+ canvasState.setViewport({
334
+ x: node.position.x - 100,
335
+ y: node.position.y - 100,
336
+ });
337
+ }
338
+ emitPrimaryWorkbenchEvent('canvas-focus-node', { nodeId: id, noPan });
339
+ if (!noPan) {
340
+ emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
341
+ }
335
342
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
343
+ return { focused: id, panned: !noPan };
336
344
  }
337
345
 
338
346
  getLayout(): CanvasLayout {
@@ -488,6 +496,10 @@ export class PmxCanvas extends EventEmitter {
488
496
  ...(input.serverName ? { serverName: input.serverName } : {}),
489
497
  });
490
498
  const toolCallId = `ext-app-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
499
+ const nodeIdSeed = `ext-app-${toolCallId}`;
500
+ const toolResult = isExcalidrawCreateView(opened.serverName, opened.toolName)
501
+ ? ensureExcalidrawCheckpointId(opened.toolResult, nodeIdSeed)
502
+ : opened.toolResult;
491
503
  emitPrimaryWorkbenchEvent('ext-app-open', {
492
504
  toolCallId,
493
505
  title: input.title ?? opened.tool.title ?? opened.tool.name,
@@ -511,8 +523,8 @@ export class PmxCanvas extends EventEmitter {
511
523
  toolCallId,
512
524
  serverName: opened.serverName,
513
525
  toolName: opened.toolName,
514
- success: opened.toolResult.isError !== true,
515
- result: opened.toolResult,
526
+ success: toolResult.isError !== true,
527
+ result: toolResult,
516
528
  });
517
529
  const nodeId = this.findCanvasExtAppNodeId(toolCallId);
518
530
  return {
@@ -39,6 +39,7 @@ import { existsSync, readFileSync, statSync, writeFileSync, appendFileSync } fro
39
39
  import { basename, extname, join, relative, resolve } from 'node:path';
40
40
  import * as marked from 'marked';
41
41
  import type {
42
+ CallToolResult,
42
43
  ListPromptsResult,
43
44
  ListResourcesResult,
44
45
  ListResourceTemplatesResult,
@@ -92,7 +93,16 @@ import {
92
93
  } from './canvas-operations.js';
93
94
  import { validateCanvasLayout } from './canvas-validation.js';
94
95
  import { describeCanvasSchema, validateStructuredCanvasPayload } from './canvas-schema.js';
95
- import { buildExcalidrawOpenMcpAppInput } from './diagram-presets.js';
96
+ import {
97
+ EXCALIDRAW_READ_CHECKPOINT_TOOL,
98
+ EXCALIDRAW_SAVE_CHECKPOINT_TOOL,
99
+ buildExcalidrawCheckpointId,
100
+ buildExcalidrawOpenMcpAppInput,
101
+ buildExcalidrawRestoreCheckpointToolInput,
102
+ ensureExcalidrawCheckpointId,
103
+ getExcalidrawCheckpointIdFromToolResult,
104
+ isExcalidrawCreateView,
105
+ } from './diagram-presets.js';
96
106
  import { traceManager } from './trace-manager.js';
97
107
  import { buildWebArtifactOnCanvas, resolveWorkspacePath } from './web-artifacts.js';
98
108
  import {
@@ -606,6 +616,79 @@ function findCanvasExtAppNodeId(toolCallId: string): string | null {
606
616
  return null;
607
617
  }
608
618
 
619
+ function isCheckpointToolName(toolName: string): boolean {
620
+ return toolName === EXCALIDRAW_SAVE_CHECKPOINT_TOOL || toolName === EXCALIDRAW_READ_CHECKPOINT_TOOL;
621
+ }
622
+
623
+ function shouldReplayAppToolResult(toolName: string, result: CallToolResult): boolean {
624
+ void toolName;
625
+ return Boolean(
626
+ result.isError ||
627
+ result.structuredContent ||
628
+ result.content.some((entry) => entry.type !== 'text' || entry.text !== 'ok'),
629
+ );
630
+ }
631
+
632
+ function isRecord(value: unknown): value is Record<string, unknown> {
633
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
634
+ }
635
+
636
+ function getExtAppNodeCheckpointId(node: CanvasNodeState): string {
637
+ const appCheckpoint = isRecord(node.data.appCheckpoint) ? node.data.appCheckpoint : null;
638
+ const storedCheckpointId = appCheckpoint?.id;
639
+ if (typeof storedCheckpointId === 'string' && storedCheckpointId.trim().length > 0) {
640
+ return storedCheckpointId.trim();
641
+ }
642
+ return getExcalidrawCheckpointIdFromToolResult(node.data.toolResult) ?? buildExcalidrawCheckpointId(node.id);
643
+ }
644
+
645
+ function getLocalExcalidrawCheckpointData(
646
+ node: CanvasNodeState,
647
+ args: Record<string, unknown> | undefined,
648
+ ): string | null {
649
+ if (!isExcalidrawCreateView(node.data.serverName, node.data.toolName)) return null;
650
+ if (!isRecord(args) || typeof args.id !== 'string') return null;
651
+ if (args.id.trim() !== getExtAppNodeCheckpointId(node)) return null;
652
+ const appCheckpoint = isRecord(node.data.appCheckpoint) ? node.data.appCheckpoint : null;
653
+ const data = appCheckpoint?.data;
654
+ return typeof data === 'string' ? data : '';
655
+ }
656
+
657
+ function persistExcalidrawCheckpointToNode(
658
+ nodeId: string,
659
+ node: CanvasNodeState,
660
+ args: Record<string, unknown> | undefined,
661
+ ): boolean {
662
+ if (!isExcalidrawCreateView(node.data.serverName, node.data.toolName)) return false;
663
+ if (!isRecord(args) || typeof args.id !== 'string') return false;
664
+ const checkpointId = getExtAppNodeCheckpointId(node);
665
+ if (args.id.trim() !== checkpointId) return false;
666
+
667
+ const currentToolInput = isRecord(node.data.toolInput) ? node.data.toolInput : {};
668
+ const nextToolInput = {
669
+ ...currentToolInput,
670
+ elements: buildExcalidrawRestoreCheckpointToolInput(checkpointId, args.data),
671
+ };
672
+ const currentToolResult = isRecord(node.data.toolResult)
673
+ ? ensureExcalidrawCheckpointId(node.data.toolResult as CallToolResult, node.id, checkpointId)
674
+ : undefined;
675
+
676
+ canvasState.updateNode(nodeId, {
677
+ data: {
678
+ ...node.data,
679
+ toolInput: nextToolInput,
680
+ ...(currentToolResult ? { toolResult: currentToolResult } : {}),
681
+ appCheckpoint: {
682
+ toolName: EXCALIDRAW_SAVE_CHECKPOINT_TOOL,
683
+ id: checkpointId,
684
+ ...(typeof args.data === 'string' ? { data: args.data } : {}),
685
+ updatedAt: new Date().toISOString(),
686
+ },
687
+ },
688
+ });
689
+ return true;
690
+ }
691
+
609
692
  function findReusableCanvasExtAppNodeId(serverName: string, toolName: string): string | null {
610
693
  for (const node of canvasState.getLayout().nodes) {
611
694
  if (
@@ -1323,7 +1406,13 @@ async function handleCanvasArrange(req: Request): Promise<Response> {
1323
1406
  }
1324
1407
  const result = arrangeCanvasNodes(layout as 'grid' | 'column' | 'flow');
1325
1408
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1326
- return responseJson({ ok: true, arranged: result.arranged, layout: result.layout });
1409
+ const validation = validateCanvasLayout(canvasState.getLayout());
1410
+ return responseJson({
1411
+ ok: validation.ok,
1412
+ arranged: result.arranged,
1413
+ layout: result.layout,
1414
+ ...(validation.ok ? {} : { validation, collisions: validation.summary.collisions }),
1415
+ });
1327
1416
  }
1328
1417
 
1329
1418
  // ── Focus on node ───────────────────────────────────────────
@@ -1333,11 +1422,17 @@ async function handleCanvasFocus(req: Request): Promise<Response> {
1333
1422
  if (!nodeId) return responseJson({ ok: false, error: 'Missing id.' }, 400);
1334
1423
  const node = canvasState.getNode(nodeId);
1335
1424
  if (!node) return responseJson({ ok: false, error: `Node "${nodeId}" not found.` }, 404);
1336
- canvasState.setViewport({ x: node.position.x - 100, y: node.position.y - 100 });
1337
- emitPrimaryWorkbenchEvent('canvas-focus-node', { nodeId });
1338
- emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
1425
+ const noPan = body.noPan === true;
1426
+ if (!noPan) {
1427
+ canvasState.setViewport({ x: node.position.x - 100, y: node.position.y - 100 });
1428
+ } else {
1429
+ const maxZ = canvasState.getLayout().nodes.reduce((max, layoutNode) => Math.max(max, layoutNode.zIndex), 0);
1430
+ canvasState.updateNode(nodeId, { zIndex: maxZ + 1 });
1431
+ }
1432
+ emitPrimaryWorkbenchEvent('canvas-focus-node', { nodeId, noPan });
1433
+ if (!noPan) emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
1339
1434
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1340
- return responseJson({ ok: true, focused: nodeId });
1435
+ return responseJson({ ok: true, focused: nodeId, panned: !noPan });
1341
1436
  }
1342
1437
 
1343
1438
  async function handleCanvasBuildWebArtifact(req: Request): Promise<Response> {
@@ -1375,6 +1470,9 @@ async function handleCanvasBuildWebArtifact(req: Request): Promise<Response> {
1375
1470
  ...(typeof body.bundleScriptPath === 'string'
1376
1471
  ? { bundleScriptPath: body.bundleScriptPath }
1377
1472
  : {}),
1473
+ ...(Array.isArray(body.deps)
1474
+ ? { deps: body.deps.filter((dep): dep is string => typeof dep === 'string') }
1475
+ : {}),
1378
1476
  ...(typeof body.timeoutMs === 'number' ? { timeoutMs: body.timeoutMs } : {}),
1379
1477
  ...(typeof body.openInCanvas === 'boolean' ? { openInCanvas: body.openInCanvas } : {}),
1380
1478
  });
@@ -1853,6 +1951,10 @@ async function runAndEmitOpenMcpApp(params: RunAndEmitOpenMcpAppParams): Promise
1853
1951
  });
1854
1952
 
1855
1953
  const toolCallId = randomExtAppToolCallId();
1954
+ const nodeIdSeed = `ext-app-${toolCallId}`;
1955
+ const toolResult = isExcalidrawCreateView(opened.serverName, opened.toolName)
1956
+ ? ensureExcalidrawCheckpointId(opened.toolResult, nodeIdSeed)
1957
+ : opened.toolResult;
1856
1958
  const nodeTitle = params.title ?? opened.tool.title ?? opened.tool.name;
1857
1959
 
1858
1960
  emitPrimaryWorkbenchEvent('ext-app-open', {
@@ -1878,8 +1980,8 @@ async function runAndEmitOpenMcpApp(params: RunAndEmitOpenMcpAppParams): Promise
1878
1980
  toolCallId,
1879
1981
  serverName: opened.serverName,
1880
1982
  toolName: opened.toolName,
1881
- success: opened.toolResult.isError !== true,
1882
- result: opened.toolResult,
1983
+ success: toolResult.isError !== true,
1984
+ result: toolResult,
1883
1985
  });
1884
1986
  const nodeId = findCanvasExtAppNodeId(toolCallId);
1885
1987
 
@@ -1979,35 +2081,53 @@ async function handleExtAppCallTool(req: Request): Promise<Response> {
1979
2081
  const nodeId = typeof body.nodeId === 'string' ? body.nodeId.trim() : '';
1980
2082
 
1981
2083
  try {
1982
- const result = await callMcpAppTool(sessionId, toolName, args);
2084
+ const requestedNode = nodeId ? canvasState.getNode(nodeId) : undefined;
2085
+ const canReadLocalCheckpoint =
2086
+ requestedNode?.type === 'mcp-app' &&
2087
+ requestedNode.data.mode === 'ext-app' &&
2088
+ requestedNode.data.appSessionId === sessionId;
2089
+ const localCheckpointData = canReadLocalCheckpoint && toolName === EXCALIDRAW_READ_CHECKPOINT_TOOL
2090
+ ? getLocalExcalidrawCheckpointData(requestedNode, args)
2091
+ : null;
2092
+ const result = localCheckpointData === null
2093
+ ? await callMcpAppTool(sessionId, toolName, args)
2094
+ : { content: [{ type: 'text', text: localCheckpointData }] } satisfies CallToolResult;
1983
2095
  if (nodeId) {
1984
2096
  const node = canvasState.getNode(nodeId);
1985
2097
  if (node?.type === 'mcp-app' && node.data.mode === 'ext-app' && node.data.appSessionId === sessionId) {
1986
- const nextData: Record<string, unknown> = {
1987
- ...node.data,
1988
- toolResult: result,
1989
- };
1990
- const nextModelContext: Record<string, unknown> = {};
1991
- if (Array.isArray(result.content)) {
1992
- nextModelContext.content = result.content;
1993
- }
1994
- if (result.structuredContent && typeof result.structuredContent === 'object' && !Array.isArray(result.structuredContent)) {
1995
- nextModelContext.structuredContent = result.structuredContent;
2098
+ let changed = false;
2099
+ if (toolName === EXCALIDRAW_SAVE_CHECKPOINT_TOOL && persistExcalidrawCheckpointToNode(nodeId, node, args)) {
2100
+ // Checkpoint saves are replayed through toolInput.elements instead of
2101
+ // replacing the original create_view result with a generic "ok".
2102
+ changed = true;
2103
+ } else if (!(isExcalidrawCreateView(node.data.serverName, node.data.toolName) && isCheckpointToolName(toolName))) {
2104
+ const nextData: Record<string, unknown> = { ...node.data };
2105
+ if (shouldReplayAppToolResult(toolName, result)) nextData.toolResult = result;
2106
+ const nextModelContext: Record<string, unknown> = {};
2107
+ if (Array.isArray(result.content)) {
2108
+ nextModelContext.content = result.content;
2109
+ }
2110
+ if (result.structuredContent && typeof result.structuredContent === 'object' && !Array.isArray(result.structuredContent)) {
2111
+ nextModelContext.structuredContent = result.structuredContent;
2112
+ }
2113
+ if (Object.keys(nextModelContext).length > 0) {
2114
+ nextData.appModelContext = {
2115
+ ...nextModelContext,
2116
+ updatedAt: new Date().toISOString(),
2117
+ };
2118
+ }
2119
+ canvasState.updateNode(nodeId, {
2120
+ data: nextData,
2121
+ });
2122
+ changed = true;
1996
2123
  }
1997
- if (Object.keys(nextModelContext).length > 0) {
1998
- nextData.appModelContext = {
1999
- ...nextModelContext,
2000
- updatedAt: new Date().toISOString(),
2001
- };
2124
+ if (changed) {
2125
+ broadcastWorkbenchEvent('canvas-layout-update', {
2126
+ layout: canvasState.getLayout(),
2127
+ sessionId: primaryWorkbenchSessionId,
2128
+ timestamp: new Date().toISOString(),
2129
+ });
2002
2130
  }
2003
- canvasState.updateNode(nodeId, {
2004
- data: nextData,
2005
- });
2006
- broadcastWorkbenchEvent('canvas-layout-update', {
2007
- layout: canvasState.getLayout(),
2008
- sessionId: primaryWorkbenchSessionId,
2009
- timestamp: new Date().toISOString(),
2010
- });
2011
2131
  }
2012
2132
  }
2013
2133
  return responseJson({ ok: true, result });
@@ -152,10 +152,20 @@ rm -rf dist bundle.html
152
152
  echo "🔨 Building with Parcel..."
153
153
  run_with_filtered_stderr run_local_binary parcel build index.html --dist-dir dist --no-source-maps --log-level error
154
154
 
155
+ if [ ! -s "dist/index.html" ]; then
156
+ echo "❌ Error: Parcel did not produce dist/index.html" >&2
157
+ exit 1
158
+ fi
159
+
155
160
  # Inline everything into single HTML
156
161
  echo "🎯 Inlining all assets into single HTML file..."
157
162
  run_with_filtered_stderr run_local_binary html-inline dist/index.html > bundle.html
158
163
 
164
+ if [ ! -s "bundle.html" ]; then
165
+ echo "❌ Error: Bundled artifact is empty" >&2
166
+ exit 1
167
+ fi
168
+
159
169
  # Get file size
160
170
  FILE_SIZE=$(du -h bundle.html | cut -f1)
161
171
 
@@ -173,7 +173,7 @@ fi
173
173
 
174
174
  echo "📦 Installing Tailwind CSS and dependencies..."
175
175
  run_pnpm_allow_build add -D tailwindcss@3.4.1 postcss @types/node tailwindcss-animate parcel @parcel/config-default parcel-resolver-tspaths html-inline
176
- run_pnpm_quiet add class-variance-authority clsx tailwind-merge lucide-react next-themes
176
+ run_pnpm_quiet add class-variance-authority clsx tailwind-merge lucide-react next-themes recharts
177
177
 
178
178
  echo "⚙️ Creating Tailwind and PostCSS configuration..."
179
179
  cat > .postcssrc.json << 'EOF'
@@ -38,6 +38,7 @@ export interface WebArtifactBuildInput {
38
38
  outputPath?: string;
39
39
  initScriptPath?: string;
40
40
  bundleScriptPath?: string;
41
+ deps?: string[];
41
42
  timeoutMs?: number;
42
43
  }
43
44
 
@@ -322,6 +323,19 @@ function ensurePackageManagerBoundary(dirPath: string): void {
322
323
  writeFileSync(packageJsonPath, JSON.stringify(nextPackageJson, null, 2), 'utf-8');
323
324
  }
324
325
 
326
+ function normalizeDependencyNames(deps: string[] | undefined): string[] {
327
+ const normalized = new Set<string>();
328
+ for (const dep of deps ?? []) {
329
+ const trimmed = dep.trim();
330
+ if (!trimmed) continue;
331
+ if (trimmed.startsWith('-') || !/^(@[a-z0-9._-]+\/)?[a-z0-9._-]+(@[\w.+~^*-][\w.+~^*-]*)?$/i.test(trimmed)) {
332
+ throw new Error(`Invalid web-artifact dependency name: ${dep}`);
333
+ }
334
+ normalized.add(trimmed);
335
+ }
336
+ return [...normalized];
337
+ }
338
+
325
339
  function summarizeArtifactLog(text: string): WebArtifactLogSummary | undefined {
326
340
  if (!text.trim()) return undefined;
327
341
 
@@ -391,6 +405,23 @@ export async function executeWebArtifactBuild(
391
405
 
392
406
  writeProjectFiles(projectPath, input);
393
407
 
408
+ const deps = normalizeDependencyNames(input.deps);
409
+ if (deps.length > 0) {
410
+ const quotedDeps = deps.map((dep) => `'${dep.replaceAll("'", "'\\''")}'`).join(' ');
411
+ const pnpmVersion = DEFAULT_PACKAGE_MANAGER.split('@')[1] ?? '10.33.0';
412
+ // Use `bash -c` (not `-lc`): `-lc` sources the user's login profile
413
+ // (~/.bashrc, ~/.zshrc), which can mutate PATH or set aliases that
414
+ // change install behavior. With deps already validated against
415
+ // npm-name format and quoted via single-quote escaping, the regular
416
+ // shell is sufficient and reproducible across machines.
417
+ const depResult = await runProcess('bash', ['-c', `if command -v pnpm >/dev/null 2>&1; then pnpm --silent add --ignore-scripts -- ${quotedDeps}; elif command -v bun >/dev/null 2>&1; then bun x pnpm@${pnpmVersion} --silent add --ignore-scripts -- ${quotedDeps}; else npm install --ignore-scripts -- ${quotedDeps}; fi`], {
418
+ cwd: projectPath,
419
+ timeoutMs,
420
+ });
421
+ stdout = [stdout, depResult.stdout].filter(Boolean).join('\n');
422
+ stderr = [stderr, depResult.stderr].filter(Boolean).join('\n');
423
+ }
424
+
394
425
  const bundleResult = await runProcess('bash', [bundleScriptPath], {
395
426
  cwd: projectPath,
396
427
  timeoutMs,
@@ -403,18 +434,29 @@ export async function executeWebArtifactBuild(
403
434
  throw new Error(`Expected bundled artifact at ${bundlePath}`);
404
435
  }
405
436
 
437
+ const bundleSize = statSync(bundlePath).size;
438
+ if (bundleSize <= 0) {
439
+ throw new Error(`Bundled artifact is empty: ${bundlePath}`);
440
+ }
441
+
406
442
  mkdirSync(dirname(outputPath), { recursive: true });
407
443
  copyFileSync(bundlePath, outputPath);
444
+ // The script-side check in bundle-artifact.sh and the bundleSize check
445
+ // above already guarantee a non-empty source; copyFileSync would throw
446
+ // on a filesystem failure. A post-copy size check would be redundant
447
+ // defensive noise — see CLAUDE.md TypeScript Guardrail #3.
448
+ const fileSize = bundleSize;
408
449
 
409
450
  return {
410
451
  filePath: outputPath,
411
- fileSize: statSync(outputPath).size,
452
+ fileSize,
412
453
  projectPath,
413
454
  metadata: {
414
455
  title: input.title,
415
456
  bundlePath,
416
457
  projectPath,
417
458
  hasIndexCss: typeof input.indexCss === 'string',
459
+ ...(deps.length > 0 ? { deps } : {}),
418
460
  extraFileCount: Object.keys(input.files ?? {}).length,
419
461
  outputPreview: readFileSync(outputPath, 'utf-8').slice(0, 200),
420
462
  },