pmx-canvas 0.2.7 → 0.3.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 +110 -1
- package/Readme.md +13 -11
- package/dist/json-render/index.css +1 -1
- package/dist/types/mcp/canvas-access.d.ts +0 -7
- package/dist/types/server/ax-state-manager.d.ts +3 -2
- package/dist/types/server/bundled-skills.d.ts +3 -3
- package/dist/types/server/canvas-state.d.ts +2 -0
- package/dist/types/server/ext-app-lookup.d.ts +1 -3
- package/dist/types/server/operations/composites.d.ts +29 -23
- package/dist/types/server/operations/http.d.ts +2 -1
- package/dist/types/shared/ax-intent.d.ts +1 -1
- package/docs/RELEASE.md +23 -8
- package/docs/ax-host-adapter-contract.md +7 -7
- package/docs/ax-state-contract.md +7 -6
- package/docs/bun-webview-integration.md +5 -5
- package/docs/cli.md +1 -1
- package/docs/http-api.md +7 -3
- package/docs/mcp.md +121 -100
- package/docs/node-types.md +45 -35
- package/docs/plans/plan-006-mcp-tool-consolidation.md +3 -1
- package/docs/plans/plan-008-registry-finish.md +2 -0
- package/docs/plans/plan-009-tech-debt-backlog.md +51 -0
- package/docs/screenshot.png +0 -0
- package/docs/tech-debt-assessment-2026-06.md +1 -1
- package/docs/tech-debt-assessment-2026-07.md +135 -0
- package/package.json +1 -1
- package/skills/data-analysis/SKILL.md +3 -3
- package/skills/frontend-design/SKILL.md +3 -2
- package/skills/json-render-mcp/SKILL.md +4 -3
- package/skills/playwright-cli/SKILL.md +1 -1
- package/skills/pmx-canvas/SKILL.md +9 -6
- package/skills/pmx-canvas/evals/evals.json +2 -2
- package/skills/pmx-canvas/references/ax-html-control-surface.md +3 -1
- package/skills/pmx-canvas/references/excalidraw-diagram-authoring.md +2 -2
- package/skills/pmx-canvas/references/full-reference.md +48 -53
- package/skills/pmx-canvas/references/html-primitives.md +3 -3
- package/skills/tufte-viz/SKILL.md +5 -4
- package/skills/web-artifacts-builder/SKILL.md +4 -4
- package/src/mcp/canvas-access.ts +4 -99
- package/src/mcp/server.ts +74 -201
- package/src/server/ax-state-manager.ts +3 -2
- package/src/server/bundled-skills.ts +3 -3
- package/src/server/canvas-schema.ts +46 -29
- package/src/server/canvas-state.ts +5 -0
- package/src/server/code-graph.ts +1 -1
- package/src/server/ext-app-lookup.ts +1 -8
- package/src/server/index.ts +1 -1
- package/src/server/intent-registry.ts +1 -1
- package/src/server/operations/composites.ts +39 -39
- package/src/server/operations/http.ts +3 -2
- package/src/server/operations/mcp.ts +7 -6
- package/src/server/operations/ops/app.ts +4 -4
- package/src/server/operations/ops/nodes.ts +5 -5
- package/src/server/operations/ops/query.ts +19 -8
- package/src/server/operations/ops/snapshots.ts +6 -6
- package/src/server/server.ts +30 -2
- package/src/shared/ax-intent.ts +1 -1
package/src/server/index.ts
CHANGED
|
@@ -885,7 +885,7 @@ export class PmxCanvas extends EventEmitter {
|
|
|
885
885
|
|
|
886
886
|
setContextPins(nodeIds: string[], mode: 'set' | 'add' | 'remove' = 'set'): { count: number; nodeIds: string[] } {
|
|
887
887
|
const result = setCanvasContextPins(nodeIds, mode);
|
|
888
|
-
emitPrimaryWorkbenchEvent('
|
|
888
|
+
emitPrimaryWorkbenchEvent('context-pins-changed', { count: result.count, nodeIds: result.nodeIds });
|
|
889
889
|
return result;
|
|
890
890
|
}
|
|
891
891
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Intents are EPHEMERAL PRESENCE, deliberately modelled like the attention /
|
|
5
5
|
* timeline ephemerality layer rather than canvas-bound state:
|
|
6
6
|
* - an in-memory Map (NOT CanvasStateManager) — never serialized, never
|
|
7
|
-
* snapshotted, never returned by
|
|
7
|
+
* snapshotted, never returned by canvas_query (layout action);
|
|
8
8
|
* - count-capped (oldest evicted) and TTL-swept so a ghost can never linger;
|
|
9
9
|
* - emitted over the same workbench SSE stream as `ax-intent` /
|
|
10
10
|
* `ax-intent-clear` frames via an INJECTED emitter (server.ts wires it,
|
|
@@ -9,17 +9,22 @@
|
|
|
9
9
|
* to the standalone `canvas_add_edge` — same op, same arg mapping, same result
|
|
10
10
|
* shape — by construction. No handler logic lives here.
|
|
11
11
|
*
|
|
12
|
-
* Migration (docs/api-stability.md + plan-006): composites
|
|
13
|
-
* v0.2 alongside the legacy single-purpose tools
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* Migration (docs/api-stability.md + plan-006): composites landed ADDITIVELY in
|
|
13
|
+
* v0.2 alongside the legacy single-purpose tools. As of v0.3.0 the legacy tools
|
|
14
|
+
* those composites fold are REMOVED (registration-suppressed — see
|
|
15
|
+
* `compositeFoldedOpNames` below); every action here maps to a registry-backed
|
|
16
|
+
* operation (plan-005 slices 1–7 + plan-008 Wave 1) that is now reachable ONLY
|
|
17
|
+
* through its composite (or `canvas_batch`).
|
|
16
18
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* Deferred to v0.4: the `canvas_snapshot` composite (still a name collision —
|
|
20
|
+
* see below). Its 6 legacy snapshot standalones (`canvas_snapshot`,
|
|
21
|
+
* `canvas_list_snapshots`, `canvas_restore`, `canvas_delete_snapshot`,
|
|
22
|
+
* `canvas_gc_snapshots`, `canvas_diff`) stay registered in v0.3.0 but are marked
|
|
23
|
+
* deprecated (description-prefixed) per docs/api-stability.md's
|
|
24
|
+
* deprecate-one-minor-before-removal rule. (`canvas_webview` shipped in plan-008
|
|
25
|
+
* Wave 3 via runner injection; `canvas_app` shipped in Wave 4 — open-mcp-app /
|
|
26
|
+
* diagram / build-artifact. Wave 5 folded the last three legacy tools
|
|
27
|
+
* deprecate-only — NO per-action input-injection mechanism was needed:
|
|
23
28
|
* `canvas_add_html_node` / `canvas_add_html_primitive` → `canvas_node` action
|
|
24
29
|
* "add" (type:"html" [+ primitive]); `canvas_refresh_webpage_node` → `canvas_node`
|
|
25
30
|
* action "update" (refresh:true). `canvas_screenshot` stays standalone — it
|
|
@@ -30,8 +35,8 @@
|
|
|
30
35
|
* name is ALREADY a legacy standalone tool (the save-snapshot tool, op
|
|
31
36
|
* `snapshot.save`), so it cannot be added additively without a name clash, and
|
|
32
37
|
* repurposing `canvas_snapshot` to be action-discriminated now would break
|
|
33
|
-
* existing callers. It lands in v0.
|
|
34
|
-
*
|
|
38
|
+
* existing callers. It lands in v0.4, in the same change that removes the 6
|
|
39
|
+
* kept-but-deprecated legacy snapshot tools and frees the name.
|
|
35
40
|
*
|
|
36
41
|
* This module must never import server.ts or index.ts.
|
|
37
42
|
*/
|
|
@@ -47,7 +52,7 @@ import { z, type ZodRawShape, type ZodTypeAny } from 'zod';
|
|
|
47
52
|
* - Two-discriminator (`canvas_ax_gate`, plan-007 Slice C): a `kind` × `action`
|
|
48
53
|
* matrix folds 9 ops into one tool. Set `extraDiscriminatorShape` (the `kind`
|
|
49
54
|
* enum), `memberOps` (the op names — used to derive the schema union + the
|
|
50
|
-
*
|
|
55
|
+
* folded-op set), `actionEnum` (the action discriminator values), and
|
|
51
56
|
* `resolveOp` (maps `{ kind, action }` → op name, or undefined for an invalid
|
|
52
57
|
* combo → a loud error at dispatch). The flat `actions` map is left empty for
|
|
53
58
|
* these; the matrix path uses `resolveOp` instead.
|
|
@@ -78,8 +83,9 @@ export interface CompositeToolDefinition {
|
|
|
78
83
|
actionEnum?: readonly string[];
|
|
79
84
|
/**
|
|
80
85
|
* Two-discriminator extension: every member op name. Used to build the schema
|
|
81
|
-
* union (all member-op fields, optional) and to
|
|
82
|
-
*
|
|
86
|
+
* union (all member-op fields, optional) and to populate the folded-op set
|
|
87
|
+
* (`compositeFoldedOpNames`) that suppresses each member op's standalone
|
|
88
|
+
* registration.
|
|
83
89
|
*/
|
|
84
90
|
memberOps?: string[];
|
|
85
91
|
/**
|
|
@@ -331,36 +337,30 @@ export const compositeToolDefinitions: CompositeToolDefinition[] = [
|
|
|
331
337
|
];
|
|
332
338
|
|
|
333
339
|
/**
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
* Deriving it
|
|
340
|
-
* folded action automatically
|
|
340
|
+
* Operation names FOLDED by a composite, DERIVED from the composites: every op
|
|
341
|
+
* a composite folds had its standalone single-purpose tool REMOVED in v0.3.0
|
|
342
|
+
* (see docs/api-stability.md). This set is the do-not-register list —
|
|
343
|
+
* `registerOperationTools` skips any op whose name is in it, since the op is
|
|
344
|
+
* only reachable through its composite (and through `canvas_batch`) now.
|
|
345
|
+
* Deriving it from the composites keeps suppression in lockstep with them — a
|
|
346
|
+
* newly folded action is automatically suppressed from standalone registration.
|
|
341
347
|
*/
|
|
342
|
-
export function
|
|
348
|
+
export function compositeFoldedOpNames(
|
|
343
349
|
definitions: CompositeToolDefinition[] = compositeToolDefinitions,
|
|
344
|
-
):
|
|
345
|
-
const
|
|
350
|
+
): Set<string> {
|
|
351
|
+
const names = new Set<string>();
|
|
346
352
|
for (const def of definitions) {
|
|
347
|
-
// Single-discriminator composites:
|
|
348
|
-
for (const
|
|
349
|
-
|
|
353
|
+
// Single-discriminator composites: the flat `actions` map values.
|
|
354
|
+
for (const opName of Object.values(def.actions)) {
|
|
355
|
+
names.add(opName);
|
|
350
356
|
}
|
|
351
|
-
// Two-discriminator composites (canvas_ax_gate):
|
|
352
|
-
//
|
|
353
|
-
|
|
354
|
-
if (def.memberOps && def.describeOp) {
|
|
357
|
+
// Two-discriminator composites (canvas_ax_gate): the explicit `memberOps`
|
|
358
|
+
// list (the flat `actions` map is empty for these).
|
|
359
|
+
if (def.memberOps) {
|
|
355
360
|
for (const opName of def.memberOps) {
|
|
356
|
-
|
|
357
|
-
if (!combo) continue;
|
|
358
|
-
notes.set(
|
|
359
|
-
opName,
|
|
360
|
-
`Deprecated: use ${def.toolName} with kind "${combo.kind}" action "${combo.action}". `,
|
|
361
|
-
);
|
|
361
|
+
names.add(opName);
|
|
362
362
|
}
|
|
363
363
|
}
|
|
364
364
|
}
|
|
365
|
-
return
|
|
365
|
+
return names;
|
|
366
366
|
}
|
|
@@ -38,7 +38,8 @@ function matchPath(template: string, pathname: string): Record<string, string> |
|
|
|
38
38
|
/**
|
|
39
39
|
* Shared body reader: preserves the parsed JSON value as-is (object, array,
|
|
40
40
|
* or primitive) — per-op `readInput` decides what to do with non-object
|
|
41
|
-
* bodies; the shared reader never coerces.
|
|
41
|
+
* bodies; the shared reader never coerces. A non-empty body that fails to
|
|
42
|
+
* parse is a 400 (OperationError), never a silent empty input.
|
|
42
43
|
*/
|
|
43
44
|
export async function readJsonValue(req: Request): Promise<unknown> {
|
|
44
45
|
let text = '';
|
|
@@ -51,7 +52,7 @@ export async function readJsonValue(req: Request): Promise<unknown> {
|
|
|
51
52
|
try {
|
|
52
53
|
return JSON.parse(text) as unknown;
|
|
53
54
|
} catch {
|
|
54
|
-
|
|
55
|
+
throw new OperationError('Malformed JSON body.');
|
|
55
56
|
}
|
|
56
57
|
}
|
|
57
58
|
|
|
@@ -12,7 +12,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
12
12
|
import { getOperation, listOperations } from './registry.js';
|
|
13
13
|
import type { OperationInvoker } from './invoker.js';
|
|
14
14
|
import { OperationError, type OperationMcpToolHost } from './types.js';
|
|
15
|
-
import {
|
|
15
|
+
import { compositeFoldedOpNames, compositeToolDefinitions, type CompositeToolDefinition } from './composites.js';
|
|
16
16
|
|
|
17
17
|
export interface OperationToolHost extends OperationMcpToolHost {
|
|
18
18
|
invoker(): OperationInvoker;
|
|
@@ -22,16 +22,17 @@ export function registerOperationTools(
|
|
|
22
22
|
server: McpServer,
|
|
23
23
|
getHost: () => Promise<OperationToolHost>,
|
|
24
24
|
): void {
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
25
|
+
// Ops folded by a composite are NOT registered standalone: their legacy
|
|
26
|
+
// single-purpose tools were removed in v0.3.0 (docs/api-stability.md). The op
|
|
27
|
+
// itself is untouched — it stays reachable via its composite and canvas_batch.
|
|
28
|
+
const foldedOpNames = compositeFoldedOpNames();
|
|
28
29
|
for (const op of listOperations()) {
|
|
30
|
+
if (foldedOpNames.has(op.name)) continue;
|
|
29
31
|
const tool = op.mcp;
|
|
30
32
|
if (!tool) continue;
|
|
31
|
-
const note = deprecations.get(op.name);
|
|
32
33
|
server.tool(
|
|
33
34
|
tool.toolName,
|
|
34
|
-
|
|
35
|
+
tool.description,
|
|
35
36
|
{ ...op.inputShape, ...(tool.extraShape ?? {}) },
|
|
36
37
|
async (input: Record<string, unknown>) => {
|
|
37
38
|
try {
|
|
@@ -94,10 +94,10 @@ export async function openMcpAppCore(
|
|
|
94
94
|
// event is dropped, so the node would never be created and the just-opened
|
|
95
95
|
// external session would leak with a null nodeId. Reject loudly BEFORE opening
|
|
96
96
|
// any session rather than silently corrupting. mcpapp.open / diagram.open are
|
|
97
|
-
// not batchable — call
|
|
97
|
+
// not batchable — call canvas_app (action "open-mcp-app" / "diagram") instead.
|
|
98
98
|
if (isEmitSuppressed()) {
|
|
99
99
|
throw new OperationError(
|
|
100
|
-
'mcpapp.open / diagram.open cannot run inside canvas_batch: the canvas node is created from the ext-app-open SSE event, which batch suppresses. Call
|
|
100
|
+
'mcpapp.open / diagram.open cannot run inside canvas_batch: the canvas node is created from the ext-app-open SSE event, which batch suppresses. Call canvas_app with action "open-mcp-app" or "diagram" outside the batch.',
|
|
101
101
|
);
|
|
102
102
|
}
|
|
103
103
|
const targetNode = input.nodeId ? canvasState.getNode(input.nodeId) : undefined;
|
|
@@ -246,7 +246,7 @@ const openMcpAppOperation = defineOperation<z.infer<typeof openMcpAppSchema>, Op
|
|
|
246
246
|
mcp: {
|
|
247
247
|
toolName: 'canvas_open_mcp_app',
|
|
248
248
|
description:
|
|
249
|
-
'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. This is a full external-MCP transport call, not the CLI kind shortcut; use
|
|
249
|
+
'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. This is a full external-MCP transport call, not the CLI kind shortcut; use canvas_app action "diagram" for the built-in Excalidraw preset.',
|
|
250
250
|
formatResult: (result) => ({
|
|
251
251
|
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
|
|
252
252
|
}),
|
|
@@ -283,7 +283,7 @@ const diagramOperation = defineOperation<z.infer<typeof diagramSchema>, OpenMcpA
|
|
|
283
283
|
mcp: {
|
|
284
284
|
toolName: 'canvas_add_diagram',
|
|
285
285
|
description:
|
|
286
|
-
'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
|
|
286
|
+
'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_app action "open-mcp-app".',
|
|
287
287
|
formatResult: (result) => ({
|
|
288
288
|
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
|
|
289
289
|
}),
|
|
@@ -654,7 +654,7 @@ function createGroupNode(body: Record<string, unknown>): NodeAddResult {
|
|
|
654
654
|
|
|
655
655
|
const nodeAddShape = {
|
|
656
656
|
intentId: z.string().optional().catch(undefined).describe('Ghost intent id returned by canvas_intent signal. A vetoed or expired intent blocks this mutation.'),
|
|
657
|
-
type: z.string().optional().catch(undefined).describe('Node type (prefer
|
|
657
|
+
type: z.string().optional().catch(undefined).describe('Node type (prefer canvas_group {action:"create"} for groups)'),
|
|
658
658
|
title: z.string().optional().catch(undefined).describe('Node title'),
|
|
659
659
|
content: z.string().optional().catch(undefined).describe('Node content (markdown for markdown nodes, file path for file nodes, image path/URL/data-URI for image nodes, URL for webpage nodes)'),
|
|
660
660
|
path: z.string().optional().catch(undefined).describe('Compatibility alias for image node content. Prefer content for image paths.'),
|
|
@@ -665,7 +665,7 @@ const nodeAddShape = {
|
|
|
665
665
|
height: z.number().optional().catch(undefined).describe('Height in pixels (default: 600)'),
|
|
666
666
|
strictSize: z.boolean().optional().catch(undefined).describe('Keep explicit width/height fixed and scroll overflowing content instead of browser auto-fitting'),
|
|
667
667
|
children: z.unknown().optional().describe('Group-only alias for childIds. Node IDs to include in a generic group node.'),
|
|
668
|
-
childIds: z.unknown().optional().describe('Group-only field. Node IDs to include in a generic group node. Prefer
|
|
668
|
+
childIds: z.unknown().optional().describe('Group-only field. Node IDs to include in a generic group node. Prefer canvas_group {action:"create"} for groups.'),
|
|
669
669
|
childLayout: z.enum(['grid', 'column', 'flow']).optional().catch(undefined).describe('Group-only optional layout for grouped children.'),
|
|
670
670
|
color: z.string().optional().catch(undefined).describe('Group-only frame accent color.'),
|
|
671
671
|
toolName: z.string().optional().catch(undefined).describe('Trace node tool or operation label'),
|
|
@@ -708,12 +708,12 @@ const nodeAddOperation = defineOperation<z.infer<typeof nodeAddSchema>, NodeAddR
|
|
|
708
708
|
},
|
|
709
709
|
mcp: {
|
|
710
710
|
toolName: 'canvas_add_node',
|
|
711
|
-
description: 'Add a basic node to the canvas. Returns the created node with normalized title/content and rendered geometry. Supported here: markdown, status, context, ledger, trace, file, image, webpage, mcp-app, html, group. Dedicated
|
|
711
|
+
description: 'Add a basic node to the canvas. Returns the created node with normalized title/content and rendered geometry. Supported here: markdown, status, context, ledger, trace, file, image, webpage, mcp-app, html, group. Dedicated routes: json-render -> canvas_render {action:"add-json-render"}, graph -> canvas_render {action:"add-graph"}, web-artifact -> canvas_app {action:"build-artifact"}, external apps -> canvas_app {action:"open-mcp-app"}, groups -> canvas_group {action:"create"}. Call canvas_render {action:"describe-schema"} for the full nodeTypeRouting table.',
|
|
712
712
|
extraShape: {
|
|
713
713
|
type: z.enum(['markdown', 'status', 'context', 'ledger', 'trace', 'file', 'image', 'webpage', 'mcp-app', 'html', 'group'])
|
|
714
|
-
.describe('Node type (prefer
|
|
714
|
+
.describe('Node type (prefer canvas_group {action:"create"} for groups)'),
|
|
715
715
|
children: z.array(z.string()).optional().describe('Group-only alias for childIds. Node IDs to include in a generic group node.'),
|
|
716
|
-
childIds: z.array(z.string()).optional().describe('Group-only field. Node IDs to include in a generic group node. Prefer
|
|
716
|
+
childIds: z.array(z.string()).optional().describe('Group-only field. Node IDs to include in a generic group node. Prefer canvas_group {action:"create"} for groups.'),
|
|
717
717
|
axCapabilities: z.object({
|
|
718
718
|
enabled: z.boolean().optional(),
|
|
719
719
|
allowed: z.array(z.string()).optional(),
|
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* - pin.set: legacy POST /api/canvas/context-pins emitted ONLY
|
|
7
7
|
* context-pins-changed (no canvas-layout-update), so mutates: false with a
|
|
8
8
|
* manual ctx.emit. The injected emitter adds the sessionId/timestamp fields
|
|
9
|
-
* the legacy handler set explicitly. (The SDK's setContextPins emits
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* the legacy handler set explicitly. (The SDK's setContextPins emits the
|
|
10
|
+
* same context-pins-changed event — its old layout-update drift was erased
|
|
11
|
+
* in v0.3.0.)
|
|
12
12
|
* - canvas.undo / canvas.redo: legacy handlers emitted canvas-viewport-update
|
|
13
13
|
* then canvas-layout-update only after an entry was actually undone/redone,
|
|
14
14
|
* so mutates: false with conditional manual emits. (The SDK's undo/redo also
|
|
@@ -87,7 +87,7 @@ const pinOperation = defineOperation<z.infer<typeof pinSchema>, Record<string, u
|
|
|
87
87
|
|
|
88
88
|
const searchShape = {
|
|
89
89
|
q: z.unknown().optional().describe('Search query — matches against node titles, content, and file paths'),
|
|
90
|
-
limit: z.unknown().optional().describe('Max results to return (default: 10
|
|
90
|
+
limit: z.unknown().optional().describe('Max results to return (default: all over HTTP, 10 via the MCP tool).'),
|
|
91
91
|
};
|
|
92
92
|
|
|
93
93
|
const searchSchema = z.looseObject(searchShape);
|
|
@@ -108,9 +108,12 @@ const searchOperation = defineOperation<z.infer<typeof searchSchema>, Record<str
|
|
|
108
108
|
query: z.string().describe('Search query — matches against node titles, content, and file paths'),
|
|
109
109
|
limit: z.number().optional().describe('Max results to return (default: 10)'),
|
|
110
110
|
},
|
|
111
|
-
//
|
|
112
|
-
// `
|
|
113
|
-
buildInput: (input) => ({
|
|
111
|
+
// Map the MCP-facing `query` arg onto the wire's `q`. The handler caps by
|
|
112
|
+
// `limit` on every transport; the MCP tool additionally defaults it to 10.
|
|
113
|
+
buildInput: (input) => ({
|
|
114
|
+
q: typeof input.query === 'string' ? input.query : '',
|
|
115
|
+
...(typeof input.limit === 'number' ? { limit: input.limit } : {}),
|
|
116
|
+
}),
|
|
114
117
|
formatResult: (result, input) => {
|
|
115
118
|
const body = isRecord(result) ? result : {};
|
|
116
119
|
const results = Array.isArray(body.results) ? body.results : [];
|
|
@@ -132,7 +135,15 @@ const searchOperation = defineOperation<z.infer<typeof searchSchema>, Record<str
|
|
|
132
135
|
if (!q.trim()) {
|
|
133
136
|
return { results: [], query: q };
|
|
134
137
|
}
|
|
135
|
-
|
|
138
|
+
const rawLimit = input.limit;
|
|
139
|
+
const limit = typeof rawLimit === 'number'
|
|
140
|
+
? rawLimit
|
|
141
|
+
: typeof rawLimit === 'string' && rawLimit.trim() !== '' ? Number(rawLimit) : Number.NaN;
|
|
142
|
+
const results = searchNodes(canvasState.getLayout().nodes, q);
|
|
143
|
+
return {
|
|
144
|
+
results: Number.isFinite(limit) && limit > 0 ? results.slice(0, Math.floor(limit)) : results,
|
|
145
|
+
query: q,
|
|
146
|
+
};
|
|
136
147
|
},
|
|
137
148
|
});
|
|
138
149
|
|
|
@@ -69,7 +69,7 @@ const snapshotListOperation = defineOperation<z.infer<typeof snapshotListSchema>
|
|
|
69
69
|
},
|
|
70
70
|
mcp: {
|
|
71
71
|
toolName: 'canvas_list_snapshots',
|
|
72
|
-
description: 'List saved canvas snapshots with IDs, names, timestamps, and node/edge counts. Defaults to the 20 newest snapshots; pass all=true to return every snapshot.',
|
|
72
|
+
description: 'Deprecated: folds into the canvas_snapshot composite in v0.4 (actions save|list|restore|delete|gc|diff). List saved canvas snapshots with IDs, names, timestamps, and node/edge counts. Defaults to the 20 newest snapshots; pass all=true to return every snapshot.',
|
|
73
73
|
extraShape: {
|
|
74
74
|
limit: z.number().optional().describe('Maximum snapshots to return (default: 20)'),
|
|
75
75
|
query: z.string().optional().describe('Optional case-insensitive ID/name filter'),
|
|
@@ -117,7 +117,7 @@ const snapshotSaveOperation = defineOperation<z.infer<typeof snapshotSaveSchema>
|
|
|
117
117
|
},
|
|
118
118
|
mcp: {
|
|
119
119
|
toolName: 'canvas_snapshot',
|
|
120
|
-
description: 'Save the current canvas state as a named snapshot. Snapshots persist to disk and can be restored later.',
|
|
120
|
+
description: 'Deprecated: folds into the canvas_snapshot composite in v0.4 (actions save|list|restore|delete|gc|diff). Save the current canvas state as a named snapshot. Snapshots persist to disk and can be restored later.',
|
|
121
121
|
extraShape: {
|
|
122
122
|
name: z.string().describe('Name for this snapshot (e.g., "before refactor", "investigation v2")'),
|
|
123
123
|
},
|
|
@@ -154,7 +154,7 @@ const snapshotGcOperation = defineOperation<z.infer<typeof snapshotGcSchema>, Re
|
|
|
154
154
|
},
|
|
155
155
|
mcp: {
|
|
156
156
|
toolName: 'canvas_gc_snapshots',
|
|
157
|
-
description: 'Delete old saved canvas snapshots, keeping the newest N snapshots. Use dryRun=true to preview deletions.',
|
|
157
|
+
description: 'Deprecated: folds into the canvas_snapshot composite in v0.4 (actions save|list|restore|delete|gc|diff). Delete old saved canvas snapshots, keeping the newest N snapshots. Use dryRun=true to preview deletions.',
|
|
158
158
|
extraShape: {
|
|
159
159
|
keep: z.number().optional().describe('Number of newest snapshots to keep (default: 20)'),
|
|
160
160
|
dryRun: z.boolean().optional().describe('Preview deletions without removing snapshot files'),
|
|
@@ -207,7 +207,7 @@ const snapshotDiffOperation = defineOperation<z.infer<typeof snapshotDiffSchema>
|
|
|
207
207
|
},
|
|
208
208
|
mcp: {
|
|
209
209
|
toolName: 'canvas_diff',
|
|
210
|
-
description: 'Compare the current canvas state against a saved snapshot. Shows added/removed/modified nodes and edges. Pass either a snapshot name or ID.',
|
|
210
|
+
description: 'Deprecated: folds into the canvas_snapshot composite in v0.4 (actions save|list|restore|delete|gc|diff). Compare the current canvas state against a saved snapshot. Shows added/removed/modified nodes and edges. Pass either a snapshot name or ID.',
|
|
211
211
|
extraShape: {
|
|
212
212
|
snapshot: z.string().describe('Snapshot name or ID to compare against'),
|
|
213
213
|
},
|
|
@@ -289,7 +289,7 @@ const snapshotRestoreOperation = defineOperation<z.infer<typeof snapshotRestoreS
|
|
|
289
289
|
},
|
|
290
290
|
mcp: {
|
|
291
291
|
toolName: 'canvas_restore',
|
|
292
|
-
description: 'Restore the canvas to a previously saved snapshot. Use canvas_snapshot to save first. Pass either the snapshot ID or name to restore.',
|
|
292
|
+
description: 'Deprecated: folds into the canvas_snapshot composite in v0.4 (actions save|list|restore|delete|gc|diff). Restore the canvas to a previously saved snapshot. Use canvas_snapshot to save first. Pass either the snapshot ID or name to restore.',
|
|
293
293
|
extraShape: {
|
|
294
294
|
id: z.string().describe('Snapshot ID or name to restore (from canvas_snapshot or snapshot list)'),
|
|
295
295
|
},
|
|
@@ -338,7 +338,7 @@ const snapshotDeleteOperation = defineOperation<z.infer<typeof snapshotDeleteSch
|
|
|
338
338
|
},
|
|
339
339
|
mcp: {
|
|
340
340
|
toolName: 'canvas_delete_snapshot',
|
|
341
|
-
description: 'Delete a saved snapshot by ID.',
|
|
341
|
+
description: 'Deprecated: folds into the canvas_snapshot composite in v0.4 (actions save|list|restore|delete|gc|diff). Delete a saved snapshot by ID.',
|
|
342
342
|
extraShape: {
|
|
343
343
|
id: z.string().describe('Snapshot ID to delete'),
|
|
344
344
|
},
|
package/src/server/server.ts
CHANGED
|
@@ -920,7 +920,8 @@ function rotatePrimaryWorkbenchSessionIfNeeded(): void {
|
|
|
920
920
|
primaryWorkbenchSessionId = `pmx-${Date.now().toString(36)}`;
|
|
921
921
|
}
|
|
922
922
|
|
|
923
|
-
|
|
923
|
+
/** Null means a non-empty body failed to parse — handlers must 400, not silently no-op. */
|
|
924
|
+
async function readJson(req: Request): Promise<Record<string, unknown> | null> {
|
|
924
925
|
let text = '';
|
|
925
926
|
try {
|
|
926
927
|
text = await req.text();
|
|
@@ -937,10 +938,14 @@ async function readJson(req: Request): Promise<Record<string, unknown>> {
|
|
|
937
938
|
return value as Record<string, unknown>;
|
|
938
939
|
} catch (error) {
|
|
939
940
|
logWorkbenchWarning('readJson', error);
|
|
940
|
-
return
|
|
941
|
+
return null;
|
|
941
942
|
}
|
|
942
943
|
}
|
|
943
944
|
|
|
945
|
+
function malformedJsonResponse(): Response {
|
|
946
|
+
return responseJson({ ok: false, error: 'Malformed JSON body.' }, 400);
|
|
947
|
+
}
|
|
948
|
+
|
|
944
949
|
function htmlEscape(value: string): string {
|
|
945
950
|
return value
|
|
946
951
|
.replaceAll('&', '&')
|
|
@@ -1228,6 +1233,7 @@ function addFrameDocument(html: string, sandbox: string): string {
|
|
|
1228
1233
|
|
|
1229
1234
|
async function handleCreateFrameDocument(req: Request): Promise<Response> {
|
|
1230
1235
|
const body = await readJson(req);
|
|
1236
|
+
if (body === null) return malformedJsonResponse();
|
|
1231
1237
|
const html = body.html;
|
|
1232
1238
|
if (typeof html !== 'string' || !html) {
|
|
1233
1239
|
return responseJson({ ok: false, error: 'Frame document requires non-empty html.' }, 400);
|
|
@@ -1370,6 +1376,7 @@ function handleNodeSurface(pathname: string, url: URL): Response {
|
|
|
1370
1376
|
|
|
1371
1377
|
async function handleCanvasUpdate(req: Request): Promise<Response> {
|
|
1372
1378
|
const body = await readJson(req);
|
|
1379
|
+
if (body === null) return malformedJsonResponse();
|
|
1373
1380
|
const updates = Array.isArray(body.updates) ? body.updates : [];
|
|
1374
1381
|
const result = body.recordHistory === false
|
|
1375
1382
|
? (() => {
|
|
@@ -1388,6 +1395,7 @@ async function handleCanvasUpdate(req: Request): Promise<Response> {
|
|
|
1388
1395
|
|
|
1389
1396
|
async function handleCanvasViewport(req: Request): Promise<Response> {
|
|
1390
1397
|
const body = await readJson(req);
|
|
1398
|
+
if (body === null) return malformedJsonResponse();
|
|
1391
1399
|
const next = {
|
|
1392
1400
|
x: typeof body.x === 'number' ? body.x : canvasState.viewport.x,
|
|
1393
1401
|
y: typeof body.y === 'number' ? body.y : canvasState.viewport.y,
|
|
@@ -1438,6 +1446,7 @@ function parseAnnotationPoints(value: unknown): CanvasAnnotation['points'] {
|
|
|
1438
1446
|
|
|
1439
1447
|
async function handleCanvasAddAnnotation(req: Request): Promise<Response> {
|
|
1440
1448
|
const body = await readJson(req);
|
|
1449
|
+
if (body === null) return malformedJsonResponse();
|
|
1441
1450
|
const type = body.type === 'text' ? 'text' : 'freehand';
|
|
1442
1451
|
const points = parseAnnotationPoints(body.points);
|
|
1443
1452
|
if (points.length < (type === 'text' ? 1 : 2)) {
|
|
@@ -1524,6 +1533,7 @@ async function handleCanvasRefreshWebpageNode(nodeId: string, req: Request): Pro
|
|
|
1524
1533
|
}
|
|
1525
1534
|
|
|
1526
1535
|
const body = await readJson(req);
|
|
1536
|
+
if (body === null) return malformedJsonResponse();
|
|
1527
1537
|
const rawUrl = typeof body.url === 'string' ? body.url : undefined;
|
|
1528
1538
|
let url: string | undefined;
|
|
1529
1539
|
if (rawUrl && rawUrl.trim().length > 0) {
|
|
@@ -1544,6 +1554,7 @@ async function handleCanvasRefreshWebpageNode(nodeId: string, req: Request): Pro
|
|
|
1544
1554
|
|
|
1545
1555
|
async function handleCanvasThemeUpdate(req: Request): Promise<Response> {
|
|
1546
1556
|
const body = await readJson(req);
|
|
1557
|
+
if (body === null) return malformedJsonResponse();
|
|
1547
1558
|
const theme = normalizeCanvasTheme(body.theme, canvasState.theme);
|
|
1548
1559
|
const next = canvasState.setTheme(theme);
|
|
1549
1560
|
broadcastWorkbenchEvent('theme-changed', {
|
|
@@ -1823,6 +1834,7 @@ function handleRead(pathLike: string): Response {
|
|
|
1823
1834
|
|
|
1824
1835
|
async function handleExtAppCallTool(req: Request): Promise<Response> {
|
|
1825
1836
|
const body = await readJson(req);
|
|
1837
|
+
if (body === null) return malformedJsonResponse();
|
|
1826
1838
|
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
|
|
1827
1839
|
const toolName = typeof body.toolName === 'string' ? body.toolName.trim() : '';
|
|
1828
1840
|
if (!sessionId || !toolName) {
|
|
@@ -1893,6 +1905,7 @@ async function handleExtAppCallTool(req: Request): Promise<Response> {
|
|
|
1893
1905
|
|
|
1894
1906
|
async function handleExtAppReadResource(req: Request): Promise<Response> {
|
|
1895
1907
|
const body = await readJson(req);
|
|
1908
|
+
if (body === null) return malformedJsonResponse();
|
|
1896
1909
|
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
|
|
1897
1910
|
const uri = typeof body.uri === 'string' ? body.uri.trim() : '';
|
|
1898
1911
|
if (!sessionId || !uri) {
|
|
@@ -1909,6 +1922,7 @@ async function handleExtAppReadResource(req: Request): Promise<Response> {
|
|
|
1909
1922
|
|
|
1910
1923
|
async function handleExtAppListTools(req: Request): Promise<Response> {
|
|
1911
1924
|
const body = await readJson(req);
|
|
1925
|
+
if (body === null) return malformedJsonResponse();
|
|
1912
1926
|
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
|
|
1913
1927
|
if (!sessionId) return responseJson({ ok: false, error: 'Missing sessionId.' }, 400);
|
|
1914
1928
|
|
|
@@ -1922,6 +1936,7 @@ async function handleExtAppListTools(req: Request): Promise<Response> {
|
|
|
1922
1936
|
|
|
1923
1937
|
async function handleExtAppListResources(req: Request): Promise<Response> {
|
|
1924
1938
|
const body = await readJson(req);
|
|
1939
|
+
if (body === null) return malformedJsonResponse();
|
|
1925
1940
|
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
|
|
1926
1941
|
if (!sessionId) return responseJson({ ok: false, error: 'Missing sessionId.' }, 400);
|
|
1927
1942
|
|
|
@@ -1935,6 +1950,7 @@ async function handleExtAppListResources(req: Request): Promise<Response> {
|
|
|
1935
1950
|
|
|
1936
1951
|
async function handleExtAppListResourceTemplates(req: Request): Promise<Response> {
|
|
1937
1952
|
const body = await readJson(req);
|
|
1953
|
+
if (body === null) return malformedJsonResponse();
|
|
1938
1954
|
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
|
|
1939
1955
|
if (!sessionId) return responseJson({ ok: false, error: 'Missing sessionId.' }, 400);
|
|
1940
1956
|
|
|
@@ -1948,6 +1964,7 @@ async function handleExtAppListResourceTemplates(req: Request): Promise<Response
|
|
|
1948
1964
|
|
|
1949
1965
|
async function handleExtAppListPrompts(req: Request): Promise<Response> {
|
|
1950
1966
|
const body = await readJson(req);
|
|
1967
|
+
if (body === null) return malformedJsonResponse();
|
|
1951
1968
|
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
|
|
1952
1969
|
if (!sessionId) return responseJson({ ok: false, error: 'Missing sessionId.' }, 400);
|
|
1953
1970
|
|
|
@@ -1961,6 +1978,7 @@ async function handleExtAppListPrompts(req: Request): Promise<Response> {
|
|
|
1961
1978
|
|
|
1962
1979
|
async function handleExtAppModelContext(req: Request): Promise<Response> {
|
|
1963
1980
|
const body = await readJson(req);
|
|
1981
|
+
if (body === null) return malformedJsonResponse();
|
|
1964
1982
|
const nodeId = typeof body.nodeId === 'string' ? body.nodeId.trim() : '';
|
|
1965
1983
|
if (!nodeId) return responseJson({ ok: false, error: 'Missing nodeId.' }, 400);
|
|
1966
1984
|
|
|
@@ -2033,6 +2051,7 @@ function currentWorkbenchUrl(): string | null {
|
|
|
2033
2051
|
|
|
2034
2052
|
async function handleWorkbenchWebViewScreenshot(req: Request): Promise<Response> {
|
|
2035
2053
|
const body = await readJson(req);
|
|
2054
|
+
if (body === null) return malformedJsonResponse();
|
|
2036
2055
|
const format = body.format === 'jpeg' || body.format === 'webp' || body.format === 'png'
|
|
2037
2056
|
? body.format
|
|
2038
2057
|
: 'png';
|
|
@@ -2063,6 +2082,7 @@ async function handleWorkbenchWebViewScreenshot(req: Request): Promise<Response>
|
|
|
2063
2082
|
|
|
2064
2083
|
async function handleWorkbenchOpen(req: Request): Promise<Response> {
|
|
2065
2084
|
const body = await readJson(req);
|
|
2085
|
+
if (body === null) return malformedJsonResponse();
|
|
2066
2086
|
const pathLike = typeof body.path === 'string' ? body.path : '';
|
|
2067
2087
|
const safePath = resolveWorkspaceMarkdownPath(pathLike);
|
|
2068
2088
|
if (!safePath) return responseText('Invalid path', 400);
|
|
@@ -2074,6 +2094,7 @@ async function handleWorkbenchOpen(req: Request): Promise<Response> {
|
|
|
2074
2094
|
|
|
2075
2095
|
async function handleWorkbenchIntent(req: Request): Promise<Response> {
|
|
2076
2096
|
const body = await readJson(req);
|
|
2097
|
+
if (body === null) return malformedJsonResponse();
|
|
2077
2098
|
const rawType = typeof body.type === 'string' ? body.type.trim() : '';
|
|
2078
2099
|
if (!rawType) return responseText('Missing intent type', 400);
|
|
2079
2100
|
if (!ALLOWED_WORKBENCH_INTENTS.has(rawType as PrimaryWorkbenchIntent['type'])) {
|
|
@@ -2234,6 +2255,7 @@ function handleWorkbenchEvents(req: Request): Response {
|
|
|
2234
2255
|
|
|
2235
2256
|
async function handleSave(req: Request): Promise<Response> {
|
|
2236
2257
|
const body = await readJson(req);
|
|
2258
|
+
if (body === null) return malformedJsonResponse();
|
|
2237
2259
|
const pathLike = typeof body.path === 'string' ? body.path : '';
|
|
2238
2260
|
const safePath = resolveWorkspaceMarkdownPath(pathLike);
|
|
2239
2261
|
if (!safePath) return responseText('Invalid path', 400);
|
|
@@ -2250,6 +2272,7 @@ async function handleSave(req: Request): Promise<Response> {
|
|
|
2250
2272
|
|
|
2251
2273
|
async function handleRender(req: Request): Promise<Response> {
|
|
2252
2274
|
const body = await readJson(req);
|
|
2275
|
+
if (body === null) return malformedJsonResponse();
|
|
2253
2276
|
const markdown = typeof body.markdown === 'string' ? body.markdown : '';
|
|
2254
2277
|
const html =
|
|
2255
2278
|
(marked.parse(normalizeMarkdownExternalUrls(markdown), {
|
|
@@ -2271,6 +2294,7 @@ function buildSelectionContextPreamble(contextNodeIds: string[]): string {
|
|
|
2271
2294
|
|
|
2272
2295
|
async function handleCanvasPrompt(req: Request): Promise<Response> {
|
|
2273
2296
|
const body = await readJson(req);
|
|
2297
|
+
if (body === null) return malformedJsonResponse();
|
|
2274
2298
|
const text = typeof body.text === 'string' ? body.text.trim() : '';
|
|
2275
2299
|
if (!text) return responseText('Missing prompt text', 400);
|
|
2276
2300
|
|
|
@@ -2604,6 +2628,7 @@ function normalizeActivityReactions(input: Record<string, unknown>): {
|
|
|
2604
2628
|
// Report primitive A: ingest a harness-forwarded agent activity; the board auto-reacts.
|
|
2605
2629
|
async function handleAxActivityIngest(req: Request): Promise<Response> {
|
|
2606
2630
|
const body = await readJson(req);
|
|
2631
|
+
if (body === null) return malformedJsonResponse();
|
|
2607
2632
|
if (!isAxActivityKind(body.kind)) {
|
|
2608
2633
|
return responseJson({ ok: false, error: "activity requires a valid 'kind': one of tool-start, tool-result, failure, error, session-start, session-end, command, note." }, 400);
|
|
2609
2634
|
}
|
|
@@ -2650,6 +2675,7 @@ function handleGetAxSurfaceSnapshot(): Response {
|
|
|
2650
2675
|
// safe presentation query params like the current theme.
|
|
2651
2676
|
async function handleOpenExternalSurface(req: Request): Promise<Response> {
|
|
2652
2677
|
const body = await readJson(req);
|
|
2678
|
+
if (body === null) return malformedJsonResponse();
|
|
2653
2679
|
const nodeId = typeof body.nodeId === 'string' ? body.nodeId : '';
|
|
2654
2680
|
if (!nodeId) return responseJson({ ok: false, error: 'nodeId is required.' }, 400);
|
|
2655
2681
|
const node = canvasState.getNode(nodeId);
|
|
@@ -2670,6 +2696,7 @@ async function handleOpenExternalSurface(req: Request): Promise<Response> {
|
|
|
2670
2696
|
|
|
2671
2697
|
async function handleAxInteraction(req: Request): Promise<Response> {
|
|
2672
2698
|
const body = await readJson(req);
|
|
2699
|
+
if (body === null) return malformedJsonResponse();
|
|
2673
2700
|
const { result, events } = applyAxInteraction(canvasState, body, normalizeAxSource(body.source, 'api'));
|
|
2674
2701
|
for (const e of events) {
|
|
2675
2702
|
broadcastWorkbenchEvent(e.event, {
|
|
@@ -2711,6 +2738,7 @@ function handleAxPolicyGet(): Response {
|
|
|
2711
2738
|
|
|
2712
2739
|
async function handleAxStatePatch(req: Request): Promise<Response> {
|
|
2713
2740
|
const body = await readJson(req);
|
|
2741
|
+
if (body === null) return malformedJsonResponse();
|
|
2714
2742
|
if (!body.focus || typeof body.focus !== 'object' || Array.isArray(body.focus)) {
|
|
2715
2743
|
return responseJson({ ok: false, error: 'PATCH /api/canvas/ax currently requires a focus object.' }, 400);
|
|
2716
2744
|
}
|
package/src/shared/ax-intent.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* agent is ABOUT to make (create / move / connect / remove / edit) so the canvas
|
|
6
6
|
* can paint a faint placeholder before the real mutation lands. Like a
|
|
7
7
|
* multiplayer cursor, it auto-expires, is count-capped, and never enters
|
|
8
|
-
* `
|
|
8
|
+
* `canvas_query` (`layout` action), `state.json`, or snapshots.
|
|
9
9
|
*
|
|
10
10
|
* This module is import-shared by the server (IntentRegistry + the intent ops)
|
|
11
11
|
* and the client (intent-store + IntentLayer); it must stay free of any
|