pmx-canvas 0.1.7 → 0.1.9

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.
@@ -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;
@@ -430,6 +430,7 @@ function normalizeSpec(spec: Record<string, unknown>): Record<string, unknown> {
430
430
  const elementChanged =
431
431
  resolvedType !== element.type ||
432
432
  JSON.stringify(normalizedProps) !== JSON.stringify(rawProps) ||
433
+ !('visible' in element) ||
433
434
  !Array.isArray(element.children) ||
434
435
  normalizedChildren.length !== element.children.length;
435
436
 
@@ -438,6 +439,7 @@ function normalizeSpec(spec: Record<string, unknown>): Record<string, unknown> {
438
439
  ...element,
439
440
  type: resolvedType,
440
441
  props: normalizedProps,
442
+ visible: 'visible' in element ? element.visible : true,
441
443
  children: normalizedChildren,
442
444
  }
443
445
  : rawElement;
@@ -447,10 +449,39 @@ function normalizeSpec(spec: Record<string, unknown>): Record<string, unknown> {
447
449
  return changed ? { ...spec, elements: normalizedElements } : spec;
448
450
  }
449
451
 
450
- export function normalizeAndValidateJsonRenderSpec(spec: unknown): JsonRenderSpec {
452
+ function isBareJsonRenderElement(spec: Record<string, unknown>): boolean {
453
+ return typeof spec.type === 'string' && !('root' in spec) && !('elements' in spec);
454
+ }
455
+
456
+ function normalizeJsonRenderInput(spec: unknown): unknown {
451
457
  const specRecord = asRecord(spec);
458
+ if (!specRecord || !isBareJsonRenderElement(specRecord)) return spec;
459
+
460
+ return {
461
+ root: 'root',
462
+ elements: {
463
+ root: {
464
+ ...specRecord,
465
+ visible: 'visible' in specRecord ? specRecord.visible : true,
466
+ children: Array.isArray(specRecord.children)
467
+ ? specRecord.children.filter((child: unknown) => typeof child === 'string')
468
+ : [],
469
+ },
470
+ },
471
+ };
472
+ }
473
+
474
+ export function inferJsonRenderNodeTitle(spec: JsonRenderSpec, fallback = 'json-render'): string {
475
+ const rootElement = asRecord(spec.elements[spec.root]);
476
+ const rootProps = asRecord(rootElement?.props);
477
+ const title = rootProps?.title ?? rootProps?.text ?? rootElement?.type;
478
+ return typeof title === 'string' && title.trim().length > 0 ? title.trim() : fallback;
479
+ }
480
+
481
+ export function normalizeAndValidateJsonRenderSpec(spec: unknown): JsonRenderSpec {
482
+ const specRecord = asRecord(normalizeJsonRenderInput(spec));
452
483
  if (!specRecord || typeof specRecord.root !== 'string' || !asRecord(specRecord.elements)) {
453
- throw new Error('Missing root and elements in spec.');
484
+ 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
485
  }
455
486
 
456
487
  const normalizedSpec = normalizeSpec(specRecord);
@@ -572,6 +603,30 @@ export function buildGraphSpec(input: GraphNodeInput): JsonRenderSpec {
572
603
  });
573
604
  }
574
605
 
606
+ export function buildGraphConfig(input: GraphNodeInput): Record<string, unknown> {
607
+ const title = input.title?.trim() || 'Graph';
608
+ return {
609
+ title,
610
+ graphType: input.graphType,
611
+ data: input.data,
612
+ ...(input.xKey ? { xKey: input.xKey } : {}),
613
+ ...(input.yKey ? { yKey: input.yKey } : {}),
614
+ ...(input.zKey ? { zKey: input.zKey } : {}),
615
+ ...(input.nameKey ? { nameKey: input.nameKey } : {}),
616
+ ...(input.valueKey ? { valueKey: input.valueKey } : {}),
617
+ ...(input.axisKey ? { axisKey: input.axisKey } : {}),
618
+ ...(input.metrics?.length ? { metrics: input.metrics } : {}),
619
+ ...(input.series?.length ? { series: input.series } : {}),
620
+ ...(input.barKey ? { barKey: input.barKey } : {}),
621
+ ...(input.lineKey ? { lineKey: input.lineKey } : {}),
622
+ ...(input.aggregate ? { aggregate: input.aggregate } : {}),
623
+ ...(input.color ? { color: input.color } : {}),
624
+ ...(input.barColor ? { barColor: input.barColor } : {}),
625
+ ...(input.lineColor ? { lineColor: input.lineColor } : {}),
626
+ ...(typeof input.height === 'number' ? { height: input.height } : {}),
627
+ };
628
+ }
629
+
575
630
  export function createJsonRenderNodeData(
576
631
  nodeId: string,
577
632
  title: string,
package/src/mcp/server.ts CHANGED
@@ -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;
@@ -88,7 +95,9 @@ function encodeBase64(bytes: Uint8Array): string {
88
95
 
89
96
  function createdNodePayload(c: PmxCanvas, id: string): Record<string, unknown> {
90
97
  const node = c.getNode(id);
91
- return node ? { ok: true, ...serializeCanvasNode(node) } : { ok: true, id };
98
+ if (!node) return { ok: true, id };
99
+ const serialized = serializeCanvasNode(node);
100
+ return { ok: true, node: serialized, ...serialized };
92
101
  }
93
102
 
94
103
  export async function startMcpServer(): Promise<void> {
@@ -140,6 +149,7 @@ export async function startMcpServer(): Promise<void> {
140
149
  .describe('Node type (prefer canvas_create_group for groups)'),
141
150
  title: z.string().optional().describe('Node title'),
142
151
  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)'),
152
+ path: z.string().optional().describe('Compatibility alias for image node content. Prefer content for image paths.'),
143
153
  url: z.string().optional().describe('Canonical webpage URL field for webpage nodes. Overrides content when both are provided.'),
144
154
  x: z.number().optional().describe('X position (auto-placed if omitted)'),
145
155
  y: z.number().optional().describe('Y position (auto-placed if omitted)'),
@@ -169,7 +179,10 @@ export async function startMcpServer(): Promise<void> {
169
179
  ...(result.ok ? {} : { isError: true }),
170
180
  };
171
181
  }
172
- const id = c.addNode(input);
182
+ const nodeInput = input.type === 'image' && input.path && !input.content
183
+ ? { ...input, content: input.path }
184
+ : input;
185
+ const id = c.addNode(nodeInput);
173
186
  return {
174
187
  content: [{ type: 'text', text: JSON.stringify(createdNodePayload(c, id), null, 2) }],
175
188
  };
@@ -442,8 +455,8 @@ export async function startMcpServer(): Promise<void> {
442
455
  'canvas_add_json_render_node',
443
456
  '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
457
  {
445
- title: z.string().describe('Node title'),
446
- spec: jsonRenderSpecSchema.describe('Complete json-render spec with root, elements, and optional state'),
458
+ title: z.string().optional().describe('Optional node title. If omitted, PMX Canvas infers one from the root element.'),
459
+ 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
460
  x: z.number().optional().describe('Optional X position'),
448
461
  y: z.number().optional().describe('Optional Y position'),
449
462
  width: z.number().optional().describe('Optional node width'),
@@ -453,7 +466,7 @@ export async function startMcpServer(): Promise<void> {
453
466
  const c = await ensureCanvas();
454
467
  try {
455
468
  const result = c.addJsonRenderNode({
456
- title: input.title,
469
+ ...(typeof input.title === 'string' ? { title: input.title } : {}),
457
470
  spec: input.spec,
458
471
  ...(typeof input.x === 'number' ? { x: input.x } : {}),
459
472
  ...(typeof input.y === 'number' ? { y: input.y } : {}),
@@ -565,10 +578,16 @@ export async function startMcpServer(): Promise<void> {
565
578
  y: z.number().optional().describe('New Y position'),
566
579
  width: z.number().optional().describe('New width'),
567
580
  height: z.number().optional().describe('New height'),
581
+ spec: z.record(z.string(), z.unknown()).optional().describe('New json-render spec, or a graph payload with graphType/data for graph nodes'),
582
+ graphType: z.string().optional().describe('Graph type when updating a graph node'),
583
+ data: z.array(z.record(z.string(), z.unknown())).optional().describe('Graph dataset when updating a graph node'),
584
+ xKey: z.string().optional().describe('Graph x/category key'),
585
+ yKey: z.string().optional().describe('Graph y/value key'),
586
+ chartHeight: z.number().optional().describe('Graph chart content height, distinct from node height'),
568
587
  collapsed: z.boolean().optional().describe('Collapse or expand the node'),
569
588
  arrangeLocked: z.boolean().optional().describe('Prevent auto-arrange from moving this node. Pinned nodes are also skipped.'),
570
589
  },
571
- async ({ id, title, content, x, y, width, height, collapsed, arrangeLocked }) => {
590
+ async ({ id, title, content, x, y, width, height, spec, graphType, data, xKey, yKey, chartHeight, collapsed, arrangeLocked }) => {
572
591
  const c = await ensureCanvas();
573
592
  const node = c.getNode(id);
574
593
  if (!node) {
@@ -587,22 +606,21 @@ export async function startMcpServer(): Promise<void> {
587
606
  if (collapsed !== undefined) {
588
607
  patch.collapsed = collapsed;
589
608
  }
590
- if (title !== undefined || content !== undefined) {
591
- patch.data = {
592
- ...node.data,
593
- ...(title !== undefined ? { title } : {}),
594
- ...(content !== undefined ? { content } : {}),
595
- };
596
- }
609
+ if (title !== undefined) patch.title = title;
610
+ if (content !== undefined) patch.content = content;
611
+ if (spec !== undefined) patch.spec = spec;
612
+ if (graphType !== undefined) patch.graphType = graphType;
613
+ if (data !== undefined) patch.data = data;
614
+ if (xKey !== undefined) patch.xKey = xKey;
615
+ if (yKey !== undefined) patch.yKey = yKey;
616
+ if (chartHeight !== undefined) patch.chartHeight = chartHeight;
597
617
  if (arrangeLocked !== undefined) {
598
- patch.data = {
599
- ...(patch.data && typeof patch.data === 'object' ? patch.data as Record<string, unknown> : node.data),
600
- arrangeLocked,
601
- };
618
+ patch.arrangeLocked = arrangeLocked;
602
619
  }
603
620
  c.updateNode(id, patch);
621
+ const updated = c.getNode(id);
604
622
  return {
605
- content: [{ type: 'text', text: JSON.stringify({ ok: true, id }) }],
623
+ content: [{ type: 'text', text: JSON.stringify(updated ? createdNodePayload(c, id) : { ok: true, id }, null, 2) }],
606
624
  };
607
625
  },
608
626
  );
@@ -732,6 +750,31 @@ export async function startMcpServer(): Promise<void> {
732
750
  },
733
751
  );
734
752
 
753
+ server.tool(
754
+ 'canvas_fit_view',
755
+ 'Fit the canvas viewport to all nodes or a selected subset. Useful before screenshots and whole-board review.',
756
+ {
757
+ width: z.number().optional().describe('Viewport width used for fit math (default 1440)'),
758
+ height: z.number().optional().describe('Viewport height used for fit math (default 900)'),
759
+ padding: z.number().optional().describe('World-space padding around fitted nodes (default 60)'),
760
+ maxScale: z.number().optional().describe('Maximum zoom scale (default 1)'),
761
+ nodeIds: z.array(z.string()).optional().describe('Optional node IDs to fit instead of the whole canvas'),
762
+ },
763
+ async (input) => {
764
+ const c = await ensureCanvas();
765
+ const result = c.fitView({
766
+ ...(typeof input.width === 'number' ? { width: input.width } : {}),
767
+ ...(typeof input.height === 'number' ? { height: input.height } : {}),
768
+ ...(typeof input.padding === 'number' ? { padding: input.padding } : {}),
769
+ ...(typeof input.maxScale === 'number' ? { maxScale: input.maxScale } : {}),
770
+ ...(Array.isArray(input.nodeIds) ? { nodeIds: input.nodeIds } : {}),
771
+ });
772
+ return {
773
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
774
+ };
775
+ },
776
+ );
777
+
735
778
  // ── canvas_clear ───────────────────────────────────────────────
736
779
  server.tool(
737
780
  'canvas_clear',
@@ -22,8 +22,10 @@ import { searchNodes } from './spatial-analysis.js';
22
22
  import { getCanvasNodeTitle, serializeCanvasNode, type SerializedCanvasNode } from './canvas-serialization.js';
23
23
  import {
24
24
  buildGraphSpec,
25
+ buildGraphConfig,
25
26
  createJsonRenderNodeData,
26
27
  GRAPH_NODE_SIZE,
28
+ inferJsonRenderNodeTitle,
27
29
  JSON_RENDER_NODE_SIZE,
28
30
  normalizeAndValidateJsonRenderSpec,
29
31
  type GraphNodeInput,
@@ -41,6 +43,33 @@ import { buildExcalidrawRestoreCheckpointToolInput, ensureExcalidrawCheckpointId
41
43
  export type CanvasArrangeMode = 'grid' | 'column' | 'flow';
42
44
  export type CanvasPinMode = 'set' | 'add' | 'remove';
43
45
 
46
+ export interface CanvasFitViewOptions {
47
+ width?: number;
48
+ height?: number;
49
+ padding?: number;
50
+ maxScale?: number;
51
+ nodeIds?: string[];
52
+ }
53
+
54
+ export interface CanvasFitViewResult {
55
+ ok: true;
56
+ viewport: { x: number; y: number; scale: number };
57
+ nodeCount: number;
58
+ bounds: { x: number; y: number; width: number; height: number } | null;
59
+ }
60
+
61
+ export interface CanvasGraphNodeUpdateInput extends Partial<GraphNodeInput> {
62
+ spec?: unknown;
63
+ type?: string;
64
+ }
65
+
66
+ export interface CanvasStructuredNodeUpdateInput extends Omit<CanvasGraphNodeUpdateInput, 'data'> {
67
+ content?: unknown;
68
+ data?: unknown;
69
+ arrangeLocked?: unknown;
70
+ chartHeight?: unknown;
71
+ }
72
+
44
73
  interface CanvasAddNodeInput {
45
74
  type: CanvasNodeState['type'];
46
75
  title?: string;
@@ -83,6 +112,246 @@ function isRecord(value: unknown): value is Record<string, unknown> {
83
112
  return value !== null && typeof value === 'object' && !Array.isArray(value);
84
113
  }
85
114
 
115
+ function positiveNumber(value: number | undefined, fallback: number): number {
116
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
117
+ }
118
+
119
+ function pickString(record: Record<string, unknown>, key: string): string | undefined {
120
+ const value = record[key];
121
+ return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
122
+ }
123
+
124
+ function pickNumber(record: Record<string, unknown>, key: string): number | undefined {
125
+ const value = record[key];
126
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
127
+ }
128
+
129
+ function pickStringArray(record: Record<string, unknown>, key: string): string[] | undefined {
130
+ const value = record[key];
131
+ if (!Array.isArray(value)) return undefined;
132
+ const strings = value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0);
133
+ return strings.length > 0 ? strings : undefined;
134
+ }
135
+
136
+ function pickGraphData(record: Record<string, unknown>, key: string): Array<Record<string, unknown>> | undefined {
137
+ const value = record[key];
138
+ if (!Array.isArray(value)) return undefined;
139
+ const rows = value.filter((item): item is Record<string, unknown> => isRecord(item));
140
+ return rows.length === value.length ? rows : undefined;
141
+ }
142
+
143
+ function pickAggregate(record: Record<string, unknown>, key: string): GraphNodeInput['aggregate'] | undefined {
144
+ const value = record[key];
145
+ return value === 'sum' || value === 'count' || value === 'avg' ? value : undefined;
146
+ }
147
+
148
+ function isJsonRenderSpecLike(value: unknown): boolean {
149
+ return isRecord(value) && typeof value.root === 'string' && isRecord(value.elements);
150
+ }
151
+
152
+ function isGraphPayloadLike(value: unknown): value is Record<string, unknown> {
153
+ return isRecord(value) && !isJsonRenderSpecLike(value) && (
154
+ Array.isArray(value.data) || typeof value.graphType === 'string'
155
+ );
156
+ }
157
+
158
+ function hasGraphUpdateFields(input: Record<string, unknown>): boolean {
159
+ return input.graphType !== undefined ||
160
+ input.type !== undefined ||
161
+ Array.isArray(input.data) ||
162
+ input.xKey !== undefined ||
163
+ input.yKey !== undefined ||
164
+ input.zKey !== undefined ||
165
+ input.nameKey !== undefined ||
166
+ input.valueKey !== undefined ||
167
+ input.axisKey !== undefined ||
168
+ input.metrics !== undefined ||
169
+ input.series !== undefined ||
170
+ input.barKey !== undefined ||
171
+ input.lineKey !== undefined ||
172
+ input.aggregate !== undefined ||
173
+ input.color !== undefined ||
174
+ input.barColor !== undefined ||
175
+ input.lineColor !== undefined ||
176
+ input.chartHeight !== undefined;
177
+ }
178
+
179
+ function graphUpdateInput(input: CanvasStructuredNodeUpdateInput): CanvasGraphNodeUpdateInput {
180
+ const data = pickGraphData(input as Record<string, unknown>, 'data');
181
+ const {
182
+ data: _data,
183
+ content: _content,
184
+ arrangeLocked: _arrangeLocked,
185
+ chartHeight,
186
+ ...graphFields
187
+ } = input;
188
+ return {
189
+ ...graphFields,
190
+ ...(data ? { data } : {}),
191
+ ...(typeof chartHeight === 'number' ? { height: chartHeight } : {}),
192
+ };
193
+ }
194
+
195
+ function mergeNodeDataFields(
196
+ base: Record<string, unknown>,
197
+ input: CanvasStructuredNodeUpdateInput,
198
+ ): Record<string, unknown> {
199
+ return {
200
+ ...base,
201
+ ...(isRecord(input.data) ? input.data : {}),
202
+ ...(typeof input.arrangeLocked === 'boolean' ? { arrangeLocked: input.arrangeLocked } : {}),
203
+ };
204
+ }
205
+
206
+ export function hasStructuredNodeUpdateFields(input: Record<string, unknown>): boolean {
207
+ return input.spec !== undefined || hasGraphUpdateFields(input);
208
+ }
209
+
210
+ export function buildStructuredNodeUpdate(
211
+ node: CanvasNodeState,
212
+ input: CanvasStructuredNodeUpdateInput,
213
+ ): { data: Record<string, unknown> } {
214
+ const inputRecord = input as Record<string, unknown>;
215
+ const hasSpec = inputRecord.spec !== undefined;
216
+ const hasGraphFields = hasGraphUpdateFields(inputRecord);
217
+
218
+ if (node.type === 'json-render') {
219
+ if (hasGraphFields) {
220
+ throw new Error(`Graph update fields can only be used with graph nodes, not ${node.type} nodes.`);
221
+ }
222
+ if (!hasSpec) {
223
+ throw new Error('json-render structured updates require a spec.');
224
+ }
225
+ return {
226
+ data: mergeNodeDataFields(buildJsonRenderNodeUpdate(node, {
227
+ ...(typeof input.title === 'string' ? { title: input.title } : {}),
228
+ spec: input.spec,
229
+ }).data, input),
230
+ };
231
+ }
232
+
233
+ if (node.type === 'graph') {
234
+ return {
235
+ data: mergeNodeDataFields(buildGraphNodeUpdate(node, graphUpdateInput(input)).data, input),
236
+ };
237
+ }
238
+
239
+ throw new Error(`Structured spec and graph updates can only be used with json-render or graph nodes, not ${node.type} nodes.`);
240
+ }
241
+
242
+ function graphConfigToInput(config: Record<string, unknown>, fallbackTitle: string): GraphNodeInput | null {
243
+ const data = pickGraphData(config, 'data');
244
+ if (!data) return null;
245
+ return {
246
+ title: pickString(config, 'title') ?? fallbackTitle,
247
+ graphType: pickString(config, 'graphType') ?? 'line',
248
+ data,
249
+ ...(pickString(config, 'xKey') ? { xKey: pickString(config, 'xKey') } : {}),
250
+ ...(pickString(config, 'yKey') ? { yKey: pickString(config, 'yKey') } : {}),
251
+ ...(pickString(config, 'zKey') ? { zKey: pickString(config, 'zKey') } : {}),
252
+ ...(pickString(config, 'nameKey') ? { nameKey: pickString(config, 'nameKey') } : {}),
253
+ ...(pickString(config, 'valueKey') ? { valueKey: pickString(config, 'valueKey') } : {}),
254
+ ...(pickString(config, 'axisKey') ? { axisKey: pickString(config, 'axisKey') } : {}),
255
+ ...(pickStringArray(config, 'metrics') ? { metrics: pickStringArray(config, 'metrics') } : {}),
256
+ ...(pickStringArray(config, 'series') ? { series: pickStringArray(config, 'series') } : {}),
257
+ ...(pickString(config, 'barKey') ? { barKey: pickString(config, 'barKey') } : {}),
258
+ ...(pickString(config, 'lineKey') ? { lineKey: pickString(config, 'lineKey') } : {}),
259
+ ...(pickAggregate(config, 'aggregate') ? { aggregate: pickAggregate(config, 'aggregate') } : {}),
260
+ ...(pickString(config, 'color') ? { color: pickString(config, 'color') } : {}),
261
+ ...(pickString(config, 'barColor') ? { barColor: pickString(config, 'barColor') } : {}),
262
+ ...(pickString(config, 'lineColor') ? { lineColor: pickString(config, 'lineColor') } : {}),
263
+ ...(pickNumber(config, 'height') !== undefined ? { height: pickNumber(config, 'height') } : {}),
264
+ };
265
+ }
266
+
267
+ function mergeGraphInput(source: Record<string, unknown>, fallback: GraphNodeInput | null): GraphNodeInput {
268
+ const data = pickGraphData(source, 'data') ?? fallback?.data;
269
+ if (!data) throw new Error('Graph update requires a data array, either in the update payload or the existing graphConfig.');
270
+ return {
271
+ title: pickString(source, 'title') ?? fallback?.title ?? 'Graph',
272
+ graphType: pickString(source, 'graphType') ?? pickString(source, 'type') ?? fallback?.graphType ?? 'line',
273
+ data,
274
+ ...((pickString(source, 'xKey') ?? fallback?.xKey) ? { xKey: pickString(source, 'xKey') ?? fallback?.xKey } : {}),
275
+ ...((pickString(source, 'yKey') ?? fallback?.yKey) ? { yKey: pickString(source, 'yKey') ?? fallback?.yKey } : {}),
276
+ ...((pickString(source, 'zKey') ?? fallback?.zKey) ? { zKey: pickString(source, 'zKey') ?? fallback?.zKey } : {}),
277
+ ...((pickString(source, 'nameKey') ?? fallback?.nameKey) ? { nameKey: pickString(source, 'nameKey') ?? fallback?.nameKey } : {}),
278
+ ...((pickString(source, 'valueKey') ?? fallback?.valueKey) ? { valueKey: pickString(source, 'valueKey') ?? fallback?.valueKey } : {}),
279
+ ...((pickString(source, 'axisKey') ?? fallback?.axisKey) ? { axisKey: pickString(source, 'axisKey') ?? fallback?.axisKey } : {}),
280
+ ...((pickStringArray(source, 'metrics') ?? fallback?.metrics) ? { metrics: pickStringArray(source, 'metrics') ?? fallback?.metrics } : {}),
281
+ ...((pickStringArray(source, 'series') ?? fallback?.series) ? { series: pickStringArray(source, 'series') ?? fallback?.series } : {}),
282
+ ...((pickString(source, 'barKey') ?? fallback?.barKey) ? { barKey: pickString(source, 'barKey') ?? fallback?.barKey } : {}),
283
+ ...((pickString(source, 'lineKey') ?? fallback?.lineKey) ? { lineKey: pickString(source, 'lineKey') ?? fallback?.lineKey } : {}),
284
+ ...((pickAggregate(source, 'aggregate') ?? fallback?.aggregate) ? { aggregate: pickAggregate(source, 'aggregate') ?? fallback?.aggregate } : {}),
285
+ ...((pickString(source, 'color') ?? fallback?.color) ? { color: pickString(source, 'color') ?? fallback?.color } : {}),
286
+ ...((pickString(source, 'barColor') ?? fallback?.barColor) ? { barColor: pickString(source, 'barColor') ?? fallback?.barColor } : {}),
287
+ ...((pickString(source, 'lineColor') ?? fallback?.lineColor) ? { lineColor: pickString(source, 'lineColor') ?? fallback?.lineColor } : {}),
288
+ ...((pickNumber(source, 'height') ?? fallback?.height) !== undefined ? { height: pickNumber(source, 'height') ?? fallback?.height } : {}),
289
+ };
290
+ }
291
+
292
+ export function buildJsonRenderNodeUpdate(
293
+ node: CanvasNodeState,
294
+ input: { title?: string; spec: unknown },
295
+ ): { data: Record<string, unknown>; spec: JsonRenderSpec } {
296
+ if (node.type !== 'json-render') throw new Error(`Node "${node.id}" is not a json-render node.`);
297
+ const spec = normalizeAndValidateJsonRenderSpec(input.spec);
298
+ const title = input.title?.trim() || inferJsonRenderNodeTitle(spec);
299
+ return {
300
+ spec,
301
+ data: {
302
+ ...node.data,
303
+ ...createJsonRenderNodeData(node.id, title, spec, { viewerType: 'json-render' }),
304
+ },
305
+ };
306
+ }
307
+
308
+ export function buildGraphNodeUpdate(
309
+ node: CanvasNodeState,
310
+ input: CanvasGraphNodeUpdateInput,
311
+ ): { data: Record<string, unknown>; spec: JsonRenderSpec; graphConfig: Record<string, unknown> } {
312
+ if (node.type !== 'graph') throw new Error(`Node "${node.id}" is not a graph node.`);
313
+ const currentConfig = isRecord(node.data.graphConfig) ? node.data.graphConfig : {};
314
+ const fallbackTitle = typeof node.data.title === 'string' ? node.data.title : 'Graph';
315
+ const fallback = graphConfigToInput(currentConfig, fallbackTitle);
316
+ const source = isGraphPayloadLike(input.spec)
317
+ ? input.spec
318
+ : Object.fromEntries(
319
+ Object.entries(input).filter(([key, value]) => key !== 'spec' && value !== undefined),
320
+ );
321
+
322
+ if (input.spec !== undefined && !isGraphPayloadLike(input.spec)) {
323
+ const spec = normalizeAndValidateJsonRenderSpec(input.spec);
324
+ const title = input.title?.trim() || fallbackTitle;
325
+ return {
326
+ spec,
327
+ graphConfig: currentConfig,
328
+ data: {
329
+ ...node.data,
330
+ ...createJsonRenderNodeData(node.id, title, spec, {
331
+ viewerType: 'graph',
332
+ graphConfig: currentConfig,
333
+ }),
334
+ },
335
+ };
336
+ }
337
+
338
+ const graphInput = mergeGraphInput(source, fallback);
339
+ const spec = buildGraphSpec(graphInput);
340
+ const graphConfig = buildGraphConfig(graphInput);
341
+ const title = graphInput.title?.trim() || 'Graph';
342
+ return {
343
+ spec,
344
+ graphConfig,
345
+ data: {
346
+ ...node.data,
347
+ ...createJsonRenderNodeData(node.id, title, spec, {
348
+ viewerType: 'graph',
349
+ graphConfig,
350
+ }),
351
+ },
352
+ };
353
+ }
354
+
86
355
  function getStoredExcalidrawCheckpointId(node: CanvasNodeState): string | null {
87
356
  const appCheckpoint = isRecord(node.data.appCheckpoint) ? node.data.appCheckpoint : null;
88
357
  const checkpointId = appCheckpoint?.id;
@@ -1000,7 +1269,7 @@ export function createCanvasJsonRenderNode(
1000
1269
  collapsed: false,
1001
1270
  pinned: false,
1002
1271
  dockPosition: null,
1003
- data: createJsonRenderNodeData(id, input.title, spec, {
1272
+ data: createJsonRenderNodeData(id, input.title?.trim() || inferJsonRenderNodeTitle(spec), spec, {
1004
1273
  viewerType: 'json-render',
1005
1274
  }),
1006
1275
  };
@@ -1032,26 +1301,7 @@ export function createCanvasGraphNode(
1032
1301
  dockPosition: null,
1033
1302
  data: createJsonRenderNodeData(id, title, spec, {
1034
1303
  viewerType: 'graph',
1035
- graphConfig: {
1036
- title,
1037
- graphType: input.graphType,
1038
- data: input.data,
1039
- ...(input.xKey ? { xKey: input.xKey } : {}),
1040
- ...(input.yKey ? { yKey: input.yKey } : {}),
1041
- ...(input.zKey ? { zKey: input.zKey } : {}),
1042
- ...(input.nameKey ? { nameKey: input.nameKey } : {}),
1043
- ...(input.valueKey ? { valueKey: input.valueKey } : {}),
1044
- ...(input.axisKey ? { axisKey: input.axisKey } : {}),
1045
- ...(input.metrics?.length ? { metrics: input.metrics } : {}),
1046
- ...(input.series?.length ? { series: input.series } : {}),
1047
- ...(input.barKey ? { barKey: input.barKey } : {}),
1048
- ...(input.lineKey ? { lineKey: input.lineKey } : {}),
1049
- ...(input.aggregate ? { aggregate: input.aggregate } : {}),
1050
- ...(input.color ? { color: input.color } : {}),
1051
- ...(input.barColor ? { barColor: input.barColor } : {}),
1052
- ...(input.lineColor ? { lineColor: input.lineColor } : {}),
1053
- ...(typeof input.height === 'number' ? { height: input.height } : {}),
1054
- },
1304
+ graphConfig: buildGraphConfig(input),
1055
1305
  }),
1056
1306
  };
1057
1307
 
@@ -1059,6 +1309,46 @@ export function createCanvasGraphNode(
1059
1309
  return { id, url: String(node.data.url), spec, node };
1060
1310
  }
1061
1311
 
1312
+ export function fitCanvasView(options: CanvasFitViewOptions = {}): CanvasFitViewResult {
1313
+ const width = positiveNumber(options.width, 1440);
1314
+ const height = positiveNumber(options.height, 900);
1315
+ const padding = positiveNumber(options.padding, 60);
1316
+ const maxScale = positiveNumber(options.maxScale, 1);
1317
+ const nodeIdFilter = options.nodeIds && options.nodeIds.length > 0 ? new Set(options.nodeIds) : null;
1318
+ const targetNodes = canvasState.getLayout().nodes.filter((node) => !nodeIdFilter || nodeIdFilter.has(node.id));
1319
+
1320
+ if (targetNodes.length === 0) {
1321
+ const viewport = canvasState.viewport;
1322
+ return { ok: true, viewport, nodeCount: 0, bounds: null };
1323
+ }
1324
+
1325
+ let minX = Number.POSITIVE_INFINITY;
1326
+ let minY = Number.POSITIVE_INFINITY;
1327
+ let maxX = Number.NEGATIVE_INFINITY;
1328
+ let maxY = Number.NEGATIVE_INFINITY;
1329
+ for (const node of targetNodes) {
1330
+ minX = Math.min(minX, node.position.x);
1331
+ minY = Math.min(minY, node.position.y);
1332
+ maxX = Math.max(maxX, node.position.x + node.size.width);
1333
+ maxY = Math.max(maxY, node.position.y + node.size.height);
1334
+ }
1335
+
1336
+ const bounds = { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
1337
+ const worldWidth = Math.max(1, bounds.width + padding * 2);
1338
+ const worldHeight = Math.max(1, bounds.height + padding * 2);
1339
+ const scale = Math.min(maxScale, width / worldWidth, height / worldHeight);
1340
+ const centerX = minX + bounds.width / 2;
1341
+ const centerY = minY + bounds.height / 2;
1342
+ const viewport = {
1343
+ x: width / 2 - centerX * scale,
1344
+ y: height / 2 - centerY * scale,
1345
+ scale,
1346
+ };
1347
+
1348
+ canvasState.setViewport(viewport);
1349
+ return { ok: true, viewport: canvasState.viewport, nodeCount: targetNodes.length, bounds };
1350
+ }
1351
+
1062
1352
  function isPlainRecord(value: unknown): value is Record<string, unknown> {
1063
1353
  return !!value && typeof value === 'object' && !Array.isArray(value);
1064
1354
  }
@@ -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.' },
@@ -439,6 +439,8 @@ export function describeCanvasSchema(): {
439
439
  'canvas_build_web_artifact',
440
440
  'canvas_open_mcp_app',
441
441
  'canvas_create_group',
442
+ 'canvas_update_node',
443
+ 'canvas_fit_view',
442
444
  'canvas_describe_schema',
443
445
  'canvas_validate_spec',
444
446
  ],