pmx-canvas 0.1.35 → 0.2.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 +461 -0
- package/Readme.md +14 -2
- package/dist/canvas/index.js +82 -41
- package/dist/json-render/index.js +89 -334
- package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -0
- package/dist/types/mcp/canvas-access.d.ts +12 -159
- package/dist/types/server/ax-context.d.ts +1 -1
- package/dist/types/server/ax-state-manager.d.ts +256 -0
- package/dist/types/server/ax-state.d.ts +29 -1
- package/dist/types/server/ax-wait.d.ts +23 -0
- package/dist/types/server/canvas-operations.d.ts +1 -12
- package/dist/types/server/canvas-state.d.ts +46 -14
- package/dist/types/server/html-surface.d.ts +7 -0
- package/dist/types/server/index.d.ts +66 -26
- package/dist/types/server/operations/composites.d.ts +121 -0
- package/dist/types/server/operations/http.d.ts +7 -0
- package/dist/types/server/operations/index.d.ts +8 -0
- package/dist/types/server/operations/invoker.d.ts +13 -0
- package/dist/types/server/operations/mcp.d.ts +15 -0
- package/dist/types/server/operations/ops/annotation.d.ts +2 -0
- package/dist/types/server/operations/ops/app.d.ts +33 -0
- package/dist/types/server/operations/ops/ax-await.d.ts +2 -0
- package/dist/types/server/operations/ops/ax-shared.d.ts +31 -0
- package/dist/types/server/operations/ops/ax-state.d.ts +2 -0
- package/dist/types/server/operations/ops/ax-timeline.d.ts +2 -0
- package/dist/types/server/operations/ops/ax-work.d.ts +2 -0
- package/dist/types/server/operations/ops/batch.d.ts +19 -0
- package/dist/types/server/operations/ops/edges.d.ts +2 -0
- package/dist/types/server/operations/ops/groups.d.ts +2 -0
- package/dist/types/server/operations/ops/json-render.d.ts +31 -0
- package/dist/types/server/operations/ops/nodes.d.ts +62 -0
- package/dist/types/server/operations/ops/query.d.ts +2 -0
- package/dist/types/server/operations/ops/snapshots.d.ts +2 -0
- package/dist/types/server/operations/ops/validate.d.ts +2 -0
- package/dist/types/server/operations/ops/viewport.d.ts +2 -0
- package/dist/types/server/operations/ops/webview.d.ts +2 -0
- package/dist/types/server/operations/registry.d.ts +15 -0
- package/dist/types/server/operations/types.d.ts +116 -0
- package/dist/types/server/operations/webview-runner.d.ts +69 -0
- package/docs/RELEASE.md +5 -0
- package/docs/adr-001-bun-only-runtime.md +46 -0
- package/docs/api-stability.md +57 -0
- package/docs/ax-host-adapter-contract.md +65 -0
- package/docs/ax-state-contract.md +72 -0
- package/docs/http-api.md +34 -2
- package/docs/mcp.md +64 -11
- package/docs/plans/plan-005-operation-registry.md +84 -0
- package/docs/plans/plan-006-mcp-tool-consolidation.md +109 -0
- package/docs/plans/plan-007-ax-domain.md +99 -0
- package/docs/plans/plan-008-registry-finish.md +91 -0
- package/docs/screenshot.png +0 -0
- package/docs/tech-debt-assessment-2026-06.md +90 -0
- package/package.json +3 -3
- package/skills/pmx-canvas/SKILL.md +233 -185
- package/skills/pmx-canvas/evals/evals.json +3 -3
- package/skills/pmx-canvas/references/codex-app-adapter.md +24 -11
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +31 -1
- package/src/cli/agent.ts +52 -31
- package/src/client/nodes/ExtAppFrame.tsx +73 -5
- package/src/client/nodes/HtmlNode.tsx +12 -3
- package/src/client/nodes/McpAppNode.tsx +12 -3
- package/src/json-render/renderer/index.tsx +3 -0
- package/src/mcp/canvas-access.ts +43 -774
- package/src/mcp/server.ts +190 -2001
- package/src/server/ax-context.ts +7 -1
- package/src/server/ax-state-manager.ts +808 -0
- package/src/server/ax-state.ts +89 -2
- package/src/server/ax-wait.ts +56 -0
- package/src/server/canvas-operations.ts +2 -328
- package/src/server/canvas-schema.ts +2 -2
- package/src/server/canvas-state.ts +140 -382
- package/src/server/html-surface.ts +49 -11
- package/src/server/index.ts +136 -192
- package/src/server/operations/composites.ts +355 -0
- package/src/server/operations/http.ts +103 -0
- package/src/server/operations/index.ts +65 -0
- package/src/server/operations/invoker.ts +87 -0
- package/src/server/operations/mcp.ts +221 -0
- package/src/server/operations/ops/annotation.ts +60 -0
- package/src/server/operations/ops/app.ts +447 -0
- package/src/server/operations/ops/ax-await.ts +216 -0
- package/src/server/operations/ops/ax-shared.ts +38 -0
- package/src/server/operations/ops/ax-state.ts +249 -0
- package/src/server/operations/ops/ax-timeline.ts +381 -0
- package/src/server/operations/ops/ax-work.ts +635 -0
- package/src/server/operations/ops/batch.ts +365 -0
- package/src/server/operations/ops/edges.ts +166 -0
- package/src/server/operations/ops/groups.ts +176 -0
- package/src/server/operations/ops/json-render.ts +691 -0
- package/src/server/operations/ops/nodes.ts +1047 -0
- package/src/server/operations/ops/query.ts +281 -0
- package/src/server/operations/ops/snapshots.ts +366 -0
- package/src/server/operations/ops/validate.ts +37 -0
- package/src/server/operations/ops/viewport.ts +219 -0
- package/src/server/operations/ops/webview.ts +339 -0
- package/src/server/operations/registry.ts +79 -0
- package/src/server/operations/types.ts +150 -0
- package/src/server/operations/webview-runner.ts +77 -0
- package/src/server/server.ts +253 -2170
- package/src/server/web-artifacts.ts +6 -2
|
@@ -0,0 +1,1047 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slice 1 operations (plan-005): node.add / node.get / node.update /
|
|
3
|
+
* node.remove / layout.get — plus the shared cores the SDK wraps directly
|
|
4
|
+
* (`createBasicCanvasNode`, `buildNodePatch`, `removeNodeCore`) and the
|
|
5
|
+
* shared helpers that used to live in server.ts / mcp/server.ts
|
|
6
|
+
* (buildNodeResponse, geometry resolvers, group-children helpers,
|
|
7
|
+
* closeNodeAppSession, MCP payload formatters).
|
|
8
|
+
*
|
|
9
|
+
* This module must never import server.ts or index.ts.
|
|
10
|
+
*/
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
import {
|
|
13
|
+
canvasState,
|
|
14
|
+
type CanvasAnnotation,
|
|
15
|
+
type CanvasLayout,
|
|
16
|
+
type CanvasNodeState,
|
|
17
|
+
} from '../../canvas-state.js';
|
|
18
|
+
import {
|
|
19
|
+
addCanvasNode,
|
|
20
|
+
createCanvasGroup,
|
|
21
|
+
buildStructuredNodeUpdate,
|
|
22
|
+
hasStructuredNodeUpdateFields,
|
|
23
|
+
hasTraceNodeDataFields,
|
|
24
|
+
mergeTraceNodeDataFields,
|
|
25
|
+
refreshCanvasWebpageNode,
|
|
26
|
+
removeCanvasNode,
|
|
27
|
+
resolveHtmlContent,
|
|
28
|
+
scheduleCodeGraphRecompute,
|
|
29
|
+
validateCanvasNodePatch,
|
|
30
|
+
MARKDOWN_NODE_DEFAULT_SIZE,
|
|
31
|
+
MCP_APP_NODE_DEFAULT_SIZE,
|
|
32
|
+
IMAGE_NODE_DEFAULT_SIZE,
|
|
33
|
+
LEDGER_NODE_DEFAULT_SIZE,
|
|
34
|
+
} from '../../canvas-operations.js';
|
|
35
|
+
import { normalizeNodeAxCapabilities } from '../../ax-interaction.js';
|
|
36
|
+
import { buildHtmlPrimitive, getHtmlPrimitiveSemanticMetadata, isHtmlPrimitiveKind } from '../../html-primitives.js';
|
|
37
|
+
import { closeMcpAppSession } from '../../mcp-app-runtime.js';
|
|
38
|
+
import {
|
|
39
|
+
getCanvasNodeTitle,
|
|
40
|
+
type SerializedCanvasNode,
|
|
41
|
+
serializeCanvasLayout,
|
|
42
|
+
serializeCanvasLayoutForAgent,
|
|
43
|
+
serializeCanvasLayoutWithBlobSummaries,
|
|
44
|
+
serializeCanvasNode,
|
|
45
|
+
serializeCanvasNodeForAgent,
|
|
46
|
+
serializeCanvasNodeWithBlobSummaries,
|
|
47
|
+
summarizeCanvasAnnotationForContext,
|
|
48
|
+
} from '../../canvas-serialization.js';
|
|
49
|
+
import { WEBPAGE_NODE_DEFAULT_SIZE, normalizeWebpageUrl } from '../../webpage-node.js';
|
|
50
|
+
import { defineOperation, OperationError, type Operation, type OperationContext } from '../types.js';
|
|
51
|
+
|
|
52
|
+
// ── Node types ────────────────────────────────────────────────
|
|
53
|
+
// Single definition site for the basic node-type list: drives the HTTP type
|
|
54
|
+
// guard (formerly the VALID_NODE_TYPES Set in server.ts), the error message,
|
|
55
|
+
// and the MCP type enum.
|
|
56
|
+
export const NODE_TYPES = [
|
|
57
|
+
'markdown',
|
|
58
|
+
'status',
|
|
59
|
+
'context',
|
|
60
|
+
'ledger',
|
|
61
|
+
'trace',
|
|
62
|
+
'file',
|
|
63
|
+
'image',
|
|
64
|
+
'mcp-app',
|
|
65
|
+
'webpage',
|
|
66
|
+
'html',
|
|
67
|
+
'group',
|
|
68
|
+
] as const;
|
|
69
|
+
|
|
70
|
+
const NODE_TYPE_SET = new Set<string>(NODE_TYPES);
|
|
71
|
+
|
|
72
|
+
/** Per-type default node frame size (formerly copy-pasted ladders). */
|
|
73
|
+
export function defaultNodeSize(type: string): { width: number; height: number } {
|
|
74
|
+
switch (type) {
|
|
75
|
+
case 'html':
|
|
76
|
+
return { width: 720, height: 640 };
|
|
77
|
+
case 'markdown':
|
|
78
|
+
return MARKDOWN_NODE_DEFAULT_SIZE;
|
|
79
|
+
case 'mcp-app':
|
|
80
|
+
return MCP_APP_NODE_DEFAULT_SIZE;
|
|
81
|
+
case 'image':
|
|
82
|
+
return IMAGE_NODE_DEFAULT_SIZE;
|
|
83
|
+
case 'ledger':
|
|
84
|
+
return LEDGER_NODE_DEFAULT_SIZE;
|
|
85
|
+
default:
|
|
86
|
+
return { width: 360, height: 200 };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Shared helpers (moved from server.ts) ─────────────────────
|
|
91
|
+
|
|
92
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
93
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function pickFiniteNumber(record: Record<string, unknown>, key: string): number | undefined {
|
|
97
|
+
const value = record[key];
|
|
98
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function getRecord(value: unknown): Record<string, unknown> | undefined {
|
|
102
|
+
return isRecord(value) ? value : undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function pickPositiveNumber(record: Record<string, unknown>, key: string): number | undefined {
|
|
106
|
+
const value = pickFiniteNumber(record, key);
|
|
107
|
+
return value !== undefined && value > 0 ? value : undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function normalizeGeometryInput(body: Record<string, unknown>): {
|
|
111
|
+
x?: number;
|
|
112
|
+
y?: number;
|
|
113
|
+
width?: number;
|
|
114
|
+
height?: number;
|
|
115
|
+
position?: { x?: number; y?: number };
|
|
116
|
+
size?: { width?: number; height?: number };
|
|
117
|
+
} {
|
|
118
|
+
const position = getRecord(body.position);
|
|
119
|
+
const size = getRecord(body.size);
|
|
120
|
+
return {
|
|
121
|
+
...(pickFiniteNumber(body, 'x') !== undefined ? { x: pickFiniteNumber(body, 'x') } : {}),
|
|
122
|
+
...(pickFiniteNumber(body, 'y') !== undefined ? { y: pickFiniteNumber(body, 'y') } : {}),
|
|
123
|
+
...(pickFiniteNumber(body, 'width') !== undefined ? { width: pickFiniteNumber(body, 'width') } : {}),
|
|
124
|
+
...(pickFiniteNumber(body, 'height') !== undefined ? { height: pickFiniteNumber(body, 'height') } : {}),
|
|
125
|
+
...(position ? {
|
|
126
|
+
position: {
|
|
127
|
+
...(pickFiniteNumber(position, 'x') !== undefined ? { x: pickFiniteNumber(position, 'x') } : {}),
|
|
128
|
+
...(pickFiniteNumber(position, 'y') !== undefined ? { y: pickFiniteNumber(position, 'y') } : {}),
|
|
129
|
+
},
|
|
130
|
+
} : {}),
|
|
131
|
+
...(size ? {
|
|
132
|
+
size: {
|
|
133
|
+
...(pickFiniteNumber(size, 'width') !== undefined ? { width: pickFiniteNumber(size, 'width') } : {}),
|
|
134
|
+
...(pickFiniteNumber(size, 'height') !== undefined ? { height: pickFiniteNumber(size, 'height') } : {}),
|
|
135
|
+
},
|
|
136
|
+
} : {}),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function resolveCreateGeometry(body: Record<string, unknown>): {
|
|
141
|
+
x?: number;
|
|
142
|
+
y?: number;
|
|
143
|
+
width?: number;
|
|
144
|
+
height?: number;
|
|
145
|
+
} {
|
|
146
|
+
const geometry = normalizeGeometryInput(body);
|
|
147
|
+
const x = geometry.x ?? geometry.position?.x;
|
|
148
|
+
const y = geometry.y ?? geometry.position?.y;
|
|
149
|
+
const width = geometry.width ?? geometry.size?.width;
|
|
150
|
+
const height = geometry.height ?? geometry.size?.height;
|
|
151
|
+
return {
|
|
152
|
+
...(x !== undefined ? { x } : {}),
|
|
153
|
+
...(y !== undefined ? { y } : {}),
|
|
154
|
+
...(width !== undefined ? { width } : {}),
|
|
155
|
+
...(height !== undefined ? { height } : {}),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function resolvePatchGeometry(
|
|
160
|
+
body: Record<string, unknown>,
|
|
161
|
+
existing: CanvasNodeState,
|
|
162
|
+
): {
|
|
163
|
+
position?: { x: number; y: number };
|
|
164
|
+
size?: { width: number; height: number };
|
|
165
|
+
} {
|
|
166
|
+
const geometry = normalizeGeometryInput(body);
|
|
167
|
+
const x = geometry.x ?? geometry.position?.x;
|
|
168
|
+
const y = geometry.y ?? geometry.position?.y;
|
|
169
|
+
const width = geometry.width ?? geometry.size?.width;
|
|
170
|
+
const height = geometry.height ?? geometry.size?.height;
|
|
171
|
+
return {
|
|
172
|
+
...(x !== undefined || y !== undefined
|
|
173
|
+
? { position: { x: x ?? existing.position.x, y: y ?? existing.position.y } }
|
|
174
|
+
: {}),
|
|
175
|
+
...(width !== undefined || height !== undefined
|
|
176
|
+
? { size: { width: width ?? existing.size.width, height: height ?? existing.size.height } }
|
|
177
|
+
: {}),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
type StringListField = { value?: string[]; error?: string };
|
|
182
|
+
|
|
183
|
+
function parseStringListField(field: string, value: unknown): StringListField {
|
|
184
|
+
if (value === undefined) return {};
|
|
185
|
+
if (!Array.isArray(value)) return { error: `"${field}" must be an array of node IDs.` };
|
|
186
|
+
const invalid = value.find((item) => typeof item !== 'string' || item.trim().length === 0);
|
|
187
|
+
if (invalid !== undefined) return { error: `"${field}" must contain only non-empty node IDs.` };
|
|
188
|
+
return { value };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function pickGroupChildIds(body: Record<string, unknown>): StringListField {
|
|
192
|
+
if ('children' in body) return parseStringListField('children', body.children);
|
|
193
|
+
if ('childIds' in body) return parseStringListField('childIds', body.childIds);
|
|
194
|
+
const data = isRecord(body.data) ? body.data : undefined;
|
|
195
|
+
return data && 'children' in data ? parseStringListField('data.children', data.children) : {};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function validateGroupChildIds(groupId: string, childIds: string[]): string | null {
|
|
199
|
+
const missingChildIds = childIds.filter((id) => !canvasState.getNode(id));
|
|
200
|
+
if (missingChildIds.length > 0) {
|
|
201
|
+
return `Missing child node ID${missingChildIds.length === 1 ? '' : 's'}: ${missingChildIds.join(', ')}.`;
|
|
202
|
+
}
|
|
203
|
+
const invalidChildIds = childIds.filter((id) => {
|
|
204
|
+
const node = canvasState.getNode(id);
|
|
205
|
+
return id === groupId || node?.type === 'group';
|
|
206
|
+
});
|
|
207
|
+
if (invalidChildIds.length > 0) {
|
|
208
|
+
return `Invalid group child ID${invalidChildIds.length === 1 ? '' : 's'}: ${invalidChildIds.join(', ')}.`;
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function setGroupChildrenFromApi(groupId: string, childIds: string[]): boolean {
|
|
214
|
+
const group = canvasState.getNode(groupId);
|
|
215
|
+
if (!group || group.type !== 'group') return false;
|
|
216
|
+
|
|
217
|
+
const dataChildIds = Array.isArray(group.data.children)
|
|
218
|
+
? group.data.children.filter((id): id is string => typeof id === 'string')
|
|
219
|
+
: [];
|
|
220
|
+
const parentBackrefIds = canvasState.getLayout().nodes
|
|
221
|
+
.filter((node) => node.id !== groupId && node.data.parentGroup === groupId)
|
|
222
|
+
.map((node) => node.id);
|
|
223
|
+
const currentChildIds = [...new Set([...dataChildIds, ...parentBackrefIds])];
|
|
224
|
+
if (currentChildIds.length > 0) {
|
|
225
|
+
if (currentChildIds.length !== dataChildIds.length || currentChildIds.some((id) => !dataChildIds.includes(id))) {
|
|
226
|
+
canvasState.updateNode(groupId, { data: { ...group.data, children: currentChildIds } });
|
|
227
|
+
}
|
|
228
|
+
canvasState.ungroupNodes(groupId);
|
|
229
|
+
}
|
|
230
|
+
if (childIds.length === 0) return true;
|
|
231
|
+
|
|
232
|
+
const latestGroup = canvasState.getNode(groupId);
|
|
233
|
+
return canvasState.groupNodes(groupId, childIds, {
|
|
234
|
+
preservePositions: true,
|
|
235
|
+
keepGroupFrame: latestGroup?.data.frameMode === 'manual',
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function nodeAppSessionId(node: CanvasNodeState | undefined): string | null {
|
|
240
|
+
if (!node || node.type !== 'mcp-app') return null;
|
|
241
|
+
const sessionId = node.data.appSessionId;
|
|
242
|
+
return typeof sessionId === 'string' && sessionId.trim().length > 0 ? sessionId : null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function closeNodeAppSession(node: CanvasNodeState | undefined): void {
|
|
246
|
+
const sessionId = nodeAppSessionId(node);
|
|
247
|
+
if (sessionId) closeMcpAppSession(sessionId);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function buildNodeResponse(node: CanvasNodeState): Record<string, unknown> {
|
|
251
|
+
const serialized = serializeCanvasNode(node);
|
|
252
|
+
return {
|
|
253
|
+
ok: true,
|
|
254
|
+
node: serialized,
|
|
255
|
+
...serialized,
|
|
256
|
+
// `nodeId` aliases `id` so HTTP/CLI node-create responses match the MCP
|
|
257
|
+
// createdNodePayload — agents using either key (or a cached schema) work.
|
|
258
|
+
nodeId: node.id,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function withContextPinReadState(node: CanvasNodeState): CanvasNodeState {
|
|
263
|
+
return {
|
|
264
|
+
...node,
|
|
265
|
+
pinned: node.pinned || canvasState.contextPinnedNodeIds.has(node.id),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function withContextPinLayoutReadState(layout: CanvasLayout): CanvasLayout {
|
|
270
|
+
return {
|
|
271
|
+
...layout,
|
|
272
|
+
nodes: layout.nodes.map(withContextPinReadState),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── MCP payload formatters (moved from mcp/server.ts) ─────────
|
|
277
|
+
|
|
278
|
+
export function wantsFullPayload(input: Record<string, unknown> = {}): boolean {
|
|
279
|
+
return input.full === true || input.verbose === true || input.includeData === true;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function compactNodePayload(node: CanvasNodeState | undefined): Record<string, unknown> | null {
|
|
283
|
+
if (!node) return null;
|
|
284
|
+
const serialized = serializeCanvasNode(node);
|
|
285
|
+
return {
|
|
286
|
+
id: serialized.id,
|
|
287
|
+
type: serialized.type,
|
|
288
|
+
kind: serialized.kind,
|
|
289
|
+
title: serialized.title,
|
|
290
|
+
content: serialized.content,
|
|
291
|
+
position: serialized.position,
|
|
292
|
+
size: serialized.size,
|
|
293
|
+
pinned: serialized.pinned,
|
|
294
|
+
collapsed: serialized.collapsed,
|
|
295
|
+
dockPosition: serialized.dockPosition,
|
|
296
|
+
provenance: serialized.provenance,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function buildSummaryFromLayout(layout: CanvasLayout, pinnedIds: string[]): Record<string, unknown> {
|
|
301
|
+
const pinned = new Set(pinnedIds);
|
|
302
|
+
const nodesByType: Record<string, number> = {};
|
|
303
|
+
const pinnedTitles: string[] = [];
|
|
304
|
+
for (const node of layout.nodes) {
|
|
305
|
+
const serialized = serializeCanvasNode(node);
|
|
306
|
+
nodesByType[serialized.kind] = (nodesByType[serialized.kind] ?? 0) + 1;
|
|
307
|
+
if (pinned.has(node.id)) pinnedTitles.push(getCanvasNodeTitle(node) ?? node.id);
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
totalNodes: layout.nodes.length,
|
|
311
|
+
totalEdges: layout.edges.length,
|
|
312
|
+
totalAnnotations: (layout.annotations ?? []).length,
|
|
313
|
+
annotations: (layout.annotations ?? []).map((annotation: CanvasAnnotation) => summarizeCanvasAnnotationForContext(annotation, layout.nodes)),
|
|
314
|
+
nodesByType,
|
|
315
|
+
pinnedCount: pinned.size,
|
|
316
|
+
pinnedTitles,
|
|
317
|
+
viewport: layout.viewport,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function compactLayoutPayload(layout: CanvasLayout, pinnedIds: string[]): Record<string, unknown> {
|
|
322
|
+
return {
|
|
323
|
+
summary: buildSummaryFromLayout(layout, pinnedIds),
|
|
324
|
+
viewport: layout.viewport,
|
|
325
|
+
annotations: (layout.annotations ?? []).map((annotation) => summarizeCanvasAnnotationForContext(annotation, layout.nodes)),
|
|
326
|
+
nodes: layout.nodes.map((node) => compactNodePayload(node)).filter((node): node is Record<string, unknown> => node !== null),
|
|
327
|
+
edges: layout.edges.map((edge) => ({
|
|
328
|
+
id: edge.id,
|
|
329
|
+
from: edge.from,
|
|
330
|
+
to: edge.to,
|
|
331
|
+
type: edge.type,
|
|
332
|
+
...(edge.label ? { label: edge.label } : {}),
|
|
333
|
+
...(edge.style ? { style: edge.style } : {}),
|
|
334
|
+
...(edge.animated !== undefined ? { animated: edge.animated } : {}),
|
|
335
|
+
})),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function agentSafeFullLayoutPayload(layout: CanvasLayout): Record<string, unknown> {
|
|
340
|
+
return {
|
|
341
|
+
...serializeCanvasLayoutForAgent(layout),
|
|
342
|
+
annotations: (layout.annotations ?? []).map((annotation) => summarizeCanvasAnnotationForContext(annotation, layout.nodes)),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Node-create/update MCP payload: exposes both `id` and a `nodeId` alias so
|
|
348
|
+
* agents using either key (or a cached schema) work — matching the
|
|
349
|
+
* external-app / web-artifact responses that already return both.
|
|
350
|
+
*/
|
|
351
|
+
export function createdNodePayloadFromNode(node: CanvasNodeState, options: Record<string, unknown> = {}): Record<string, unknown> {
|
|
352
|
+
if (!wantsFullPayload(options)) {
|
|
353
|
+
return { ok: true, node: compactNodePayload(node), id: node.id, nodeId: node.id };
|
|
354
|
+
}
|
|
355
|
+
const serialized = serializeCanvasNodeForAgent(node);
|
|
356
|
+
return { ok: true, node: serialized, ...serialized, nodeId: node.id };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── Operation cores (the SDK wraps these directly) ────────────
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Create a basic (non-webpage / non-group / non-primitive) node. Union of the
|
|
363
|
+
* legacy handleCanvasAddNode generic branch; the SDK passes fileMode 'path',
|
|
364
|
+
* the HTTP/MCP operation passes fileMode 'auto'.
|
|
365
|
+
*/
|
|
366
|
+
export function createBasicCanvasNode(
|
|
367
|
+
body: Record<string, unknown>,
|
|
368
|
+
options: { fileMode: 'auto' | 'path' },
|
|
369
|
+
): { node: CanvasNodeState; needsCodeGraphRecompute: boolean } {
|
|
370
|
+
const type = typeof body.type === 'string' ? body.type : '';
|
|
371
|
+
const extraData = isRecord(body.data) ? body.data : undefined;
|
|
372
|
+
if (type === 'html') {
|
|
373
|
+
if ('html' in body && typeof body.html !== 'string') {
|
|
374
|
+
throw new OperationError('HTML node field "html" must be a string.');
|
|
375
|
+
}
|
|
376
|
+
if (extraData && 'html' in extraData && typeof extraData.html !== 'string') {
|
|
377
|
+
throw new OperationError('HTML node field "data.html" must be a string.');
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const content = type === 'image' && typeof body.path === 'string' && typeof body.content !== 'string'
|
|
381
|
+
? body.path
|
|
382
|
+
: body.content;
|
|
383
|
+
// For html nodes, accept top-level `html` AND `axCapabilities` and merge into data
|
|
384
|
+
// so callers can POST { type: 'html', title, html, axCapabilities } without nesting
|
|
385
|
+
// under `data` (report #53 — transport parity with MCP canvas_add_html_node). A
|
|
386
|
+
// top-level value overrides the same key under `data` (mirrors the `html` precedence).
|
|
387
|
+
const topAxCapabilities = type === 'html' ? normalizeNodeAxCapabilities(body.axCapabilities) : null;
|
|
388
|
+
const htmlMergedData = type === 'html'
|
|
389
|
+
? {
|
|
390
|
+
...(extraData ?? {}),
|
|
391
|
+
...(typeof body.html === 'string' ? { html: resolveHtmlContent(body.html) } : {}),
|
|
392
|
+
...(typeof body.summary === 'string' ? { summary: body.summary } : {}),
|
|
393
|
+
...(typeof body.agentSummary === 'string' ? { agentSummary: body.agentSummary } : {}),
|
|
394
|
+
...(typeof body.description === 'string' ? { description: body.description } : {}),
|
|
395
|
+
...(body.presentation === true ? { presentation: true } : {}),
|
|
396
|
+
...(Array.isArray(body.slideTitles) ? { slideTitles: body.slideTitles } : {}),
|
|
397
|
+
...(Array.isArray(body.embeddedNodeIds) ? { embeddedNodeIds: body.embeddedNodeIds } : {}),
|
|
398
|
+
...(Array.isArray(body.embeddedUrls) ? { embeddedUrls: body.embeddedUrls } : {}),
|
|
399
|
+
...(topAxCapabilities ? { axCapabilities: topAxCapabilities } : {}),
|
|
400
|
+
}
|
|
401
|
+
: extraData;
|
|
402
|
+
const geometry = resolveCreateGeometry(body);
|
|
403
|
+
const defaults = defaultNodeSize(type);
|
|
404
|
+
try {
|
|
405
|
+
const { node, needsCodeGraphRecompute } = addCanvasNode({
|
|
406
|
+
type: type as CanvasNodeState['type'],
|
|
407
|
+
...(typeof body.title === 'string' ? { title: body.title } : {}),
|
|
408
|
+
...(typeof content === 'string' ? { content } : {}),
|
|
409
|
+
...(htmlMergedData && Object.keys(htmlMergedData).length > 0 ? { data: htmlMergedData } : {}),
|
|
410
|
+
...(type === 'trace' && typeof body.toolName === 'string' ? { toolName: body.toolName } : {}),
|
|
411
|
+
...(type === 'trace' && typeof body.category === 'string' ? { category: body.category } : {}),
|
|
412
|
+
...(type === 'trace' && typeof body.status === 'string' ? { status: body.status } : {}),
|
|
413
|
+
...(type === 'trace' && typeof body.duration === 'string' ? { duration: body.duration } : {}),
|
|
414
|
+
...(type === 'trace' && typeof body.resultSummary === 'string' ? { resultSummary: body.resultSummary } : {}),
|
|
415
|
+
...(type === 'trace' && typeof body.error === 'string' ? { error: body.error } : {}),
|
|
416
|
+
...(body.strictSize === true ? { strictSize: true } : {}),
|
|
417
|
+
...geometry,
|
|
418
|
+
defaultWidth: defaults.width,
|
|
419
|
+
defaultHeight: defaults.height,
|
|
420
|
+
fileMode: options.fileMode,
|
|
421
|
+
});
|
|
422
|
+
return { node, needsCodeGraphRecompute };
|
|
423
|
+
} catch (error) {
|
|
424
|
+
if (error instanceof OperationError) throw error;
|
|
425
|
+
throw new OperationError(error instanceof Error ? error.message : String(error));
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Build a node patch with the full HTTP superset semantics (webpage
|
|
431
|
+
* titleSource, html top-level fields, axCapabilities merge, group children,
|
|
432
|
+
* structured spec/graph updates, trace fields). Throws OperationError on
|
|
433
|
+
* validation failures. The SDK's updateNode delegates here.
|
|
434
|
+
*/
|
|
435
|
+
export function buildNodePatch(
|
|
436
|
+
existing: CanvasNodeState,
|
|
437
|
+
body: Record<string, unknown>,
|
|
438
|
+
): { patch: Partial<CanvasNodeState>; groupChildIds?: string[] } {
|
|
439
|
+
const groupChildList = existing.type === 'group' ? pickGroupChildIds(body) : {};
|
|
440
|
+
if (groupChildList.error) throw new OperationError(`Cannot update group: ${groupChildList.error}`);
|
|
441
|
+
const groupChildIds = groupChildList.value;
|
|
442
|
+
if (groupChildIds !== undefined) {
|
|
443
|
+
const childError = validateGroupChildIds(existing.id, groupChildIds);
|
|
444
|
+
if (childError) throw new OperationError(`Cannot update group: ${childError}`);
|
|
445
|
+
}
|
|
446
|
+
const patch: Record<string, unknown> = resolvePatchGeometry(body, existing);
|
|
447
|
+
if (body.collapsed !== undefined) patch.collapsed = body.collapsed;
|
|
448
|
+
if (body.pinned !== undefined) patch.pinned = Boolean(body.pinned);
|
|
449
|
+
if (body.dockPosition === null || body.dockPosition === 'left' || body.dockPosition === 'right') {
|
|
450
|
+
patch.dockPosition = body.dockPosition;
|
|
451
|
+
}
|
|
452
|
+
if (hasStructuredNodeUpdateFields(body)) {
|
|
453
|
+
try {
|
|
454
|
+
patch.data = buildStructuredNodeUpdate(existing, body).data;
|
|
455
|
+
} catch (error) {
|
|
456
|
+
throw new OperationError(error instanceof Error ? error.message : String(error));
|
|
457
|
+
}
|
|
458
|
+
} else if (
|
|
459
|
+
body.title !== undefined ||
|
|
460
|
+
body.content !== undefined ||
|
|
461
|
+
body.data ||
|
|
462
|
+
typeof body.arrangeLocked === 'boolean' ||
|
|
463
|
+
typeof body.strictSize === 'boolean' ||
|
|
464
|
+
(existing.type === 'trace' && hasTraceNodeDataFields(body)) ||
|
|
465
|
+
(existing.type === 'html' && body.html !== undefined) ||
|
|
466
|
+
body.axCapabilities !== undefined
|
|
467
|
+
) {
|
|
468
|
+
const data = { ...existing.data };
|
|
469
|
+
if (body.title !== undefined) {
|
|
470
|
+
data.title = String(body.title);
|
|
471
|
+
if (existing.type === 'webpage') {
|
|
472
|
+
data.titleSource = 'user';
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (body.content !== undefined) data.content = String(body.content);
|
|
476
|
+
if (typeof body.arrangeLocked === 'boolean') data.arrangeLocked = body.arrangeLocked;
|
|
477
|
+
if (typeof body.strictSize === 'boolean') data.strictSize = body.strictSize;
|
|
478
|
+
// Merge extra data fields (for status, context, ledger, trace nodes)
|
|
479
|
+
if (body.data && typeof body.data === 'object' && !Array.isArray(body.data)) {
|
|
480
|
+
Object.assign(data, body.data as Record<string, unknown>);
|
|
481
|
+
}
|
|
482
|
+
// Report #53: accept top-level `html` on PATCH too (top-level overrides the
|
|
483
|
+
// `data.*` merge above — matches POST + MCP parity).
|
|
484
|
+
if (existing.type === 'html' && body.html !== undefined) {
|
|
485
|
+
if (typeof body.html !== 'string') {
|
|
486
|
+
throw new OperationError('HTML node field "html" must be a string.');
|
|
487
|
+
}
|
|
488
|
+
data.html = resolveHtmlContent(body.html);
|
|
489
|
+
}
|
|
490
|
+
// Top-level `axCapabilities` merges into node data for any node type (the
|
|
491
|
+
// legacy MCP canvas_update_node behavior; the html-only HTTP special case
|
|
492
|
+
// is generalized here). Capabilities are clamped to the node-type ceiling
|
|
493
|
+
// at interaction time.
|
|
494
|
+
const patchAxCapabilities = normalizeNodeAxCapabilities(body.axCapabilities);
|
|
495
|
+
if (patchAxCapabilities) data.axCapabilities = patchAxCapabilities;
|
|
496
|
+
if (existing.type === 'webpage') {
|
|
497
|
+
const nextUrl = typeof body.url === 'string'
|
|
498
|
+
? body.url
|
|
499
|
+
: typeof (body.data as Record<string, unknown> | undefined)?.url === 'string'
|
|
500
|
+
? (body.data as Record<string, unknown>).url as string
|
|
501
|
+
: undefined;
|
|
502
|
+
if (typeof nextUrl === 'string' && nextUrl.trim().length > 0) {
|
|
503
|
+
try {
|
|
504
|
+
data.url = normalizeWebpageUrl(nextUrl);
|
|
505
|
+
} catch (error) {
|
|
506
|
+
throw new OperationError(error instanceof Error ? error.message : 'Invalid webpage URL.');
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
patch.data = existing.type === 'trace'
|
|
511
|
+
? mergeTraceNodeDataFields(data, body)
|
|
512
|
+
: data;
|
|
513
|
+
}
|
|
514
|
+
const error = validateCanvasNodePatch({
|
|
515
|
+
...(patch.position ? { position: patch.position as { x: number; y: number } } : {}),
|
|
516
|
+
...(patch.size ? { size: patch.size as { width: number; height: number } } : {}),
|
|
517
|
+
});
|
|
518
|
+
if (error) throw new OperationError(error);
|
|
519
|
+
return {
|
|
520
|
+
patch: patch as Partial<CanvasNodeState>,
|
|
521
|
+
...(groupChildIds !== undefined ? { groupChildIds } : {}),
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Remove a node (closing any mcp-app session). Missing id → OperationError 404
|
|
527
|
+
* on ALL surfaces (plan-005 deliberately unifies the old silent local success).
|
|
528
|
+
*/
|
|
529
|
+
export function removeNodeCore(id: string): { needsCodeGraphRecompute: boolean } {
|
|
530
|
+
const existing = canvasState.getNode(id);
|
|
531
|
+
if (!existing) throw new OperationError(`Node "${id}" not found.`, 404);
|
|
532
|
+
closeNodeAppSession(existing);
|
|
533
|
+
const { removed, needsCodeGraphRecompute } = removeCanvasNode(id);
|
|
534
|
+
if (!removed) throw new OperationError(`Node "${id}" not found.`, 404);
|
|
535
|
+
return { needsCodeGraphRecompute };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ── node.add ──────────────────────────────────────────────────
|
|
539
|
+
|
|
540
|
+
interface NodeAddResult {
|
|
541
|
+
node: CanvasNodeState;
|
|
542
|
+
extras?: Record<string, unknown>;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async function createWebpageNode(body: Record<string, unknown>, ctx: OperationContext): Promise<NodeAddResult> {
|
|
546
|
+
const rawUrl = typeof body.url === 'string' && body.url.trim().length > 0
|
|
547
|
+
? body.url
|
|
548
|
+
: typeof body.content === 'string'
|
|
549
|
+
? body.content
|
|
550
|
+
: '';
|
|
551
|
+
|
|
552
|
+
let normalizedUrl: string;
|
|
553
|
+
try {
|
|
554
|
+
normalizedUrl = normalizeWebpageUrl(rawUrl);
|
|
555
|
+
} catch (error) {
|
|
556
|
+
throw new OperationError(error instanceof Error ? error.message : 'Invalid webpage URL.');
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const extraData = isRecord(body.data) ? body.data : undefined;
|
|
560
|
+
const geometry = resolveCreateGeometry(body);
|
|
561
|
+
const { id, node } = addCanvasNode({
|
|
562
|
+
type: 'webpage',
|
|
563
|
+
...(typeof body.title === 'string' ? { title: body.title } : {}),
|
|
564
|
+
content: normalizedUrl,
|
|
565
|
+
...(extraData ? { data: extraData } : {}),
|
|
566
|
+
...(body.strictSize === true ? { strictSize: true } : {}),
|
|
567
|
+
...geometry,
|
|
568
|
+
...(geometry.width === undefined ? { width: WEBPAGE_NODE_DEFAULT_SIZE.width } : {}),
|
|
569
|
+
...(geometry.height === undefined ? { height: WEBPAGE_NODE_DEFAULT_SIZE.height } : {}),
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
// The node should appear before the (slow) page fetch completes; the
|
|
573
|
+
// registry emits the final layout update after the handler returns.
|
|
574
|
+
ctx.emit('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
575
|
+
const refreshed = await refreshCanvasWebpageNode(id);
|
|
576
|
+
const created = canvasState.getNode(id) ?? node;
|
|
577
|
+
return {
|
|
578
|
+
node: created,
|
|
579
|
+
extras: {
|
|
580
|
+
fetch: refreshed.ok
|
|
581
|
+
? { ok: true }
|
|
582
|
+
: { ok: false, error: refreshed.error ?? 'Failed to fetch webpage content.' },
|
|
583
|
+
...(refreshed.ok ? {} : { error: refreshed.error }),
|
|
584
|
+
},
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function createHtmlPrimitiveNode(body: Record<string, unknown>): NodeAddResult {
|
|
589
|
+
const rawKind = typeof body.primitive === 'string' ? body.primitive : body.kind;
|
|
590
|
+
if (typeof rawKind !== 'string' || !isHtmlPrimitiveKind(rawKind)) {
|
|
591
|
+
throw new OperationError(`Unknown HTML primitive: ${String(rawKind)}.`);
|
|
592
|
+
}
|
|
593
|
+
const data = isRecord(body.data) ? body.data : {};
|
|
594
|
+
let built: ReturnType<typeof buildHtmlPrimitive>;
|
|
595
|
+
try {
|
|
596
|
+
built = buildHtmlPrimitive({
|
|
597
|
+
kind: rawKind,
|
|
598
|
+
...(typeof body.title === 'string' ? { title: body.title } : {}),
|
|
599
|
+
data,
|
|
600
|
+
});
|
|
601
|
+
} catch (error) {
|
|
602
|
+
throw new OperationError(error instanceof Error ? error.message : String(error));
|
|
603
|
+
}
|
|
604
|
+
const geometry = resolveCreateGeometry(body);
|
|
605
|
+
const { node } = addCanvasNode({
|
|
606
|
+
type: 'html',
|
|
607
|
+
title: built.title,
|
|
608
|
+
data: {
|
|
609
|
+
html: built.html,
|
|
610
|
+
htmlPrimitive: built.kind,
|
|
611
|
+
primitiveData: built.data,
|
|
612
|
+
description: built.summary,
|
|
613
|
+
agentSummary: typeof data.agentSummary === 'string' ? data.agentSummary : built.summary,
|
|
614
|
+
...(typeof data.summary === 'string' ? { summary: data.summary } : {}),
|
|
615
|
+
...getHtmlPrimitiveSemanticMetadata(built.data),
|
|
616
|
+
},
|
|
617
|
+
...(body.strictSize === true ? { strictSize: true } : {}),
|
|
618
|
+
...geometry,
|
|
619
|
+
defaultWidth: built.defaultSize.width,
|
|
620
|
+
defaultHeight: built.defaultSize.height,
|
|
621
|
+
});
|
|
622
|
+
return {
|
|
623
|
+
node,
|
|
624
|
+
extras: {
|
|
625
|
+
primitive: {
|
|
626
|
+
kind: built.kind,
|
|
627
|
+
title: built.title,
|
|
628
|
+
htmlBytes: Buffer.byteLength(built.html, 'utf-8'),
|
|
629
|
+
defaultSize: built.defaultSize,
|
|
630
|
+
},
|
|
631
|
+
},
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function createGroupNode(body: Record<string, unknown>): NodeAddResult {
|
|
636
|
+
const geometry = resolveCreateGeometry(body);
|
|
637
|
+
const childList = pickGroupChildIds(body);
|
|
638
|
+
if (childList.error) throw new OperationError(`Cannot create group: ${childList.error}`);
|
|
639
|
+
const childIds = childList.value ?? [];
|
|
640
|
+
const childError = validateGroupChildIds('', childIds);
|
|
641
|
+
if (childError) throw new OperationError(`Cannot create group: ${childError}`);
|
|
642
|
+
const childLayout = body.childLayout === 'grid' || body.childLayout === 'column' || body.childLayout === 'flow'
|
|
643
|
+
? body.childLayout
|
|
644
|
+
: undefined;
|
|
645
|
+
const { node } = createCanvasGroup({
|
|
646
|
+
...(typeof body.title === 'string' ? { title: body.title } : {}),
|
|
647
|
+
childIds,
|
|
648
|
+
...(typeof body.color === 'string' ? { color: body.color } : {}),
|
|
649
|
+
...(childLayout ? { childLayout } : {}),
|
|
650
|
+
...geometry,
|
|
651
|
+
});
|
|
652
|
+
return { node };
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const nodeAddShape = {
|
|
656
|
+
type: z.string().optional().catch(undefined).describe('Node type (prefer canvas_create_group for groups)'),
|
|
657
|
+
title: z.string().optional().catch(undefined).describe('Node title'),
|
|
658
|
+
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)'),
|
|
659
|
+
path: z.string().optional().catch(undefined).describe('Compatibility alias for image node content. Prefer content for image paths.'),
|
|
660
|
+
url: z.string().optional().catch(undefined).describe('Canonical webpage URL field for webpage nodes. Overrides content when both are provided.'),
|
|
661
|
+
x: z.number().optional().catch(undefined).describe('X position (auto-placed if omitted)'),
|
|
662
|
+
y: z.number().optional().catch(undefined).describe('Y position (auto-placed if omitted)'),
|
|
663
|
+
width: z.number().optional().catch(undefined).describe('Width in pixels (default: 720)'),
|
|
664
|
+
height: z.number().optional().catch(undefined).describe('Height in pixels (default: 600)'),
|
|
665
|
+
strictSize: z.boolean().optional().catch(undefined).describe('Keep explicit width/height fixed and scroll overflowing content instead of browser auto-fitting'),
|
|
666
|
+
children: z.unknown().optional().describe('Group-only alias for childIds. Node IDs to include in a generic group node.'),
|
|
667
|
+
childIds: z.unknown().optional().describe('Group-only field. Node IDs to include in a generic group node. Prefer canvas_create_group for groups.'),
|
|
668
|
+
childLayout: z.enum(['grid', 'column', 'flow']).optional().catch(undefined).describe('Group-only optional layout for grouped children.'),
|
|
669
|
+
color: z.string().optional().catch(undefined).describe('Group-only frame accent color.'),
|
|
670
|
+
toolName: z.string().optional().catch(undefined).describe('Trace node tool or operation label'),
|
|
671
|
+
category: z.string().optional().catch(undefined).describe('Trace node category: mcp, file, subagent, or other'),
|
|
672
|
+
status: z.string().optional().catch(undefined).describe('Trace node status: running, success, or failed'),
|
|
673
|
+
duration: z.string().optional().catch(undefined).describe('Trace node duration badge text'),
|
|
674
|
+
resultSummary: z.string().optional().catch(undefined).describe('Trace node result summary'),
|
|
675
|
+
error: z.string().optional().catch(undefined).describe('Trace node error message'),
|
|
676
|
+
data: z.unknown().optional().describe('Extra node data merged into the created node.'),
|
|
677
|
+
html: z.unknown().optional().describe('HTML node document/fragment (html nodes only).'),
|
|
678
|
+
primitive: z.unknown().optional().describe('HTML primitive kind (html-primitive creation).'),
|
|
679
|
+
kind: z.unknown().optional().describe('Alias for primitive.'),
|
|
680
|
+
summary: z.string().optional().catch(undefined).describe('Agent-readable semantic summary (html nodes).'),
|
|
681
|
+
agentSummary: z.string().optional().catch(undefined).describe('Explicit agent-readable summary (html nodes).'),
|
|
682
|
+
description: z.string().optional().catch(undefined).describe('Short description included in search and pinned/spatial context (html nodes).'),
|
|
683
|
+
presentation: z.boolean().optional().catch(undefined).describe('Marks an html node as a fullscreen presentation/deck.'),
|
|
684
|
+
slideTitles: z.unknown().optional().describe('Agent-readable slide titles for presentation HTML.'),
|
|
685
|
+
embeddedNodeIds: z.unknown().optional().describe('Canvas node IDs embedded or represented by this HTML surface.'),
|
|
686
|
+
embeddedUrls: z.unknown().optional().describe('URLs embedded or represented by this HTML surface.'),
|
|
687
|
+
axCapabilities: z.unknown().optional().describe('Opt an html node into AX interactions. Merged into node data for html nodes; clamped to the node-type ceiling server-side.'),
|
|
688
|
+
position: z.unknown().optional().describe('Geometry alias: { x, y } object form.'),
|
|
689
|
+
size: z.unknown().optional().describe('Geometry alias: { width, height } object form.'),
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
const nodeAddSchema = z.looseObject(nodeAddShape);
|
|
693
|
+
|
|
694
|
+
const fullVerboseShape = {
|
|
695
|
+
full: z.boolean().optional().describe('Return the full created node payload. Default false returns compact metadata.'),
|
|
696
|
+
verbose: z.boolean().optional().describe('Alias for full:true.'),
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
const nodeAddOperation = defineOperation<z.infer<typeof nodeAddSchema>, NodeAddResult>({
|
|
700
|
+
name: 'node.add',
|
|
701
|
+
mutates: true,
|
|
702
|
+
input: nodeAddSchema,
|
|
703
|
+
inputShape: nodeAddShape,
|
|
704
|
+
http: {
|
|
705
|
+
method: 'POST',
|
|
706
|
+
path: '/api/canvas/node',
|
|
707
|
+
},
|
|
708
|
+
mcp: {
|
|
709
|
+
toolName: 'canvas_add_node',
|
|
710
|
+
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 node tools: json-render -> canvas_add_json_render_node, graph -> canvas_add_graph_node, web-artifact -> canvas_build_web_artifact, external apps -> canvas_open_mcp_app, html (preferred) -> canvas_add_html_node, groups -> canvas_create_group. Call canvas_describe_schema for the full nodeTypeRouting table.',
|
|
711
|
+
extraShape: {
|
|
712
|
+
type: z.enum(['markdown', 'status', 'context', 'ledger', 'trace', 'file', 'image', 'webpage', 'mcp-app', 'html', 'group'])
|
|
713
|
+
.describe('Node type (prefer canvas_create_group for groups)'),
|
|
714
|
+
children: z.array(z.string()).optional().describe('Group-only alias for childIds. Node IDs to include in a generic group node.'),
|
|
715
|
+
childIds: z.array(z.string()).optional().describe('Group-only field. Node IDs to include in a generic group node. Prefer canvas_create_group for groups.'),
|
|
716
|
+
axCapabilities: z.object({
|
|
717
|
+
enabled: z.boolean().optional(),
|
|
718
|
+
allowed: z.array(z.string()).optional(),
|
|
719
|
+
}).optional().describe('Opt an html node into AX interactions (e.g. { enabled: true, allowed: ["ax.work.create"] }) so its sandboxed UI can emit ax.* via window.PMX_AX.emit. html nodes are AX-disabled by default; merged into node data, clamped to the node-type ceiling server-side.'),
|
|
720
|
+
...fullVerboseShape,
|
|
721
|
+
},
|
|
722
|
+
buildInput: (input) => {
|
|
723
|
+
if (input.type === 'webpage') {
|
|
724
|
+
const url = (typeof input.url === 'string' ? input.url : undefined)
|
|
725
|
+
?? (typeof input.content === 'string' ? input.content : undefined);
|
|
726
|
+
if (!url) {
|
|
727
|
+
throw new OperationError('Webpage nodes require a page URL via "url" (preferred) or "content".');
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return input;
|
|
731
|
+
},
|
|
732
|
+
formatResult: (result, input) => {
|
|
733
|
+
const body = isRecord(result) ? result : {};
|
|
734
|
+
if (input.type === 'webpage') {
|
|
735
|
+
return {
|
|
736
|
+
content: [{ type: 'text' as const, text: JSON.stringify(body) }],
|
|
737
|
+
...(body.ok === false ? { isError: true } : {}),
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
const node = body.node as CanvasNodeState | undefined;
|
|
741
|
+
const payload = node ? createdNodePayloadFromNode(node, input) : { ok: true };
|
|
742
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(payload, null, 2) }] };
|
|
743
|
+
},
|
|
744
|
+
},
|
|
745
|
+
handler: async (input, ctx) => {
|
|
746
|
+
const body: Record<string, unknown> = input;
|
|
747
|
+
const type = typeof body.type === 'string' ? body.type : '';
|
|
748
|
+
// Report #50: require a resolvable type rather than silently defaulting to a
|
|
749
|
+
// markdown node — an empty / type-less body created a phantom node before.
|
|
750
|
+
if (!type) {
|
|
751
|
+
throw new OperationError(
|
|
752
|
+
`node creation requires a 'type' — pass it in the JSON body ({ "type": "markdown", ... }) or as a ?type= query param. Valid types: ${NODE_TYPES.join(', ')} (json-render / graph / web-artifact have dedicated endpoints).`,
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
if (!NODE_TYPE_SET.has(type)) {
|
|
756
|
+
if (type === 'json-render') {
|
|
757
|
+
throw new OperationError('Node type "json-render" is created via POST /api/canvas/json-render. See /api/canvas/schema for the required spec shape.');
|
|
758
|
+
}
|
|
759
|
+
if (type === 'graph') {
|
|
760
|
+
throw new OperationError('Node type "graph" is created via POST /api/canvas/graph. See /api/canvas/schema for graphType + data fields.');
|
|
761
|
+
}
|
|
762
|
+
if (type === 'web-artifact') {
|
|
763
|
+
throw new OperationError('Node type "web-artifact" is created via POST /api/canvas/web-artifact with appTsx + title.');
|
|
764
|
+
}
|
|
765
|
+
if (type === 'html-primitive') {
|
|
766
|
+
return createHtmlPrimitiveNode(body);
|
|
767
|
+
}
|
|
768
|
+
throw new OperationError(`Invalid node type: "${type}".`);
|
|
769
|
+
}
|
|
770
|
+
if (type === 'webpage') {
|
|
771
|
+
return await createWebpageNode(body, ctx);
|
|
772
|
+
}
|
|
773
|
+
if (type === 'html' && (typeof body.primitive === 'string' || typeof body.kind === 'string')) {
|
|
774
|
+
return createHtmlPrimitiveNode(body);
|
|
775
|
+
}
|
|
776
|
+
if (type === 'group') {
|
|
777
|
+
return createGroupNode(body);
|
|
778
|
+
}
|
|
779
|
+
const { node, needsCodeGraphRecompute } = createBasicCanvasNode(body, { fileMode: 'auto' });
|
|
780
|
+
if (needsCodeGraphRecompute) {
|
|
781
|
+
scheduleCodeGraphRecompute(() => {
|
|
782
|
+
ctx.emit('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
return { node };
|
|
786
|
+
},
|
|
787
|
+
serialize: ({ node, extras }) => ({ ...buildNodeResponse(node), ...(extras ?? {}) }),
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
// ── node.get ──────────────────────────────────────────────────
|
|
791
|
+
|
|
792
|
+
const nodeGetShape = {
|
|
793
|
+
id: z.string().describe('The node ID to retrieve'),
|
|
794
|
+
includeBlobs: z.unknown().optional().describe('Include full blob payloads instead of blob summaries.'),
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
const nodeGetSchema = z.looseObject(nodeGetShape);
|
|
798
|
+
|
|
799
|
+
const nodeGetOperation = defineOperation<z.infer<typeof nodeGetSchema>, SerializedCanvasNode>({
|
|
800
|
+
name: 'node.get',
|
|
801
|
+
mutates: false,
|
|
802
|
+
input: nodeGetSchema,
|
|
803
|
+
inputShape: nodeGetShape,
|
|
804
|
+
http: {
|
|
805
|
+
method: 'GET',
|
|
806
|
+
path: '/api/canvas/node/:id',
|
|
807
|
+
},
|
|
808
|
+
mcp: {
|
|
809
|
+
toolName: 'canvas_get_node',
|
|
810
|
+
description: 'Get a single node by ID. Defaults to compact metadata; pass full:true to include full data/tool results.',
|
|
811
|
+
extraShape: {
|
|
812
|
+
full: z.boolean().optional().describe('Include full node data, including mcp-app tool results. Default false.'),
|
|
813
|
+
verbose: z.boolean().optional().describe('Alias for full:true.'),
|
|
814
|
+
},
|
|
815
|
+
buildInput: (input) => ({ id: input.id, includeBlobs: true }),
|
|
816
|
+
formatResult: (result, input) => {
|
|
817
|
+
const node = result as CanvasNodeState;
|
|
818
|
+
const payload = wantsFullPayload(input) ? serializeCanvasNodeForAgent(node) : compactNodePayload(node);
|
|
819
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(payload, null, 2) }] };
|
|
820
|
+
},
|
|
821
|
+
},
|
|
822
|
+
handler: ({ id, includeBlobs }) => {
|
|
823
|
+
const full = includeBlobs === true || includeBlobs === 'true';
|
|
824
|
+
const node = full ? canvasState.getNode(id) : canvasState.getNodeForPersistence(id);
|
|
825
|
+
if (!node) throw new OperationError(`Node "${id}" not found.`, 404);
|
|
826
|
+
const responseNode = withContextPinReadState(node);
|
|
827
|
+
return full
|
|
828
|
+
? serializeCanvasNode(responseNode)
|
|
829
|
+
: serializeCanvasNodeWithBlobSummaries(responseNode);
|
|
830
|
+
},
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
// ── node.update ───────────────────────────────────────────────
|
|
834
|
+
|
|
835
|
+
const nodeUpdateShape = {
|
|
836
|
+
id: z.string().describe('Node ID to update'),
|
|
837
|
+
title: z.unknown().optional().describe('New title'),
|
|
838
|
+
content: z.unknown().optional().describe('New content'),
|
|
839
|
+
x: z.number().optional().catch(undefined).describe('New X position'),
|
|
840
|
+
y: z.number().optional().catch(undefined).describe('New Y position'),
|
|
841
|
+
width: z.number().optional().catch(undefined).describe('New width'),
|
|
842
|
+
height: z.number().optional().catch(undefined).describe('New height'),
|
|
843
|
+
position: z.unknown().optional().describe('Geometry alias: { x, y } object form.'),
|
|
844
|
+
size: z.unknown().optional().describe('Geometry alias: { width, height } object form.'),
|
|
845
|
+
spec: z.unknown().optional().describe('New json-render spec, or a graph payload with graphType/data for graph nodes'),
|
|
846
|
+
graphType: z.unknown().optional().describe('Graph type when updating a graph node'),
|
|
847
|
+
data: z.unknown().optional().describe('Graph dataset (array) when updating a graph node, or extra data fields (object) merged into the node data'),
|
|
848
|
+
xKey: z.unknown().optional().describe('Graph x/category key'),
|
|
849
|
+
yKey: z.unknown().optional().describe('Graph y/value key'),
|
|
850
|
+
chartHeight: z.unknown().optional().describe('Graph chart content height, distinct from node height'),
|
|
851
|
+
toolName: z.string().optional().catch(undefined).describe('Trace node tool or operation label'),
|
|
852
|
+
category: z.string().optional().catch(undefined).describe('Trace node category: mcp, file, subagent, or other'),
|
|
853
|
+
status: z.string().optional().catch(undefined).describe('Trace node status: running, success, or failed'),
|
|
854
|
+
duration: z.string().optional().catch(undefined).describe('Trace node duration badge text'),
|
|
855
|
+
resultSummary: z.string().optional().catch(undefined).describe('Trace node result summary'),
|
|
856
|
+
error: z.string().optional().catch(undefined).describe('Trace node error message'),
|
|
857
|
+
collapsed: z.unknown().optional().describe('Collapse or expand the node'),
|
|
858
|
+
dockPosition: z.unknown().optional().describe('Dock the node to the left/right HUD column, or pass null to return it to the canvas'),
|
|
859
|
+
pinned: z.unknown().optional().describe('Pin or unpin the node to exclude it from auto-arrange'),
|
|
860
|
+
arrangeLocked: z.boolean().optional().catch(undefined).describe('Prevent auto-arrange from moving this node. Pinned nodes are also skipped.'),
|
|
861
|
+
strictSize: z.boolean().optional().catch(undefined).describe('Keep explicit width/height fixed and scroll overflowing content.'),
|
|
862
|
+
axCapabilities: z.unknown().optional().describe('Enable/disable AX interactions on an existing node. Merged into the node data; clamped to the node-type ceiling server-side.'),
|
|
863
|
+
html: z.unknown().optional().describe('New HTML document/fragment (html nodes only).'),
|
|
864
|
+
url: z.unknown().optional().describe('New URL for webpage nodes.'),
|
|
865
|
+
refresh: z.unknown().optional().describe('Webpage nodes: pass true to re-fetch the page instead of patching fields.'),
|
|
866
|
+
children: z.unknown().optional().describe('Group nodes: replacement child node ID list.'),
|
|
867
|
+
childIds: z.unknown().optional().describe('Group nodes: alias for children.'),
|
|
868
|
+
};
|
|
869
|
+
|
|
870
|
+
const nodeUpdateSchema = z.looseObject(nodeUpdateShape);
|
|
871
|
+
|
|
872
|
+
const nodeUpdateOperation = defineOperation<z.infer<typeof nodeUpdateSchema>, Record<string, unknown>>({
|
|
873
|
+
name: 'node.update',
|
|
874
|
+
mutates: true,
|
|
875
|
+
input: nodeUpdateSchema,
|
|
876
|
+
inputShape: nodeUpdateShape,
|
|
877
|
+
http: {
|
|
878
|
+
method: 'PATCH',
|
|
879
|
+
path: '/api/canvas/node/:id',
|
|
880
|
+
// The webpage refresh delegation keeps the legacy non-2xx status when the
|
|
881
|
+
// re-fetch fails ({ ok:false, id, error } with HTTP 400).
|
|
882
|
+
status: (result) => (isRecord(result) && result.ok === false ? 400 : 200),
|
|
883
|
+
},
|
|
884
|
+
mcp: {
|
|
885
|
+
toolName: 'canvas_update_node',
|
|
886
|
+
description: 'Update an existing node. You can change its content, title, position, size, dock placement, or data.',
|
|
887
|
+
extraShape: {
|
|
888
|
+
title: z.string().optional().describe('New title'),
|
|
889
|
+
content: z.string().optional().describe('New content'),
|
|
890
|
+
spec: z.record(z.string(), z.unknown()).optional().describe('New json-render spec, or a graph payload with graphType/data for graph nodes'),
|
|
891
|
+
graphType: z.string().optional().describe('Graph type when updating a graph node'),
|
|
892
|
+
data: z.array(z.record(z.string(), z.unknown())).optional().describe('Graph dataset when updating a graph node'),
|
|
893
|
+
xKey: z.string().optional().describe('Graph x/category key'),
|
|
894
|
+
yKey: z.string().optional().describe('Graph y/value key'),
|
|
895
|
+
chartHeight: z.number().optional().describe('Graph chart content height, distinct from node height'),
|
|
896
|
+
collapsed: z.boolean().optional().describe('Collapse or expand the node'),
|
|
897
|
+
dockPosition: z.enum(['left', 'right']).nullable().optional().describe('Dock the node to the left/right HUD column, or pass null to return it to the canvas'),
|
|
898
|
+
pinned: z.boolean().optional().describe('Pin or unpin the node to exclude it from auto-arrange'),
|
|
899
|
+
arrangeLocked: z.boolean().optional().describe('Prevent auto-arrange from moving this node. Pinned nodes are also skipped.'),
|
|
900
|
+
axCapabilities: z.object({
|
|
901
|
+
enabled: z.boolean().optional(),
|
|
902
|
+
allowed: z.array(z.string()).optional(),
|
|
903
|
+
}).optional().describe('Enable/disable AX interactions on an existing node (e.g. flip an html node on with { enabled: true, allowed: ["ax.work.create"] }). Merged into the node data; clamped to the node-type ceiling server-side.'),
|
|
904
|
+
full: z.boolean().optional().describe('Return the full updated node payload. Default false returns compact metadata.'),
|
|
905
|
+
verbose: z.boolean().optional().describe('Alias for full:true.'),
|
|
906
|
+
},
|
|
907
|
+
buildInput: (input) => {
|
|
908
|
+
// A graph dataset update (`data` array) and an axCapabilities toggle collide
|
|
909
|
+
// on the node-data merge (array vs object) — reject rather than silently
|
|
910
|
+
// dropping the dataset.
|
|
911
|
+
if (input.axCapabilities !== undefined && Array.isArray(input.data)) {
|
|
912
|
+
throw new OperationError('Update the graph dataset and axCapabilities in separate canvas_update_node calls.');
|
|
913
|
+
}
|
|
914
|
+
return input;
|
|
915
|
+
},
|
|
916
|
+
formatResult: (result, input) => {
|
|
917
|
+
const body = isRecord(result) ? result : {};
|
|
918
|
+
const node = body.node as CanvasNodeState | undefined;
|
|
919
|
+
if (node) {
|
|
920
|
+
const payload = createdNodePayloadFromNode(node, input);
|
|
921
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(payload, null, 2) }] };
|
|
922
|
+
}
|
|
923
|
+
// No `node` field: a webpage-refresh result ({ ok, id, fetch?, error? }) or
|
|
924
|
+
// the node-vanished fallback. Pass the body through verbatim and surface a
|
|
925
|
+
// FAILED refresh as isError — matching the HTTP 400 status mapping above and
|
|
926
|
+
// the legacy canvas_refresh_webpage_node tool. Without this, a failed
|
|
927
|
+
// refresh via canvas_node { action:'update', refresh:true } leaked back as a
|
|
928
|
+
// false { ok:true } over the local invoker (no isError).
|
|
929
|
+
const payload = body.ok !== undefined ? body : { ok: true, id: input.id };
|
|
930
|
+
return {
|
|
931
|
+
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
|
|
932
|
+
...(payload.ok === false ? { isError: true } : {}),
|
|
933
|
+
};
|
|
934
|
+
},
|
|
935
|
+
},
|
|
936
|
+
handler: async (input) => {
|
|
937
|
+
const body: Record<string, unknown> = input;
|
|
938
|
+
const id = input.id;
|
|
939
|
+
const existing = canvasState.getNode(id);
|
|
940
|
+
if (!existing) throw new OperationError(`Node "${id}" not found.`, 404);
|
|
941
|
+
if (existing.type === 'webpage' && body.refresh === true) {
|
|
942
|
+
const rawUrl = typeof body.url === 'string' ? body.url : undefined;
|
|
943
|
+
let url: string | undefined;
|
|
944
|
+
if (rawUrl && rawUrl.trim().length > 0) {
|
|
945
|
+
try {
|
|
946
|
+
url = normalizeWebpageUrl(rawUrl);
|
|
947
|
+
} catch (error) {
|
|
948
|
+
throw new OperationError(error instanceof Error ? error.message : 'Invalid webpage URL.');
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
const result = await refreshCanvasWebpageNode(id, { ...(url ? { url } : {}) });
|
|
952
|
+
return result as unknown as Record<string, unknown>;
|
|
953
|
+
}
|
|
954
|
+
const { patch, groupChildIds } = buildNodePatch(existing, body);
|
|
955
|
+
canvasState.updateNode(id, patch);
|
|
956
|
+
if (groupChildIds !== undefined && !setGroupChildrenFromApi(id, groupChildIds)) {
|
|
957
|
+
throw new OperationError(`Group "${id}" not found.`, 404);
|
|
958
|
+
}
|
|
959
|
+
const updated = canvasState.getNode(id);
|
|
960
|
+
return updated ? buildNodeResponse(updated) : { ok: true, id };
|
|
961
|
+
},
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
// ── node.remove ───────────────────────────────────────────────
|
|
965
|
+
|
|
966
|
+
const nodeRemoveShape = {
|
|
967
|
+
id: z.string().describe('Node ID to remove'),
|
|
968
|
+
};
|
|
969
|
+
|
|
970
|
+
const nodeRemoveSchema = z.looseObject(nodeRemoveShape);
|
|
971
|
+
|
|
972
|
+
const nodeRemoveOperation = defineOperation<z.infer<typeof nodeRemoveSchema>, Record<string, unknown>>({
|
|
973
|
+
name: 'node.remove',
|
|
974
|
+
mutates: true,
|
|
975
|
+
input: nodeRemoveSchema,
|
|
976
|
+
inputShape: nodeRemoveShape,
|
|
977
|
+
http: {
|
|
978
|
+
method: 'DELETE',
|
|
979
|
+
path: '/api/canvas/node/:id',
|
|
980
|
+
},
|
|
981
|
+
mcp: {
|
|
982
|
+
toolName: 'canvas_remove_node',
|
|
983
|
+
description: 'Remove a node from the canvas. Also removes all edges connected to it.',
|
|
984
|
+
formatResult: (result) => ({
|
|
985
|
+
content: [{ type: 'text' as const, text: JSON.stringify(result) }],
|
|
986
|
+
}),
|
|
987
|
+
},
|
|
988
|
+
handler: ({ id }, ctx) => {
|
|
989
|
+
const { needsCodeGraphRecompute } = removeNodeCore(id);
|
|
990
|
+
if (needsCodeGraphRecompute) {
|
|
991
|
+
scheduleCodeGraphRecompute(() => {
|
|
992
|
+
ctx.emit('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
return { ok: true, removed: id };
|
|
996
|
+
},
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
// ── layout.get ────────────────────────────────────────────────
|
|
1000
|
+
|
|
1001
|
+
const layoutGetShape = {
|
|
1002
|
+
includeBlobs: z.unknown().optional().describe('Include full blob payloads instead of blob summaries.'),
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
const layoutGetSchema = z.looseObject(layoutGetShape);
|
|
1006
|
+
|
|
1007
|
+
const layoutGetOperation = defineOperation<z.infer<typeof layoutGetSchema>, Record<string, unknown>>({
|
|
1008
|
+
name: 'layout.get',
|
|
1009
|
+
mutates: false,
|
|
1010
|
+
input: layoutGetSchema,
|
|
1011
|
+
inputShape: layoutGetShape,
|
|
1012
|
+
http: {
|
|
1013
|
+
method: 'GET',
|
|
1014
|
+
path: '/api/canvas/state',
|
|
1015
|
+
},
|
|
1016
|
+
mcp: {
|
|
1017
|
+
toolName: 'canvas_get_layout',
|
|
1018
|
+
description: 'Get the canvas layout. Defaults to a compact agent-safe projection; pass full:true for full node data.',
|
|
1019
|
+
extraShape: {
|
|
1020
|
+
full: z.boolean().optional().describe('Return the full layout including node data. Default false keeps responses compact.'),
|
|
1021
|
+
verbose: z.boolean().optional().describe('Alias for full:true.'),
|
|
1022
|
+
},
|
|
1023
|
+
buildInput: () => ({ includeBlobs: true }),
|
|
1024
|
+
formatResult: async (result, input, host) => {
|
|
1025
|
+
const layout = result as CanvasLayout;
|
|
1026
|
+
const payload = wantsFullPayload(input)
|
|
1027
|
+
? agentSafeFullLayoutPayload(layout)
|
|
1028
|
+
: compactLayoutPayload(layout, await host.getPinnedNodeIds());
|
|
1029
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(payload, null, 2) }] };
|
|
1030
|
+
},
|
|
1031
|
+
},
|
|
1032
|
+
handler: ({ includeBlobs }) => {
|
|
1033
|
+
const full = includeBlobs === true || includeBlobs === 'true';
|
|
1034
|
+
return (full
|
|
1035
|
+
? serializeCanvasLayout(canvasState.getLayout())
|
|
1036
|
+
: serializeCanvasLayoutWithBlobSummaries(withContextPinLayoutReadState(canvasState.getLayoutForPersistence()))
|
|
1037
|
+
) as unknown as Record<string, unknown>;
|
|
1038
|
+
},
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
export const nodeOperations: Operation[] = [
|
|
1042
|
+
nodeAddOperation,
|
|
1043
|
+
nodeGetOperation,
|
|
1044
|
+
nodeUpdateOperation,
|
|
1045
|
+
nodeRemoveOperation,
|
|
1046
|
+
layoutGetOperation,
|
|
1047
|
+
];
|