pmx-canvas 0.1.0
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 +38 -0
- package/LICENSE +21 -0
- package/Readme.md +865 -0
- package/dist/canvas/global.css +3173 -0
- package/dist/canvas/index.js +183 -0
- package/dist/json-render/index.css +2 -0
- package/dist/json-render/index.js +389 -0
- package/dist/types/cli/agent.d.ts +13 -0
- package/dist/types/cli/index.d.ts +2 -0
- package/dist/types/cli/watch.d.ts +5 -0
- package/dist/types/client/App.d.ts +1 -0
- package/dist/types/client/canvas/AttentionHistory.d.ts +1 -0
- package/dist/types/client/canvas/AttentionToast.d.ts +1 -0
- package/dist/types/client/canvas/CanvasNode.d.ts +8 -0
- package/dist/types/client/canvas/CanvasViewport.d.ts +8 -0
- package/dist/types/client/canvas/CommandPalette.d.ts +4 -0
- package/dist/types/client/canvas/ContextMenu.d.ts +24 -0
- package/dist/types/client/canvas/ContextPinBar.d.ts +1 -0
- package/dist/types/client/canvas/ContextPinHud.d.ts +1 -0
- package/dist/types/client/canvas/DockedNode.d.ts +4 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
- package/dist/types/client/canvas/ExpandedNodeOverlay.d.ts +1 -0
- package/dist/types/client/canvas/FocusFieldLayer.d.ts +1 -0
- package/dist/types/client/canvas/Minimap.d.ts +23 -0
- package/dist/types/client/canvas/SelectionBar.d.ts +1 -0
- package/dist/types/client/canvas/ShortcutOverlay.d.ts +3 -0
- package/dist/types/client/canvas/SnapshotPanel.d.ts +7 -0
- package/dist/types/client/canvas/snap-guides.d.ts +23 -0
- package/dist/types/client/canvas/use-node-drag.d.ts +15 -0
- package/dist/types/client/canvas/use-node-resize.d.ts +15 -0
- package/dist/types/client/canvas/use-pan-zoom.d.ts +16 -0
- package/dist/types/client/ext-app/bridge.d.ts +161 -0
- package/dist/types/client/icons.d.ts +70 -0
- package/dist/types/client/index.d.ts +1 -0
- package/dist/types/client/nodes/ContextNode.d.ts +34 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +18 -0
- package/dist/types/client/nodes/FileNode.d.ts +5 -0
- package/dist/types/client/nodes/GroupNode.d.ts +6 -0
- package/dist/types/client/nodes/ImageNode.d.ts +10 -0
- package/dist/types/client/nodes/InlineFormatBar.d.ts +7 -0
- package/dist/types/client/nodes/InlineMarkdownEditor.d.ts +14 -0
- package/dist/types/client/nodes/LedgerNode.d.ts +4 -0
- package/dist/types/client/nodes/MarkdownNode.d.ts +6 -0
- package/dist/types/client/nodes/McpAppNode.d.ts +4 -0
- package/dist/types/client/nodes/MdFormatBar.d.ts +8 -0
- package/dist/types/client/nodes/PromptNode.d.ts +5 -0
- package/dist/types/client/nodes/ResponseNode.d.ts +5 -0
- package/dist/types/client/nodes/StatusNode.d.ts +4 -0
- package/dist/types/client/nodes/StatusSummary.d.ts +4 -0
- package/dist/types/client/nodes/TraceNode.d.ts +4 -0
- package/dist/types/client/nodes/WebpageNode.d.ts +5 -0
- package/dist/types/client/nodes/image-warnings.d.ts +6 -0
- package/dist/types/client/nodes/inline-editor-commands.d.ts +11 -0
- package/dist/types/client/nodes/md-format.d.ts +25 -0
- package/dist/types/client/state/attention-bridge.d.ts +3 -0
- package/dist/types/client/state/attention-store.d.ts +25 -0
- package/dist/types/client/state/canvas-store.d.ts +74 -0
- package/dist/types/client/state/intent-bridge.d.ts +158 -0
- package/dist/types/client/state/sse-bridge.d.ts +5 -0
- package/dist/types/client/theme/tokens.d.ts +27 -0
- package/dist/types/client/types.d.ts +40 -0
- package/dist/types/client/utils/ext-app-tool-result.d.ts +1 -0
- package/dist/types/client/utils/placement.d.ts +1 -0
- package/dist/types/client/utils/platform.d.ts +2 -0
- package/dist/types/json-render/catalog.d.ts +815 -0
- package/dist/types/json-render/charts/components.d.ts +54 -0
- package/dist/types/json-render/charts/definitions.d.ts +103 -0
- package/dist/types/json-render/charts/extra-components.d.ts +58 -0
- package/dist/types/json-render/charts/extra-definitions.d.ts +181 -0
- package/dist/types/json-render/renderer/index.d.ts +16 -0
- package/dist/types/json-render/schema.d.ts +46 -0
- package/dist/types/json-render/server.d.ts +55 -0
- package/dist/types/mcp/server.d.ts +22 -0
- package/dist/types/server/agent-context.d.ts +21 -0
- package/dist/types/server/artifact-paths.d.ts +3 -0
- package/dist/types/server/canvas-operations.d.ts +154 -0
- package/dist/types/server/canvas-provenance.d.ts +13 -0
- package/dist/types/server/canvas-schema.d.ts +49 -0
- package/dist/types/server/canvas-serialization.d.ts +25 -0
- package/dist/types/server/canvas-state.d.ts +174 -0
- package/dist/types/server/canvas-validation.d.ts +33 -0
- package/dist/types/server/chart-template.d.ts +29 -0
- package/dist/types/server/code-graph.d.ts +67 -0
- package/dist/types/server/context-cards.d.ts +24 -0
- package/dist/types/server/diagram-presets.d.ts +28 -0
- package/dist/types/server/ext-app-call-registry.d.ts +16 -0
- package/dist/types/server/ext-app-tool-result.d.ts +1 -0
- package/dist/types/server/file-watcher.d.ts +16 -0
- package/dist/types/server/index.d.ts +243 -0
- package/dist/types/server/mcp-app-candidate.d.ts +25 -0
- package/dist/types/server/mcp-app-host.d.ts +65 -0
- package/dist/types/server/mcp-app-runtime.d.ts +47 -0
- package/dist/types/server/mutation-history.d.ts +105 -0
- package/dist/types/server/placement.d.ts +37 -0
- package/dist/types/server/server.d.ts +103 -0
- package/dist/types/server/spatial-analysis.d.ts +87 -0
- package/dist/types/server/trace-manager.d.ts +48 -0
- package/dist/types/server/web-artifacts.d.ts +50 -0
- package/dist/types/server/webpage-node.d.ts +25 -0
- package/dist/types/shared/auto-arrange.d.ts +29 -0
- package/dist/types/shared/ext-app-tool-result.d.ts +9 -0
- package/dist/types/shared/placement.d.ts +26 -0
- package/dist/types/shared/semantic-attention.d.ts +97 -0
- package/package.json +109 -0
- package/skills/data-analysis/SKILL.md +324 -0
- package/skills/doc-coauthoring/SKILL.md +375 -0
- package/skills/frontend-design/SKILL.md +45 -0
- package/skills/json-render-codegen/SKILL.md +112 -0
- package/skills/json-render-core/SKILL.md +265 -0
- package/skills/json-render-ink/SKILL.md +273 -0
- package/skills/json-render-mcp/SKILL.md +132 -0
- package/skills/json-render-react/SKILL.md +264 -0
- package/skills/json-render-shadcn/SKILL.md +159 -0
- package/skills/playwright-cli/SKILL.md +67 -0
- package/skills/pmx-canvas/SKILL.md +668 -0
- package/skills/pmx-canvas/evals/evals.json +186 -0
- package/skills/pmx-canvas-testing/SKILL.md +78 -0
- package/skills/published-consumer-e2e/SKILL.md +43 -0
- package/skills/published-consumer-e2e/scripts/run-published-consumer-e2e.sh +241 -0
- package/skills/web-artifacts-builder/SKILL.md +80 -0
- package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +167 -0
- package/skills/web-artifacts-builder/scripts/init-artifact.sh +425 -0
- package/skills/web-artifacts-builder/scripts/shadcn-components.tar.gz +0 -0
- package/skills/web-design-guidelines/SKILL.md +39 -0
- package/src/cli/agent.ts +2144 -0
- package/src/cli/index.ts +622 -0
- package/src/cli/watch.ts +88 -0
- package/src/client/App.tsx +507 -0
- package/src/client/canvas/AttentionHistory.tsx +81 -0
- package/src/client/canvas/AttentionToast.tsx +19 -0
- package/src/client/canvas/CanvasNode.tsx +363 -0
- package/src/client/canvas/CanvasViewport.tsx +590 -0
- package/src/client/canvas/CommandPalette.tsx +302 -0
- package/src/client/canvas/ContextMenu.tsx +601 -0
- package/src/client/canvas/ContextPinBar.tsx +25 -0
- package/src/client/canvas/ContextPinHud.tsx +22 -0
- package/src/client/canvas/DockedNode.tsx +66 -0
- package/src/client/canvas/EdgeLayer.tsx +280 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +260 -0
- package/src/client/canvas/FocusFieldLayer.tsx +107 -0
- package/src/client/canvas/Minimap.tsx +301 -0
- package/src/client/canvas/SelectionBar.tsx +69 -0
- package/src/client/canvas/ShortcutOverlay.tsx +69 -0
- package/src/client/canvas/SnapshotPanel.tsx +236 -0
- package/src/client/canvas/snap-guides.ts +170 -0
- package/src/client/canvas/use-node-drag.ts +51 -0
- package/src/client/canvas/use-node-resize.ts +59 -0
- package/src/client/canvas/use-pan-zoom.ts +191 -0
- package/src/client/ext-app/bridge.ts +542 -0
- package/src/client/icons.tsx +424 -0
- package/src/client/index.tsx +7 -0
- package/src/client/nodes/ContextNode.tsx +412 -0
- package/src/client/nodes/ExtAppFrame.tsx +509 -0
- package/src/client/nodes/FileNode.tsx +256 -0
- package/src/client/nodes/GroupNode.tsx +39 -0
- package/src/client/nodes/ImageNode.tsx +160 -0
- package/src/client/nodes/InlineFormatBar.tsx +169 -0
- package/src/client/nodes/InlineMarkdownEditor.tsx +123 -0
- package/src/client/nodes/LedgerNode.tsx +37 -0
- package/src/client/nodes/MarkdownNode.tsx +359 -0
- package/src/client/nodes/McpAppNode.tsx +85 -0
- package/src/client/nodes/MdFormatBar.tsx +109 -0
- package/src/client/nodes/PromptNode.tsx +597 -0
- package/src/client/nodes/ResponseNode.tsx +153 -0
- package/src/client/nodes/StatusNode.tsx +84 -0
- package/src/client/nodes/StatusSummary.tsx +38 -0
- package/src/client/nodes/TraceNode.tsx +120 -0
- package/src/client/nodes/WebpageNode.tsx +288 -0
- package/src/client/nodes/image-warnings.ts +95 -0
- package/src/client/nodes/inline-editor-commands.ts +37 -0
- package/src/client/nodes/md-format.ts +206 -0
- package/src/client/state/attention-bridge.ts +328 -0
- package/src/client/state/attention-store.ts +73 -0
- package/src/client/state/canvas-store.ts +631 -0
- package/src/client/state/intent-bridge.ts +315 -0
- package/src/client/state/sse-bridge.ts +965 -0
- package/src/client/theme/global.css +3173 -0
- package/src/client/theme/tokens.ts +72 -0
- package/src/client/types-shims.d.ts +5 -0
- package/src/client/types.ts +81 -0
- package/src/client/utils/ext-app-tool-result.ts +4 -0
- package/src/client/utils/placement.ts +4 -0
- package/src/client/utils/platform.ts +2 -0
- package/src/json-render/catalog.ts +256 -0
- package/src/json-render/charts/components.tsx +198 -0
- package/src/json-render/charts/definitions.ts +81 -0
- package/src/json-render/charts/extra-components.tsx +267 -0
- package/src/json-render/charts/extra-definitions.ts +145 -0
- package/src/json-render/renderer/index.css +174 -0
- package/src/json-render/renderer/index.tsx +86 -0
- package/src/json-render/schema.ts +62 -0
- package/src/json-render/server.ts +597 -0
- package/src/mcp/server.ts +1377 -0
- package/src/server/agent-context.ts +242 -0
- package/src/server/artifact-paths.ts +17 -0
- package/src/server/canvas-operations.ts +1279 -0
- package/src/server/canvas-provenance.ts +243 -0
- package/src/server/canvas-schema.ts +432 -0
- package/src/server/canvas-serialization.ts +95 -0
- package/src/server/canvas-state.ts +1134 -0
- package/src/server/canvas-validation.ts +114 -0
- package/src/server/chart-template.ts +449 -0
- package/src/server/code-graph.ts +370 -0
- package/src/server/context-cards.ts +31 -0
- package/src/server/diagram-presets.ts +71 -0
- package/src/server/ext-app-call-registry.ts +77 -0
- package/src/server/ext-app-tool-result.ts +4 -0
- package/src/server/file-watcher.ts +121 -0
- package/src/server/index.ts +647 -0
- package/src/server/mcp-app-candidate.ts +174 -0
- package/src/server/mcp-app-host.ts +814 -0
- package/src/server/mcp-app-runtime.ts +459 -0
- package/src/server/mutation-history.ts +350 -0
- package/src/server/placement.ts +125 -0
- package/src/server/server.ts +3846 -0
- package/src/server/spatial-analysis.ts +356 -0
- package/src/server/trace-manager.ts +333 -0
- package/src/server/web-artifacts/scripts/bundle-artifact.sh +167 -0
- package/src/server/web-artifacts/scripts/init-artifact.sh +426 -0
- package/src/server/web-artifacts/scripts/shadcn-components.tar.gz +0 -0
- package/src/server/web-artifacts.ts +442 -0
- package/src/server/webpage-node.ts +328 -0
- package/src/shared/auto-arrange.ts +439 -0
- package/src/shared/ext-app-tool-result.ts +76 -0
- package/src/shared/placement.ts +81 -0
- package/src/shared/semantic-attention.ts +598 -0
|
@@ -0,0 +1,1377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PMX Canvas MCP Server
|
|
3
|
+
*
|
|
4
|
+
* Exposes the canvas as an MCP tool server so any MCP-capable agent
|
|
5
|
+
* (Claude Code, Codex, Cursor, Windsurf, pi, etc.) can control the
|
|
6
|
+
* canvas with zero configuration beyond adding the server to their config.
|
|
7
|
+
*
|
|
8
|
+
* Auto-starts the HTTP server and opens the browser on first tool call.
|
|
9
|
+
*
|
|
10
|
+
* Usage in agent MCP config:
|
|
11
|
+
* ```json
|
|
12
|
+
* {
|
|
13
|
+
* "mcpServers": {
|
|
14
|
+
* "canvas": {
|
|
15
|
+
* "command": "bunx",
|
|
16
|
+
* "args": ["pmx-canvas", "--mcp"]
|
|
17
|
+
* }
|
|
18
|
+
* }
|
|
19
|
+
* }
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
24
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
25
|
+
import { isAbsolute, relative, resolve } from 'node:path';
|
|
26
|
+
import { z } from 'zod';
|
|
27
|
+
import {
|
|
28
|
+
createCanvas,
|
|
29
|
+
canvasState,
|
|
30
|
+
describeCanvasSchema,
|
|
31
|
+
validateStructuredCanvasPayload,
|
|
32
|
+
type PmxCanvas,
|
|
33
|
+
} from '../server/index.js';
|
|
34
|
+
import { serializeNodeForAgentContext } from '../server/agent-context.js';
|
|
35
|
+
import { emitPrimaryWorkbenchEvent } from '../server/server.js';
|
|
36
|
+
import { searchNodes, buildSpatialContext, findNeighborhoods } from '../server/spatial-analysis.js';
|
|
37
|
+
import { mutationHistory, diffLayouts, formatDiff } from '../server/mutation-history.js';
|
|
38
|
+
import { buildCodeGraphSummary, formatCodeGraph } from '../server/code-graph.js';
|
|
39
|
+
import { buildCanvasSummary, serializeCanvasLayout, serializeCanvasNode } from '../server/canvas-serialization.js';
|
|
40
|
+
|
|
41
|
+
let canvas: PmxCanvas | null = null;
|
|
42
|
+
|
|
43
|
+
const jsonRenderSpecSchema = z.object({
|
|
44
|
+
root: z.string(),
|
|
45
|
+
elements: z.record(z.string(), z.unknown()),
|
|
46
|
+
state: z.record(z.string(), z.unknown()).optional(),
|
|
47
|
+
}).passthrough();
|
|
48
|
+
|
|
49
|
+
function workspaceRoot(): string {
|
|
50
|
+
return resolve(process.cwd());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isPathInside(base: string, candidate: string): boolean {
|
|
54
|
+
const rel = relative(base, candidate);
|
|
55
|
+
if (rel === '') return true;
|
|
56
|
+
return !rel.startsWith('..') && rel !== '..' && !isAbsolute(rel);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function safeWorkspacePath(pathLike: string): string {
|
|
60
|
+
const workspace = workspaceRoot();
|
|
61
|
+
const resolved = resolve(workspace, pathLike);
|
|
62
|
+
if (!isPathInside(workspace, resolved)) {
|
|
63
|
+
throw new Error(`Path "${pathLike}" resolves outside workspace.`);
|
|
64
|
+
}
|
|
65
|
+
return resolved;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function ensureCanvas(): Promise<PmxCanvas> {
|
|
69
|
+
if (!canvas) {
|
|
70
|
+
const port = parseInt(process.env.PMX_CANVAS_PORT ?? '4313');
|
|
71
|
+
canvas = createCanvas({ port });
|
|
72
|
+
await canvas.start({ open: true });
|
|
73
|
+
}
|
|
74
|
+
return canvas;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function encodeBase64(bytes: Uint8Array): string {
|
|
78
|
+
return Buffer.from(bytes).toString('base64');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function createdNodePayload(c: PmxCanvas, id: string): Record<string, unknown> {
|
|
82
|
+
const node = c.getNode(id);
|
|
83
|
+
return node ? { ok: true, ...serializeCanvasNode(node) } : { ok: true, id };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function startMcpServer(): Promise<void> {
|
|
87
|
+
const server = new McpServer({
|
|
88
|
+
name: 'pmx-canvas',
|
|
89
|
+
version: '0.1.0',
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ── canvas_get_layout ──────────────────────────────────────────
|
|
93
|
+
server.tool(
|
|
94
|
+
'canvas_get_layout',
|
|
95
|
+
'Get the full canvas state: all nodes, edges, and viewport. Call this first to understand what is on the canvas.',
|
|
96
|
+
{},
|
|
97
|
+
async () => {
|
|
98
|
+
const c = await ensureCanvas();
|
|
99
|
+
const layout = serializeCanvasLayout(c.getLayout());
|
|
100
|
+
return {
|
|
101
|
+
content: [{ type: 'text', text: JSON.stringify(layout, null, 2) }],
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// ── canvas_get_node ────────────────────────────────────────────
|
|
107
|
+
server.tool(
|
|
108
|
+
'canvas_get_node',
|
|
109
|
+
'Get a single node by ID, including its full data.',
|
|
110
|
+
{ id: z.string().describe('The node ID to retrieve') },
|
|
111
|
+
async ({ id }) => {
|
|
112
|
+
const c = await ensureCanvas();
|
|
113
|
+
const node = c.getNode(id);
|
|
114
|
+
if (!node) {
|
|
115
|
+
return {
|
|
116
|
+
content: [{ type: 'text', text: `Node "${id}" not found.` }],
|
|
117
|
+
isError: true,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
content: [{ type: 'text', text: JSON.stringify(serializeCanvasNode(node), null, 2) }],
|
|
122
|
+
};
|
|
123
|
+
},
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// ── canvas_add_node ────────────────────────────────────────────
|
|
127
|
+
server.tool(
|
|
128
|
+
'canvas_add_node',
|
|
129
|
+
'Add a node to the canvas. Returns the created node with normalized title/content and rendered geometry. Node types: markdown (rich content), status (compact indicator), context, ledger, trace, file (live file viewer — set content to a file path), image (set content to an image file path, data URI, or URL), webpage (prefer url for the page URL; content is still accepted), mcp-app. Use canvas_add_json_render_node, canvas_add_graph_node, and canvas_build_web_artifact for structured UI, graph, and artifact nodes.',
|
|
130
|
+
{
|
|
131
|
+
type: z.enum(['markdown', 'status', 'context', 'ledger', 'trace', 'file', 'image', 'webpage', 'mcp-app', 'group'])
|
|
132
|
+
.describe('Node type (prefer canvas_create_group for groups)'),
|
|
133
|
+
title: z.string().optional().describe('Node title'),
|
|
134
|
+
content: z.string().optional().describe('Node content (markdown for markdown nodes, file path for file nodes, image path/URL/data-URI for image nodes, URL for webpage nodes)'),
|
|
135
|
+
url: z.string().optional().describe('Canonical webpage URL field for webpage nodes. Overrides content when both are provided.'),
|
|
136
|
+
x: z.number().optional().describe('X position (auto-placed if omitted)'),
|
|
137
|
+
y: z.number().optional().describe('Y position (auto-placed if omitted)'),
|
|
138
|
+
width: z.number().optional().describe('Width in pixels (default: 720)'),
|
|
139
|
+
height: z.number().optional().describe('Height in pixels (default: 600)'),
|
|
140
|
+
},
|
|
141
|
+
async (input) => {
|
|
142
|
+
const c = await ensureCanvas();
|
|
143
|
+
if (input.type === 'webpage') {
|
|
144
|
+
const url = input.url ?? input.content;
|
|
145
|
+
if (!url) {
|
|
146
|
+
return {
|
|
147
|
+
content: [{ type: 'text', text: 'Webpage nodes require a page URL via "url" (preferred) or "content".' }],
|
|
148
|
+
isError: true,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const result = await c.addWebpageNode({
|
|
152
|
+
...(typeof input.title === 'string' ? { title: input.title } : {}),
|
|
153
|
+
url,
|
|
154
|
+
...(typeof input.x === 'number' ? { x: input.x } : {}),
|
|
155
|
+
...(typeof input.y === 'number' ? { y: input.y } : {}),
|
|
156
|
+
...(typeof input.width === 'number' ? { width: input.width } : {}),
|
|
157
|
+
...(typeof input.height === 'number' ? { height: input.height } : {}),
|
|
158
|
+
});
|
|
159
|
+
return {
|
|
160
|
+
content: [{ type: 'text', text: JSON.stringify(result) }],
|
|
161
|
+
...(result.ok ? {} : { isError: true }),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const id = c.addNode(input);
|
|
165
|
+
return {
|
|
166
|
+
content: [{ type: 'text', text: JSON.stringify(createdNodePayload(c, id), null, 2) }],
|
|
167
|
+
};
|
|
168
|
+
},
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
server.tool(
|
|
172
|
+
'canvas_open_mcp_app',
|
|
173
|
+
'Connect to an external MCP server that declares a ui:// app resource, call the specified tool, and open the resulting MCP App inside a canvas mcp-app node.',
|
|
174
|
+
{
|
|
175
|
+
toolName: z.string().describe('Tool name on the external MCP server'),
|
|
176
|
+
serverName: z.string().optional().describe('Optional display name for the external MCP server'),
|
|
177
|
+
toolArguments: z.record(z.string(), z.unknown()).optional().describe('Arguments passed to the external tool call'),
|
|
178
|
+
title: z.string().optional().describe('Optional canvas node title override'),
|
|
179
|
+
x: z.number().optional().describe('X position (auto-placed if omitted)'),
|
|
180
|
+
y: z.number().optional().describe('Y position (auto-placed if omitted)'),
|
|
181
|
+
width: z.number().optional().describe('Width in pixels (default: 720)'),
|
|
182
|
+
height: z.number().optional().describe('Height in pixels (default: 500)'),
|
|
183
|
+
transport: z.union([
|
|
184
|
+
z.object({
|
|
185
|
+
type: z.literal('stdio'),
|
|
186
|
+
command: z.string().describe('Executable used to start the external MCP server'),
|
|
187
|
+
args: z.array(z.string()).optional().describe('Arguments for the executable'),
|
|
188
|
+
cwd: z.string().optional().describe('Optional working directory'),
|
|
189
|
+
env: z.record(z.string(), z.string()).optional().describe('Optional environment overrides'),
|
|
190
|
+
}),
|
|
191
|
+
z.object({
|
|
192
|
+
type: z.literal('http'),
|
|
193
|
+
url: z.string().describe('Streamable HTTP MCP endpoint URL'),
|
|
194
|
+
headers: z.record(z.string(), z.string()).optional().describe('Optional HTTP headers'),
|
|
195
|
+
}),
|
|
196
|
+
]).describe('How PMX Canvas should connect to the external MCP server'),
|
|
197
|
+
},
|
|
198
|
+
async (input) => {
|
|
199
|
+
const c = await ensureCanvas();
|
|
200
|
+
try {
|
|
201
|
+
const result = await c.openMcpApp({
|
|
202
|
+
transport: input.transport,
|
|
203
|
+
toolName: input.toolName,
|
|
204
|
+
...(typeof input.serverName === 'string' ? { serverName: input.serverName } : {}),
|
|
205
|
+
...(input.toolArguments ? { toolArguments: input.toolArguments } : {}),
|
|
206
|
+
...(typeof input.title === 'string' ? { title: input.title } : {}),
|
|
207
|
+
...(typeof input.x === 'number' ? { x: input.x } : {}),
|
|
208
|
+
...(typeof input.y === 'number' ? { y: input.y } : {}),
|
|
209
|
+
...(typeof input.width === 'number' ? { width: input.width } : {}),
|
|
210
|
+
...(typeof input.height === 'number' ? { height: input.height } : {}),
|
|
211
|
+
});
|
|
212
|
+
return {
|
|
213
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
214
|
+
};
|
|
215
|
+
} catch (error) {
|
|
216
|
+
return {
|
|
217
|
+
content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
|
|
218
|
+
isError: true,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
server.tool(
|
|
225
|
+
'canvas_add_diagram',
|
|
226
|
+
'Draw a hand-drawn diagram on the canvas via the hosted Excalidraw MCP app. Pass an array of Excalidraw elements (rectangles, ellipses, diamonds, arrows, text). The diagram opens inside an mcp-app node that supports fullscreen editing. For other MCP apps, use canvas_open_mcp_app.',
|
|
227
|
+
{
|
|
228
|
+
elements: z.union([
|
|
229
|
+
z.string().describe('JSON array string of Excalidraw elements'),
|
|
230
|
+
z.array(z.record(z.string(), z.unknown())).describe('Array of Excalidraw elements'),
|
|
231
|
+
]).describe('Excalidraw elements to render. See https://github.com/excalidraw/excalidraw-mcp for the element format.'),
|
|
232
|
+
title: z.string().optional().describe('Optional canvas node title override'),
|
|
233
|
+
x: z.number().optional().describe('X position (auto-placed if omitted)'),
|
|
234
|
+
y: z.number().optional().describe('Y position (auto-placed if omitted)'),
|
|
235
|
+
width: z.number().optional().describe('Width in pixels (default: 720)'),
|
|
236
|
+
height: z.number().optional().describe('Height in pixels (default: 500)'),
|
|
237
|
+
},
|
|
238
|
+
async (input) => {
|
|
239
|
+
const c = await ensureCanvas();
|
|
240
|
+
try {
|
|
241
|
+
const result = await c.addDiagram({
|
|
242
|
+
elements: input.elements,
|
|
243
|
+
...(typeof input.title === 'string' ? { title: input.title } : {}),
|
|
244
|
+
...(typeof input.x === 'number' ? { x: input.x } : {}),
|
|
245
|
+
...(typeof input.y === 'number' ? { y: input.y } : {}),
|
|
246
|
+
...(typeof input.width === 'number' ? { width: input.width } : {}),
|
|
247
|
+
...(typeof input.height === 'number' ? { height: input.height } : {}),
|
|
248
|
+
});
|
|
249
|
+
return {
|
|
250
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
251
|
+
};
|
|
252
|
+
} catch (error) {
|
|
253
|
+
return {
|
|
254
|
+
content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
|
|
255
|
+
isError: true,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
server.tool(
|
|
262
|
+
'canvas_describe_schema',
|
|
263
|
+
'Describe the current server-supported canvas create schemas, json-render component catalog, canonical examples, and related MCP entry points.',
|
|
264
|
+
{},
|
|
265
|
+
async () => ({
|
|
266
|
+
content: [{ type: 'text', text: JSON.stringify(describeCanvasSchema(), null, 2) }],
|
|
267
|
+
}),
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
server.tool(
|
|
271
|
+
'canvas_validate_spec',
|
|
272
|
+
'Validate a json-render spec or graph payload without creating a node. Returns the normalized json-render spec that the server would accept.',
|
|
273
|
+
{
|
|
274
|
+
type: z.enum(['json-render', 'graph']).describe('Structured payload type to validate'),
|
|
275
|
+
spec: jsonRenderSpecSchema.optional().describe('json-render spec to validate when type="json-render"'),
|
|
276
|
+
title: z.string().optional().describe('Optional graph title'),
|
|
277
|
+
graphType: z.string().optional().describe('Graph type when type="graph"'),
|
|
278
|
+
data: z.array(z.record(z.string(), z.unknown())).optional().describe('Graph dataset when type="graph"'),
|
|
279
|
+
xKey: z.string().optional().describe('X-axis key for line/bar graphs'),
|
|
280
|
+
yKey: z.string().optional().describe('Y-axis key for line/bar graphs'),
|
|
281
|
+
nameKey: z.string().optional().describe('Slice name key for pie graphs'),
|
|
282
|
+
valueKey: z.string().optional().describe('Slice value key for pie graphs'),
|
|
283
|
+
aggregate: z.enum(['sum', 'count', 'avg']).optional().describe('Optional aggregation for repeated keys'),
|
|
284
|
+
color: z.string().optional().describe('Optional graph color'),
|
|
285
|
+
height: z.number().optional().describe('Optional graph content height'),
|
|
286
|
+
},
|
|
287
|
+
async (input) => {
|
|
288
|
+
try {
|
|
289
|
+
const result = input.type === 'json-render'
|
|
290
|
+
? validateStructuredCanvasPayload({
|
|
291
|
+
type: 'json-render',
|
|
292
|
+
spec: input.spec,
|
|
293
|
+
})
|
|
294
|
+
: validateStructuredCanvasPayload({
|
|
295
|
+
type: 'graph',
|
|
296
|
+
graph: {
|
|
297
|
+
title: input.title,
|
|
298
|
+
graphType: input.graphType ?? 'line',
|
|
299
|
+
data: input.data ?? [],
|
|
300
|
+
...(typeof input.xKey === 'string' ? { xKey: input.xKey } : {}),
|
|
301
|
+
...(typeof input.yKey === 'string' ? { yKey: input.yKey } : {}),
|
|
302
|
+
...(typeof input.nameKey === 'string' ? { nameKey: input.nameKey } : {}),
|
|
303
|
+
...(typeof input.valueKey === 'string' ? { valueKey: input.valueKey } : {}),
|
|
304
|
+
...(typeof input.aggregate === 'string' ? { aggregate: input.aggregate } : {}),
|
|
305
|
+
...(typeof input.color === 'string' ? { color: input.color } : {}),
|
|
306
|
+
...(typeof input.height === 'number' ? { height: input.height } : {}),
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
312
|
+
};
|
|
313
|
+
} catch (error) {
|
|
314
|
+
return {
|
|
315
|
+
content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
|
|
316
|
+
isError: true,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
server.tool(
|
|
323
|
+
'canvas_refresh_webpage_node',
|
|
324
|
+
'Refresh a webpage node from its persisted URL so the server re-fetches and caches the latest page text and metadata.',
|
|
325
|
+
{
|
|
326
|
+
id: z.string().describe('Webpage node ID to refresh'),
|
|
327
|
+
url: z.string().optional().describe('Optional replacement URL before refresh'),
|
|
328
|
+
},
|
|
329
|
+
async ({ id, url }) => {
|
|
330
|
+
const c = await ensureCanvas();
|
|
331
|
+
const result = await c.refreshWebpageNode(id, url);
|
|
332
|
+
return {
|
|
333
|
+
content: [{ type: 'text', text: JSON.stringify(result) }],
|
|
334
|
+
...(result.ok ? {} : { isError: true }),
|
|
335
|
+
};
|
|
336
|
+
},
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
// ── canvas_build_web_artifact ───────────────────────────────
|
|
340
|
+
server.tool(
|
|
341
|
+
'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
|
+
{
|
|
344
|
+
title: z.string().describe('Artifact title used for default project and output paths'),
|
|
345
|
+
appTsx: z.string().describe('Contents for src/App.tsx'),
|
|
346
|
+
indexCss: z.string().optional().describe('Optional contents for src/index.css'),
|
|
347
|
+
mainTsx: z.string().optional().describe('Optional contents for src/main.tsx'),
|
|
348
|
+
indexHtml: z.string().optional().describe('Optional contents for index.html'),
|
|
349
|
+
files: z.record(z.string(), z.string()).optional().describe('Optional map of additional project-relative file paths to file contents'),
|
|
350
|
+
projectPath: z.string().optional().describe('Optional workspace-relative reusable project path. Defaults to .pmx-canvas/artifacts/.web-artifacts/<slug>'),
|
|
351
|
+
outputPath: z.string().optional().describe('Optional workspace-relative HTML output path. Defaults to .pmx-canvas/artifacts/<slug>.html'),
|
|
352
|
+
openInCanvas: z.boolean().optional().describe('Open the generated artifact in canvas after build (default true)'),
|
|
353
|
+
includeLogs: z.boolean().optional().describe('Include raw build stdout/stderr in the response (default false)'),
|
|
354
|
+
initScriptPath: z.string().optional().describe('Optional absolute script path override for tests/debugging'),
|
|
355
|
+
bundleScriptPath: z.string().optional().describe('Optional absolute script path override for tests/debugging'),
|
|
356
|
+
timeoutMs: z.number().optional().describe('Optional timeout in milliseconds for init and bundle commands'),
|
|
357
|
+
},
|
|
358
|
+
async (input) => {
|
|
359
|
+
const c = await ensureCanvas();
|
|
360
|
+
try {
|
|
361
|
+
const result = await c.buildWebArtifact({
|
|
362
|
+
title: input.title,
|
|
363
|
+
appTsx: input.appTsx,
|
|
364
|
+
...(typeof input.indexCss === 'string' ? { indexCss: input.indexCss } : {}),
|
|
365
|
+
...(typeof input.mainTsx === 'string' ? { mainTsx: input.mainTsx } : {}),
|
|
366
|
+
...(typeof input.indexHtml === 'string' ? { indexHtml: input.indexHtml } : {}),
|
|
367
|
+
...(input.files ? { files: input.files } : {}),
|
|
368
|
+
...(typeof input.projectPath === 'string'
|
|
369
|
+
? { projectPath: safeWorkspacePath(input.projectPath) }
|
|
370
|
+
: {}),
|
|
371
|
+
...(typeof input.outputPath === 'string'
|
|
372
|
+
? { outputPath: safeWorkspacePath(input.outputPath) }
|
|
373
|
+
: {}),
|
|
374
|
+
...(typeof input.initScriptPath === 'string'
|
|
375
|
+
? { initScriptPath: input.initScriptPath }
|
|
376
|
+
: {}),
|
|
377
|
+
...(typeof input.bundleScriptPath === 'string'
|
|
378
|
+
? { bundleScriptPath: input.bundleScriptPath }
|
|
379
|
+
: {}),
|
|
380
|
+
...(typeof input.timeoutMs === 'number' ? { timeoutMs: input.timeoutMs } : {}),
|
|
381
|
+
...(typeof input.openInCanvas === 'boolean' ? { openInCanvas: input.openInCanvas } : {}),
|
|
382
|
+
});
|
|
383
|
+
return {
|
|
384
|
+
content: [{
|
|
385
|
+
type: 'text',
|
|
386
|
+
text: JSON.stringify({
|
|
387
|
+
path: result.filePath,
|
|
388
|
+
bytes: result.fileSize,
|
|
389
|
+
projectPath: result.projectPath,
|
|
390
|
+
openedInCanvas: result.openedInCanvas,
|
|
391
|
+
nodeId: result.nodeId,
|
|
392
|
+
url: result.url,
|
|
393
|
+
metadata: result.metadata,
|
|
394
|
+
logs: result.logs,
|
|
395
|
+
...(input.includeLogs === true ? {
|
|
396
|
+
stdout: result.stdout,
|
|
397
|
+
stderr: result.stderr,
|
|
398
|
+
} : {}),
|
|
399
|
+
}, null, 2),
|
|
400
|
+
}],
|
|
401
|
+
};
|
|
402
|
+
} catch (error) {
|
|
403
|
+
return {
|
|
404
|
+
content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
|
|
405
|
+
isError: true,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
},
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
// ── canvas_add_json_render_node ───────────────────────────
|
|
412
|
+
server.tool(
|
|
413
|
+
'canvas_add_json_render_node',
|
|
414
|
+
'Create a native json-render canvas node from a complete spec. Use this for structured dashboards, forms, tables, and other interactive UI panels that should render directly inside PMX Canvas.',
|
|
415
|
+
{
|
|
416
|
+
title: z.string().describe('Node title'),
|
|
417
|
+
spec: jsonRenderSpecSchema.describe('Complete json-render spec with root, elements, and optional state'),
|
|
418
|
+
x: z.number().optional().describe('Optional X position'),
|
|
419
|
+
y: z.number().optional().describe('Optional Y position'),
|
|
420
|
+
width: z.number().optional().describe('Optional node width'),
|
|
421
|
+
height: z.number().optional().describe('Optional node height'),
|
|
422
|
+
},
|
|
423
|
+
async (input) => {
|
|
424
|
+
const c = await ensureCanvas();
|
|
425
|
+
try {
|
|
426
|
+
const result = c.addJsonRenderNode({
|
|
427
|
+
title: input.title,
|
|
428
|
+
spec: input.spec,
|
|
429
|
+
...(typeof input.x === 'number' ? { x: input.x } : {}),
|
|
430
|
+
...(typeof input.y === 'number' ? { y: input.y } : {}),
|
|
431
|
+
...(typeof input.width === 'number' ? { width: input.width } : {}),
|
|
432
|
+
...(typeof input.height === 'number' ? { height: input.height } : {}),
|
|
433
|
+
});
|
|
434
|
+
return {
|
|
435
|
+
content: [{
|
|
436
|
+
type: 'text',
|
|
437
|
+
text: JSON.stringify({
|
|
438
|
+
...createdNodePayload(c, result.id),
|
|
439
|
+
url: result.url,
|
|
440
|
+
spec: result.spec,
|
|
441
|
+
}, null, 2),
|
|
442
|
+
}],
|
|
443
|
+
};
|
|
444
|
+
} catch (error) {
|
|
445
|
+
return {
|
|
446
|
+
content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
|
|
447
|
+
isError: true,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
// ── canvas_add_graph_node ─────────────────────────────────
|
|
454
|
+
server.tool(
|
|
455
|
+
'canvas_add_graph_node',
|
|
456
|
+
'Create a native graph node backed by the json-render chart catalog. Supports line, bar, pie, area, scatter, radar, stacked-bar, and composed (bar+line) graphs rendered directly inside PMX Canvas.',
|
|
457
|
+
{
|
|
458
|
+
title: z.string().optional().describe('Optional node title'),
|
|
459
|
+
graphType: z.string().describe('Graph type: line, bar, pie, area, scatter, radar, stacked-bar (or "stack"), composed (or "combo")'),
|
|
460
|
+
data: z.array(z.record(z.string(), z.unknown())).describe('Array of chart data objects'),
|
|
461
|
+
xKey: z.string().optional().describe('X-axis key (line/bar/area/scatter/stacked/composed)'),
|
|
462
|
+
yKey: z.string().optional().describe('Y-axis key (line/bar/area/scatter); falls back to barKey for composed'),
|
|
463
|
+
zKey: z.string().optional().describe('Optional bubble-size key for scatter charts'),
|
|
464
|
+
nameKey: z.string().optional().describe('Name key for pie graphs'),
|
|
465
|
+
valueKey: z.string().optional().describe('Value key for pie graphs'),
|
|
466
|
+
axisKey: z.string().optional().describe('Category key for radar charts'),
|
|
467
|
+
metrics: z.array(z.string()).optional().describe('Series keys to plot as radar polygons (defaults to non-axis numeric columns)'),
|
|
468
|
+
series: z.array(z.string()).optional().describe('Series keys for stacked-bar segments (defaults to non-x numeric columns)'),
|
|
469
|
+
barKey: z.string().optional().describe('Bar series key for composed charts'),
|
|
470
|
+
lineKey: z.string().optional().describe('Line series key for composed charts'),
|
|
471
|
+
aggregate: z.enum(['sum', 'count', 'avg']).optional().describe('Optional aggregation for repeated x-axis values (line/bar/area/stacked)'),
|
|
472
|
+
color: z.string().optional().describe('Optional series color (line/bar/area/scatter)'),
|
|
473
|
+
barColor: z.string().optional().describe('Optional bar color for composed charts'),
|
|
474
|
+
lineColor: z.string().optional().describe('Optional line color for composed charts'),
|
|
475
|
+
height: z.number().optional().describe('Optional chart content height'),
|
|
476
|
+
x: z.number().optional().describe('Optional X position'),
|
|
477
|
+
y: z.number().optional().describe('Optional Y position'),
|
|
478
|
+
width: z.number().optional().describe('Optional node width'),
|
|
479
|
+
nodeHeight: z.number().optional().describe('Optional node height'),
|
|
480
|
+
},
|
|
481
|
+
async (input) => {
|
|
482
|
+
const c = await ensureCanvas();
|
|
483
|
+
try {
|
|
484
|
+
const result = c.addGraphNode({
|
|
485
|
+
graphType: input.graphType,
|
|
486
|
+
data: input.data,
|
|
487
|
+
...(typeof input.title === 'string' ? { title: input.title } : {}),
|
|
488
|
+
...(typeof input.xKey === 'string' ? { xKey: input.xKey } : {}),
|
|
489
|
+
...(typeof input.yKey === 'string' ? { yKey: input.yKey } : {}),
|
|
490
|
+
...(typeof input.zKey === 'string' ? { zKey: input.zKey } : {}),
|
|
491
|
+
...(typeof input.nameKey === 'string' ? { nameKey: input.nameKey } : {}),
|
|
492
|
+
...(typeof input.valueKey === 'string' ? { valueKey: input.valueKey } : {}),
|
|
493
|
+
...(typeof input.axisKey === 'string' ? { axisKey: input.axisKey } : {}),
|
|
494
|
+
...(Array.isArray(input.metrics) ? { metrics: input.metrics } : {}),
|
|
495
|
+
...(Array.isArray(input.series) ? { series: input.series } : {}),
|
|
496
|
+
...(typeof input.barKey === 'string' ? { barKey: input.barKey } : {}),
|
|
497
|
+
...(typeof input.lineKey === 'string' ? { lineKey: input.lineKey } : {}),
|
|
498
|
+
...(typeof input.aggregate === 'string' ? { aggregate: input.aggregate } : {}),
|
|
499
|
+
...(typeof input.color === 'string' ? { color: input.color } : {}),
|
|
500
|
+
...(typeof input.barColor === 'string' ? { barColor: input.barColor } : {}),
|
|
501
|
+
...(typeof input.lineColor === 'string' ? { lineColor: input.lineColor } : {}),
|
|
502
|
+
...(typeof input.height === 'number' ? { height: input.height } : {}),
|
|
503
|
+
...(typeof input.x === 'number' ? { x: input.x } : {}),
|
|
504
|
+
...(typeof input.y === 'number' ? { y: input.y } : {}),
|
|
505
|
+
...(typeof input.width === 'number' ? { width: input.width } : {}),
|
|
506
|
+
...(typeof input.nodeHeight === 'number' ? { heightPx: input.nodeHeight } : {}),
|
|
507
|
+
});
|
|
508
|
+
return {
|
|
509
|
+
content: [{
|
|
510
|
+
type: 'text',
|
|
511
|
+
text: JSON.stringify({
|
|
512
|
+
...createdNodePayload(c, result.id),
|
|
513
|
+
url: result.url,
|
|
514
|
+
spec: result.spec,
|
|
515
|
+
}, null, 2),
|
|
516
|
+
}],
|
|
517
|
+
};
|
|
518
|
+
} catch (error) {
|
|
519
|
+
return {
|
|
520
|
+
content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
|
|
521
|
+
isError: true,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
},
|
|
525
|
+
);
|
|
526
|
+
|
|
527
|
+
// ── canvas_update_node ─────────────────────────────────────────
|
|
528
|
+
server.tool(
|
|
529
|
+
'canvas_update_node',
|
|
530
|
+
'Update an existing node. You can change its content, title, position, size, or data.',
|
|
531
|
+
{
|
|
532
|
+
id: z.string().describe('Node ID to update'),
|
|
533
|
+
title: z.string().optional().describe('New title'),
|
|
534
|
+
content: z.string().optional().describe('New content'),
|
|
535
|
+
x: z.number().optional().describe('New X position'),
|
|
536
|
+
y: z.number().optional().describe('New Y position'),
|
|
537
|
+
width: z.number().optional().describe('New width'),
|
|
538
|
+
height: z.number().optional().describe('New height'),
|
|
539
|
+
collapsed: z.boolean().optional().describe('Collapse or expand the node'),
|
|
540
|
+
arrangeLocked: z.boolean().optional().describe('Prevent auto-arrange from moving this node. Pinned nodes are also skipped.'),
|
|
541
|
+
},
|
|
542
|
+
async ({ id, title, content, x, y, width, height, collapsed, arrangeLocked }) => {
|
|
543
|
+
const c = await ensureCanvas();
|
|
544
|
+
const node = c.getNode(id);
|
|
545
|
+
if (!node) {
|
|
546
|
+
return {
|
|
547
|
+
content: [{ type: 'text', text: `Node "${id}" not found.` }],
|
|
548
|
+
isError: true,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
const patch: Record<string, unknown> = {};
|
|
552
|
+
if (x !== undefined || y !== undefined) {
|
|
553
|
+
patch.position = { x: x ?? node.position.x, y: y ?? node.position.y };
|
|
554
|
+
}
|
|
555
|
+
if (width !== undefined || height !== undefined) {
|
|
556
|
+
patch.size = { width: width ?? node.size.width, height: height ?? node.size.height };
|
|
557
|
+
}
|
|
558
|
+
if (collapsed !== undefined) {
|
|
559
|
+
patch.collapsed = collapsed;
|
|
560
|
+
}
|
|
561
|
+
if (title !== undefined || content !== undefined) {
|
|
562
|
+
patch.data = {
|
|
563
|
+
...node.data,
|
|
564
|
+
...(title !== undefined ? { title } : {}),
|
|
565
|
+
...(content !== undefined ? { content } : {}),
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
if (arrangeLocked !== undefined) {
|
|
569
|
+
patch.data = {
|
|
570
|
+
...(patch.data && typeof patch.data === 'object' ? patch.data as Record<string, unknown> : node.data),
|
|
571
|
+
arrangeLocked,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
c.updateNode(id, patch);
|
|
575
|
+
return {
|
|
576
|
+
content: [{ type: 'text', text: JSON.stringify({ ok: true, id }) }],
|
|
577
|
+
};
|
|
578
|
+
},
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
// ── canvas_remove_node ─────────────────────────────────────────
|
|
582
|
+
server.tool(
|
|
583
|
+
'canvas_remove_node',
|
|
584
|
+
'Remove a node from the canvas. Also removes all edges connected to it.',
|
|
585
|
+
{ id: z.string().describe('Node ID to remove') },
|
|
586
|
+
async ({ id }) => {
|
|
587
|
+
const c = await ensureCanvas();
|
|
588
|
+
c.removeNode(id);
|
|
589
|
+
return {
|
|
590
|
+
content: [{ type: 'text', text: JSON.stringify({ ok: true, removed: id }) }],
|
|
591
|
+
};
|
|
592
|
+
},
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
// ── canvas_add_edge ────────────────────────────────────────────
|
|
596
|
+
server.tool(
|
|
597
|
+
'canvas_add_edge',
|
|
598
|
+
'Add an edge (connection) between two nodes. Edge types: flow (sequential), depends-on (dependency), relation (general), references (cross-reference).',
|
|
599
|
+
{
|
|
600
|
+
from: z.string().optional().describe('Source node ID'),
|
|
601
|
+
to: z.string().optional().describe('Target node ID'),
|
|
602
|
+
fromSearch: z.string().optional().describe('Resolve the source node by exact or fuzzy title/content search'),
|
|
603
|
+
toSearch: z.string().optional().describe('Resolve the target node by exact or fuzzy title/content search'),
|
|
604
|
+
type: z.enum(['flow', 'depends-on', 'relation', 'references']).describe('Edge type'),
|
|
605
|
+
label: z.string().optional().describe('Edge label text'),
|
|
606
|
+
style: z.enum(['solid', 'dashed', 'dotted']).optional().describe('Optional edge stroke style'),
|
|
607
|
+
animated: z.boolean().optional().describe('Animate the edge stroke'),
|
|
608
|
+
},
|
|
609
|
+
async (input) => {
|
|
610
|
+
const c = await ensureCanvas();
|
|
611
|
+
if (!input.from && !input.fromSearch) {
|
|
612
|
+
return {
|
|
613
|
+
content: [{ type: 'text', text: 'Provide either "from" or "fromSearch".' }],
|
|
614
|
+
isError: true,
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
if (!input.to && !input.toSearch) {
|
|
618
|
+
return {
|
|
619
|
+
content: [{ type: 'text', text: 'Provide either "to" or "toSearch".' }],
|
|
620
|
+
isError: true,
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
try {
|
|
624
|
+
const id = c.addEdge(input);
|
|
625
|
+
const edge = c.getLayout().edges.find((entry) => entry.id === id);
|
|
626
|
+
return {
|
|
627
|
+
content: [{
|
|
628
|
+
type: 'text',
|
|
629
|
+
text: JSON.stringify(edge ? { id, from: edge.from, to: edge.to, type: edge.type, label: edge.label, style: edge.style, animated: edge.animated } : { id }, null, 2),
|
|
630
|
+
}],
|
|
631
|
+
};
|
|
632
|
+
} catch (error) {
|
|
633
|
+
return {
|
|
634
|
+
content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
|
|
635
|
+
isError: true,
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
},
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
// ── canvas_remove_edge ─────────────────────────────────────────
|
|
642
|
+
server.tool(
|
|
643
|
+
'canvas_remove_edge',
|
|
644
|
+
'Remove an edge from the canvas.',
|
|
645
|
+
{ id: z.string().describe('Edge ID to remove') },
|
|
646
|
+
async ({ id }) => {
|
|
647
|
+
const c = await ensureCanvas();
|
|
648
|
+
c.removeEdge(id);
|
|
649
|
+
return {
|
|
650
|
+
content: [{ type: 'text', text: JSON.stringify({ ok: true, removed: id }) }],
|
|
651
|
+
};
|
|
652
|
+
},
|
|
653
|
+
);
|
|
654
|
+
|
|
655
|
+
// ── canvas_arrange ─────────────────────────────────────────────
|
|
656
|
+
server.tool(
|
|
657
|
+
'canvas_arrange',
|
|
658
|
+
'Auto-arrange all nodes on the canvas. Layouts: grid (default), column (vertical stack), flow (horizontal row).',
|
|
659
|
+
{
|
|
660
|
+
layout: z.enum(['grid', 'column', 'flow']).optional().describe('Arrangement layout (default: grid)'),
|
|
661
|
+
},
|
|
662
|
+
async ({ layout }) => {
|
|
663
|
+
const c = await ensureCanvas();
|
|
664
|
+
c.arrange(layout ?? 'grid');
|
|
665
|
+
return {
|
|
666
|
+
content: [{ type: 'text', text: JSON.stringify({ ok: true, layout: layout ?? 'grid' }) }],
|
|
667
|
+
};
|
|
668
|
+
},
|
|
669
|
+
);
|
|
670
|
+
|
|
671
|
+
// ── canvas_focus_node ──────────────────────────────────────────
|
|
672
|
+
server.tool(
|
|
673
|
+
'canvas_focus_node',
|
|
674
|
+
'Pan the viewport to center on a specific node.',
|
|
675
|
+
{ id: z.string().describe('Node ID to focus on') },
|
|
676
|
+
async ({ id }) => {
|
|
677
|
+
const c = await ensureCanvas();
|
|
678
|
+
c.focusNode(id);
|
|
679
|
+
return {
|
|
680
|
+
content: [{ type: 'text', text: JSON.stringify({ ok: true, focused: id }) }],
|
|
681
|
+
};
|
|
682
|
+
},
|
|
683
|
+
);
|
|
684
|
+
|
|
685
|
+
// ── canvas_clear ───────────────────────────────────────────────
|
|
686
|
+
server.tool(
|
|
687
|
+
'canvas_clear',
|
|
688
|
+
'Remove all nodes and edges from the canvas. Use with caution.',
|
|
689
|
+
{},
|
|
690
|
+
async () => {
|
|
691
|
+
const c = await ensureCanvas();
|
|
692
|
+
c.clear();
|
|
693
|
+
return {
|
|
694
|
+
content: [{ type: 'text', text: JSON.stringify({ ok: true, cleared: true }) }],
|
|
695
|
+
};
|
|
696
|
+
},
|
|
697
|
+
);
|
|
698
|
+
|
|
699
|
+
// ── canvas_search ───────────────────────────────────────────
|
|
700
|
+
server.tool(
|
|
701
|
+
'canvas_search',
|
|
702
|
+
'Search for nodes by title or content keywords. Returns matching nodes ranked by relevance with snippets. Much faster than reading the full layout when you need to find specific nodes.',
|
|
703
|
+
{
|
|
704
|
+
query: z.string().describe('Search query — matches against node titles, content, and file paths'),
|
|
705
|
+
limit: z.number().optional().describe('Max results to return (default: 10)'),
|
|
706
|
+
},
|
|
707
|
+
async ({ query, limit }) => {
|
|
708
|
+
await ensureCanvas();
|
|
709
|
+
const results = searchNodes(canvasState.getLayout().nodes, query);
|
|
710
|
+
const capped = results.slice(0, limit ?? 10);
|
|
711
|
+
return {
|
|
712
|
+
content: [{
|
|
713
|
+
type: 'text',
|
|
714
|
+
text: JSON.stringify({ query, resultCount: results.length, results: capped }, null, 2),
|
|
715
|
+
}],
|
|
716
|
+
};
|
|
717
|
+
},
|
|
718
|
+
);
|
|
719
|
+
|
|
720
|
+
// ── canvas_undo ────────────────────────────────────────────────
|
|
721
|
+
server.tool(
|
|
722
|
+
'canvas_undo',
|
|
723
|
+
'Undo the last canvas mutation. Returns a description of what was undone. Use this to backtrack when an approach is wrong — explore without fear.',
|
|
724
|
+
{},
|
|
725
|
+
async () => {
|
|
726
|
+
const c = await ensureCanvas();
|
|
727
|
+
const result = await c.undo();
|
|
728
|
+
return {
|
|
729
|
+
content: [{ type: 'text', text: JSON.stringify({ ...result, canUndo: mutationHistory.canUndo(), canRedo: mutationHistory.canRedo() }) }],
|
|
730
|
+
};
|
|
731
|
+
},
|
|
732
|
+
);
|
|
733
|
+
|
|
734
|
+
// ── canvas_redo ────────────────────────────────────────────────
|
|
735
|
+
server.tool(
|
|
736
|
+
'canvas_redo',
|
|
737
|
+
'Redo the last undone canvas mutation. Use after undo to re-apply a change.',
|
|
738
|
+
{},
|
|
739
|
+
async () => {
|
|
740
|
+
const c = await ensureCanvas();
|
|
741
|
+
const result = await c.redo();
|
|
742
|
+
return {
|
|
743
|
+
content: [{ type: 'text', text: JSON.stringify({ ...result, canUndo: mutationHistory.canUndo(), canRedo: mutationHistory.canRedo() }) }],
|
|
744
|
+
};
|
|
745
|
+
},
|
|
746
|
+
);
|
|
747
|
+
|
|
748
|
+
// ── canvas_diff ────────────────────────────────────────────────
|
|
749
|
+
server.tool(
|
|
750
|
+
'canvas_diff',
|
|
751
|
+
'Compare the current canvas state against a saved snapshot. Shows added/removed/modified nodes and edges. Pass either a snapshot name or ID.',
|
|
752
|
+
{
|
|
753
|
+
snapshot: z.string().describe('Snapshot name or ID to compare against'),
|
|
754
|
+
},
|
|
755
|
+
async ({ snapshot }) => {
|
|
756
|
+
await ensureCanvas();
|
|
757
|
+
const snapData = canvasState.getSnapshotData(snapshot);
|
|
758
|
+
if (!snapData) {
|
|
759
|
+
return { content: [{ type: 'text', text: `Snapshot "${snapshot}" not found. Use canvas_snapshot to save one first.` }], isError: true };
|
|
760
|
+
}
|
|
761
|
+
const current = canvasState.getLayout();
|
|
762
|
+
const diff = diffLayouts(snapData.name, snapData, current);
|
|
763
|
+
return {
|
|
764
|
+
content: [{ type: 'text', text: formatDiff(diff) }],
|
|
765
|
+
};
|
|
766
|
+
},
|
|
767
|
+
);
|
|
768
|
+
|
|
769
|
+
// ── canvas_webview_status ─────────────────────────────────────
|
|
770
|
+
server.tool(
|
|
771
|
+
'canvas_webview_status',
|
|
772
|
+
'Get the current Bun.WebView automation status for the PMX Canvas workbench. Returns whether Bun.WebView is supported, whether an automation session is active, backend, viewport size, and the current workbench URL if active.',
|
|
773
|
+
{},
|
|
774
|
+
async () => {
|
|
775
|
+
const c = await ensureCanvas();
|
|
776
|
+
return {
|
|
777
|
+
content: [{ type: 'text', text: JSON.stringify(c.getAutomationWebViewStatus(), null, 2) }],
|
|
778
|
+
};
|
|
779
|
+
},
|
|
780
|
+
);
|
|
781
|
+
|
|
782
|
+
// ── canvas_webview_start ──────────────────────────────────────
|
|
783
|
+
server.tool(
|
|
784
|
+
'canvas_webview_start',
|
|
785
|
+
'Start or replace the headless Bun.WebView automation session for the current PMX Canvas workbench. Use this before screenshot, evaluate, or resize when no automation session is active.',
|
|
786
|
+
{
|
|
787
|
+
backend: z.enum(['chrome', 'webkit']).optional()
|
|
788
|
+
.describe('Automation backend. Default: webkit on macOS, chrome elsewhere.'),
|
|
789
|
+
width: z.number().optional().describe('Viewport width in pixels (default: 1280)'),
|
|
790
|
+
height: z.number().optional().describe('Viewport height in pixels (default: 800)'),
|
|
791
|
+
chromePath: z.string().optional().describe('Optional Chrome/Chromium executable path'),
|
|
792
|
+
chromeArgv: z.array(z.string()).optional().describe('Optional extra Chrome launch args'),
|
|
793
|
+
dataStoreDir: z.string().optional().describe('Optional persistent data store directory'),
|
|
794
|
+
},
|
|
795
|
+
async ({ backend, width, height, chromePath, chromeArgv, dataStoreDir }) => {
|
|
796
|
+
const c = await ensureCanvas();
|
|
797
|
+
try {
|
|
798
|
+
const status = await c.startAutomationWebView({
|
|
799
|
+
...(backend ? { backend } : {}),
|
|
800
|
+
...(typeof width === 'number' ? { width } : {}),
|
|
801
|
+
...(typeof height === 'number' ? { height } : {}),
|
|
802
|
+
...(typeof chromePath === 'string' ? { chromePath } : {}),
|
|
803
|
+
...(Array.isArray(chromeArgv) ? { chromeArgv } : {}),
|
|
804
|
+
...(typeof dataStoreDir === 'string' ? { dataStoreDir: safeWorkspacePath(dataStoreDir) } : {}),
|
|
805
|
+
});
|
|
806
|
+
return {
|
|
807
|
+
content: [{ type: 'text', text: JSON.stringify(status, null, 2) }],
|
|
808
|
+
};
|
|
809
|
+
} catch (error) {
|
|
810
|
+
return {
|
|
811
|
+
content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
|
|
812
|
+
isError: true,
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
},
|
|
816
|
+
);
|
|
817
|
+
|
|
818
|
+
// ── canvas_webview_stop ───────────────────────────────────────
|
|
819
|
+
server.tool(
|
|
820
|
+
'canvas_webview_stop',
|
|
821
|
+
'Stop the current Bun.WebView automation session if one is active.',
|
|
822
|
+
{},
|
|
823
|
+
async () => {
|
|
824
|
+
const c = await ensureCanvas();
|
|
825
|
+
try {
|
|
826
|
+
const stopped = await c.stopAutomationWebView();
|
|
827
|
+
return {
|
|
828
|
+
content: [{
|
|
829
|
+
type: 'text',
|
|
830
|
+
text: JSON.stringify({
|
|
831
|
+
ok: true,
|
|
832
|
+
stopped,
|
|
833
|
+
webview: c.getAutomationWebViewStatus(),
|
|
834
|
+
}, null, 2),
|
|
835
|
+
}],
|
|
836
|
+
};
|
|
837
|
+
} catch (error) {
|
|
838
|
+
return {
|
|
839
|
+
content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
|
|
840
|
+
isError: true,
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
},
|
|
844
|
+
);
|
|
845
|
+
|
|
846
|
+
// ── canvas_evaluate ───────────────────────────────────────────
|
|
847
|
+
server.tool(
|
|
848
|
+
'canvas_evaluate',
|
|
849
|
+
'Evaluate JavaScript in the active Bun.WebView automation session for the workbench page. Use this to inspect rendered browser state. Requires an active automation session started via canvas_webview_start.',
|
|
850
|
+
{
|
|
851
|
+
expression: z.string().optional().describe('JavaScript expression to evaluate in the page context'),
|
|
852
|
+
script: z.string().optional().describe('Multi-statement JavaScript body. The MCP server wraps it in an IIFE and evaluates the return value.'),
|
|
853
|
+
},
|
|
854
|
+
async ({ expression, script }) => {
|
|
855
|
+
const c = await ensureCanvas();
|
|
856
|
+
if ((expression ? 1 : 0) + (script ? 1 : 0) !== 1) {
|
|
857
|
+
return {
|
|
858
|
+
content: [{ type: 'text', text: 'Pass exactly one of "expression" or "script".' }],
|
|
859
|
+
isError: true,
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const source = script ? `(() => {\n${script}\n})()` : expression!;
|
|
864
|
+
try {
|
|
865
|
+
const value = await c.evaluateAutomationWebView(source);
|
|
866
|
+
return {
|
|
867
|
+
content: [{ type: 'text', text: JSON.stringify({ value }, null, 2) }],
|
|
868
|
+
};
|
|
869
|
+
} catch (error) {
|
|
870
|
+
return {
|
|
871
|
+
content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
|
|
872
|
+
isError: true,
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
},
|
|
876
|
+
);
|
|
877
|
+
|
|
878
|
+
// ── canvas_resize ─────────────────────────────────────────────
|
|
879
|
+
server.tool(
|
|
880
|
+
'canvas_resize',
|
|
881
|
+
'Resize the active Bun.WebView automation viewport. Requires an active automation session started via canvas_webview_start.',
|
|
882
|
+
{
|
|
883
|
+
width: z.number().describe('Viewport width in pixels'),
|
|
884
|
+
height: z.number().describe('Viewport height in pixels'),
|
|
885
|
+
},
|
|
886
|
+
async ({ width, height }) => {
|
|
887
|
+
const c = await ensureCanvas();
|
|
888
|
+
try {
|
|
889
|
+
const status = await c.resizeAutomationWebView(width, height);
|
|
890
|
+
return {
|
|
891
|
+
content: [{ type: 'text', text: JSON.stringify(status, null, 2) }],
|
|
892
|
+
};
|
|
893
|
+
} catch (error) {
|
|
894
|
+
return {
|
|
895
|
+
content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
|
|
896
|
+
isError: true,
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
},
|
|
900
|
+
);
|
|
901
|
+
|
|
902
|
+
// ── canvas_screenshot ─────────────────────────────────────────
|
|
903
|
+
server.tool(
|
|
904
|
+
'canvas_screenshot',
|
|
905
|
+
'Capture a screenshot from the active Bun.WebView automation session. Returns both an MCP image payload and JSON metadata. Requires an active automation session started via canvas_webview_start.',
|
|
906
|
+
{
|
|
907
|
+
format: z.enum(['png', 'jpeg', 'webp']).optional().describe('Screenshot format (default depends on Bun; png recommended)'),
|
|
908
|
+
quality: z.number().optional().describe('Optional quality for lossy formats'),
|
|
909
|
+
},
|
|
910
|
+
async ({ format, quality }) => {
|
|
911
|
+
const c = await ensureCanvas();
|
|
912
|
+
try {
|
|
913
|
+
const bytes = await c.screenshotAutomationWebView({
|
|
914
|
+
...(format ? { format } : {}),
|
|
915
|
+
...(typeof quality === 'number' ? { quality } : {}),
|
|
916
|
+
});
|
|
917
|
+
const status = c.getAutomationWebViewStatus();
|
|
918
|
+
return {
|
|
919
|
+
content: [
|
|
920
|
+
{
|
|
921
|
+
type: 'image',
|
|
922
|
+
data: encodeBase64(bytes),
|
|
923
|
+
mimeType:
|
|
924
|
+
format === 'jpeg'
|
|
925
|
+
? 'image/jpeg'
|
|
926
|
+
: format === 'webp'
|
|
927
|
+
? 'image/webp'
|
|
928
|
+
: 'image/png',
|
|
929
|
+
},
|
|
930
|
+
{
|
|
931
|
+
type: 'text',
|
|
932
|
+
text: JSON.stringify({
|
|
933
|
+
bytes: bytes.byteLength,
|
|
934
|
+
backend: status.backend,
|
|
935
|
+
width: status.width,
|
|
936
|
+
height: status.height,
|
|
937
|
+
mimeType:
|
|
938
|
+
format === 'jpeg'
|
|
939
|
+
? 'image/jpeg'
|
|
940
|
+
: format === 'webp'
|
|
941
|
+
? 'image/webp'
|
|
942
|
+
: 'image/png',
|
|
943
|
+
}, null, 2),
|
|
944
|
+
},
|
|
945
|
+
],
|
|
946
|
+
};
|
|
947
|
+
} catch (error) {
|
|
948
|
+
return {
|
|
949
|
+
content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
|
|
950
|
+
isError: true,
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
},
|
|
954
|
+
);
|
|
955
|
+
|
|
956
|
+
// ── MCP Resources: Canvas as Context ──────────────────────────
|
|
957
|
+
//
|
|
958
|
+
// The human pins nodes on the canvas → those nodes become the agent's
|
|
959
|
+
// working context. Spatial arrangement IS semantic curation.
|
|
960
|
+
|
|
961
|
+
server.resource(
|
|
962
|
+
'schema',
|
|
963
|
+
'canvas://schema',
|
|
964
|
+
{
|
|
965
|
+
description:
|
|
966
|
+
'Machine-readable create schemas, canonical examples, and json-render catalog details from the running PMX Canvas server version.',
|
|
967
|
+
mimeType: 'application/json',
|
|
968
|
+
},
|
|
969
|
+
async () => ({
|
|
970
|
+
contents: [
|
|
971
|
+
{
|
|
972
|
+
uri: 'canvas://schema',
|
|
973
|
+
mimeType: 'application/json',
|
|
974
|
+
text: JSON.stringify(describeCanvasSchema(), null, 2),
|
|
975
|
+
},
|
|
976
|
+
],
|
|
977
|
+
}),
|
|
978
|
+
);
|
|
979
|
+
|
|
980
|
+
server.resource(
|
|
981
|
+
'pinned-context',
|
|
982
|
+
'canvas://pinned-context',
|
|
983
|
+
{
|
|
984
|
+
description:
|
|
985
|
+
'Content of all pinned nodes on the canvas. When the human pins nodes, ' +
|
|
986
|
+
'they are telling the agent "this is what matters right now." Read this ' +
|
|
987
|
+
'resource to get structured context from the canvas.',
|
|
988
|
+
mimeType: 'application/json',
|
|
989
|
+
},
|
|
990
|
+
async () => {
|
|
991
|
+
const c = await ensureCanvas();
|
|
992
|
+
const pinnedIds = canvasState.contextPinnedNodeIds;
|
|
993
|
+
const layout = c.getLayout();
|
|
994
|
+
|
|
995
|
+
const pinnedNodes = layout.nodes.filter((n) => pinnedIds.has(n.id));
|
|
996
|
+
const pinnedEdges = layout.edges.filter(
|
|
997
|
+
(e) => pinnedIds.has(e.from) && pinnedIds.has(e.to),
|
|
998
|
+
);
|
|
999
|
+
|
|
1000
|
+
// Compute neighborhoods: for each pinned node, find nearby unpinned nodes
|
|
1001
|
+
const neighborhoods = findNeighborhoods(layout.nodes, pinnedIds);
|
|
1002
|
+
|
|
1003
|
+
const context = {
|
|
1004
|
+
pinnedCount: pinnedNodes.length,
|
|
1005
|
+
nodes: pinnedNodes.map((n) => serializeNodeForAgentContext(n, {
|
|
1006
|
+
defaultTextLength: 700,
|
|
1007
|
+
webpageTextLength: 1600,
|
|
1008
|
+
includePosition: true,
|
|
1009
|
+
})),
|
|
1010
|
+
edges: pinnedEdges.map((e) => ({
|
|
1011
|
+
id: e.id,
|
|
1012
|
+
from: e.from,
|
|
1013
|
+
to: e.to,
|
|
1014
|
+
type: e.type,
|
|
1015
|
+
label: e.label ?? null,
|
|
1016
|
+
})),
|
|
1017
|
+
neighborhoods: neighborhoods.map((nh) => ({
|
|
1018
|
+
pinnedNodeId: nh.pinnedNodeId,
|
|
1019
|
+
pinnedNodeTitle: nh.pinnedNodeTitle,
|
|
1020
|
+
nearbyNodes: nh.neighbors,
|
|
1021
|
+
})),
|
|
1022
|
+
};
|
|
1023
|
+
|
|
1024
|
+
return {
|
|
1025
|
+
contents: [
|
|
1026
|
+
{
|
|
1027
|
+
uri: 'canvas://pinned-context',
|
|
1028
|
+
mimeType: 'application/json',
|
|
1029
|
+
text: JSON.stringify(context, null, 2),
|
|
1030
|
+
},
|
|
1031
|
+
],
|
|
1032
|
+
};
|
|
1033
|
+
},
|
|
1034
|
+
);
|
|
1035
|
+
|
|
1036
|
+
server.resource(
|
|
1037
|
+
'canvas-layout',
|
|
1038
|
+
'canvas://layout',
|
|
1039
|
+
{
|
|
1040
|
+
description:
|
|
1041
|
+
'The full canvas layout — all nodes, edges, and viewport state. ' +
|
|
1042
|
+
'Use this to understand the complete spatial workspace.',
|
|
1043
|
+
mimeType: 'application/json',
|
|
1044
|
+
},
|
|
1045
|
+
async () => {
|
|
1046
|
+
const c = await ensureCanvas();
|
|
1047
|
+
const layout = serializeCanvasLayout(c.getLayout());
|
|
1048
|
+
return {
|
|
1049
|
+
contents: [
|
|
1050
|
+
{
|
|
1051
|
+
uri: 'canvas://layout',
|
|
1052
|
+
mimeType: 'application/json',
|
|
1053
|
+
text: JSON.stringify(layout, null, 2),
|
|
1054
|
+
},
|
|
1055
|
+
],
|
|
1056
|
+
};
|
|
1057
|
+
},
|
|
1058
|
+
);
|
|
1059
|
+
|
|
1060
|
+
server.resource(
|
|
1061
|
+
'canvas-summary',
|
|
1062
|
+
'canvas://summary',
|
|
1063
|
+
{
|
|
1064
|
+
description:
|
|
1065
|
+
'A compact summary of the canvas: node count by type, edge count, ' +
|
|
1066
|
+
'pinned node titles. Useful for quick orientation without reading all content.',
|
|
1067
|
+
mimeType: 'application/json',
|
|
1068
|
+
},
|
|
1069
|
+
async () => {
|
|
1070
|
+
await ensureCanvas();
|
|
1071
|
+
return {
|
|
1072
|
+
contents: [
|
|
1073
|
+
{
|
|
1074
|
+
uri: 'canvas://summary',
|
|
1075
|
+
mimeType: 'application/json',
|
|
1076
|
+
text: JSON.stringify(buildCanvasSummary(), null, 2),
|
|
1077
|
+
},
|
|
1078
|
+
],
|
|
1079
|
+
};
|
|
1080
|
+
},
|
|
1081
|
+
);
|
|
1082
|
+
|
|
1083
|
+
server.resource(
|
|
1084
|
+
'spatial-context',
|
|
1085
|
+
'canvas://spatial-context',
|
|
1086
|
+
{
|
|
1087
|
+
description:
|
|
1088
|
+
'Spatial intelligence for the canvas. Detects proximity clusters (nodes the human ' +
|
|
1089
|
+
'grouped together), provides reading order (top-left to bottom-right), and shows ' +
|
|
1090
|
+
'neighborhoods around pinned nodes (nearby unpinned nodes the human implicitly associated). ' +
|
|
1091
|
+
'This makes "spatial arrangement is communication" real — read this to understand the ' +
|
|
1092
|
+
'human\'s spatial intent, not just which nodes are pinned.',
|
|
1093
|
+
mimeType: 'application/json',
|
|
1094
|
+
},
|
|
1095
|
+
async () => {
|
|
1096
|
+
await ensureCanvas();
|
|
1097
|
+
const layout = canvasState.getLayout();
|
|
1098
|
+
const spatial = buildSpatialContext(layout.nodes, layout.edges, canvasState.contextPinnedNodeIds);
|
|
1099
|
+
return {
|
|
1100
|
+
contents: [
|
|
1101
|
+
{
|
|
1102
|
+
uri: 'canvas://spatial-context',
|
|
1103
|
+
mimeType: 'application/json',
|
|
1104
|
+
text: JSON.stringify(spatial, null, 2),
|
|
1105
|
+
},
|
|
1106
|
+
],
|
|
1107
|
+
};
|
|
1108
|
+
},
|
|
1109
|
+
);
|
|
1110
|
+
|
|
1111
|
+
server.resource(
|
|
1112
|
+
'history',
|
|
1113
|
+
'canvas://history',
|
|
1114
|
+
{
|
|
1115
|
+
description:
|
|
1116
|
+
'Mutation history timeline for the canvas. Shows what changed and when, ' +
|
|
1117
|
+
'with undo/redo position. Read this to understand how the canvas evolved ' +
|
|
1118
|
+
'during this session — useful for metacognition and tracking your own actions.',
|
|
1119
|
+
mimeType: 'text/plain',
|
|
1120
|
+
},
|
|
1121
|
+
async () => {
|
|
1122
|
+
await ensureCanvas();
|
|
1123
|
+
return {
|
|
1124
|
+
contents: [
|
|
1125
|
+
{
|
|
1126
|
+
uri: 'canvas://history',
|
|
1127
|
+
mimeType: 'text/plain',
|
|
1128
|
+
text: mutationHistory.toHumanReadable(),
|
|
1129
|
+
},
|
|
1130
|
+
],
|
|
1131
|
+
};
|
|
1132
|
+
},
|
|
1133
|
+
);
|
|
1134
|
+
|
|
1135
|
+
server.resource(
|
|
1136
|
+
'code-graph',
|
|
1137
|
+
'canvas://code-graph',
|
|
1138
|
+
{
|
|
1139
|
+
description:
|
|
1140
|
+
'Auto-detected dependency graph between file nodes on the canvas. Shows which files import ' +
|
|
1141
|
+
'which other files, central files (most depended on), and isolated files. Dependencies are ' +
|
|
1142
|
+
'parsed from import/require/from statements in JS/TS/Python/Go/Rust. Edges are created and ' +
|
|
1143
|
+
'updated automatically as file nodes are added or files change.',
|
|
1144
|
+
mimeType: 'application/json',
|
|
1145
|
+
},
|
|
1146
|
+
async () => {
|
|
1147
|
+
await ensureCanvas();
|
|
1148
|
+
const summary = buildCodeGraphSummary();
|
|
1149
|
+
return {
|
|
1150
|
+
contents: [
|
|
1151
|
+
{
|
|
1152
|
+
uri: 'canvas://code-graph',
|
|
1153
|
+
mimeType: 'application/json',
|
|
1154
|
+
text: JSON.stringify(summary, null, 2),
|
|
1155
|
+
},
|
|
1156
|
+
],
|
|
1157
|
+
};
|
|
1158
|
+
},
|
|
1159
|
+
);
|
|
1160
|
+
|
|
1161
|
+
// ── canvas_create_group ──────────────────────────────────────
|
|
1162
|
+
server.tool(
|
|
1163
|
+
'canvas_create_group',
|
|
1164
|
+
'Create a group (frame) on the canvas that visually contains other nodes. Groups are spatial containers — they communicate "these nodes belong together." If childIds are provided, grouping preserves child positions by default; pass childLayout to auto-pack them. You can also provide an explicit frame (x/y/width/height) and auto-arrange children inside it.',
|
|
1165
|
+
{
|
|
1166
|
+
title: z.string().optional().describe('Group title (default: "Group")'),
|
|
1167
|
+
childIds: z.array(z.string()).optional().describe('Node IDs to include in the group. Group auto-sizes to fit them.'),
|
|
1168
|
+
color: z.string().optional().describe('Group accent color (CSS color string, e.g. "#4a9eff")'),
|
|
1169
|
+
x: z.number().optional().describe('X position (auto-computed from children if omitted)'),
|
|
1170
|
+
y: z.number().optional().describe('Y position (auto-computed from children if omitted)'),
|
|
1171
|
+
width: z.number().optional().describe('Width (auto-computed from children if omitted)'),
|
|
1172
|
+
height: z.number().optional().describe('Height (auto-computed from children if omitted)'),
|
|
1173
|
+
childLayout: z.enum(['grid', 'column', 'flow']).optional().describe('Optional child auto-layout. Omit to preserve current child positions.'),
|
|
1174
|
+
},
|
|
1175
|
+
async (input) => {
|
|
1176
|
+
const c = await ensureCanvas();
|
|
1177
|
+
const id = c.createGroup(input);
|
|
1178
|
+
return {
|
|
1179
|
+
content: [{ type: 'text', text: JSON.stringify(createdNodePayload(c, id), null, 2) }],
|
|
1180
|
+
};
|
|
1181
|
+
},
|
|
1182
|
+
);
|
|
1183
|
+
|
|
1184
|
+
// ── canvas_group_nodes ──────────────────────────────────────
|
|
1185
|
+
server.tool(
|
|
1186
|
+
'canvas_group_nodes',
|
|
1187
|
+
'Add nodes to an existing group. The nodes will be visually contained within the group frame.',
|
|
1188
|
+
{
|
|
1189
|
+
groupId: z.string().describe('The group node ID'),
|
|
1190
|
+
childIds: z.array(z.string()).describe('Node IDs to add to the group'),
|
|
1191
|
+
childLayout: z.enum(['grid', 'column', 'flow']).optional().describe('Optional child layout to apply while grouping'),
|
|
1192
|
+
},
|
|
1193
|
+
async ({ groupId, childIds, childLayout }) => {
|
|
1194
|
+
const c = await ensureCanvas();
|
|
1195
|
+
const ok = c.groupNodes(groupId, childIds, childLayout ? { childLayout } : undefined);
|
|
1196
|
+
if (!ok) {
|
|
1197
|
+
return { content: [{ type: 'text', text: 'Group not found or no valid children.' }], isError: true };
|
|
1198
|
+
}
|
|
1199
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, groupId }) }] };
|
|
1200
|
+
},
|
|
1201
|
+
);
|
|
1202
|
+
|
|
1203
|
+
server.tool(
|
|
1204
|
+
'canvas_batch',
|
|
1205
|
+
'Run a batch of canvas operations with optional assigned references. Supports node.add, node.update, graph.add, edge.add, group.create, group.add, group.remove, pin.set/add/remove, snapshot.save, and arrange.',
|
|
1206
|
+
{
|
|
1207
|
+
operations: z.array(z.object({
|
|
1208
|
+
op: z.string().describe('Operation name, e.g. "node.add" or "edge.add"'),
|
|
1209
|
+
assign: z.string().optional().describe('Optional reference name for later operations'),
|
|
1210
|
+
args: z.record(z.string(), z.unknown()).optional().describe('Operation arguments'),
|
|
1211
|
+
})).describe('Ordered array of batch operations'),
|
|
1212
|
+
},
|
|
1213
|
+
async ({ operations }) => {
|
|
1214
|
+
const c = await ensureCanvas();
|
|
1215
|
+
const result = await c.runBatch(operations);
|
|
1216
|
+
return {
|
|
1217
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
1218
|
+
...(result.ok ? {} : { isError: true }),
|
|
1219
|
+
};
|
|
1220
|
+
},
|
|
1221
|
+
);
|
|
1222
|
+
|
|
1223
|
+
server.tool(
|
|
1224
|
+
'canvas_validate',
|
|
1225
|
+
'Validate the current canvas layout. Distinguishes true node collisions from expected group-child containment and reports missing edge endpoints.',
|
|
1226
|
+
{},
|
|
1227
|
+
async () => {
|
|
1228
|
+
const c = await ensureCanvas();
|
|
1229
|
+
return {
|
|
1230
|
+
content: [{ type: 'text', text: JSON.stringify(c.validate(), null, 2) }],
|
|
1231
|
+
};
|
|
1232
|
+
},
|
|
1233
|
+
);
|
|
1234
|
+
|
|
1235
|
+
// ── canvas_ungroup ──────────────────────────────────────────
|
|
1236
|
+
server.tool(
|
|
1237
|
+
'canvas_ungroup',
|
|
1238
|
+
'Remove all children from a group, releasing them as independent nodes. The group node itself remains (delete it separately with canvas_remove_node if desired).',
|
|
1239
|
+
{
|
|
1240
|
+
groupId: z.string().describe('The group node ID to ungroup'),
|
|
1241
|
+
},
|
|
1242
|
+
async ({ groupId }) => {
|
|
1243
|
+
const c = await ensureCanvas();
|
|
1244
|
+
const ok = c.ungroupNodes(groupId);
|
|
1245
|
+
if (!ok) {
|
|
1246
|
+
return { content: [{ type: 'text', text: 'Group not found or already empty.' }], isError: true };
|
|
1247
|
+
}
|
|
1248
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, groupId }) }] };
|
|
1249
|
+
},
|
|
1250
|
+
);
|
|
1251
|
+
|
|
1252
|
+
// ── canvas_pin_nodes ─────────────────────────────────────────
|
|
1253
|
+
server.tool(
|
|
1254
|
+
'canvas_pin_nodes',
|
|
1255
|
+
'Pin nodes to include them in the agent context. Pinned nodes appear in the canvas://pinned-context resource. The human can also pin nodes by clicking in the browser.',
|
|
1256
|
+
{
|
|
1257
|
+
nodeIds: z.array(z.string()).describe('Array of node IDs to pin'),
|
|
1258
|
+
mode: z.enum(['set', 'add', 'remove']).optional()
|
|
1259
|
+
.describe('set: replace all pins, add: add to existing pins, remove: unpin these nodes (default: set)'),
|
|
1260
|
+
},
|
|
1261
|
+
async ({ nodeIds, mode }) => {
|
|
1262
|
+
const c = await ensureCanvas();
|
|
1263
|
+
const result = c.setContextPins(nodeIds, mode ?? 'set');
|
|
1264
|
+
|
|
1265
|
+
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1266
|
+
|
|
1267
|
+
return {
|
|
1268
|
+
content: [{
|
|
1269
|
+
type: 'text',
|
|
1270
|
+
text: JSON.stringify({
|
|
1271
|
+
ok: true,
|
|
1272
|
+
pinnedNodeIds: result.nodeIds,
|
|
1273
|
+
}),
|
|
1274
|
+
}],
|
|
1275
|
+
};
|
|
1276
|
+
},
|
|
1277
|
+
);
|
|
1278
|
+
|
|
1279
|
+
// ── canvas_snapshot ──────────────────────────────────────────
|
|
1280
|
+
server.tool(
|
|
1281
|
+
'canvas_snapshot',
|
|
1282
|
+
'Save the current canvas state as a named snapshot. Snapshots persist to disk and can be restored later.',
|
|
1283
|
+
{
|
|
1284
|
+
name: z.string().describe('Name for this snapshot (e.g., "before refactor", "investigation v2")'),
|
|
1285
|
+
},
|
|
1286
|
+
async (input) => {
|
|
1287
|
+
const c = await ensureCanvas();
|
|
1288
|
+
const snapshot = c.saveSnapshot(input.name);
|
|
1289
|
+
if (!snapshot) {
|
|
1290
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'Failed to save snapshot' }) }] };
|
|
1291
|
+
}
|
|
1292
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, snapshot }) }] };
|
|
1293
|
+
},
|
|
1294
|
+
);
|
|
1295
|
+
|
|
1296
|
+
// ── canvas_list_snapshots ───────────────────────────────────
|
|
1297
|
+
server.tool(
|
|
1298
|
+
'canvas_list_snapshots',
|
|
1299
|
+
'List all saved canvas snapshots with IDs, names, timestamps, and node/edge counts.',
|
|
1300
|
+
{},
|
|
1301
|
+
async () => {
|
|
1302
|
+
const c = await ensureCanvas();
|
|
1303
|
+
return {
|
|
1304
|
+
content: [{ type: 'text', text: JSON.stringify({ snapshots: c.listSnapshots() }, null, 2) }],
|
|
1305
|
+
};
|
|
1306
|
+
},
|
|
1307
|
+
);
|
|
1308
|
+
|
|
1309
|
+
// ── canvas_restore ──────────────────────────────────────────
|
|
1310
|
+
server.tool(
|
|
1311
|
+
'canvas_restore',
|
|
1312
|
+
'Restore the canvas to a previously saved snapshot. Use canvas_snapshot to save first. Pass either the snapshot ID or name to restore.',
|
|
1313
|
+
{
|
|
1314
|
+
id: z.string().describe('Snapshot ID or name to restore (from canvas_snapshot or snapshot list)'),
|
|
1315
|
+
},
|
|
1316
|
+
async (input) => {
|
|
1317
|
+
const c = await ensureCanvas();
|
|
1318
|
+
const result = await c.restoreSnapshot(input.id);
|
|
1319
|
+
if (!result.ok) {
|
|
1320
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'Snapshot not found' }) }] };
|
|
1321
|
+
}
|
|
1322
|
+
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1323
|
+
return {
|
|
1324
|
+
content: [{ type: 'text', text: JSON.stringify({ ok: true, layout: serializeCanvasLayout(canvasState.getLayout()) }) }],
|
|
1325
|
+
};
|
|
1326
|
+
},
|
|
1327
|
+
);
|
|
1328
|
+
|
|
1329
|
+
// ── canvas_delete_snapshot ──────────────────────────────────
|
|
1330
|
+
server.tool(
|
|
1331
|
+
'canvas_delete_snapshot',
|
|
1332
|
+
'Delete a saved snapshot by ID.',
|
|
1333
|
+
{
|
|
1334
|
+
id: z.string().describe('Snapshot ID to delete'),
|
|
1335
|
+
},
|
|
1336
|
+
async ({ id }) => {
|
|
1337
|
+
const c = await ensureCanvas();
|
|
1338
|
+
const result = c.deleteSnapshot(id);
|
|
1339
|
+
if (!result.ok) {
|
|
1340
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'Snapshot not found' }) }], isError: true };
|
|
1341
|
+
}
|
|
1342
|
+
return {
|
|
1343
|
+
content: [{ type: 'text', text: JSON.stringify({ ok: true, deleted: id }) }],
|
|
1344
|
+
};
|
|
1345
|
+
},
|
|
1346
|
+
);
|
|
1347
|
+
|
|
1348
|
+
// ── Resource change notifications ──────────────────────────
|
|
1349
|
+
// When canvas state changes (nodes, edges, pins), notify MCP clients
|
|
1350
|
+
// so they can re-read resources like canvas://pinned-context.
|
|
1351
|
+
canvasState.onChange((type) => {
|
|
1352
|
+
try {
|
|
1353
|
+
if (type === 'pins') {
|
|
1354
|
+
server.server.sendResourceUpdated({ uri: 'canvas://pinned-context' });
|
|
1355
|
+
}
|
|
1356
|
+
server.server.sendResourceUpdated({ uri: 'canvas://layout' });
|
|
1357
|
+
server.server.sendResourceUpdated({ uri: 'canvas://summary' });
|
|
1358
|
+
server.server.sendResourceUpdated({ uri: 'canvas://spatial-context' });
|
|
1359
|
+
server.server.sendResourceUpdated({ uri: 'canvas://history' });
|
|
1360
|
+
server.server.sendResourceUpdated({ uri: 'canvas://code-graph' });
|
|
1361
|
+
} catch (error) {
|
|
1362
|
+
console.debug('[mcp] resource notification failed', error);
|
|
1363
|
+
}
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
// Connect via stdio
|
|
1367
|
+
const transport = new StdioServerTransport();
|
|
1368
|
+
await server.connect(transport);
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// Allow direct execution: bun run src/mcp/server.ts
|
|
1372
|
+
if (import.meta.main) {
|
|
1373
|
+
startMcpServer().catch((err) => {
|
|
1374
|
+
console.error('Failed to start MCP server:', err);
|
|
1375
|
+
process.exit(1);
|
|
1376
|
+
});
|
|
1377
|
+
}
|