pmx-canvas 0.1.6 → 0.1.8

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 CHANGED
@@ -3,6 +3,106 @@
3
3
  All notable changes to `pmx-canvas` are documented here. This project follows
4
4
  [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [0.1.8] - 2026-04-25
7
+
8
+ Retest-driven follow-up to 0.1.7. This next release restores compatibility
9
+ for image and json-render node creation, fixes the counter MCP fixture's
10
+ accidental iframe overflow, and syncs packaged testing guidance with the
11
+ canonical repo skill.
12
+
13
+ ### Fixed
14
+
15
+ - **Image `path` is accepted as a compatibility alias for `content`.**
16
+ `pmx-canvas node add --type image --path <file>`, HTTP
17
+ `/api/canvas/node` payloads with `{ type: "image", path }`, and MCP
18
+ `canvas_add_node({ type: "image", path })` now all validate the file
19
+ and populate `data.src` instead of creating an empty/broken image node.
20
+ - **json-render creation keeps 0.1.7 compatibility.**
21
+ `canvas_add_json_render_node`, `POST /api/canvas/json-render`, and
22
+ CLI `node add --type json-render` accept omitted titles and infer a
23
+ node title from the root element. Bare legacy component specs like
24
+ `{ type: "Badge", props: {...} }` are wrapped into a one-element
25
+ document before validation, while complete `{ root, elements }` specs
26
+ remain the canonical shape.
27
+ - **Counter MCP fixture no longer creates a scrollable iframe by
28
+ accident.** The fixture now uses border-box sizing and hides root
29
+ overflow so `100vh` layouts with padding do not become `100vh +
30
+ padding`. This removes the observed repeated downward scroll in the
31
+ counter app and adds browser coverage for zero iframe body overflow.
32
+
33
+ ### Changed
34
+
35
+ - **Packaged PMX Canvas testing skill is back in sync with canonical
36
+ guidance.** It now documents that `test:coverage` covers only the Bun
37
+ unit suite, names the coverage artifact, and calls out the WebView
38
+ automation timeout caveat used to distinguish environment limits from
39
+ product regressions.
40
+ - **Schema metadata for the `path` alias and relaxed json-render contract
41
+ is version-stable.** `canvas_describe_schema` and `canvas://schema`
42
+ surface the image `path` alias and `title.required: false` on
43
+ json-render so agents discover the new shapes without reading the
44
+ CHANGELOG.
45
+ - **Agent-facing `skills/pmx-canvas/SKILL.md` documents the same
46
+ contract.** The skill now describes the `path` alias for image nodes
47
+ and the relaxed json-render contract (omitted titles, bare component
48
+ specs) so agents do not need to retry across shapes.
49
+
50
+ ### Internal
51
+
52
+ - Regression coverage for image `path` alias handling across CLI/HTTP/MCP,
53
+ json-render compatibility for omitted titles plus bare component specs,
54
+ and hosted counter MCP app iframe overflow.
55
+
56
+ ## [0.1.7] - 2026-04-26
57
+
58
+ Small retest-driven follow-up to 0.1.6. Three agent-facing ergonomics:
59
+ `canvas_evaluate` now accepts top-level `await`, snapshot responses gain
60
+ a flat `id` alias for add-style consistency, and the PMX Canvas skill
61
+ documents real DOM selectors plus several quirks an agent would
62
+ otherwise have to discover by trial and error.
63
+
64
+ ### Added
65
+
66
+ - **Snapshot save responses include a flat `id` alias.** Both
67
+ `canvas_snapshot` and `POST /api/canvas/snapshots` still return the
68
+ nested `snapshot` object, and now also include `id: snapshot.id` at
69
+ the top level — same shape as every other add-style response in the
70
+ canvas API. HTTP and MCP surfaces are aligned.
71
+
72
+ ### Changed
73
+
74
+ - **`canvas_evaluate` script mode supports top-level `await`.** Both
75
+ MCP and HTTP WebView script mode wrap multi-statement bodies in an
76
+ async IIFE and serialize the resolved return value, so an agent can
77
+ write `const r = await fetch(...); return r.json();` directly without
78
+ scaffolding the wrapper itself. WebView script documentation now
79
+ describes the async behavior explicitly.
80
+ - **PMX Canvas skill docs now ship a defensive ID extractor pattern.**
81
+ The skill recommends `r.id ?? r.nodeId ?? r.snapshot?.id` so agents
82
+ pull the right id field across add-style, web-artifact, and snapshot
83
+ responses without branching per tool.
84
+ - **PMX Canvas skill docs name the real WebView CSS selectors.** The
85
+ bundled skill calls out `.canvas-node`, `.hud-layer`,
86
+ `.canvas-toolbar`, `.connection-dot`, and related classes, and is
87
+ explicit that nodes do **not** expose stable `data-node-id`
88
+ attributes — agents driving the canvas via `canvas_evaluate` no
89
+ longer have to discover selectors by trial and error.
90
+ - **PMX Canvas skill edge docs list the valid edge types.** `flow`,
91
+ `depends-on`, `relation`, `references` — same as the rest of the
92
+ surface but now explicit in the skill so the agent doesn't guess.
93
+ - **PMX Canvas skill diagram docs clarify
94
+ `canvas_add_diagram.elements`.** The field expects Excalidraw element
95
+ objects (rectangles, ellipses, arrows with bindings, labels), not
96
+ Mermaid / DOT / Graphviz source text or any other diagram DSL.
97
+
98
+ ### Internal
99
+
100
+ - Regression coverage for snapshot flat-`id` aliases on both MCP and
101
+ HTTP surfaces, plus async / top-level-`await` WebView script bodies.
102
+
103
+ [0.1.8]: https://github.com/pskoett/pmx-canvas/releases/tag/v0.1.8
104
+ [0.1.7]: https://github.com/pskoett/pmx-canvas/releases/tag/v0.1.7
105
+
6
106
  ## [0.1.6] - 2026-04-26
7
107
 
8
108
  CLI/MCP regression cleanup after the 0.1.5 coverage pass. This release tightens
@@ -50,7 +150,6 @@ drift without guessing.
50
150
  omitted from the response instead of being `undefined`. Consumers can now
51
151
  reliably use `'id' in response` to detect the build-only case. `nodeId` is
52
152
  always present and remains the canonical identifier.
53
-
54
153
  ### Fixed
55
154
 
56
155
  - **`pmx-canvas graph add` no longer starts a rogue server.** The top-level
@@ -4,7 +4,7 @@ export interface JsonRenderSpec {
4
4
  state?: Record<string, unknown>;
5
5
  }
6
6
  export interface JsonRenderNodeInput {
7
- title: string;
7
+ title?: string;
8
8
  spec: unknown;
9
9
  x?: number;
10
10
  y?: number;
@@ -44,6 +44,7 @@ export declare const GRAPH_NODE_SIZE: {
44
44
  height: number;
45
45
  };
46
46
  export type GraphChartType = 'LineChart' | 'BarChart' | 'PieChart' | 'AreaChart' | 'ScatterChart' | 'RadarChart' | 'StackedBarChart' | 'ComposedChart';
47
+ export declare function inferJsonRenderNodeTitle(spec: JsonRenderSpec, fallback?: string): string;
47
48
  export declare function normalizeAndValidateJsonRenderSpec(spec: unknown): JsonRenderSpec;
48
49
  export declare function normalizeGraphType(value: string): GraphChartType;
49
50
  export declare function buildGraphSpec(input: GraphNodeInput): JsonRenderSpec;
@@ -60,6 +60,7 @@ export declare function getCanvasAutomationWebViewStatus(): CanvasAutomationWebV
60
60
  export declare function stopCanvasAutomationWebView(): Promise<boolean>;
61
61
  export declare function startCanvasAutomationWebView(url: string, options?: CanvasAutomationWebViewOptions): Promise<CanvasAutomationWebViewStatus>;
62
62
  export declare function evaluateCanvasAutomationWebView(expression: string): Promise<unknown>;
63
+ export declare function wrapCanvasAutomationScript(script: string): string;
63
64
  export declare function resizeCanvasAutomationWebView(width: number, height: number): Promise<CanvasAutomationWebViewStatus>;
64
65
  export declare function screenshotCanvasAutomationWebView(options?: Record<string, unknown>): Promise<Uint8Array>;
65
66
  export interface PrimaryWorkbenchIntent {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Spatial canvas workbench for coding agents — infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
5
5
  "type": "module",
6
6
  "main": "./src/server/index.ts",
@@ -270,6 +270,7 @@ If a node type is rejected by `canvas_add_node`, call `canvas_describe_schema` a
270
270
  - `content`: for most types, this is markdown text. For `file` type, pass the **file path**
271
271
  (e.g., `"src/auth/login.ts"`) — the server auto-loads the file content and watches for changes.
272
272
  For `image` type, pass a file path, URL, or data URI.
273
+ - `path`: compatibility alias for image paths only; prefer `content` for new image calls
273
274
  - `x`, `y`: position (auto-placed if omitted — prefer omitting for auto-layout)
274
275
  - `width`, `height`: dimensions (sensible defaults provided)
275
276
  - `color`: semantic color
@@ -306,8 +307,9 @@ If a node type is rejected by `canvas_add_node`, call `canvas_describe_schema` a
306
307
  ```
307
308
 
308
309
  **`canvas_add_json_render_node`** — Add a native json-render node
309
- - Required: `title`, `spec`
310
- - The `spec` must be a complete json-render object with `root`, `elements`, and optional `state`
310
+ - Required: `spec`; `title` is optional and inferred from the root element when omitted
311
+ - Prefer a complete json-render object with `root`, `elements`, and optional `state`
312
+ - Legacy bare component specs like `{ type: "Badge", props: {...} }` are accepted and wrapped into a one-element document for compatibility
311
313
  - Use this when you want a structured UI panel rendered directly inside PMX Canvas
312
314
  - For shadcn `Badge`, prefer `props.text` with variants `default`, `secondary`, `destructive`, or
313
315
  `outline`. Legacy `props.label` and status variants (`success`, `info`, `warning`, `error`,
@@ -332,6 +334,10 @@ If a node type is rejected by `canvas_add_node`, call `canvas_describe_schema` a
332
334
  - Cold builds commonly take 45-60 seconds; use a long client timeout such as 300000 ms or more
333
335
  - Returns both `id` and `nodeId` for the created artifact node when `openInCanvas` is true
334
336
 
337
+ ID extraction for mixed tool responses:
338
+ - Most add-style tools return a flat `id`; web artifacts return `id` plus `nodeId`; snapshots return `id` plus nested `snapshot.id`.
339
+ - Defensive extractor: `const getId = (r) => r.id ?? r.nodeId ?? r.snapshot?.id;`
340
+
335
341
  **`canvas_open_mcp_app`** — Open a tool-backed external MCP app node
336
342
  - Required: `toolName`, `transport`
337
343
  - `transport` is either `{ type: "stdio", command, args?, cwd?, env? }` or `{ type: "http", url, headers? }`
@@ -366,7 +372,7 @@ If a node type is rejected by `canvas_add_node`, call `canvas_describe_schema` a
366
372
  - `from`, `to` (required): source and target node IDs
367
373
  - `fromSearch`, `toSearch`: optional search-based selectors when you do not have IDs. Each search
368
374
  query must resolve to exactly one node or the edge creation fails with an ambiguity error.
369
- - `type`: edge type (default: `relation`)
375
+ - `type`: `flow`, `depends-on`, `relation`, or `references` (default: `relation`)
370
376
  - `label`: descriptive relationship label
371
377
  - `style`: `solid`, `dashed`, or `dotted`
372
378
  - `animated`: boolean for visual emphasis
@@ -465,6 +471,7 @@ Current product caveats for grouped comparison boards:
465
471
  **`canvas_redo`** — Redo the last undone mutation
466
472
  **`canvas_snapshot`** — Save a named snapshot to disk
467
473
  - `name` (required): descriptive snapshot name (e.g., "before-refactor")
474
+ - Returns `{ ok, id, snapshot }`; the flat `id` is an alias for `snapshot.id`
468
475
  **`canvas_restore`** — Restore canvas from a saved snapshot
469
476
  - `id`: snapshot to restore
470
477
  **`canvas_diff`** — Compare current canvas against a saved snapshot
@@ -502,7 +509,8 @@ tools below operate on the live canvas state.
502
509
  **`canvas_webview_stop`** — Tear down the automation session
503
510
 
504
511
  **`canvas_evaluate`** — Run JavaScript inside the workbench page and return the result
505
- - Required: `expression` (a JS expression or a function body)
512
+ - Required: exactly one of `expression` (single JS expression) or `script` (multi-statement body)
513
+ - `script` is wrapped in an async IIFE, so top-level `await` works inside script bodies
506
514
  - Useful for asserting DOM state after a sequence of canvas mutations
507
515
  - Example: read the count of rendered `.canvas-node` elements:
508
516
 
@@ -510,6 +518,20 @@ tools below operate on the live canvas state.
510
518
  canvas_evaluate({ expression: 'document.querySelectorAll(".canvas-node").length' })
511
519
  ```
512
520
 
521
+ Useful workbench selectors:
522
+ - Nodes: `.canvas-node`, `.canvas-node.active`, `.canvas-node.context-pinned`, `.canvas-node.group-node`
523
+ - Node internals: `.node-title`, `.node-titlebar`, `.node-body`, `.node-type-badge`, `.node-controls`
524
+ - Canvas chrome: `.hud-layer`, `.canvas-toolbar`, `.connection-dot`, `.canvas-bootstrap-card`
525
+ - Nodes do not expose stable `data-node-id` attributes. Use `canvas_get_layout`, `canvas_search`, or MCP resource data for exact node IDs.
526
+
527
+ Async script example:
528
+
529
+ ```typescript
530
+ canvas_evaluate({
531
+ script: 'const title = await Promise.resolve(document.title); return title;',
532
+ })
533
+ ```
534
+
513
535
  **`canvas_resize`** — Change the WebView viewport
514
536
  - Required: `width`, `height`
515
537
  - Use before `canvas_screenshot` when the human needs a specific aspect ratio
@@ -535,6 +557,7 @@ canvas_webview_stop();
535
557
  [Excalidraw MCP app](https://github.com/excalidraw/excalidraw-mcp)
536
558
  - Required: `elements` — an array of Excalidraw elements (rectangles, ellipses, diamonds, arrows,
537
559
  text). Can also be a JSON-array string.
560
+ - `elements` must be Excalidraw element objects, not Mermaid/DOT/source-text diagrams. Convert source diagrams to Excalidraw elements first or use a markdown/web-artifact node.
538
561
  - Optional: `title`, `x`, `y`, `width`, `height`
539
562
  - The diagram opens inside an `mcp-app` node with fullscreen editing and draw-on animations
540
563
  - CLI equivalent: `pmx-canvas external-app add --kind excalidraw --title "Diagram"`
@@ -40,11 +40,25 @@ bun run test:all # Bun suite + browser smoke
40
40
  Manual browser validation also requires a fresh client bundle. `bun run test:web-canvas`
41
41
  already does this for you.
42
42
 
43
+ ## Coverage Notes
44
+
45
+ - `bun run test:coverage` covers the Bun unit suite under `tests/unit/`
46
+ - Coverage output is written to `coverage/lcov.info` and also printed as a text summary
47
+ - CI currently uses that same unit-test coverage command, then runs browser smoke separately
48
+ - Do not describe `test:coverage` as full-stack coverage; Playwright coverage is not wired in here
49
+
43
50
  ## Current Project Test Surface
44
51
 
45
52
  - Bun tests live under `tests/unit/`
46
53
  - Playwright browser smoke lives under `tests/e2e/`
47
- - CI runs coverage plus the browser smoke flow
54
+ - CI runs Bun coverage plus the browser smoke flow
55
+
56
+ ## WebView Automation Caveat
57
+
58
+ - Some Linux/CI environments expose `Bun.WebView` but still cannot start a usable automation
59
+ session within the timeout window
60
+ - When testing WebView automation, treat a cleanly reported unsupported/timeout runtime boundary
61
+ as distinct from a product regression
48
62
 
49
63
  Prefer extending the existing suites before inventing a one-off script.
50
64
 
package/src/cli/agent.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import { readFileSync, writeFileSync } from 'node:fs';
15
- import { openUrlInExternalBrowser } from '../server/server.js';
15
+ import { openUrlInExternalBrowser, wrapCanvasAutomationScript } from '../server/server.js';
16
16
  import { DEFAULT_EXCALIDRAW_ELEMENTS } from '../server/diagram-presets.js';
17
17
  import {
18
18
  ALL_SEMANTIC_WATCH_EVENT_TYPES,
@@ -522,11 +522,8 @@ async function buildJsonRenderRequestBody(
522
522
  flags: Record<string, string | true>,
523
523
  ): Promise<Record<string, unknown>> {
524
524
  const hint =
525
- 'Use: pmx-canvas node add --type json-render --title "Ops Dashboard" --spec-file ./dashboard.json';
525
+ 'Use: pmx-canvas node add --type json-render --spec-file ./dashboard.json --title "Ops Dashboard"';
526
526
  const title = typeof flags.title === 'string' ? flags.title.trim() : '';
527
- if (!title) {
528
- die('json-render nodes require --title.', hint);
529
- }
530
527
 
531
528
  const rawSpec = await readTextInput(flags, {
532
529
  fileFlags: ['spec-file'],
@@ -538,7 +535,7 @@ async function buildJsonRenderRequestBody(
538
535
  });
539
536
 
540
537
  const spec = parseJsonValue(rawSpec, 'JSON spec', hint);
541
- const body: Record<string, unknown> = { title, spec };
538
+ const body: Record<string, unknown> = { ...(title ? { title } : {}), spec };
542
539
  applyCommonGeometryFlags(body, flags, {
543
540
  x: 'Use a finite number, e.g. --x 500',
544
541
  y: 'Use a finite number, e.g. --y 300',
@@ -985,8 +982,11 @@ cmd('node add', 'Add a node to the canvas', [
985
982
  const body: Record<string, unknown> = { type };
986
983
  if (flags.title) body.title = flags.title;
987
984
  const webpageUrl = getStringFlag(flags, 'url');
985
+ const imagePath = getStringFlag(flags, 'path');
988
986
  if (type === 'webpage' && webpageUrl) {
989
987
  body.url = webpageUrl;
988
+ } else if (type === 'image' && imagePath && !flags.content) {
989
+ body.content = imagePath;
990
990
  } else if (flags.content) {
991
991
  body.content = flags.content;
992
992
  }
@@ -1856,9 +1856,9 @@ cmd('webview evaluate', 'Evaluate JavaScript in the active Bun.WebView automatio
1856
1856
  'pmx-canvas webview evaluate --file ./probe.js',
1857
1857
  );
1858
1858
  }
1859
- expression = `(() => {\n${script}\n})()`;
1859
+ expression = wrapCanvasAutomationScript(script);
1860
1860
  } else if (typeof flags.script === 'string') {
1861
- expression = `(() => {\n${flags.script}\n})()`;
1861
+ expression = wrapCanvasAutomationScript(flags.script);
1862
1862
  } else {
1863
1863
  expression = requireFlag(
1864
1864
  flags,
@@ -11,7 +11,7 @@ export interface JsonRenderSpec {
11
11
  }
12
12
 
13
13
  export interface JsonRenderNodeInput {
14
- title: string;
14
+ title?: string;
15
15
  spec: unknown;
16
16
  x?: number;
17
17
  y?: number;
@@ -447,10 +447,38 @@ function normalizeSpec(spec: Record<string, unknown>): Record<string, unknown> {
447
447
  return changed ? { ...spec, elements: normalizedElements } : spec;
448
448
  }
449
449
 
450
- export function normalizeAndValidateJsonRenderSpec(spec: unknown): JsonRenderSpec {
450
+ function isBareJsonRenderElement(spec: Record<string, unknown>): boolean {
451
+ return typeof spec.type === 'string' && !('root' in spec) && !('elements' in spec);
452
+ }
453
+
454
+ function normalizeJsonRenderInput(spec: unknown): unknown {
451
455
  const specRecord = asRecord(spec);
456
+ if (!specRecord || !isBareJsonRenderElement(specRecord)) return spec;
457
+
458
+ return {
459
+ root: 'root',
460
+ elements: {
461
+ root: {
462
+ ...specRecord,
463
+ children: Array.isArray(specRecord.children)
464
+ ? specRecord.children.filter((child: unknown) => typeof child === 'string')
465
+ : [],
466
+ },
467
+ },
468
+ };
469
+ }
470
+
471
+ export function inferJsonRenderNodeTitle(spec: JsonRenderSpec, fallback = 'json-render'): string {
472
+ const rootElement = asRecord(spec.elements[spec.root]);
473
+ const rootProps = asRecord(rootElement?.props);
474
+ const title = rootProps?.title ?? rootProps?.text ?? rootElement?.type;
475
+ return typeof title === 'string' && title.trim().length > 0 ? title.trim() : fallback;
476
+ }
477
+
478
+ export function normalizeAndValidateJsonRenderSpec(spec: unknown): JsonRenderSpec {
479
+ const specRecord = asRecord(normalizeJsonRenderInput(spec));
452
480
  if (!specRecord || typeof specRecord.root !== 'string' || !asRecord(specRecord.elements)) {
453
- throw new Error('Missing root and elements in spec.');
481
+ throw new Error('Missing root and elements in spec. Pass a complete {root, elements} document, or a single bare component object with a type field.');
454
482
  }
455
483
 
456
484
  const normalizedSpec = normalizeSpec(specRecord);
package/src/mcp/server.ts CHANGED
@@ -32,7 +32,7 @@ import {
32
32
  type PmxCanvas,
33
33
  } from '../server/index.js';
34
34
  import { serializeNodeForAgentContext } from '../server/agent-context.js';
35
- import { emitPrimaryWorkbenchEvent } from '../server/server.js';
35
+ import { emitPrimaryWorkbenchEvent, wrapCanvasAutomationScript } from '../server/server.js';
36
36
  import { searchNodes, buildSpatialContext, findNeighborhoods } from '../server/spatial-analysis.js';
37
37
  import { mutationHistory, diffLayouts, formatDiff } from '../server/mutation-history.js';
38
38
  import { buildCodeGraphSummary, formatCodeGraph } from '../server/code-graph.js';
@@ -41,11 +41,18 @@ import { listBundledSkills, readBundledSkill } from '../server/bundled-skills.js
41
41
 
42
42
  let canvas: PmxCanvas | null = null;
43
43
 
44
- const jsonRenderSpecSchema = z.object({
45
- root: z.string(),
46
- elements: z.record(z.string(), z.unknown()),
47
- state: z.record(z.string(), z.unknown()).optional(),
48
- }).passthrough();
44
+ const jsonRenderSpecSchema = z.union([
45
+ z.object({
46
+ root: z.string(),
47
+ elements: z.record(z.string(), z.unknown()),
48
+ state: z.record(z.string(), z.unknown()).optional(),
49
+ }).passthrough(),
50
+ z.object({
51
+ type: z.string(),
52
+ props: z.record(z.string(), z.unknown()).optional(),
53
+ children: z.array(z.string()).optional(),
54
+ }).passthrough(),
55
+ ]);
49
56
 
50
57
  function structuredSchemaDescription(): string {
51
58
  const routing = describeCanvasSchema().mcp.nodeTypeRouting;
@@ -140,6 +147,7 @@ export async function startMcpServer(): Promise<void> {
140
147
  .describe('Node type (prefer canvas_create_group for groups)'),
141
148
  title: z.string().optional().describe('Node title'),
142
149
  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)'),
150
+ path: z.string().optional().describe('Compatibility alias for image node content. Prefer content for image paths.'),
143
151
  url: z.string().optional().describe('Canonical webpage URL field for webpage nodes. Overrides content when both are provided.'),
144
152
  x: z.number().optional().describe('X position (auto-placed if omitted)'),
145
153
  y: z.number().optional().describe('Y position (auto-placed if omitted)'),
@@ -169,7 +177,10 @@ export async function startMcpServer(): Promise<void> {
169
177
  ...(result.ok ? {} : { isError: true }),
170
178
  };
171
179
  }
172
- const id = c.addNode(input);
180
+ const nodeInput = input.type === 'image' && input.path && !input.content
181
+ ? { ...input, content: input.path }
182
+ : input;
183
+ const id = c.addNode(nodeInput);
173
184
  return {
174
185
  content: [{ type: 'text', text: JSON.stringify(createdNodePayload(c, id), null, 2) }],
175
186
  };
@@ -442,8 +453,8 @@ export async function startMcpServer(): Promise<void> {
442
453
  'canvas_add_json_render_node',
443
454
  '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.',
444
455
  {
445
- title: z.string().describe('Node title'),
446
- spec: jsonRenderSpecSchema.describe('Complete json-render spec with root, elements, and optional state'),
456
+ title: z.string().optional().describe('Optional node title. If omitted, PMX Canvas infers one from the root element.'),
457
+ spec: z.unknown().describe('json-render spec. Prefer a complete {root, elements, state?} document; a single bare component object is accepted for legacy callers.'),
447
458
  x: z.number().optional().describe('Optional X position'),
448
459
  y: z.number().optional().describe('Optional Y position'),
449
460
  width: z.number().optional().describe('Optional node width'),
@@ -453,7 +464,7 @@ export async function startMcpServer(): Promise<void> {
453
464
  const c = await ensureCanvas();
454
465
  try {
455
466
  const result = c.addJsonRenderNode({
456
- title: input.title,
467
+ ...(typeof input.title === 'string' ? { title: input.title } : {}),
457
468
  spec: input.spec,
458
469
  ...(typeof input.x === 'number' ? { x: input.x } : {}),
459
470
  ...(typeof input.y === 'number' ? { y: input.y } : {}),
@@ -899,7 +910,7 @@ export async function startMcpServer(): Promise<void> {
899
910
  '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.',
900
911
  {
901
912
  expression: z.string().optional().describe('JavaScript expression to evaluate in the page context'),
902
- script: z.string().optional().describe('Multi-statement JavaScript body. The MCP server wraps it in an IIFE and evaluates the return value.'),
913
+ script: z.string().optional().describe('Multi-statement JavaScript body. The MCP server wraps it in an async IIFE and evaluates the resolved return value.'),
903
914
  },
904
915
  async ({ expression, script }) => {
905
916
  const c = await ensureCanvas();
@@ -910,7 +921,7 @@ export async function startMcpServer(): Promise<void> {
910
921
  };
911
922
  }
912
923
 
913
- const source = script ? `(() => {\n${script}\n})()` : expression!;
924
+ const source = script ? wrapCanvasAutomationScript(script) : expression!;
914
925
  try {
915
926
  const value = await c.evaluateAutomationWebView(source);
916
927
  return {
@@ -1401,7 +1412,7 @@ export async function startMcpServer(): Promise<void> {
1401
1412
  if (!snapshot) {
1402
1413
  return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'Failed to save snapshot' }) }] };
1403
1414
  }
1404
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, snapshot }) }] };
1415
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true, id: snapshot.id, snapshot }) }] };
1405
1416
  },
1406
1417
  );
1407
1418
 
@@ -24,6 +24,7 @@ import {
24
24
  buildGraphSpec,
25
25
  createJsonRenderNodeData,
26
26
  GRAPH_NODE_SIZE,
27
+ inferJsonRenderNodeTitle,
27
28
  JSON_RENDER_NODE_SIZE,
28
29
  normalizeAndValidateJsonRenderSpec,
29
30
  type GraphNodeInput,
@@ -1000,7 +1001,7 @@ export function createCanvasJsonRenderNode(
1000
1001
  collapsed: false,
1001
1002
  pinned: false,
1002
1003
  dockPosition: null,
1003
- data: createJsonRenderNodeData(id, input.title, spec, {
1004
+ data: createJsonRenderNodeData(id, input.title?.trim() || inferJsonRenderNodeTitle(spec), spec, {
1004
1005
  viewerType: 'json-render',
1005
1006
  }),
1006
1007
  };
@@ -171,7 +171,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
171
171
  endpoint: '/api/canvas/node',
172
172
  mcpTool: 'canvas_add_node',
173
173
  fields: [
174
- { name: 'content', type: 'string', required: true, description: 'Image path, URL, or data URI.' },
174
+ { name: 'content', type: 'string', required: true, description: 'Image path, URL, or data URI.', aliases: ['path'] },
175
175
  { name: 'title', type: 'string', required: false, description: 'Optional title override.' },
176
176
  { name: 'data.warning', type: 'string | { title?: string; detail: string }', required: false, description: 'Optional agent-supplied warning shown above the image.' },
177
177
  { name: 'data.warnings', type: 'Array<string | { title?: string; detail: string }>', required: false, description: 'Optional list of agent-supplied image warnings.' },
@@ -280,8 +280,8 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
280
280
  endpoint: '/api/canvas/json-render',
281
281
  mcpTool: 'canvas_add_json_render_node',
282
282
  fields: [
283
- { name: 'title', type: 'string', required: true, description: 'Rendered node title.' },
284
- { name: 'spec', type: 'JsonRenderSpec', required: true, description: 'Complete json-render spec.' },
283
+ { name: 'title', type: 'string', required: false, description: 'Optional rendered node title; inferred from the root element when omitted.' },
284
+ { name: 'spec', type: 'JsonRenderSpec | JsonRenderElement', required: true, description: 'Complete {root, elements} json-render spec, or a legacy single bare component object with a type field.' },
285
285
  { name: 'x', type: 'number', required: false, description: 'Optional X position.' },
286
286
  { name: 'y', type: 'number', required: false, description: 'Optional Y position.' },
287
287
  { name: 'width', type: 'number', required: false, description: 'Optional node width.' },
@@ -473,6 +473,10 @@ export async function evaluateCanvasAutomationWebView(expression: string): Promi
473
473
  ));
474
474
  }
475
475
 
476
+ export function wrapCanvasAutomationScript(script: string): string {
477
+ return `(async () => {\n${script}\n})()`;
478
+ }
479
+
476
480
  export async function resizeCanvasAutomationWebView(
477
481
  width: number,
478
482
  height: number,
@@ -1206,12 +1210,15 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
1206
1210
  const extraData = body.data && typeof body.data === 'object' && !Array.isArray(body.data)
1207
1211
  ? body.data as Record<string, unknown>
1208
1212
  : undefined;
1213
+ const content = type === 'image' && typeof body.path === 'string' && typeof body.content !== 'string'
1214
+ ? body.path
1215
+ : body.content;
1209
1216
  let added: ReturnType<typeof addCanvasNode>;
1210
1217
  try {
1211
1218
  added = addCanvasNode({
1212
1219
  type: type as CanvasNodeState['type'],
1213
1220
  ...(typeof body.title === 'string' ? { title: body.title } : {}),
1214
- ...(typeof body.content === 'string' ? { content: body.content } : {}),
1221
+ ...(typeof content === 'string' ? { content } : {}),
1215
1222
  ...(extraData ? { data: extraData } : {}),
1216
1223
  ...(typeof body.x === 'number' ? { x: body.x } : {}),
1217
1224
  ...(typeof body.y === 'number' ? { y: body.y } : {}),
@@ -1599,13 +1606,10 @@ async function handleCanvasAddJsonRender(req: Request): Promise<Response> {
1599
1606
  const title = typeof body.title === 'string' ? body.title.trim() : '';
1600
1607
  const rawSpec =
1601
1608
  body.spec && typeof body.spec === 'object' && !Array.isArray(body.spec) ? body.spec : body;
1602
- if (!title) {
1603
- return responseJson({ ok: false, error: 'Missing required field: title.' }, 400);
1604
- }
1605
1609
 
1606
1610
  try {
1607
1611
  const result = createCanvasJsonRenderNode({
1608
- title,
1612
+ ...(title ? { title } : {}),
1609
1613
  spec: rawSpec,
1610
1614
  ...(typeof body.x === 'number' ? { x: body.x } : {}),
1611
1615
  ...(typeof body.y === 'number' ? { y: body.y } : {}),
@@ -2389,10 +2393,10 @@ async function handleWorkbenchWebViewEvaluate(req: Request): Promise<Response> {
2389
2393
  if ((expression ? 1 : 0) + (script ? 1 : 0) !== 1) {
2390
2394
  return responseJson({
2391
2395
  ok: false,
2392
- error: 'Pass exactly one of "expression" (single JS expression) or "script" (multi-statement body, wrapped in an IIFE).',
2396
+ error: 'Pass exactly one of "expression" (single JS expression) or "script" (multi-statement body, wrapped in an async IIFE).',
2393
2397
  }, 400);
2394
2398
  }
2395
- const source = script ? `(() => {\n${script}\n})()` : expression;
2399
+ const source = script ? wrapCanvasAutomationScript(script) : expression;
2396
2400
 
2397
2401
  try {
2398
2402
  const value = await evaluateCanvasAutomationWebView(source);
@@ -2899,7 +2903,7 @@ async function handleSnapshotSave(req: Request): Promise<Response> {
2899
2903
  if (!name) return responseText('Missing snapshot name', 400);
2900
2904
  const snapshot = saveCanvasSnapshot(name);
2901
2905
  if (!snapshot) return responseText('Failed to save snapshot', 500);
2902
- return responseJson({ ok: true, snapshot });
2906
+ return responseJson({ ok: true, id: snapshot.id, snapshot });
2903
2907
  }
2904
2908
 
2905
2909
  async function handleContextPinsUpdate(req: Request): Promise<Response> {