pmx-canvas 0.1.1 → 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.
Files changed (36) hide show
  1. package/CHANGELOG.md +131 -0
  2. package/Readme.md +35 -8
  3. package/dist/canvas/index.js +70 -70
  4. package/dist/types/client/nodes/ExtAppFrame.d.ts +13 -1
  5. package/dist/types/client/state/canvas-store.d.ts +2 -1
  6. package/dist/types/client/types.d.ts +3 -0
  7. package/dist/types/server/bundled-skills.d.ts +40 -0
  8. package/dist/types/server/diagram-presets.d.ts +13 -0
  9. package/dist/types/server/index.d.ts +6 -1
  10. package/dist/types/server/web-artifacts.d.ts +1 -0
  11. package/dist/types/shared/ext-app-tool-result.d.ts +12 -0
  12. package/package.json +2 -1
  13. package/skills/pmx-canvas/SKILL.md +26 -5
  14. package/skills/pmx-canvas/references/installing-pmx-canvas.md +66 -0
  15. package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +10 -0
  16. package/skills/web-artifacts-builder/scripts/init-artifact.sh +1 -1
  17. package/src/cli/agent.ts +78 -7
  18. package/src/cli/index.ts +22 -2
  19. package/src/client/App.tsx +2 -1
  20. package/src/client/canvas/CanvasNode.tsx +3 -2
  21. package/src/client/canvas/ExpandedNodeOverlay.tsx +6 -1
  22. package/src/client/nodes/ExtAppFrame.tsx +183 -38
  23. package/src/client/state/canvas-store.ts +63 -1
  24. package/src/client/state/sse-bridge.ts +5 -0
  25. package/src/client/types.ts +12 -0
  26. package/src/mcp/server.ts +92 -6
  27. package/src/server/bundled-skills.ts +143 -0
  28. package/src/server/canvas-operations.ts +57 -8
  29. package/src/server/canvas-schema.ts +2 -1
  30. package/src/server/diagram-presets.ts +219 -4
  31. package/src/server/index.ts +22 -10
  32. package/src/server/server.ts +172 -45
  33. package/src/server/web-artifacts/scripts/bundle-artifact.sh +10 -0
  34. package/src/server/web-artifacts/scripts/init-artifact.sh +1 -1
  35. package/src/server/web-artifacts.ts +83 -3
  36. package/src/shared/ext-app-tool-result.ts +25 -0
package/src/mcp/server.ts CHANGED
@@ -37,6 +37,7 @@ import { searchNodes, buildSpatialContext, findNeighborhoods } from '../server/s
37
37
  import { mutationHistory, diffLayouts, formatDiff } from '../server/mutation-history.js';
38
38
  import { buildCodeGraphSummary, formatCodeGraph } from '../server/code-graph.js';
39
39
  import { buildCanvasSummary, serializeCanvasLayout, serializeCanvasNode } from '../server/canvas-serialization.js';
40
+ import { listBundledSkills, readBundledSkill } from '../server/bundled-skills.js';
40
41
 
41
42
  let canvas: PmxCanvas | null = null;
42
43
 
