pmx-canvas 0.6.2 → 0.6.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 +19 -0
- package/Readme.md +13 -1
- package/dist/canvas/index.js +50 -50
- package/dist/json-render/index.js +57 -57
- package/dist/types/client/canvas/auto-fit.d.ts +3 -0
- package/dist/types/client/nodes/use-iframe-content-height.d.ts +3 -3
- package/dist/types/client/state/human-store.d.ts +1 -0
- package/dist/types/json-render/catalog.d.ts +20 -20
- package/dist/types/json-render/schema.d.ts +40 -0
- package/dist/types/mcp/skills.d.ts +13 -0
- package/dist/types/server/bundled-skills.d.ts +32 -4
- package/docs/http-api.md +5 -1
- package/docs/mcp.md +35 -1
- package/docs/product-context-vision-2026-09.md +16 -5
- package/docs/product-vision-2026-09.md +4 -2
- package/package.json +11 -11
- package/skills/json-render-mcp/SKILL.md +118 -2
- package/skills/pmx-canvas/SKILL.md +34 -11
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +5 -5
- package/src/client/canvas/auto-fit.ts +20 -1
- package/src/client/nodes/use-iframe-content-height.ts +44 -24
- package/src/client/state/human-store.ts +6 -6
- package/src/json-render/catalog.ts +6 -0
- package/src/json-render/renderer/index.tsx +17 -0
- package/src/json-render/schema.ts +4 -0
- package/src/mcp/server.ts +9 -4
- package/src/mcp/skills.ts +47 -0
- package/src/server/bundled-skills.ts +92 -53
- package/src/server/operations/ops/nodes.ts +43 -11
- package/src/shared/content-height-reporter.ts +4 -2
|
@@ -18,12 +18,24 @@ Humans curate agent context by pinning nodes; agents read that curation through
|
|
|
18
18
|
|
|
19
19
|
## Runtime prerequisites
|
|
20
20
|
|
|
21
|
-
PMX Canvas 0.6.
|
|
21
|
+
PMX Canvas 0.6.3 requires **Bun >=1.4.2**, including when installed through npm.
|
|
22
22
|
Check `bun --version` and ensure Bun is on the MCP host's PATH, not just your shell's;
|
|
23
23
|
use an absolute executable path if needed. Install/configure only when requested.
|
|
24
24
|
For pinned installation, MCP configuration, managed services, and disposable verification,
|
|
25
25
|
read [Installing PMX Canvas](references/installing-pmx-canvas.md).
|
|
26
26
|
|
|
27
|
+
## Reading bundled skills over MCP
|
|
28
|
+
|
|
29
|
+
When the server advertises `io.modelcontextprotocol/skills`, supporting hosts can discover
|
|
30
|
+
bundled skills with `skills/list` and retrieve a manifest with `skills/get`. These are MCP
|
|
31
|
+
protocol methods, not canvas tools. Read `skill://pmx-canvas/SKILL.md` and relative supporting
|
|
32
|
+
files (for example `skill://pmx-canvas/references/installing-pmx-canvas.md`) through
|
|
33
|
+
`resources/read`, fetching only what is needed. Hosts without extension support can still read
|
|
34
|
+
ordinary resources or use installed filesystem skills; `canvas://skills` remains available.
|
|
35
|
+
Resource reads do not activate skills or authorize executing their scripts. The host must
|
|
36
|
+
preserve MCP origin and apply integrity/approval checks. Restart MCP after upgrading the package;
|
|
37
|
+
the served catalog is a startup snapshot. Local mirrors still use `skills sync` below.
|
|
38
|
+
|
|
27
39
|
## Required Operating Sequence
|
|
28
40
|
|
|
29
41
|
0. **After a `pmx-canvas` upgrade, refresh this skill first.** Run
|
|
@@ -237,8 +249,18 @@ graph 760×520, mcp-app 960×600, web-artifact 960×720. A *hosted* app opened w
|
|
|
237
249
|
`canvas_app { action: "open-mcp-app" | "diagram" }` — including the Excalidraw diagram preset — is
|
|
238
250
|
the exception: request `width: 720, height: 500` for its standard landscape preset instead of
|
|
239
251
|
relying on the 960×600 default for a directly created `mcp-app` node. After the app mounts, read
|
|
240
|
-
|
|
241
|
-
|
|
252
|
+
only its geometry, e.g. `pmx-canvas node get <id> --fields id,type,position,size`, and use the
|
|
253
|
+
persisted size for subsequent placement. With MCP tool execution that supports output projection,
|
|
254
|
+
project the layout to node id/type/position/size before returning it to context; otherwise prefer
|
|
255
|
+
a targeted `canvas_node { action: "get", id }`. Read the full layout only when its other state
|
|
256
|
+
is needed. Mounted apps may change the requested dimensions.
|
|
257
|
+
Authored iframe surfaces grow in height as content changes, up to 1400px. A growing ungrouped,
|
|
258
|
+
unpinned card moves down when needed to leave a 24px gap without moving its neighbors; grouped
|
|
259
|
+
cards retain their authored placement. Width stays explicit, and `strictSize` or manually resized
|
|
260
|
+
cards do not auto-grow. Fitting waits while a human holds the card, including in another tab;
|
|
261
|
+
automatic relocations are undoable. This is not a shrink-to-fit action or `view.fit` (which only changes zoom).
|
|
262
|
+
After content settles, inspect the rendered result and arrange the intended node IDs if more
|
|
263
|
+
spacing is needed; do not compensate with arbitrarily huge fixed frames.
|
|
242
264
|
Since 0.4.6 the server clamps explicit creation sizes UP to per-type
|
|
243
265
|
readability floors (e.g. markdown 360×180, graph/json-render/html 420×280, mcp-app 480×320) —
|
|
244
266
|
a tiny probe size silently becomes the floor. `strictSize: true` is the only opt-out (a fixed
|
|
@@ -467,20 +489,21 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
|
|
|
467
489
|
iframe's per-mount nonce, so AX buttons only work inside the in-canvas node (0.4.4 Codex note).
|
|
468
490
|
Do not tell a user a standalone tab's controls will steer the agent.
|
|
469
491
|
- A hosted ext-app (Excalidraw) node in a **WebKit** host panel (e.g. the GitHub Copilot app's
|
|
470
|
-
embedded WKWebView)
|
|
471
|
-
|
|
472
|
-
|
|
492
|
+
embedded WKWebView) can remain blank even when its API session is healthy and `sessionStatus`
|
|
493
|
+
is `ready`. This host-specific rendering issue remains open; successful Chrome/Codex rendering
|
|
494
|
+
does not establish native-host health. Since 0.4.6 the canvas runs a **paint oracle +
|
|
473
495
|
recovery ladder** under WebKit: present-at-load ext-apps mount strictly one at a time (the
|
|
474
496
|
cold burst was the trigger), each frame answers a double-rAF **paint probe** after settle, and
|
|
475
497
|
on silence the ladder escalates — soft-expand cycle (the automatic analogue of the proven
|
|
476
498
|
enlarge+close) → serialized remount → an explicit "App surface failed to paint / Retry"
|
|
477
|
-
affordance.
|
|
478
|
-
|
|
499
|
+
affordance. Native Copilot/WebKit surfaces can still remain blank despite a successful
|
|
500
|
+
probe, including when visibility is reported as visible. The recovery trail
|
|
479
501
|
(`GET /api/canvas/debug/ext-app-recovery` / `window.__PMX_EXTAPP_LOG`) now records
|
|
480
502
|
`mount-slot`, `paint-ok`, `paint-fail`, `soft-expand-cycle`, `recovery-exhausted`, and
|
|
481
|
-
`assume-visible-rearm
|
|
482
|
-
|
|
483
|
-
|
|
503
|
+
`assume-visible-rearm`. Treat `paint-ok`/`paint-fail` as diagnostic evidence, not compositor
|
|
504
|
+
proof. Require a rendered screenshot in the affected host before declaring its surface
|
|
505
|
+
healthy; a Chromium pass does not close a native Copilot/WebKit failure. In a host that
|
|
506
|
+
reports the document hidden, `paint-ok` is recorded as
|
|
484
507
|
`paint-ok (unverified: host hidden)` — the app answered the paint probe, but nothing is
|
|
485
508
|
proven composited, so do not treat it as a verified paint. If a
|
|
486
509
|
tile still shows the Retry affordance, click Retry (fresh recovery budget) or expand-then-close;
|
|
@@ -4,7 +4,7 @@ Use this reference when the `pmx-canvas` skill is installed but the `pmx-canvas`
|
|
|
4
4
|
|
|
5
5
|
## Prerequisites and version
|
|
6
6
|
|
|
7
|
-
The examples pin **PMX Canvas 0.6.
|
|
7
|
+
The examples pin **PMX Canvas 0.6.3**, which requires **Bun >=1.4.2** even when
|
|
8
8
|
installed through npm: the CLI runs through Bun, not Node. Check `bun --version`
|
|
9
9
|
first. If missing or older, follow the [Bun installation instructions](https://bun.sh/docs/installation)
|
|
10
10
|
with the user's authorization. Ensure Bun is on the MCP host's PATH as well as the
|
|
@@ -17,11 +17,11 @@ in `SKILL.md`. Do not pair newer checkout guidance with an older runtime unknowi
|
|
|
17
17
|
## Install from npm
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
|
-
npm install -g pmx-canvas@0.6.
|
|
20
|
+
npm install -g pmx-canvas@0.6.3
|
|
21
21
|
pmx-canvas --version
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
Without a global install, use `bunx pmx-canvas@0.6.
|
|
24
|
+
Without a global install, use `bunx pmx-canvas@0.6.3` in place of `pmx-canvas` in
|
|
25
25
|
the commands below. Do not use unpinned `bunx pmx-canvas` for a version-specific check.
|
|
26
26
|
|
|
27
27
|
## Choose the workspace and target
|
|
@@ -73,7 +73,7 @@ bun run build
|
|
|
73
73
|
bun run src/cli/index.ts --version
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
-
This uses the checked-out source, not necessarily the published
|
|
76
|
+
This uses the checked-out source, not necessarily the published package. Follow
|
|
77
77
|
that checkout's `package.json` Bun requirement and bundled skill. Set the same root/port
|
|
78
78
|
environment above; substitute `bun run /absolute/path/to/pmx-canvas/src/cli/index.ts`
|
|
79
79
|
for `pmx-canvas` in startup and verification commands. The target workspace can be
|
|
@@ -91,7 +91,7 @@ discards empty env values, remove `PMX_CANVAS_URL` from its launch environment i
|
|
|
91
91
|
"mcpServers": {
|
|
92
92
|
"canvas": {
|
|
93
93
|
"command": "bunx",
|
|
94
|
-
"args": ["pmx-canvas@0.6.
|
|
94
|
+
"args": ["pmx-canvas@0.6.3", "--mcp"],
|
|
95
95
|
"env": {
|
|
96
96
|
"PMX_CANVAS_WORKSPACE_ROOT": "/absolute/path/to/project",
|
|
97
97
|
"PMX_CANVAS_PORT": "14313",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CanvasNodeState } from '../types';
|
|
2
2
|
import { nodeMinSize } from '../../shared/node-sizes.js';
|
|
3
|
+
import { findBlocker } from '../../shared/placement.js';
|
|
3
4
|
|
|
4
5
|
export const AUTO_FIT_TITLEBAR_HEIGHT = 37;
|
|
5
6
|
export const AUTO_FIT_MAX_HEIGHT = 600;
|
|
@@ -81,7 +82,25 @@ export function shouldContentFitIframeNode(node: CanvasNodeState): boolean {
|
|
|
81
82
|
* capped at the iframe ceiling.
|
|
82
83
|
*/
|
|
83
84
|
export function computeContentGrowHeight(node: CanvasNodeState, contentHeight: number): number | null {
|
|
84
|
-
if (!shouldContentFitIframeNode(node) || contentHeight <= 0) return null;
|
|
85
|
+
if (!shouldContentFitIframeNode(node) || !Number.isFinite(contentHeight) || contentHeight <= 0) return null;
|
|
85
86
|
const want = Math.min(contentHeight + AUTO_FIT_TITLEBAR_HEIGHT + AUTO_FIT_BODY_PADDING, AUTO_FIT_MAX_HEIGHT_IFRAME);
|
|
86
87
|
return want > node.size.height + 8 ? want : null;
|
|
87
88
|
}
|
|
89
|
+
|
|
90
|
+
/** Keep an automatically growing card clear without moving its neighbors or
|
|
91
|
+
* changing its column. Groups and screen-pinned cards retain authored placement. */
|
|
92
|
+
export function contentFitPosition(
|
|
93
|
+
node: CanvasNodeState,
|
|
94
|
+
height: number,
|
|
95
|
+
allNodes: CanvasNodeState[],
|
|
96
|
+
): CanvasNodeState['position'] {
|
|
97
|
+
if (height <= node.size.height || node.pinned || node.data.parentGroup) return node.position;
|
|
98
|
+
const obstacles = allNodes.filter((other) => other.id !== node.id && !other.pinned && other.type !== 'group');
|
|
99
|
+
const position = { ...node.position };
|
|
100
|
+
let blocker = findBlocker(position, node.size.width, height, obstacles, 24);
|
|
101
|
+
while (blocker) {
|
|
102
|
+
position.y = blocker.position.y + blocker.size.height + 24;
|
|
103
|
+
blocker = findBlocker(position, node.size.width, height, obstacles, 24);
|
|
104
|
+
}
|
|
105
|
+
return position;
|
|
106
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { effect } from '@preact/signals';
|
|
2
|
+
import { useEffect } from 'preact/hooks';
|
|
3
|
+
import { nodes, updateNode } from '../state/canvas-store';
|
|
4
|
+
import { grabbingNodeId, otherHumans } from '../state/human-store';
|
|
5
|
+
import { pushCanvasUpdate } from '../state/intent-bridge';
|
|
6
|
+
import { computeContentGrowHeight, contentFitPosition } from '../canvas/auto-fit';
|
|
4
7
|
import type { CanvasNodeState } from '../types';
|
|
5
8
|
|
|
6
9
|
/**
|
|
@@ -11,43 +14,60 @@ import type { CanvasNodeState } from '../types';
|
|
|
11
14
|
* because growth is monotonic with a dead-band — cannot oscillate. This is the
|
|
12
15
|
* fix for iframe nodes whose body scrollHeight the parent can't measure.
|
|
13
16
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
+
* Debounce before changing geometry, retaining the latest measurement during a
|
|
18
|
+
* human grab. Read current geometry at application time; persist only this node.
|
|
19
|
+
* Relocations are undoable, height-only adjustments do not fill the undo stack.
|
|
17
20
|
*/
|
|
18
21
|
export function useIframeContentHeight(
|
|
19
22
|
node: CanvasNodeState,
|
|
20
23
|
iframeRef: { current: HTMLIFrameElement | null },
|
|
21
24
|
frameToken: string,
|
|
22
25
|
): void {
|
|
23
|
-
const nodeRef = useRef(node);
|
|
24
|
-
nodeRef.current = node;
|
|
25
|
-
const persistTimer = useRef<number | null>(null);
|
|
26
|
-
|
|
27
26
|
useEffect(() => {
|
|
28
27
|
if (!frameToken) return undefined;
|
|
28
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
29
|
+
let pendingHeight: number | null = null;
|
|
30
|
+
const held = () =>
|
|
31
|
+
grabbingNodeId.value === node.id || otherHumans.value.some((human) => human.grabbingNodeId === node.id);
|
|
32
|
+
function apply() {
|
|
33
|
+
timer = null;
|
|
34
|
+
if (held() || pendingHeight === null) return;
|
|
35
|
+
const reported = pendingHeight;
|
|
36
|
+
pendingHeight = null;
|
|
37
|
+
const current = nodes.value.get(node.id);
|
|
38
|
+
if (!current) return;
|
|
39
|
+
const target = computeContentGrowHeight(current, reported);
|
|
40
|
+
if (target === null) return;
|
|
41
|
+
const position = contentFitPosition(current, target, Array.from(nodes.value.values()));
|
|
42
|
+
const moved = position.x !== current.position.x || position.y !== current.position.y;
|
|
43
|
+
const patch = {
|
|
44
|
+
size: { width: current.size.width, height: target },
|
|
45
|
+
...(moved ? { position } : {}),
|
|
46
|
+
};
|
|
47
|
+
updateNode(current.id, patch);
|
|
48
|
+
void pushCanvasUpdate([{ id: current.id, ...patch }], { recordHistory: moved });
|
|
49
|
+
}
|
|
50
|
+
function schedule() {
|
|
51
|
+
if (timer !== null) clearTimeout(timer);
|
|
52
|
+
timer = pendingHeight !== null && !held() ? setTimeout(apply, 300) : null;
|
|
53
|
+
}
|
|
54
|
+
const dispose = effect(() => {
|
|
55
|
+
held();
|
|
56
|
+
schedule();
|
|
57
|
+
});
|
|
29
58
|
function onMessage(event: MessageEvent) {
|
|
30
59
|
if (event.source !== iframeRef.current?.contentWindow) return;
|
|
31
60
|
const d = event.data as { source?: string; type?: string; token?: string; height?: unknown } | null;
|
|
32
61
|
if (!d || d.source !== 'pmx-canvas-frame' || d.type !== 'content-height' || d.token !== frameToken) return;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if (target === null) return;
|
|
37
|
-
resizeNode(current.id, { width: current.size.width, height: target });
|
|
38
|
-
if (persistTimer.current !== null) window.clearTimeout(persistTimer.current);
|
|
39
|
-
persistTimer.current = window.setTimeout(() => {
|
|
40
|
-
persistLayout({ recordHistory: false });
|
|
41
|
-
persistTimer.current = null;
|
|
42
|
-
}, 300);
|
|
62
|
+
if (typeof d.height !== 'number' || !Number.isFinite(d.height) || d.height <= 0) return;
|
|
63
|
+
pendingHeight = d.height;
|
|
64
|
+
schedule();
|
|
43
65
|
}
|
|
44
66
|
window.addEventListener('message', onMessage);
|
|
45
67
|
return () => {
|
|
46
68
|
window.removeEventListener('message', onMessage);
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
persistTimer.current = null;
|
|
50
|
-
}
|
|
69
|
+
dispose();
|
|
70
|
+
if (timer !== null) clearTimeout(timer);
|
|
51
71
|
};
|
|
52
72
|
}, [node.id, frameToken]);
|
|
53
73
|
}
|
|
@@ -58,7 +58,7 @@ export function applyHumanSnapshot(snapshot: Partial<HumanPresenceSnapshot> | nu
|
|
|
58
58
|
humans.value = snapshot.humans;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
export const grabbingNodeId = signal<string | null>(null);
|
|
62
62
|
let lastCursor: { x: number; y: number } | null = null;
|
|
63
63
|
let lastSentAt = 0;
|
|
64
64
|
let pending: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -74,7 +74,7 @@ function post(body: Record<string, unknown>): void {
|
|
|
74
74
|
function flushCursor(): void {
|
|
75
75
|
pending = null;
|
|
76
76
|
lastSentAt = Date.now();
|
|
77
|
-
post({ cursor: lastCursor, grabbingNodeId:
|
|
77
|
+
post({ cursor: lastCursor, grabbingNodeId: grabbingNodeId.value });
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
/** Report the pointer in world coordinates (throttled); null when it leaves the canvas. */
|
|
@@ -91,7 +91,7 @@ export function reportHumanCursor(cursor: { x: number; y: number } | null): void
|
|
|
91
91
|
|
|
92
92
|
/** Hold / release a node (drag, rename): the edit lock agents must respect. */
|
|
93
93
|
export function reportHumanGrab(nodeId: string | null): void {
|
|
94
|
-
|
|
94
|
+
grabbingNodeId.value = nodeId;
|
|
95
95
|
post({ cursor: lastCursor, grabbingNodeId: nodeId });
|
|
96
96
|
}
|
|
97
97
|
|
|
@@ -147,8 +147,8 @@ let heartbeat: ReturnType<typeof setInterval> | null = null;
|
|
|
147
147
|
|
|
148
148
|
/** Announce this tab and keep it alive; renews a held grab. Call once per connection. */
|
|
149
149
|
export function startHumanPresence(): () => void {
|
|
150
|
-
post({ cursor: lastCursor, grabbingNodeId:
|
|
151
|
-
heartbeat = setInterval(() => post({ grabbingNodeId:
|
|
150
|
+
post({ cursor: lastCursor, grabbingNodeId: grabbingNodeId.value });
|
|
151
|
+
heartbeat = setInterval(() => post({ grabbingNodeId: grabbingNodeId.value }), 3000);
|
|
152
152
|
const leave = () => {
|
|
153
153
|
try {
|
|
154
154
|
navigator.sendBeacon?.(
|
|
@@ -170,6 +170,6 @@ export function startHumanPresence(): () => void {
|
|
|
170
170
|
export function resetHumanPresence(): void {
|
|
171
171
|
humans.value = [];
|
|
172
172
|
yieldedNodes.value = new Map();
|
|
173
|
-
|
|
173
|
+
grabbingNodeId.value = null;
|
|
174
174
|
lastCursor = null;
|
|
175
175
|
}
|
|
@@ -20,6 +20,12 @@ const buttonDefinition = shadcnComponentDefinitions.Button;
|
|
|
20
20
|
|
|
21
21
|
export const allComponentDefinitions = {
|
|
22
22
|
...shadcnComponentDefinitions,
|
|
23
|
+
Card: {
|
|
24
|
+
...shadcnComponentDefinitions.Card,
|
|
25
|
+
slots: ['default', 'header', 'footer'],
|
|
26
|
+
description:
|
|
27
|
+
'Card container. Use children for its body and optional header/footer slots for custom headings or actions. A header slot replaces the title and description props.',
|
|
28
|
+
},
|
|
23
29
|
Badge: {
|
|
24
30
|
...badgeDefinition,
|
|
25
31
|
props: badgeDefinition.props.extend({
|
|
@@ -69,9 +69,26 @@ function Button({ props, emit }: { props: ButtonProps; emit: (event: string) =>
|
|
|
69
69
|
);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
function Card({ props, children, slots, ...rest }: Parameters<typeof shadcnComponents.Card>[0]) {
|
|
73
|
+
const header = slots?.header;
|
|
74
|
+
const footer = slots?.footer;
|
|
75
|
+
return (
|
|
76
|
+
<shadcnComponents.Card {...rest} props={header ? { ...props, title: null, description: null } : props}>
|
|
77
|
+
{header && <header data-pmx-card-slot="header">{header}</header>}
|
|
78
|
+
{children}
|
|
79
|
+
{footer && (
|
|
80
|
+
<footer data-pmx-card-slot="footer" className="flex flex-wrap items-center gap-3">
|
|
81
|
+
{footer}
|
|
82
|
+
</footer>
|
|
83
|
+
)}
|
|
84
|
+
</shadcnComponents.Card>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
72
88
|
const { registry } = defineRegistry(catalog as never, {
|
|
73
89
|
components: {
|
|
74
90
|
...shadcnComponents,
|
|
91
|
+
Card,
|
|
75
92
|
Badge,
|
|
76
93
|
Button,
|
|
77
94
|
...chartComponents,
|
|
@@ -4,12 +4,16 @@ export const schema = defineSchema(
|
|
|
4
4
|
(s) => ({
|
|
5
5
|
spec: s.object({
|
|
6
6
|
root: s.string(),
|
|
7
|
+
state: { ...s.record(s.any()), ...s.optional() },
|
|
7
8
|
elements: s.record(
|
|
8
9
|
s.object({
|
|
9
10
|
type: s.ref('catalog.components'),
|
|
10
11
|
props: s.propsOf('catalog.components'),
|
|
11
12
|
children: s.array(s.string()),
|
|
12
13
|
visible: s.any(),
|
|
14
|
+
repeat: { ...s.any(), ...s.optional() },
|
|
15
|
+
watch: { ...s.record(s.any()), ...s.optional() },
|
|
16
|
+
slots: { ...s.record(s.array(s.string())), ...s.optional() },
|
|
13
17
|
// Event→action bindings (on.press, on.change, …). Preserved through
|
|
14
18
|
// validation so spec authors can wire actions — including the ax.*
|
|
15
19
|
// handlers the viewer forwards to the canvas AX bridge.
|
package/src/mcp/server.ts
CHANGED
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
summarizeCanvasAnnotationForContext,
|
|
42
42
|
} from '../server/canvas-serialization.js';
|
|
43
43
|
import { listBundledSkills, readBundledSkill } from '../server/bundled-skills.js';
|
|
44
|
+
import { registerSkillExtension } from './skills.js';
|
|
44
45
|
|
|
45
46
|
let canvas: CanvasAccess | null = null;
|
|
46
47
|
let resourceNotificationServer: McpServer | null = null;
|
|
@@ -212,11 +213,15 @@ function buildSummaryFromLayout(
|
|
|
212
213
|
}
|
|
213
214
|
|
|
214
215
|
export async function startMcpServer(): Promise<void> {
|
|
215
|
-
const server = new McpServer(
|
|
216
|
-
name: 'pmx-canvas',
|
|
217
|
-
|
|
218
|
-
|
|
216
|
+
const server = new McpServer(
|
|
217
|
+
{ name: 'pmx-canvas', version: readPackageVersion() },
|
|
218
|
+
{
|
|
219
|
+
instructions:
|
|
220
|
+
'Bundled workbench guidance: skill://pmx-canvas/SKILL.md. Discover skills via skills/list when supported, or canvas://skills; read files through resources/read. Skill delivery does not grant permission to execute bundled scripts.',
|
|
221
|
+
},
|
|
222
|
+
);
|
|
219
223
|
resourceNotificationServer = server;
|
|
224
|
+
registerSkillExtension(server);
|
|
220
225
|
|
|
221
226
|
// ── Operation-registry tools (plan-005) ────────────────────────
|
|
222
227
|
// Standalone tools are registered from the shared operation registry for
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { createBundledSkillCatalog } from '../server/bundled-skills.js';
|
|
5
|
+
|
|
6
|
+
/** SEP-2640 methods are protocol requests, not model-facing canvas tools. */
|
|
7
|
+
export function registerSkillExtension(server: McpServer, catalog = createBundledSkillCatalog()): void {
|
|
8
|
+
// The v1 SDK accepts custom methods and transmits capabilities unchanged,
|
|
9
|
+
// but its ServerCapabilities type predates the extensions field.
|
|
10
|
+
const capabilities = { resources: {}, extensions: { 'io.modelcontextprotocol/skills': {} } };
|
|
11
|
+
server.server.registerCapabilities(capabilities);
|
|
12
|
+
server.server.setRequestHandler(
|
|
13
|
+
z.object({ method: z.literal('skills/list'), params: z.object({ cursor: z.string().optional() }).optional() }),
|
|
14
|
+
async ({ params }) => {
|
|
15
|
+
// The bounded package catalog is a single page, so no cursor is issued.
|
|
16
|
+
if (params?.cursor !== undefined) throw new McpError(ErrorCode.InvalidParams, 'Unknown skills cursor');
|
|
17
|
+
return { resultType: 'complete', skills: catalog.skills };
|
|
18
|
+
},
|
|
19
|
+
);
|
|
20
|
+
server.server.setRequestHandler(
|
|
21
|
+
z.object({ method: z.literal('skills/get'), params: z.object({ uri: z.string() }) }),
|
|
22
|
+
async ({ params }) => {
|
|
23
|
+
const skill = catalog.skills.find((entry) => entry.uri === params.uri);
|
|
24
|
+
if (!skill) throw new McpError(ErrorCode.InvalidParams, `No skill is served at ${params.uri}`);
|
|
25
|
+
return { resultType: 'complete', skill };
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
server.registerResource(
|
|
29
|
+
'skill-files',
|
|
30
|
+
new ResourceTemplate('skill://{name}/{+path}', { list: undefined }),
|
|
31
|
+
{ description: 'Packaged skill files. Discover complete manifests through skills/list or skills/get.' },
|
|
32
|
+
async (uri) => {
|
|
33
|
+
// Exact catalog lookup: a URI never becomes a filesystem path.
|
|
34
|
+
const content = catalog.files.get(uri.href);
|
|
35
|
+
if (!content) throw new McpError(ErrorCode.InvalidParams, `No skill file is served at ${uri.href}`);
|
|
36
|
+
return { contents: [content] };
|
|
37
|
+
},
|
|
38
|
+
);
|
|
39
|
+
for (const skill of catalog.skills) {
|
|
40
|
+
server.registerResource(
|
|
41
|
+
skill.frontmatter.name,
|
|
42
|
+
skill.uri,
|
|
43
|
+
{ mimeType: 'text/markdown', description: skill.frontmatter.description },
|
|
44
|
+
async () => ({ contents: [catalog.files.get(skill.uri)!] }),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -17,15 +17,29 @@
|
|
|
17
17
|
* - `canvas://skills/<name>` → full markdown content
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import {
|
|
20
|
+
import { createHash } from 'node:crypto';
|
|
21
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
21
22
|
import { dirname, join, resolve } from 'node:path';
|
|
22
23
|
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { z } from 'zod';
|
|
25
|
+
|
|
26
|
+
const skillFrontmatterSchema = z
|
|
27
|
+
.object({
|
|
28
|
+
name: z
|
|
29
|
+
.string()
|
|
30
|
+
.min(1)
|
|
31
|
+
.max(64)
|
|
32
|
+
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
|
33
|
+
description: z.string().min(1).max(1024),
|
|
34
|
+
})
|
|
35
|
+
.passthrough();
|
|
23
36
|
|
|
24
37
|
export interface BundledSkill {
|
|
25
38
|
name: string;
|
|
26
39
|
description: string;
|
|
27
40
|
uri: string;
|
|
28
41
|
filePath: string;
|
|
42
|
+
frontmatter: z.infer<typeof skillFrontmatterSchema>;
|
|
29
43
|
}
|
|
30
44
|
|
|
31
45
|
const MAX_DESCRIPTION_LENGTH = 400;
|
|
@@ -65,52 +79,18 @@ export function findBundledSkillsRoot(): string | null {
|
|
|
65
79
|
return null;
|
|
66
80
|
}
|
|
67
81
|
|
|
68
|
-
function
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const markdown = rawMarkdown.replace(/\r\n/g, '\n');
|
|
73
|
-
// YAML frontmatter lives between two `---` fences at the very top.
|
|
74
|
-
if (!markdown.startsWith('---')) return '';
|
|
75
|
-
const end = markdown.indexOf('\n---', 3);
|
|
76
|
-
if (end === -1) return '';
|
|
77
|
-
const frontmatter = markdown.slice(3, end);
|
|
78
|
-
const lines = frontmatter.split('\n');
|
|
79
|
-
|
|
80
|
-
// Support both single-line `description: ...` and block-scalar `description: >` /
|
|
81
|
-
// `description: |` forms (indented continuation lines).
|
|
82
|
-
for (let i = 0; i < lines.length; i++) {
|
|
83
|
-
const line = lines[i] ?? '';
|
|
84
|
-
const match = /^description:\s*(.*)$/.exec(line);
|
|
85
|
-
if (!match) continue;
|
|
86
|
-
const first = (match[1] ?? '').trim();
|
|
87
|
-
if (first && first !== '>' && first !== '|' && first !== '>-' && first !== '|-') {
|
|
88
|
-
return first.slice(0, MAX_DESCRIPTION_LENGTH);
|
|
89
|
-
}
|
|
90
|
-
// Block scalar — concatenate indented follow-on lines.
|
|
91
|
-
const parts: string[] = [];
|
|
92
|
-
for (let j = i + 1; j < lines.length; j++) {
|
|
93
|
-
const follow = lines[j] ?? '';
|
|
94
|
-
if (follow.length === 0) {
|
|
95
|
-
parts.push('');
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
|
-
if (!/^\s/.test(follow)) break;
|
|
99
|
-
parts.push(follow.trim());
|
|
100
|
-
}
|
|
101
|
-
return parts.join(' ').replace(/\s+/g, ' ').trim().slice(0, MAX_DESCRIPTION_LENGTH);
|
|
102
|
-
}
|
|
103
|
-
return '';
|
|
82
|
+
function parseFrontmatter(markdown: string): BundledSkill['frontmatter'] {
|
|
83
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(markdown);
|
|
84
|
+
if (!match) throw new Error('Bundled skill is missing YAML frontmatter');
|
|
85
|
+
return skillFrontmatterSchema.parse(Bun.YAML.parse(match[1]!));
|
|
104
86
|
}
|
|
105
87
|
|
|
106
88
|
/**
|
|
107
89
|
* Enumerate every `<name>/SKILL.md` under the bundled skills root and return
|
|
108
|
-
* a compact index.
|
|
109
|
-
*
|
|
110
|
-
* break the MCP server's resource listing.
|
|
90
|
+
* a compact index. Invalid packaged skills fail explicitly rather than being
|
|
91
|
+
* silently omitted from the Skills extension's integrity manifest.
|
|
111
92
|
*/
|
|
112
|
-
export function listBundledSkills(): BundledSkill[] {
|
|
113
|
-
const root = findBundledSkillsRoot();
|
|
93
|
+
export function listBundledSkills(root = findBundledSkillsRoot()): BundledSkill[] {
|
|
114
94
|
if (!root) return [];
|
|
115
95
|
const entries = readdirSync(root);
|
|
116
96
|
const skills: BundledSkill[] = [];
|
|
@@ -118,18 +98,19 @@ export function listBundledSkills(): BundledSkill[] {
|
|
|
118
98
|
if (entry.startsWith('.')) continue;
|
|
119
99
|
const skillFile = join(root, entry, 'SKILL.md');
|
|
120
100
|
if (!existsSync(skillFile)) continue;
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const description = parseFrontmatterDescription(markdown);
|
|
124
|
-
skills.push({
|
|
125
|
-
name: entry,
|
|
126
|
-
description,
|
|
127
|
-
uri: `canvas://skills/${entry}`,
|
|
128
|
-
filePath: skillFile,
|
|
129
|
-
});
|
|
130
|
-
} catch {
|
|
131
|
-
// skip unreadable files
|
|
101
|
+
if (lstatSync(join(root, entry)).isSymbolicLink() || lstatSync(skillFile).isSymbolicLink()) {
|
|
102
|
+
throw new Error(`Bundled skill contains a symbolic link: ${entry}`);
|
|
132
103
|
}
|
|
104
|
+
if (!lstatSync(skillFile).isFile()) throw new Error(`Bundled skill contains a non-regular file: ${skillFile}`);
|
|
105
|
+
const frontmatter = parseFrontmatter(readFileSync(skillFile, 'utf-8'));
|
|
106
|
+
if (frontmatter.name !== entry) throw new Error(`Bundled skill name does not match directory: ${entry}`);
|
|
107
|
+
skills.push({
|
|
108
|
+
name: entry,
|
|
109
|
+
description: frontmatter.description.slice(0, MAX_DESCRIPTION_LENGTH),
|
|
110
|
+
uri: `canvas://skills/${entry}`,
|
|
111
|
+
filePath: skillFile,
|
|
112
|
+
frontmatter,
|
|
113
|
+
});
|
|
133
114
|
}
|
|
134
115
|
skills.sort((a, b) => a.name.localeCompare(b.name));
|
|
135
116
|
return skills;
|
|
@@ -145,3 +126,61 @@ export function readBundledSkill(name: string): string | null {
|
|
|
145
126
|
return null;
|
|
146
127
|
}
|
|
147
128
|
}
|
|
129
|
+
|
|
130
|
+
export interface SkillEntry {
|
|
131
|
+
uri: string;
|
|
132
|
+
frontmatter: BundledSkill['frontmatter'];
|
|
133
|
+
resources: Array<{ uri: string; digest: string; size: number }>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
type SkillContent = { uri: string; mimeType: string } & ({ text: string } | { blob: string });
|
|
137
|
+
|
|
138
|
+
/** Capture immutable package bytes once per MCP process, never read caller-supplied paths. */
|
|
139
|
+
export function createBundledSkillCatalog(root = findBundledSkillsRoot()) {
|
|
140
|
+
const skills: SkillEntry[] = [];
|
|
141
|
+
const files = new Map<string, SkillContent>();
|
|
142
|
+
for (const skill of listBundledSkills(root)) {
|
|
143
|
+
const entry: SkillEntry = { uri: `skill://${skill.name}/SKILL.md`, frontmatter: skill.frontmatter, resources: [] };
|
|
144
|
+
let totalBytes = 0;
|
|
145
|
+
function walk(directory: string, prefix = ''): void {
|
|
146
|
+
for (const child of readdirSync(directory, { withFileTypes: true }).sort((a, b) =>
|
|
147
|
+
a.name.localeCompare(b.name),
|
|
148
|
+
)) {
|
|
149
|
+
const filePath = join(directory, child.name);
|
|
150
|
+
const relativePath = prefix + encodeURIComponent(child.name);
|
|
151
|
+
if (child.isSymbolicLink()) throw new Error(`Bundled skill contains a symbolic link: ${filePath}`);
|
|
152
|
+
if (child.isDirectory()) {
|
|
153
|
+
walk(filePath, `${relativePath}/`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (!child.isFile()) throw new Error(`Bundled skill contains a non-regular file: ${filePath}`);
|
|
157
|
+
totalBytes += lstatSync(filePath).size;
|
|
158
|
+
if (entry.resources.length >= 512 || totalBytes > 16 * 1024 * 1024) {
|
|
159
|
+
throw new Error(`Bundled skill exceeds SEP-2640 file/byte limits: ${skill.name}`);
|
|
160
|
+
}
|
|
161
|
+
const bytes = readFileSync(filePath);
|
|
162
|
+
const uri = `skill://${skill.name}/${relativePath}`;
|
|
163
|
+
const mimeType = child.name.endsWith('.md') ? 'text/markdown' : Bun.file(filePath).type;
|
|
164
|
+
// Round-trip detection preserves invalid UTF-8 and binary assets as blobs.
|
|
165
|
+
const text = bytes.toString('utf8');
|
|
166
|
+
const content =
|
|
167
|
+
!bytes.includes(0) && Buffer.from(text).equals(bytes)
|
|
168
|
+
? { uri, mimeType, text }
|
|
169
|
+
: { uri, mimeType, blob: bytes.toString('base64') };
|
|
170
|
+
files.set(uri, content);
|
|
171
|
+
entry.resources.push({
|
|
172
|
+
uri,
|
|
173
|
+
size: bytes.length,
|
|
174
|
+
digest: `sha256:${createHash('sha256').update(bytes).digest('hex')}`,
|
|
175
|
+
});
|
|
176
|
+
if (uri === entry.uri) entry.frontmatter = parseFrontmatter(text);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
walk(dirname(skill.filePath));
|
|
180
|
+
if (entry.frontmatter.name !== skill.name)
|
|
181
|
+
throw new Error(`Bundled skill name changed during loading: ${skill.name}`);
|
|
182
|
+
entry.resources.sort((a, b) => (a.uri < b.uri ? -1 : a.uri > b.uri ? 1 : 0));
|
|
183
|
+
skills.push(entry);
|
|
184
|
+
}
|
|
185
|
+
return { skills, files };
|
|
186
|
+
}
|