pmx-canvas 0.1.1 → 0.1.2
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 +69 -0
- package/dist/canvas/index.js +53 -53
- package/dist/types/client/nodes/ExtAppFrame.d.ts +1 -1
- package/dist/types/server/bundled-skills.d.ts +40 -0
- package/dist/types/shared/ext-app-tool-result.d.ts +12 -0
- package/package.json +1 -1
- package/src/cli/index.ts +19 -1
- package/src/client/nodes/ExtAppFrame.tsx +91 -17
- package/src/mcp/server.ts +64 -1
- package/src/server/bundled-skills.ts +143 -0
- package/src/server/canvas-operations.ts +22 -3
- package/src/server/server.ts +20 -13
- package/src/server/web-artifacts.ts +40 -2
- package/src/shared/ext-app-tool-result.ts +25 -0
|
@@ -14,5 +14,5 @@ export declare function resolveExtAppDisplayModeRequest(requestedMode: DisplayMo
|
|
|
14
14
|
export declare function sendExtAppBootstrapState(bridge: ExtAppBridgeNotifications, toolInput: Record<string, unknown>, toolResult: CallToolResult | undefined): Promise<void>;
|
|
15
15
|
export declare function ExtAppFrame({ node }: {
|
|
16
16
|
node: CanvasNodeState;
|
|
17
|
-
}): import("preact/
|
|
17
|
+
}): import("preact/jsx-runtime").JSX.Element;
|
|
18
18
|
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
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
|
+
export interface BundledSkill {
|
|
20
|
+
name: string;
|
|
21
|
+
description: string;
|
|
22
|
+
uri: string;
|
|
23
|
+
filePath: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the packaged `skills/` directory. Walks parents from this module
|
|
27
|
+
* looking for a sibling `skills/` that contains at least one `<name>/SKILL.md`,
|
|
28
|
+
* so it works whether the code runs from source (`src/server/…`), from a
|
|
29
|
+
* compiled bundle (`dist/…`), or from a global npm install
|
|
30
|
+
* (`/opt/homebrew/lib/node_modules/pmx-canvas/src/server/…`).
|
|
31
|
+
*/
|
|
32
|
+
export declare function findBundledSkillsRoot(): string | null;
|
|
33
|
+
/**
|
|
34
|
+
* Enumerate every `<name>/SKILL.md` under the bundled skills root and return
|
|
35
|
+
* a compact index. Hidden directories (dotfolders) and files that don't parse
|
|
36
|
+
* are skipped silently rather than throwing — missing metadata should never
|
|
37
|
+
* break the MCP server's resource listing.
|
|
38
|
+
*/
|
|
39
|
+
export declare function listBundledSkills(): BundledSkill[];
|
|
40
|
+
export declare function readBundledSkill(name: string): string | null;
|
|
@@ -7,3 +7,15 @@ export interface NormalizeExtAppToolResultInput {
|
|
|
7
7
|
detailedContent?: string;
|
|
8
8
|
}
|
|
9
9
|
export declare function normalizeExtAppToolResult(input: NormalizeExtAppToolResultInput): CallToolResult;
|
|
10
|
+
/**
|
|
11
|
+
* Structural equality between two `CallToolResult` values, used by the host
|
|
12
|
+
* ExtAppFrame to suppress echo-back re-renders when an SSE layout update
|
|
13
|
+
* mints a new object reference for an otherwise-unchanged tool result.
|
|
14
|
+
*
|
|
15
|
+
* JSON-stringify is adequate here: tool results are strictly JSON (no
|
|
16
|
+
* functions, symbols, or cycles), typically small, and on the hot path we
|
|
17
|
+
* only hit this when references already differ. For very large payloads
|
|
18
|
+
* (> ~2MB) an early length check skips the stringify to avoid a user-visible
|
|
19
|
+
* stall — such results are treated as "changed" and forwarded to the widget.
|
|
20
|
+
*/
|
|
21
|
+
export declare function extAppToolResultsMatch(a: CallToolResult, b: CallToolResult): boolean;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmx-canvas",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Spatial canvas workbench for coding agents — infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/server/index.ts",
|
package/src/cli/index.ts
CHANGED
|
@@ -1,13 +1,31 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
-
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { runAgentCli } from './agent.js';
|
|
7
7
|
import { createCanvas } from '../server/index.js';
|
|
8
8
|
|
|
9
9
|
const args = process.argv.slice(2);
|
|
10
10
|
|
|
11
|
+
// ── --version / -v ─────────────────────────────────────────────
|
|
12
|
+
// Print the installed package version and exit. Resolved from the
|
|
13
|
+
// sibling package.json so it stays accurate through bunx, global npm
|
|
14
|
+
// installs, and repo-local runs (no hard-coded string, no build step
|
|
15
|
+
// required).
|
|
16
|
+
if (args.includes('--version') || args.includes('-v')) {
|
|
17
|
+
try {
|
|
18
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
|
|
19
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version?: string };
|
|
20
|
+
console.log(pkg.version ?? 'unknown');
|
|
21
|
+
process.exit(0);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
24
|
+
console.error(`pmx-canvas: failed to read package.json (${message})`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
11
29
|
// ── Agent CLI subcommands ────────────────────────────────────
|
|
12
30
|
// If first arg is a known subcommand (not a --flag), route to the agent CLI.
|
|
13
31
|
const AGENT_COMMANDS = new Set([
|
|
@@ -2,6 +2,7 @@ import type { CallToolResult, ListToolsResult, RequestId, Tool } from '@modelcon
|
|
|
2
2
|
import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
3
3
|
import { AppBridge, PostMessageTransport, buildAllowAttribute } from '@modelcontextprotocol/ext-apps/app-bridge';
|
|
4
4
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
5
|
+
import { extAppToolResultsMatch } from '../../shared/ext-app-tool-result.js';
|
|
5
6
|
import {
|
|
6
7
|
canvasTheme,
|
|
7
8
|
collapseExpandedNode,
|
|
@@ -103,6 +104,7 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
103
104
|
const latestToolInputRef = useRef<Record<string, unknown>>({});
|
|
104
105
|
const latestToolResultRef = useRef<CallToolResult | undefined>(undefined);
|
|
105
106
|
const toolResultSentRef = useRef(false);
|
|
107
|
+
const lastSentToolResultRef = useRef<CallToolResult | undefined>(undefined);
|
|
106
108
|
const toolResultSendingRef = useRef<Promise<void> | null>(null);
|
|
107
109
|
const bridgeReadyRef = useRef(false);
|
|
108
110
|
const themeUnsubRef = useRef<(() => void) | null>(null);
|
|
@@ -140,13 +142,33 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
140
142
|
|
|
141
143
|
const flushToolResult = (bridge: AppBridge | null): Promise<void> | null => {
|
|
142
144
|
const pendingToolResult = latestToolResultRef.current;
|
|
143
|
-
if (!bridge || !bridgeReadyRef.current || !pendingToolResult
|
|
145
|
+
if (!bridge || !bridgeReadyRef.current || !pendingToolResult) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
// Skip when the content is unchanged. Updates from callServerTool
|
|
149
|
+
// (e.g. Excalidraw saving edits) produce a new reference via SSE and
|
|
150
|
+
// must be forwarded to keep other clients in sync — but SSE layout
|
|
151
|
+
// updates *also* mint new references when nothing in the tool result
|
|
152
|
+
// has actually changed (e.g. after the widget's own updateModelContext
|
|
153
|
+
// call), which would echo the result back and cause the widget to
|
|
154
|
+
// re-render mid-interaction (see: Counter fixture click instability).
|
|
155
|
+
// Deep-equality via structural compare handles both cases: new content
|
|
156
|
+
// is forwarded, unchanged content is suppressed.
|
|
157
|
+
if (lastSentToolResultRef.current === pendingToolResult) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
if (
|
|
161
|
+
lastSentToolResultRef.current &&
|
|
162
|
+
extAppToolResultsMatch(lastSentToolResultRef.current, pendingToolResult)
|
|
163
|
+
) {
|
|
164
|
+
lastSentToolResultRef.current = pendingToolResult;
|
|
144
165
|
return null;
|
|
145
166
|
}
|
|
146
167
|
if (toolResultSendingRef.current) return toolResultSendingRef.current;
|
|
147
168
|
const sendPromise = bridge
|
|
148
169
|
.sendToolResult(pendingToolResult)
|
|
149
170
|
.then(() => {
|
|
171
|
+
lastSentToolResultRef.current = pendingToolResult;
|
|
150
172
|
toolResultSentRef.current = true;
|
|
151
173
|
setStatus('done');
|
|
152
174
|
})
|
|
@@ -170,6 +192,7 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
170
192
|
let disposed = false;
|
|
171
193
|
let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
|
|
172
194
|
toolResultSentRef.current = false;
|
|
195
|
+
lastSentToolResultRef.current = undefined;
|
|
173
196
|
toolResultSendingRef.current = null;
|
|
174
197
|
bridgeReadyRef.current = false;
|
|
175
198
|
|
|
@@ -323,10 +346,14 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
323
346
|
// handshake timing differs across SDK versions.
|
|
324
347
|
fallbackTimer = setTimeout(() => {
|
|
325
348
|
if (disposed || bridgeReadyRef.current) return;
|
|
326
|
-
|
|
349
|
+
const bootstrapToolResult = latestToolResultRef.current;
|
|
350
|
+
void sendExtAppBootstrapState(bridge, latestToolInputRef.current, bootstrapToolResult)
|
|
327
351
|
.then(() => {
|
|
328
|
-
toolResultSentRef.current = Boolean(
|
|
329
|
-
|
|
352
|
+
toolResultSentRef.current = Boolean(bootstrapToolResult);
|
|
353
|
+
if (bootstrapToolResult) {
|
|
354
|
+
lastSentToolResultRef.current = bootstrapToolResult;
|
|
355
|
+
}
|
|
356
|
+
setStatus(bootstrapToolResult ? 'done' : 'ready');
|
|
330
357
|
setError(null);
|
|
331
358
|
})
|
|
332
359
|
.catch((err) => {
|
|
@@ -344,7 +371,8 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
344
371
|
bridgeRef.current = bridge;
|
|
345
372
|
transportRef.current = transport;
|
|
346
373
|
|
|
347
|
-
// Propagate theme changes to ext-app iframe
|
|
374
|
+
// Propagate theme changes to ext-app iframe. Read current expanded state
|
|
375
|
+
// at fire time so the widget keeps its fullscreen/inline context accurate.
|
|
348
376
|
let firstFire = true;
|
|
349
377
|
themeUnsubRef.current = canvasTheme.subscribe((newTheme) => {
|
|
350
378
|
if (firstFire) { firstFire = false; return; }
|
|
@@ -353,7 +381,7 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
353
381
|
theme: toMcpTheme(newTheme),
|
|
354
382
|
platform: 'web',
|
|
355
383
|
containerDimensions: { maxHeight },
|
|
356
|
-
displayMode: 'inline',
|
|
384
|
+
displayMode: expandedNodeId.value === nodeId ? 'fullscreen' : 'inline',
|
|
357
385
|
locale: navigator.language,
|
|
358
386
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
359
387
|
});
|
|
@@ -392,6 +420,24 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
392
420
|
}
|
|
393
421
|
}, [toolResult, status]);
|
|
394
422
|
|
|
423
|
+
// Keep the widget's displayMode in sync when the host expands or collapses
|
|
424
|
+
// the node. Without this, a widget that opened in inline mode would never
|
|
425
|
+
// learn that it is now fullscreen (and vice versa), so features gated on
|
|
426
|
+
// fullscreen (like Excalidraw's edit mode) would not activate on the same
|
|
427
|
+
// click that triggered the expansion.
|
|
428
|
+
useEffect(() => {
|
|
429
|
+
const bridge = bridgeRef.current;
|
|
430
|
+
if (!bridge || !bridgeReadyRef.current) return;
|
|
431
|
+
bridge.setHostContext?.({
|
|
432
|
+
theme: toMcpTheme(canvasTheme.value),
|
|
433
|
+
platform: 'web',
|
|
434
|
+
containerDimensions: { maxHeight },
|
|
435
|
+
displayMode: isExpanded ? 'fullscreen' : 'inline',
|
|
436
|
+
locale: navigator.language,
|
|
437
|
+
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
438
|
+
});
|
|
439
|
+
}, [isExpanded, maxHeight]);
|
|
440
|
+
|
|
395
441
|
// Loading state — HTML not yet fetched
|
|
396
442
|
if (!html) {
|
|
397
443
|
return (
|
|
@@ -493,17 +539,45 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
493
539
|
Connecting to ext-app viewer...
|
|
494
540
|
</div>
|
|
495
541
|
)}
|
|
496
|
-
{/*
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
542
|
+
{/* Iframe stack: the widget renders a preview; when not expanded, a
|
|
543
|
+
transparent click-catcher sits on top so the first click always
|
|
544
|
+
expands the node. Without this, widgets like Excalidraw show their
|
|
545
|
+
own "Edit" button inline, which triggers a fullscreen request and
|
|
546
|
+
remounts the iframe in the overlay — forcing the user to click Edit
|
|
547
|
+
a second time to actually enter edit mode. Routing all inline clicks
|
|
548
|
+
to "expand" makes the flow "open → edit" instead of "edit → expand → edit". */}
|
|
549
|
+
<div style={{ flex: 1, position: 'relative', display: 'flex', minHeight: 0 }}>
|
|
550
|
+
<iframe
|
|
551
|
+
key={frameKey}
|
|
552
|
+
ref={iframeRef}
|
|
553
|
+
srcdoc={html}
|
|
554
|
+
sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox"
|
|
555
|
+
allow={buildAllowAttribute(resourceMeta?.permissions)}
|
|
556
|
+
style={{ flex: 1, border: 'none', background: 'var(--c-panel)' }}
|
|
557
|
+
title={`Ext App: ${toolName}`}
|
|
558
|
+
/>
|
|
559
|
+
{!isExpanded && (
|
|
560
|
+
<button
|
|
561
|
+
type="button"
|
|
562
|
+
onClick={(e) => {
|
|
563
|
+
e.stopPropagation();
|
|
564
|
+
expandNode(nodeId);
|
|
565
|
+
}}
|
|
566
|
+
class="ext-app-preview-catcher"
|
|
567
|
+
title="Click to open"
|
|
568
|
+
style={{
|
|
569
|
+
position: 'absolute',
|
|
570
|
+
inset: 0,
|
|
571
|
+
background: 'transparent',
|
|
572
|
+
border: 'none',
|
|
573
|
+
padding: 0,
|
|
574
|
+
margin: 0,
|
|
575
|
+
cursor: 'zoom-in',
|
|
576
|
+
}}
|
|
577
|
+
aria-label="Open full view to edit"
|
|
578
|
+
/>
|
|
579
|
+
)}
|
|
580
|
+
</div>
|
|
507
581
|
</div>
|
|
508
582
|
);
|
|
509
583
|
}
|
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'),
|
|
@@ -1158,6 +1159,68 @@ export async function startMcpServer(): Promise<void> {
|
|
|
1158
1159
|
},
|
|
1159
1160
|
);
|
|
1160
1161
|
|
|
1162
|
+
// ── canvas://skills ────────────────────────────────────────
|
|
1163
|
+
// Discoverability for the skill prompts bundled with the npm package
|
|
1164
|
+
// (skills/<name>/SKILL.md). Before 0.1.2 these files shipped but were
|
|
1165
|
+
// invisible to agents — calling canvas_build_web_artifact without the
|
|
1166
|
+
// companion `web-artifacts-builder` skill led to predictable misuse.
|
|
1167
|
+
// The index lists every bundled skill with its frontmatter description;
|
|
1168
|
+
// individual skills are served verbatim at canvas://skills/<name>.
|
|
1169
|
+
server.resource(
|
|
1170
|
+
'bundled-skills',
|
|
1171
|
+
'canvas://skills',
|
|
1172
|
+
{
|
|
1173
|
+
description:
|
|
1174
|
+
'Index of agent skills bundled with this PMX Canvas install. Lists name, ' +
|
|
1175
|
+
'description, and per-skill URI (canvas://skills/<name>). Read a specific ' +
|
|
1176
|
+
'skill for workflow guidance — notably web-artifacts-builder for ' +
|
|
1177
|
+
'canvas_build_web_artifact, and pmx-canvas for the broader workbench.',
|
|
1178
|
+
mimeType: 'application/json',
|
|
1179
|
+
},
|
|
1180
|
+
async () => {
|
|
1181
|
+
const skills = listBundledSkills();
|
|
1182
|
+
const index = {
|
|
1183
|
+
count: skills.length,
|
|
1184
|
+
skills: skills.map((s) => ({ name: s.name, description: s.description, uri: s.uri })),
|
|
1185
|
+
};
|
|
1186
|
+
return {
|
|
1187
|
+
contents: [
|
|
1188
|
+
{
|
|
1189
|
+
uri: 'canvas://skills',
|
|
1190
|
+
mimeType: 'application/json',
|
|
1191
|
+
text: JSON.stringify(index, null, 2),
|
|
1192
|
+
},
|
|
1193
|
+
],
|
|
1194
|
+
};
|
|
1195
|
+
},
|
|
1196
|
+
);
|
|
1197
|
+
|
|
1198
|
+
// Register each bundled skill as its own resource so agents can address
|
|
1199
|
+
// them individually (canvas://skills/web-artifacts-builder, etc.) and
|
|
1200
|
+
// MCP clients can display them with per-skill descriptions.
|
|
1201
|
+
for (const skill of listBundledSkills()) {
|
|
1202
|
+
server.resource(
|
|
1203
|
+
`skill-${skill.name}`,
|
|
1204
|
+
skill.uri,
|
|
1205
|
+
{
|
|
1206
|
+
description: skill.description || `Bundled PMX Canvas skill: ${skill.name}`,
|
|
1207
|
+
mimeType: 'text/markdown',
|
|
1208
|
+
},
|
|
1209
|
+
async () => {
|
|
1210
|
+
const markdown = readBundledSkill(skill.name);
|
|
1211
|
+
return {
|
|
1212
|
+
contents: [
|
|
1213
|
+
{
|
|
1214
|
+
uri: skill.uri,
|
|
1215
|
+
mimeType: 'text/markdown',
|
|
1216
|
+
text: markdown ?? `# ${skill.name}\n\n_Skill file not found on disk._\n`,
|
|
1217
|
+
},
|
|
1218
|
+
],
|
|
1219
|
+
};
|
|
1220
|
+
},
|
|
1221
|
+
);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1161
1224
|
// ── canvas_create_group ──────────────────────────────────────
|
|
1162
1225
|
server.tool(
|
|
1163
1226
|
'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
|
+
}
|
|
@@ -392,17 +392,36 @@ function buildImageNodeData(input: CanvasAddNodeInput): Record<string, unknown>
|
|
|
392
392
|
const src = input.content ?? '';
|
|
393
393
|
const isDataUri = src.startsWith('data:');
|
|
394
394
|
const isUrl = src.startsWith('http://') || src.startsWith('https://');
|
|
395
|
+
|
|
396
|
+
if (isDataUri) {
|
|
397
|
+
// Basic data-URI sanity: must be an image/* mediatype.
|
|
398
|
+
const header = src.slice(5, src.indexOf(',') >= 0 ? src.indexOf(',') : src.length);
|
|
399
|
+
if (!/^image\//i.test(header)) {
|
|
400
|
+
throw new Error(
|
|
401
|
+
`Invalid image node: data URI must be an image/* media type (got "${header.slice(0, 40)}"). ` +
|
|
402
|
+
`Accepted: png, jpeg, gif, svg+xml, webp, bmp, avif, x-icon.`,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
395
407
|
if (!isDataUri && !isUrl && src) {
|
|
396
408
|
const resolved = resolve(src);
|
|
409
|
+
const ext = resolved.split('.').pop()?.toLowerCase() ?? '';
|
|
397
410
|
const fileName = resolved.split('/').pop() ?? src;
|
|
411
|
+
const mime = IMAGE_MIME_MAP[ext];
|
|
412
|
+
if (!mime) {
|
|
413
|
+
throw new Error(
|
|
414
|
+
`Invalid image node: "${fileName}" has unsupported extension ".${ext}". ` +
|
|
415
|
+
`Accepted: ${Object.keys(IMAGE_MIME_MAP).join(', ')}. ` +
|
|
416
|
+
`For non-image files use type="file" (live viewer) or type="webpage" (URL) instead.`,
|
|
417
|
+
);
|
|
418
|
+
}
|
|
398
419
|
return {
|
|
399
420
|
...(input.data ?? {}),
|
|
400
421
|
src: resolved,
|
|
401
422
|
title: input.title ?? fileName,
|
|
402
423
|
path: resolved,
|
|
403
|
-
|
|
404
|
-
? { mimeType: IMAGE_MIME_MAP[resolved.split('.').pop()?.toLowerCase() ?? ''] }
|
|
405
|
-
: {}),
|
|
424
|
+
mimeType: mime,
|
|
406
425
|
};
|
|
407
426
|
}
|
|
408
427
|
|
package/src/server/server.ts
CHANGED
|
@@ -1105,19 +1105,26 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
|
|
|
1105
1105
|
const extraData = body.data && typeof body.data === 'object' && !Array.isArray(body.data)
|
|
1106
1106
|
? body.data as Record<string, unknown>
|
|
1107
1107
|
: undefined;
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1108
|
+
let added: ReturnType<typeof addCanvasNode>;
|
|
1109
|
+
try {
|
|
1110
|
+
added = addCanvasNode({
|
|
1111
|
+
type: type as CanvasNodeState['type'],
|
|
1112
|
+
...(typeof body.title === 'string' ? { title: body.title } : {}),
|
|
1113
|
+
...(typeof body.content === 'string' ? { content: body.content } : {}),
|
|
1114
|
+
...(extraData ? { data: extraData } : {}),
|
|
1115
|
+
...(typeof body.x === 'number' ? { x: body.x } : {}),
|
|
1116
|
+
...(typeof body.y === 'number' ? { y: body.y } : {}),
|
|
1117
|
+
...(typeof body.width === 'number' ? { width: body.width } : {}),
|
|
1118
|
+
...(typeof body.height === 'number' ? { height: body.height } : {}),
|
|
1119
|
+
defaultWidth: 360,
|
|
1120
|
+
defaultHeight: 200,
|
|
1121
|
+
fileMode: 'auto',
|
|
1122
|
+
});
|
|
1123
|
+
} catch (error) {
|
|
1124
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1125
|
+
return responseJson({ ok: false, error: message }, 400);
|
|
1126
|
+
}
|
|
1127
|
+
const { node, needsCodeGraphRecompute } = added;
|
|
1121
1128
|
|
|
1122
1129
|
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1123
1130
|
if (needsCodeGraphRecompute) {
|