@@ -339,7 +340,7 @@ export async function startMcpServer(): Promise<void> {
339
340
  // ── canvas_build_web_artifact ───────────────────────────────
340
341
  server.tool(
341
342
  'canvas_build_web_artifact',
342
- 'Build a bundled single-file HTML web artifact from React/Tailwind source files using the bundled web-artifacts-builder skill scripts. Optionally opens the generated artifact as an embedded node on the canvas.',
343
+ 'Build a bundled single-file HTML web artifact from React/Tailwind source files using the bundled web-artifacts-builder skill scripts. Optionally opens the generated artifact as an embedded node on the canvas. Read canvas://skills/web-artifacts-builder for the full workflow, stack, and anti-slop design guidelines before calling.',
343
344
  {
344
345
  title: z.string().describe('Artifact title used for default project and output paths'),
345
346
  appTsx: z.string().describe('Contents for src/App.tsx'),
@@ -347,6 +348,7 @@ export async function startMcpServer(): Promise<void> {
347
348
  mainTsx: z.string().optional().describe('Optional contents for src/main.tsx'),
348
349
  indexHtml: z.string().optional().describe('Optional contents for index.html'),
349
350
  files: z.record(z.string(), z.string()).optional().describe('Optional map of additional project-relative file paths to file contents'),
351
+ deps: z.array(z.string()).optional().describe('Optional npm dependencies to install before bundling (e.g. ["recharts", "framer-motion@^11"]). Validated against npm-name format; flags and shell metacharacters are rejected.'),
350
352
  projectPath: z.string().optional().describe('Optional workspace-relative reusable project path. Defaults to .pmx-canvas/artifacts/.web-artifacts/<slug>'),
351
353
  outputPath: z.string().optional().describe('Optional workspace-relative HTML output path. Defaults to .pmx-canvas/artifacts/<slug>.html'),
352
354
  openInCanvas: z.boolean().optional().describe('Open the generated artifact in canvas after build (default true)'),
@@ -365,6 +367,7 @@ export async function startMcpServer(): Promise<void> {
365
367
  ...(typeof input.mainTsx === 'string' ? { mainTsx: input.mainTsx } : {}),
366
368
  ...(typeof input.indexHtml === 'string' ? { indexHtml: input.indexHtml } : {}),
367
369
  ...(input.files ? { files: input.files } : {}),
370
+ ...(Array.isArray(input.deps) ? { deps: input.deps } : {}),
368
371
  ...(typeof input.projectPath === 'string'
369
372
  ? { projectPath: safeWorkspacePath(input.projectPath) }
370
373
  : {}),
@@ -671,13 +674,34 @@ export async function startMcpServer(): Promise<void> {
671
674
  // ── canvas_focus_node ──────────────────────────────────────────
672
675
  server.tool(
673
676
  'canvas_focus_node',
674
- 'Pan the viewport to center on a specific node.',
675
- { id: z.string().describe('Node ID to focus on') },
676
- async ({ id }) => {
677
+ 'Bring a node into focus. By default the viewport pans so the node is centered. Pass noPan=true to raise/select the node without moving the human\'s camera (useful when reacting to background events without disrupting the human\'s current view).',
678
+ {
679
+ id: z.string().describe('Node ID to focus on'),
680
+ noPan: z
681
+ .boolean()
682
+ .optional()
683
+ .describe('If true, raise/select the node without panning the viewport. Default false.'),
684
+ },
685
+ async ({ id, noPan }) => {
677
686
  const c = await ensureCanvas();
678
- c.focusNode(id);
687
+ const result = c.focusNode(id, { ...(noPan === true ? { noPan: true } : {}) });
688
+ if (!result) {
689
+ return {
690
+ content: [
691
+ {
692
+ type: 'text',
693
+ text: JSON.stringify({ ok: false, error: `Node "${id}" not found.` }),
694
+ },
695
+ ],
696
+ };
697
+ }
679
698
  return {
680
- content: [{ type: 'text', text: JSON.stringify({ ok: true, focused: id }) }],
699
+ content: [
700
+ {
701
+ type: 'text',
702
+ text: JSON.stringify({ ok: true, focused: result.focused, panned: result.panned }),
703
+ },
704
+ ],
681
705
  };
682
706
  },
683
707
  );
@@ -1158,6 +1182,68 @@ export async function startMcpServer(): Promise<void> {
1158
1182
  },
1159
1183
  );
1160
1184
 
1185
+ // ── canvas://skills ────────────────────────────────────────
1186
+ // Discoverability for the skill prompts bundled with the npm package
1187
+ // (skills/<name>/SKILL.md). Before 0.1.2 these files shipped but were
1188
+ // invisible to agents — calling canvas_build_web_artifact without the
1189
+ // companion `web-artifacts-builder` skill led to predictable misuse.
1190
+ // The index lists every bundled skill with its frontmatter description;
1191
+ // individual skills are served verbatim at canvas://skills/<name>.
1192
+ server.resource(
1193
+ 'bundled-skills',
1194
+ 'canvas://skills',
1195
+ {
1196
+ description:
1197
+ 'Index of agent skills bundled with this PMX Canvas install. Lists name, ' +
1198
+ 'description, and per-skill URI (canvas://skills/<name>). Read a specific ' +
1199
+ 'skill for workflow guidance — notably web-artifacts-builder for ' +
1200
+ 'canvas_build_web_artifact, and pmx-canvas for the broader workbench.',
1201
+ mimeType: 'application/json',
1202
+ },
1203
+ async () => {
1204
+ const skills = listBundledSkills();
1205
+ const index = {
1206
+ count: skills.length,
1207
+ skills: skills.map((s) => ({ name: s.name, description: s.description, uri: s.uri })),
1208
+ };
1209
+ return {
1210
+ contents: [
1211
+ {
1212
+ uri: 'canvas://skills',
1213
+ mimeType: 'application/json',
1214
+ text: JSON.stringify(index, null, 2),
1215
+ },
1216
+ ],
1217
+ };
1218
+ },
1219
+ );
1220
+
1221
+ // Register each bundled skill as its own resource so agents can address
1222
+ // them individually (canvas://skills/web-artifacts-builder, etc.) and
1223
+ // MCP clients can display them with per-skill descriptions.
1224
+ for (const skill of listBundledSkills()) {
1225
+ server.resource(
1226
+ `skill-${skill.name}`,
1227
+ skill.uri,
1228
+ {
1229
+ description: skill.description || `Bundled PMX Canvas skill: ${skill.name}`,
1230
+ mimeType: 'text/markdown',
1231
+ },
1232
+ async () => {
1233
+ const markdown = readBundledSkill(skill.name);
1234
+ return {
1235
+ contents: [
1236
+ {
1237
+ uri: skill.uri,
1238
+ mimeType: 'text/markdown',
1239
+ text: markdown ?? `# ${skill.name}\n\n_Skill file not found on disk._\n`,
1240
+ },
1241
+ ],
1242
+ };
1243
+ },
1244
+ );
1245
+ }
1246
+
1161
1247
  // ── canvas_create_group ──────────────────────────────────────
1162
1248
  server.tool(
1163
1249
  'canvas_create_group',
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Bundled-skill discovery for the PMX Canvas MCP server.
3
+ *
4
+ * Skill files ship inside the npm package under `skills/<name>/SKILL.md`
5
+ * but until 0.1.2 they were not discoverable to the agent — an agent
6
+ * calling `canvas_build_web_artifact` had no way to find the companion
7
+ * `skills/web-artifacts-builder/SKILL.md` prompt that documents the
8
+ * workflow, stack choices, and gotchas.
9
+ *
10
+ * This module locates the bundled `skills/` directory relative to the
11
+ * package root (works for both repo-local development and global npm
12
+ * installs), parses the YAML frontmatter of each `SKILL.md` to produce
13
+ * a compact index, and reads individual skill content on demand.
14
+ *
15
+ * Exposed via MCP as:
16
+ * - `canvas://skills` → JSON index
17
+ * - `canvas://skills/<name>` → full markdown content
18
+ */
19
+
20
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
21
+ import { dirname, join, resolve } from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ export interface BundledSkill {
25
+ name: string;
26
+ description: string;
27
+ uri: string;
28
+ filePath: string;
29
+ }
30
+
31
+ const MAX_DESCRIPTION_LENGTH = 400;
32
+
33
+ /**
34
+ * Resolve the packaged `skills/` directory. Walks parents from this module
35
+ * looking for a sibling `skills/` that contains at least one `<name>/SKILL.md`,
36
+ * so it works whether the code runs from source (`src/server/…`), from a
37
+ * compiled bundle (`dist/…`), or from a global npm install
38
+ * (`/opt/homebrew/lib/node_modules/pmx-canvas/src/server/…`).
39
+ */
40
+ export function findBundledSkillsRoot(): string | null {
41
+ let current = dirname(fileURLToPath(import.meta.url));
42
+ const seen = new Set<string>();
43
+ while (!seen.has(current)) {
44
+ seen.add(current);
45
+ const candidate = join(current, 'skills');
46
+ if (existsSync(candidate)) {
47
+ try {
48
+ if (statSync(candidate).isDirectory()) {
49
+ const entries = readdirSync(candidate);
50
+ for (const entry of entries) {
51
+ if (existsSync(join(candidate, entry, 'SKILL.md'))) {
52
+ return resolve(candidate);
53
+ }
54
+ }
55
+ }
56
+ } catch {
57
+ // swallow and keep walking up — a permissions/transient error on this
58
+ // candidate shouldn't prevent finding a valid skills root higher up.
59
+ }
60
+ }
61
+ const parent = dirname(current);
62
+ if (parent === current) break;
63
+ current = parent;
64
+ }
65
+ return null;
66
+ }
67
+
68
+ function parseFrontmatterDescription(markdown: string): string {
69
+ // YAML frontmatter lives between two `---` fences at the very top.
70
+ if (!markdown.startsWith('---')) return '';
71
+ const end = markdown.indexOf('\n---', 3);
72
+ if (end === -1) return '';
73
+ const frontmatter = markdown.slice(3, end);
74
+ const lines = frontmatter.split('\n');
75
+
76
+ // Support both single-line `description: ...` and block-scalar `description: >` /
77
+ // `description: |` forms (indented continuation lines).
78
+ for (let i = 0; i < lines.length; i++) {
79
+ const line = lines[i] ?? '';
80
+ const match = /^description:\s*(.*)$/.exec(line);
81
+ if (!match) continue;
82
+ const first = (match[1] ?? '').trim();
83
+ if (first && first !== '>' && first !== '|' && first !== '>-' && first !== '|-') {
84
+ return first.slice(0, MAX_DESCRIPTION_LENGTH);
85
+ }
86
+ // Block scalar — concatenate indented follow-on lines.
87
+ const parts: string[] = [];
88
+ for (let j = i + 1; j < lines.length; j++) {
89
+ const follow = lines[j] ?? '';
90
+ if (follow.length === 0) {
91
+ parts.push('');
92
+ continue;
93
+ }
94
+ if (!/^\s/.test(follow)) break;
95
+ parts.push(follow.trim());
96
+ }
97
+ return parts.join(' ').replace(/\s+/g, ' ').trim().slice(0, MAX_DESCRIPTION_LENGTH);
98
+ }
99
+ return '';
100
+ }
101
+
102
+ /**
103
+ * Enumerate every `<name>/SKILL.md` under the bundled skills root and return
104
+ * a compact index. Hidden directories (dotfolders) and files that don't parse
105
+ * are skipped silently rather than throwing — missing metadata should never
106
+ * break the MCP server's resource listing.
107
+ */
108
+ export function listBundledSkills(): BundledSkill[] {
109
+ const root = findBundledSkillsRoot();
110
+ if (!root) return [];
111
+ const entries = readdirSync(root);
112
+ const skills: BundledSkill[] = [];
113
+ for (const entry of entries) {
114
+ if (entry.startsWith('.')) continue;
115
+ const skillFile = join(root, entry, 'SKILL.md');
116
+ if (!existsSync(skillFile)) continue;
117
+ try {
118
+ const markdown = readFileSync(skillFile, 'utf-8');
119
+ const description = parseFrontmatterDescription(markdown);
120
+ skills.push({
121
+ name: entry,
122
+ description,
123
+ uri: `canvas://skills/${entry}`,
124
+ filePath: skillFile,
125
+ });
126
+ } catch {
127
+ // skip unreadable files
128
+ }
129
+ }
130
+ skills.sort((a, b) => a.name.localeCompare(b.name));
131
+ return skills;
132
+ }
133
+
134
+ export function readBundledSkill(name: string): string | null {
135
+ const skills = listBundledSkills();
136
+ const match = skills.find((s) => s.name === name);
137
+ if (!match) return null;
138
+ try {
139
+ return readFileSync(match.filePath, 'utf-8');
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
@@ -36,6 +36,7 @@ import {
36
36
  getWebpageFetchErrorDetails,
37
37
  normalizeWebpageUrl,
38
38
  } from './webpage-node.js';
39
+ import { buildExcalidrawRestoreCheckpointToolInput, ensureExcalidrawCheckpointId, isExcalidrawCreateView } from './diagram-presets.js';
39
40
 
40
41
  export type CanvasArrangeMode = 'grid' | 'column' | 'flow';
41
42
  export type CanvasPinMode = 'set' | 'add' | 'remove';
@@ -82,6 +83,29 @@ function isRecord(value: unknown): value is Record<string, unknown> {
82
83
  return value !== null && typeof value === 'object' && !Array.isArray(value);
83
84
  }
84
85
 
86
+ function getStoredExcalidrawCheckpointId(node: CanvasNodeState): string | null {
87
+ const appCheckpoint = isRecord(node.data.appCheckpoint) ? node.data.appCheckpoint : null;
88
+ const checkpointId = appCheckpoint?.id;
89
+ return typeof checkpointId === 'string' && checkpointId.trim().length > 0 ? checkpointId.trim() : null;
90
+ }
91
+
92
+ function resolveExtAppRehydratedToolInput(
93
+ node: CanvasNodeState,
94
+ openedToolInput: Record<string, unknown>,
95
+ ): Record<string, unknown> {
96
+ if (!isExcalidrawCreateView(node.data.serverName, node.data.toolName)) return openedToolInput;
97
+ const checkpointId = getStoredExcalidrawCheckpointId(node);
98
+ if (!checkpointId) return openedToolInput;
99
+ const appCheckpoint = isRecord(node.data.appCheckpoint) ? node.data.appCheckpoint : null;
100
+ return {
101
+ ...openedToolInput,
102
+ elements: buildExcalidrawRestoreCheckpointToolInput(
103
+ checkpointId,
104
+ typeof appCheckpoint?.data === 'string' ? appCheckpoint.data : undefined,
105
+ ),
106
+ };
107
+ }
108
+
85
109
  function isExtAppNode(node: CanvasNodeState | undefined): node is CanvasNodeState {
86
110
  return node?.type === 'mcp-app' && node.data.mode === 'ext-app';
87
111
  }
@@ -280,13 +304,18 @@ export async function syncCanvasRuntimeBackends(
280
304
  ? { serverName: current.data.serverName.trim() }
281
305
  : {}),
282
306
  });
307
+ const toolInput = resolveExtAppRehydratedToolInput(current, opened.toolInput);
308
+ const storedCheckpointId = getStoredExcalidrawCheckpointId(current);
309
+ const toolResult = isExcalidrawCreateView(opened.serverName, opened.toolName)
310
+ ? ensureExcalidrawCheckpointId(opened.toolResult, nodeId, storedCheckpointId)
311
+ : opened.toolResult;
283
312
 
284
313
  canvasState.withSuppressedRecording(() => {
285
314
  setExtAppRuntimeState(nodeId, {
286
315
  appSessionId: opened.sessionId,
287
316
  html: opened.html,
288
- toolInput: opened.toolInput,
289
- toolResult: opened.toolResult,
317
+ toolInput,
318
+ toolResult,
290
319
  resourceUri: opened.resourceUri,
291
320
  toolDefinition: opened.tool,
292
321
  resourceMeta: opened.resourceMeta,
@@ -392,17 +421,36 @@ function buildImageNodeData(input: CanvasAddNodeInput): Record<string, unknown>
392
421
  const src = input.content ?? '';
393
422
  const isDataUri = src.startsWith('data:');
394
423
  const isUrl = src.startsWith('http://') || src.startsWith('https://');
424
+
425
+ if (isDataUri) {
426
+ // Basic data-URI sanity: must be an image/* mediatype.
427
+ const header = src.slice(5, src.indexOf(',') >= 0 ? src.indexOf(',') : src.length);
428
+ if (!/^image\//i.test(header)) {
429
+ throw new Error(
430
+ `Invalid image node: data URI must be an image/* media type (got "${header.slice(0, 40)}"). ` +
431
+ `Accepted: png, jpeg, gif, svg+xml, webp, bmp, avif, x-icon.`,
432
+ );
433
+ }
434
+ }
435
+
395
436
  if (!isDataUri && !isUrl && src) {
396
437
  const resolved = resolve(src);
438
+ const ext = resolved.split('.').pop()?.toLowerCase() ?? '';
397
439
  const fileName = resolved.split('/').pop() ?? src;
440
+ const mime = IMAGE_MIME_MAP[ext];
441
+ if (!mime) {
442
+ throw new Error(
443
+ `Invalid image node: "${fileName}" has unsupported extension ".${ext}". ` +
444
+ `Accepted: ${Object.keys(IMAGE_MIME_MAP).join(', ')}. ` +
445
+ `For non-image files use type="file" (live viewer) or type="webpage" (URL) instead.`,
446
+ );
447
+ }
398
448
  return {
399
449
  ...(input.data ?? {}),
400
450
  src: resolved,
401
451
  title: input.title ?? fileName,
402
452
  path: resolved,
403
- ...(IMAGE_MIME_MAP[resolved.split('.').pop()?.toLowerCase() ?? '']
404
- ? { mimeType: IMAGE_MIME_MAP[resolved.split('.').pop()?.toLowerCase() ?? ''] }
405
- : {}),
453
+ mimeType: mime,
406
454
  };
407
455
  }
408
456
 
@@ -685,7 +733,7 @@ function collectArrangeExcludedNodeIds(nodes: CanvasNodeState[]): Set<string> {
685
733
  const excluded = new Set<string>();
686
734
  for (const node of nodes) {
687
735
  const parentGroup = typeof node.data.parentGroup === 'string' ? node.data.parentGroup : null;
688
- if (isArrangeLocked(node) || (parentGroup && excludedGroupIds.has(parentGroup))) {
736
+ if (parentGroup || isArrangeLocked(node)) {
689
737
  excluded.add(node.id);
690
738
  }
691
739
  }
@@ -728,12 +776,13 @@ export function arrangeCanvasNodes(layout: CanvasArrangeMode): { arranged: numbe
728
776
  return;
729
777
  }
730
778
 
731
- const cols = Math.max(1, Math.floor(1440 / (360 + gap)));
779
+ const maxNodeWidth = movableNodes.reduce((max, node) => Math.max(max, node.size.width), 360);
780
+ const cols = Math.max(1, Math.floor(1440 / (maxNodeWidth + gap)));
732
781
  let col = 0;
733
782
  let rowY = 80;
734
783
  let rowMaxHeight = 0;
735
784
  for (const node of movableNodes) {
736
- const x = 40 + col * (360 + gap);
785
+ const x = 40 + col * (maxNodeWidth + gap);
737
786
  canvasState.updateNode(node.id, { position: { x, y: rowY } });
738
787
  rowMaxHeight = Math.max(rowMaxHeight, node.size.height);
739
788
  col++;
@@ -283,7 +283,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
283
283
  required: true,
284
284
  description: 'Chart type. Aliases like "stack" and "combo" are normalized server-side.',
285
285
  },
286
- { name: 'data', type: 'Record<string, unknown>[]', required: true, description: 'Chart dataset.' },
286
+ { name: 'data', type: 'Record<string, unknown>[]', required: true, description: 'Chart dataset.', aliases: ['data-json'] },
287
287
  { name: 'title', type: 'string', required: false, description: 'Optional graph title.' },
288
288
  { name: 'xKey', type: 'string', required: false, description: 'X-axis/category key for line, bar, area, scatter, stacked-bar, and composed charts.' },
289
289
  { name: 'yKey', type: 'string', required: false, description: 'Y-axis value key for line, bar, area, and scatter charts. Also used as a fallback bar key for composed charts.' },
@@ -334,6 +334,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
334
334
  { name: 'outputPath', type: 'string', required: false, description: 'Optional output HTML path.' },
335
335
  { name: 'openInCanvas', type: 'boolean', required: false, description: 'Open the built artifact on the canvas (default true).' },
336
336
  { name: 'includeLogs', type: 'boolean', required: false, description: 'Include raw build stdout/stderr in the response (default false).' },
337
+ { name: 'deps', type: 'string[]', required: false, description: 'Optional npm dependencies to add before bundling, e.g. recharts.', aliases: ['deps'] },
337
338
  ],
338
339
  example: {
339
340
  title: 'Dashboard Artifact',
@@ -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,