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.
- package/CHANGELOG.md +131 -0
- package/Readme.md +35 -8
- package/dist/canvas/index.js +70 -70
- package/dist/types/client/nodes/ExtAppFrame.d.ts +13 -1
- package/dist/types/client/state/canvas-store.d.ts +2 -1
- package/dist/types/client/types.d.ts +3 -0
- package/dist/types/server/bundled-skills.d.ts +40 -0
- package/dist/types/server/diagram-presets.d.ts +13 -0
- package/dist/types/server/index.d.ts +6 -1
- package/dist/types/server/web-artifacts.d.ts +1 -0
- package/dist/types/shared/ext-app-tool-result.d.ts +12 -0
- package/package.json +2 -1
- package/skills/pmx-canvas/SKILL.md +26 -5
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +66 -0
- package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +10 -0
- package/skills/web-artifacts-builder/scripts/init-artifact.sh +1 -1
- package/src/cli/agent.ts +78 -7
- package/src/cli/index.ts +22 -2
- package/src/client/App.tsx +2 -1
- package/src/client/canvas/CanvasNode.tsx +3 -2
- package/src/client/canvas/ExpandedNodeOverlay.tsx +6 -1
- package/src/client/nodes/ExtAppFrame.tsx +183 -38
- package/src/client/state/canvas-store.ts +63 -1
- package/src/client/state/sse-bridge.ts +5 -0
- package/src/client/types.ts +12 -0
- package/src/mcp/server.ts +92 -6
- package/src/server/bundled-skills.ts +143 -0
- package/src/server/canvas-operations.ts +57 -8
- package/src/server/canvas-schema.ts +2 -1
- package/src/server/diagram-presets.ts +219 -4
- package/src/server/index.ts +22 -10
- package/src/server/server.ts +172 -45
- package/src/server/web-artifacts/scripts/bundle-artifact.sh +10 -0
- package/src/server/web-artifacts/scripts/init-artifact.sh +1 -1
- package/src/server/web-artifacts.ts +83 -3
- 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
|
-
'
|
|
675
|
-
{
|
|
676
|
-
|
|
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: [
|
|
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
|
|
289
|
-
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
|
-
|
|
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 (
|
|
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
|
|
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 * (
|
|
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
|
-
|
|
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
|
|
85
|
+
return parsed.filter(isRecord);
|
|
50
86
|
}
|
|
87
|
+
|
|
51
88
|
if (Array.isArray(elements)) {
|
|
52
|
-
return
|
|
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 =
|
|
273
|
+
const elements = normalizeExcalidrawElementsForToolInput(input.elements);
|
|
59
274
|
const out: ExcalidrawOpenMcpAppInput = {
|
|
60
275
|
transport: EXCALIDRAW_MCP_TRANSPORT,
|
|
61
276
|
toolName: EXCALIDRAW_CREATE_VIEW_TOOL,
|