pmx-canvas 0.9.2 → 0.10.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/.github/extensions/pmx-canvas/extension.mjs +9 -3
- package/.github/extensions/pmx-canvas/runtime.json +1 -1
- package/.github/extensions/pmx-canvas/tools.json +9278 -2014
- package/CHANGELOG.md +34 -1
- package/dist/canvas/global.css +12 -17
- package/dist/canvas/index.js +58 -58
- package/dist/canvas/mcp-app.html +75 -80
- package/dist/types/client/canvas/submit-without-navigation.d.ts +3 -0
- package/dist/types/client/state/intent-bridge.d.ts +0 -3
- package/dist/types/client/state/presence-store.d.ts +3 -3
- package/dist/types/client/types.d.ts +1 -1
- package/dist/types/mcp/canvas-access.d.ts +1 -12
- package/dist/types/mcp/resource-subscriptions.d.ts +7 -0
- package/dist/types/mcp/server.d.ts +2 -0
- package/dist/types/server/ax-wait.d.ts +1 -0
- package/dist/types/server/canvas-operations.d.ts +1 -0
- package/dist/types/server/canvas-state.d.ts +2 -0
- package/dist/types/server/index.d.ts +5 -5
- package/dist/types/server/knowledge-lifecycle.d.ts +11 -2
- package/dist/types/server/lifecycle-status.d.ts +3 -1
- package/dist/types/server/operations/http.d.ts +10 -0
- package/dist/types/server/operations/index.d.ts +2 -2
- package/dist/types/server/operations/invoker.d.ts +4 -3
- package/dist/types/server/operations/mcp-contracts.d.ts +20 -0
- package/dist/types/server/operations/mcp.d.ts +2 -0
- package/dist/types/server/operations/ops/batch.d.ts +1 -0
- package/dist/types/server/operations/registry.d.ts +2 -2
- package/dist/types/server/operations/types.d.ts +12 -2
- package/dist/types/server/server.d.ts +2 -0
- package/dist/types/server/tour-control.d.ts +6 -3
- package/dist/types/server/webpage-node.d.ts +1 -0
- package/docs/api-reference.md +2 -2
- package/docs/cli.md +10 -1
- package/docs/design/Chrome.dc.html +1 -0
- package/docs/design.md +3 -1
- package/docs/http-api.md +31 -0
- package/docs/mcp.md +98 -22
- package/docs/node-types.md +3 -0
- package/docs/plans/plan-019-post-0.9-roadmap.md +187 -11
- package/docs/sdk.md +10 -0
- package/package.json +1 -1
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/extension.mjs +9 -3
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +1 -1
- package/plugins/pmx-canvas/plugin.json +1 -1
- package/plugins/pmx-canvas/runtime.json +1 -1
- package/plugins/pmx-canvas/tools.json +9278 -2014
- package/skills/pmx-canvas/SKILL.md +12 -7
- package/skills/pmx-canvas/references/full-reference.md +35 -2
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +2 -2
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +7 -7
- package/src/client/canvas/BoardCopyDialog.tsx +2 -1
- package/src/client/canvas/KnowledgeDialog.tsx +3 -0
- package/src/client/canvas/LifecyclePanel.tsx +4 -0
- package/src/client/canvas/RelationPresetsDialog.tsx +2 -0
- package/src/client/canvas/ToolRail.tsx +2 -0
- package/src/client/canvas/submit-without-navigation.ts +14 -0
- package/src/client/state/canvas-store.ts +12 -24
- package/src/client/state/intent-bridge.ts +0 -6
- package/src/client/state/presence-store.ts +3 -3
- package/src/client/state/sse-bridge.ts +11 -6
- package/src/client/theme/global.css +12 -17
- package/src/mcp/canvas-access.ts +6 -47
- package/src/mcp/resource-subscriptions.ts +181 -0
- package/src/mcp/server.ts +185 -219
- package/src/server/ax-wait.ts +21 -6
- package/src/server/canvas-operations.ts +6 -2
- package/src/server/canvas-state.ts +10 -0
- package/src/server/index.ts +20 -32
- package/src/server/knowledge-lifecycle.ts +66 -0
- package/src/server/lifecycle-status.ts +79 -7
- package/src/server/operations/http.ts +69 -21
- package/src/server/operations/index.ts +8 -1
- package/src/server/operations/invoker.ts +53 -5
- package/src/server/operations/mcp-contracts.ts +339 -0
- package/src/server/operations/mcp.ts +153 -18
- package/src/server/operations/ops/ax-await.ts +18 -10
- package/src/server/operations/ops/ax-read.ts +2 -0
- package/src/server/operations/ops/ax-timeline.ts +2 -2
- package/src/server/operations/ops/batch.ts +27 -8
- package/src/server/operations/ops/lifecycle.ts +3 -7
- package/src/server/operations/ops/nodes.ts +20 -8
- package/src/server/operations/ops/viewport.ts +3 -7
- package/src/server/operations/registry.ts +24 -4
- package/src/server/operations/types.ts +16 -2
- package/src/server/server.ts +23 -10
- package/src/server/tour-control.ts +17 -8
- package/src/server/webpage-node.ts +9 -4
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP's output is the FORMATTER projection, never the larger HTTP payload.
|
|
3
|
+
* `result` wraps prose/arrays without pretending they are JSON objects. Each
|
|
4
|
+
* operation validates its stable fields; loose payloads preserve full:true and
|
|
5
|
+
* extensible node/app data. No schema advertises an unconstrained root record.
|
|
6
|
+
*/
|
|
7
|
+
import { z, type ZodType } from 'zod';
|
|
8
|
+
import type { CallToolResult, ToolAnnotations } from '@modelcontextprotocol/sdk/types.js';
|
|
9
|
+
import { tourSchema, tourStopSchema } from '../../shared/tour.js';
|
|
10
|
+
|
|
11
|
+
const id = z.string().min(1);
|
|
12
|
+
const count = z.number().int().nonnegative();
|
|
13
|
+
const ok = z.looseObject({ ok: z.boolean() });
|
|
14
|
+
const identified = z.looseObject({ id });
|
|
15
|
+
const message = z.strictObject({ message: z.string() });
|
|
16
|
+
const node = z.looseObject({
|
|
17
|
+
id,
|
|
18
|
+
type: z.string(),
|
|
19
|
+
contentRevision: count.optional(),
|
|
20
|
+
linksRevision: count.optional(),
|
|
21
|
+
});
|
|
22
|
+
const created = z.looseObject({
|
|
23
|
+
ok: z.boolean(),
|
|
24
|
+
id,
|
|
25
|
+
nodeId: id.optional(),
|
|
26
|
+
node: node.optional(),
|
|
27
|
+
contentRevision: count.optional(),
|
|
28
|
+
});
|
|
29
|
+
const edge = z.looseObject({ id, from: id, to: id, type: z.string() });
|
|
30
|
+
const boards = z.looseObject({ ok: z.boolean(), activeBoardId: id.nullable(), boards: z.array(identified) });
|
|
31
|
+
const board = z.looseObject({ ok: z.boolean(), board: identified });
|
|
32
|
+
const viewport = z.object({ x: z.number(), y: z.number(), scale: z.number().positive() });
|
|
33
|
+
const webview = z.looseObject({ supported: z.boolean(), active: z.boolean() });
|
|
34
|
+
const entity = (key: string) => z.looseObject({ ok: z.boolean(), [key]: identified });
|
|
35
|
+
const gate = (key: string) =>
|
|
36
|
+
z.looseObject({ ok: z.boolean(), [key]: identified.nullable(), pending: z.boolean().optional() });
|
|
37
|
+
const history = z.looseObject({ ok: z.boolean(), description: z.string(), canUndo: z.boolean(), canRedo: z.boolean() });
|
|
38
|
+
const batchEntry = z.looseObject({
|
|
39
|
+
ok: z.boolean(),
|
|
40
|
+
id: id.optional(),
|
|
41
|
+
nodeId: id.optional(),
|
|
42
|
+
contentRevision: count.optional(),
|
|
43
|
+
});
|
|
44
|
+
const batch = z.discriminatedUnion('ok', [
|
|
45
|
+
z.looseObject({ ok: z.literal(true), results: z.array(batchEntry), refs: z.record(z.string(), batchEntry) }),
|
|
46
|
+
z.looseObject({
|
|
47
|
+
ok: z.literal(false),
|
|
48
|
+
failedIndex: count,
|
|
49
|
+
error: z.string(),
|
|
50
|
+
cancelled: z.boolean().optional(),
|
|
51
|
+
results: z.array(batchEntry),
|
|
52
|
+
refs: z.record(z.string(), batchEntry),
|
|
53
|
+
}),
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
/** Required stable fields, keyed by dispatched operation (including every composite action). */
|
|
57
|
+
const payloads: Record<string, ZodType> = {
|
|
58
|
+
'pin.set': z.looseObject({ ok: z.boolean(), pinnedNodeIds: z.array(id) }),
|
|
59
|
+
'ax.command.invoke': entity('event'),
|
|
60
|
+
'canvas.batch': batch,
|
|
61
|
+
'import.list': z.looseObject({ ok: z.boolean(), imports: z.array(identified) }),
|
|
62
|
+
'import.read': z.looseObject({
|
|
63
|
+
ok: z.boolean(),
|
|
64
|
+
import: identified,
|
|
65
|
+
attachment: identified,
|
|
66
|
+
downloadPath: z.string(),
|
|
67
|
+
}),
|
|
68
|
+
'import.submit': entity('import'),
|
|
69
|
+
'import.unavailable': entity('import'),
|
|
70
|
+
'snapshot.save': z.looseObject({ ok: z.boolean(), id, snapshot: identified }),
|
|
71
|
+
'snapshot.list': z.looseObject({ snapshots: z.array(identified) }),
|
|
72
|
+
'snapshot.restore': z.looseObject({
|
|
73
|
+
ok: z.boolean(),
|
|
74
|
+
restored: id,
|
|
75
|
+
summary: z.looseObject({ nodeCount: count, edgeCount: count }),
|
|
76
|
+
}),
|
|
77
|
+
'snapshot.restore-node': z.looseObject({ ok: z.boolean(), id, restored: z.boolean() }),
|
|
78
|
+
'snapshot.delete': z.looseObject({ ok: z.boolean(), deleted: id }),
|
|
79
|
+
'snapshot.gc': ok,
|
|
80
|
+
'snapshot.diff': message,
|
|
81
|
+
'board.list': boards,
|
|
82
|
+
'board.get': board,
|
|
83
|
+
'board.create': board,
|
|
84
|
+
'board.create-from': z.union([
|
|
85
|
+
board,
|
|
86
|
+
z.looseObject({ ok: z.boolean(), sourceBoardId: id, cards: z.array(node), edges: z.array(edge) }),
|
|
87
|
+
]),
|
|
88
|
+
'board.open': boards,
|
|
89
|
+
'board.update': boards,
|
|
90
|
+
'board.pin': boards,
|
|
91
|
+
'board.unpin': boards,
|
|
92
|
+
'knowledge.list': z.looseObject({
|
|
93
|
+
ok: z.boolean(),
|
|
94
|
+
categories: z.array(identified),
|
|
95
|
+
folders: z.array(z.looseObject({ path: z.string() })),
|
|
96
|
+
}),
|
|
97
|
+
'knowledge.category-set': entity('category'),
|
|
98
|
+
'knowledge.category-remove': z.looseObject({ ok: z.boolean(), id }),
|
|
99
|
+
'knowledge.relation-preset-set': entity('preset'),
|
|
100
|
+
'knowledge.relation-preset-remove': z.looseObject({ ok: z.boolean(), id }),
|
|
101
|
+
'knowledge.folder-set': z.looseObject({ ok: z.boolean(), folder: z.looseObject({ path: z.string() }) }),
|
|
102
|
+
'node.add': created,
|
|
103
|
+
'node.get': node,
|
|
104
|
+
'node.update': created,
|
|
105
|
+
'node.remove': z.looseObject({ ok: z.boolean(), removed: id }),
|
|
106
|
+
'node.lifecycle-get': z.looseObject({ ok: z.boolean(), boardId: id, nodeId: id, contentRevision: count }),
|
|
107
|
+
'node.lifecycle-set': ok,
|
|
108
|
+
'node.review': entity('record'),
|
|
109
|
+
'node.lifecycle-restore': entity('record'),
|
|
110
|
+
'schema.describe': z.looseObject({ ok: z.boolean(), nodeTypes: z.array(z.looseObject({ type: z.string() })) }),
|
|
111
|
+
'spec.validate': ok,
|
|
112
|
+
'jsonrender.add': created,
|
|
113
|
+
'jsonrender.stream': created.extend({ specVersion: count, applied: count, skipped: count }),
|
|
114
|
+
'graph.add': created,
|
|
115
|
+
'render.workboard': z.looseObject({ ok: z.boolean(), id }),
|
|
116
|
+
'edge.add': edge,
|
|
117
|
+
'edge.update': z.looseObject({ id, type: z.string() }),
|
|
118
|
+
'edge.remove': z.looseObject({ ok: z.boolean(), removed: id }),
|
|
119
|
+
'group.create': created,
|
|
120
|
+
'group.add': z.looseObject({ ok: z.boolean(), groupId: id }),
|
|
121
|
+
'group.remove': z.looseObject({ ok: z.boolean(), groupId: id }),
|
|
122
|
+
'canvas.undo': history,
|
|
123
|
+
'canvas.redo': history,
|
|
124
|
+
'tour.get': z.looseObject({ tour: tourSchema, derived: z.boolean(), position: count.nullable() }),
|
|
125
|
+
'tour.set': ok,
|
|
126
|
+
'tour.go': z.looseObject({ ok: z.boolean(), index: count, total: count, stop: tourStopSchema }),
|
|
127
|
+
'tour.exit': ok,
|
|
128
|
+
'camera.move': z.looseObject({ ok: z.boolean(), stop: tourStopSchema, viewport }),
|
|
129
|
+
arrange: z.looseObject({ ok: z.boolean(), layout: z.enum(['grid', 'column', 'flow']) }),
|
|
130
|
+
'node.focus': z.looseObject({ ok: z.boolean(), focused: id, panned: z.boolean() }),
|
|
131
|
+
'view.fit': z.looseObject({ ok: z.boolean(), viewport }),
|
|
132
|
+
'canvas.clear': z.looseObject({ ok: z.boolean(), cleared: z.literal(true) }),
|
|
133
|
+
'annotation.remove': ok,
|
|
134
|
+
'context.get': z.looseObject({ version: z.literal(1), budgetUnit: z.literal('utf16-code-units') }),
|
|
135
|
+
'memory-graph.get': z.looseObject({
|
|
136
|
+
nodes: z.array(z.looseObject({ key: id, boardId: id, kind: z.enum(['board', 'card']) })),
|
|
137
|
+
edges: z.array(z.looseObject({ id, from: id, to: id })),
|
|
138
|
+
depth: count,
|
|
139
|
+
}),
|
|
140
|
+
search: z.looseObject({ query: z.string(), resultCount: count, results: z.array(node) }),
|
|
141
|
+
'layout.get': z.looseObject({ nodes: z.array(node), edges: z.array(edge), viewport }),
|
|
142
|
+
'validate.get': ok,
|
|
143
|
+
'mcpapp.open': z.looseObject({
|
|
144
|
+
ok: z.boolean(),
|
|
145
|
+
nodeId: id.nullable(),
|
|
146
|
+
sessionId: id,
|
|
147
|
+
toolCallId: id,
|
|
148
|
+
resourceUri: z.string(),
|
|
149
|
+
}),
|
|
150
|
+
'diagram.open': z.looseObject({
|
|
151
|
+
ok: z.boolean(),
|
|
152
|
+
nodeId: id.nullable(),
|
|
153
|
+
sessionId: id,
|
|
154
|
+
toolCallId: id,
|
|
155
|
+
resourceUri: z.string(),
|
|
156
|
+
}),
|
|
157
|
+
'webartifact.build': z.looseObject({
|
|
158
|
+
ok: z.boolean(),
|
|
159
|
+
path: z.string(),
|
|
160
|
+
bytes: count,
|
|
161
|
+
openedInCanvas: z.boolean(),
|
|
162
|
+
id: id.optional(),
|
|
163
|
+
nodeId: id.nullable().optional(),
|
|
164
|
+
}),
|
|
165
|
+
'webview.status': webview,
|
|
166
|
+
'webview.start': webview,
|
|
167
|
+
'webview.stop': z.looseObject({ ok: z.boolean(), stopped: z.boolean(), webview }),
|
|
168
|
+
'webview.resize': webview,
|
|
169
|
+
// Evaluation deliberately returns arbitrary page values, including undefined
|
|
170
|
+
// (serialized as an absent value). This is the one untyped leaf, not a root contract.
|
|
171
|
+
'webview.evaluate': z.strictObject({ value: z.unknown().optional() }),
|
|
172
|
+
'ax.get': ok,
|
|
173
|
+
'ax.focus.set': z.looseObject({ ok: z.boolean(), focus: z.looseObject({ nodeIds: z.array(id) }) }),
|
|
174
|
+
'ax.policy.set': ok,
|
|
175
|
+
'ax.host-capability.report': ok,
|
|
176
|
+
'ax.presence.get': ok,
|
|
177
|
+
'ax.presence.set': ok,
|
|
178
|
+
'ax.work.create': entity('workItem'),
|
|
179
|
+
'ax.work.update': entity('workItem'),
|
|
180
|
+
'ax.review.add': entity('reviewAnnotation'),
|
|
181
|
+
'ax.approval.request': gate('approvalGate'),
|
|
182
|
+
'ax.approval.resolve': gate('approvalGate'),
|
|
183
|
+
'ax.approval.get': gate('approvalGate'),
|
|
184
|
+
'ax.elicitation.request': gate('elicitation'),
|
|
185
|
+
'ax.elicitation.respond': gate('elicitation'),
|
|
186
|
+
'ax.elicitation.get': gate('elicitation'),
|
|
187
|
+
'ax.mode.request': gate('modeRequest'),
|
|
188
|
+
'ax.mode.resolve': gate('modeRequest'),
|
|
189
|
+
'ax.mode.get': gate('modeRequest'),
|
|
190
|
+
'ax.timeline.get': ok,
|
|
191
|
+
'ax.event.record': entity('event'),
|
|
192
|
+
'ax.evidence.add': entity('evidence'),
|
|
193
|
+
'ax.steer': entity('steering'),
|
|
194
|
+
'ax.reads.list': ok,
|
|
195
|
+
'ax.reads.status': z.looseObject({ ok: z.boolean(), boardId: id.nullable() }),
|
|
196
|
+
'ax.delivery.pending': z.looseObject({ ok: z.boolean(), pending: z.array(identified) }),
|
|
197
|
+
'ax.delivery.mark': z.looseObject({ ok: z.boolean(), delivered: z.boolean() }),
|
|
198
|
+
'intent.signal': entity('intent'),
|
|
199
|
+
'intent.update': entity('intent'),
|
|
200
|
+
'intent.clear': z.looseObject({ ok: z.boolean(), cleared: z.boolean() }),
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
function payloadSchema(operation: string): ZodType {
|
|
204
|
+
const schema = payloads[operation];
|
|
205
|
+
if (!schema) throw new Error(`Missing MCP output contract for ${operation}.`);
|
|
206
|
+
return schema;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const errorData = z.strictObject({ message: z.string() });
|
|
210
|
+
const formattedErrorData = z.looseObject({ ok: z.literal(false), error: z.string() });
|
|
211
|
+
|
|
212
|
+
export function operationOutputSchema(operations: string[]) {
|
|
213
|
+
// Shared schema identity proves equivalent validation without comparing or
|
|
214
|
+
// rewriting JSON Schema. Keep distinct contracts tied to their operations.
|
|
215
|
+
const successGroups = new Map<ZodType, string[]>();
|
|
216
|
+
const variants: ZodType[] = operations.flatMap((operation): ZodType[] => {
|
|
217
|
+
if (operation === 'canvas.batch')
|
|
218
|
+
return [
|
|
219
|
+
z.strictObject({ operation: z.literal(operation), outcome: z.literal('success'), data: batch.options[0] }),
|
|
220
|
+
z.strictObject({
|
|
221
|
+
operation: z.literal(operation),
|
|
222
|
+
outcome: z.literal('error'),
|
|
223
|
+
data: batch.options[1].extend({ results: z.array(batchEntry).length(0) }),
|
|
224
|
+
}),
|
|
225
|
+
z.strictObject({
|
|
226
|
+
operation: z.literal(operation),
|
|
227
|
+
outcome: z.literal('partial'),
|
|
228
|
+
data: batch.options[1].extend({ results: z.array(batchEntry).min(1) }),
|
|
229
|
+
}),
|
|
230
|
+
];
|
|
231
|
+
const schema = payloadSchema(operation);
|
|
232
|
+
const group = successGroups.get(schema);
|
|
233
|
+
if (group) group.push(operation);
|
|
234
|
+
else successGroups.set(schema, [operation]);
|
|
235
|
+
return [];
|
|
236
|
+
});
|
|
237
|
+
for (const [data, names] of successGroups)
|
|
238
|
+
variants.push(
|
|
239
|
+
z.strictObject({
|
|
240
|
+
operation: names.length === 1 ? z.literal(names[0]) : z.enum(names),
|
|
241
|
+
outcome: z.literal('success'),
|
|
242
|
+
data,
|
|
243
|
+
}),
|
|
244
|
+
);
|
|
245
|
+
const formattedErrors = operations.filter((operation) => operation !== 'canvas.batch');
|
|
246
|
+
if (formattedErrors.length)
|
|
247
|
+
variants.push(
|
|
248
|
+
z.strictObject({
|
|
249
|
+
operation: z.enum(formattedErrors),
|
|
250
|
+
outcome: z.literal('error'),
|
|
251
|
+
data: z.union([errorData, formattedErrorData]),
|
|
252
|
+
}),
|
|
253
|
+
);
|
|
254
|
+
// null identifies an exception rather than a formatted operation result;
|
|
255
|
+
// it does NOT promise that no writes occurred before the exception.
|
|
256
|
+
return z.strictObject({
|
|
257
|
+
result: z.union([
|
|
258
|
+
...variants,
|
|
259
|
+
z.strictObject({ operation: z.null(), outcome: z.literal('error'), data: errorData }),
|
|
260
|
+
]),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function structuredOperationResult(operation: string, formatted: CallToolResult): CallToolResult {
|
|
265
|
+
const text = formatted.content
|
|
266
|
+
.filter((item) => item.type === 'text')
|
|
267
|
+
.map((item) => item.text)
|
|
268
|
+
.join('\n');
|
|
269
|
+
let data: unknown;
|
|
270
|
+
try {
|
|
271
|
+
data = JSON.parse(text);
|
|
272
|
+
} catch {
|
|
273
|
+
data = { message: text };
|
|
274
|
+
}
|
|
275
|
+
// Error payloads can contain meaningful partial state. Never replace them
|
|
276
|
+
// with the thrown-error message shape, or lose batch results/refs.
|
|
277
|
+
if (!formatted.isError || operation === 'canvas.batch') payloadSchema(operation).parse(data);
|
|
278
|
+
else z.union([errorData, formattedErrorData]).parse(data);
|
|
279
|
+
const partial =
|
|
280
|
+
operation === 'canvas.batch' &&
|
|
281
|
+
formatted.isError &&
|
|
282
|
+
typeof data === 'object' &&
|
|
283
|
+
data !== null &&
|
|
284
|
+
'results' in data &&
|
|
285
|
+
Array.isArray(data.results) &&
|
|
286
|
+
data.results.length > 0;
|
|
287
|
+
return {
|
|
288
|
+
...formatted,
|
|
289
|
+
structuredContent: {
|
|
290
|
+
result: { operation, outcome: partial ? 'partial' : formatted.isError ? 'error' : 'success', data },
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function operationToolError(error: unknown): CallToolResult {
|
|
296
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
297
|
+
return {
|
|
298
|
+
content: [{ type: 'text', text: message }],
|
|
299
|
+
isError: true,
|
|
300
|
+
structuredContent: { result: { operation: null, outcome: 'error', data: { message } } },
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Explicit worst-case audit of all 21 generated tools. `mutates` only controls
|
|
306
|
+
* layout events and is intentionally absent here. Tuple = destructive,
|
|
307
|
+
* idempotent, open-world. All tools have a write action, including query's
|
|
308
|
+
* durable consumer cursor. Read logging alone would not make a tool a write.
|
|
309
|
+
*/
|
|
310
|
+
const annotations: Record<string, readonly [boolean, boolean, boolean]> = {
|
|
311
|
+
canvas_pin_nodes: [true, true, false], // set/remove can replace the human's pins
|
|
312
|
+
canvas_invoke_command: [false, false, false], // appends an allowlisted intent, not arbitrary execution
|
|
313
|
+
canvas_batch: [true, false, true], // remove/replace; webpage creation can fetch
|
|
314
|
+
canvas_import: [false, false, false], // submit/unavailable transition a requested job
|
|
315
|
+
canvas_snapshot: [true, false, true], // restore/delete/gc; restoring apps can reconnect
|
|
316
|
+
canvas_board: [true, false, true], // category/template removal; open can rehydrate apps
|
|
317
|
+
canvas_node: [true, false, true], // remove/update; webpage fetching and file nodes
|
|
318
|
+
canvas_render: [true, false, false], // stream/workboard can replace an existing spec
|
|
319
|
+
canvas_edge: [true, false, false],
|
|
320
|
+
canvas_group: [true, false, false],
|
|
321
|
+
canvas_history: [true, false, true], // undo/redo can restore and reconnect app sessions
|
|
322
|
+
canvas_view: [true, false, false], // clear/remove annotation; tour next is not idempotent
|
|
323
|
+
canvas_query: [false, false, false], // context advances a durable consumer cursor
|
|
324
|
+
canvas_app: [true, false, true], // arbitrary external tools/build processes; existing-node replacement
|
|
325
|
+
canvas_webview: [true, false, true], // evaluate is arbitrary page JavaScript, not a read
|
|
326
|
+
canvas_ax_state: [true, false, false], // replaces policy/focus; presence can append sessions
|
|
327
|
+
canvas_ax_work: [true, false, false], // update replaces existing work-item fields
|
|
328
|
+
canvas_ax_gate: [true, false, false], // resolve changes pending gates; request creates IDs
|
|
329
|
+
canvas_ax_timeline: [false, false, false], // appends events/evidence/steering
|
|
330
|
+
canvas_ax_delivery: [false, false, false], // claim/mark consume delivery state
|
|
331
|
+
canvas_intent: [true, false, false], // update/clear; signal generates an ID and TTL
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
export function operationToolAnnotations(toolName: string): ToolAnnotations {
|
|
335
|
+
const flags = annotations[toolName];
|
|
336
|
+
if (!flags) throw new Error(`Missing MCP annotation audit for ${toolName}.`);
|
|
337
|
+
const [destructiveHint, idempotentHint, openWorldHint] = flags;
|
|
338
|
+
return { readOnlyHint: false, destructiveHint, idempotentHint, openWorldHint };
|
|
339
|
+
}
|
|
@@ -9,9 +9,17 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { z, type ZodRawShape, type ZodTypeAny } from 'zod';
|
|
11
11
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
12
|
+
import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
|
|
13
|
+
import type { ServerRequest, ServerNotification } from '@modelcontextprotocol/sdk/types.js';
|
|
12
14
|
import { getOperation, listOperations } from './registry.js';
|
|
13
15
|
import { agentSourceLabel, type OperationInvoker } from './invoker.js';
|
|
14
|
-
import { OperationError, type Operation, type OperationMcpToolHost } from './types.js';
|
|
16
|
+
import { OperationError, type Operation, type OperationMcpToolHost, type OperationExecutionOptions } from './types.js';
|
|
17
|
+
import {
|
|
18
|
+
operationOutputSchema,
|
|
19
|
+
operationToolAnnotations,
|
|
20
|
+
operationToolError,
|
|
21
|
+
structuredOperationResult,
|
|
22
|
+
} from './mcp-contracts.js';
|
|
15
23
|
import {
|
|
16
24
|
CONTEXT_READ_OPS,
|
|
17
25
|
contextReadFromPayload,
|
|
@@ -28,23 +36,51 @@ export interface OperationToolHost extends OperationMcpToolHost {
|
|
|
28
36
|
recordContextRead(read: ContextReadInput): Promise<void>;
|
|
29
37
|
}
|
|
30
38
|
|
|
39
|
+
function executionOptions(extra: RequestHandlerExtra<ServerRequest, ServerNotification>): OperationExecutionOptions {
|
|
40
|
+
const progressToken = extra._meta?.progressToken;
|
|
41
|
+
return {
|
|
42
|
+
signal: extra.signal,
|
|
43
|
+
...(progressToken !== undefined
|
|
44
|
+
? {
|
|
45
|
+
onProgress: (progress: Parameters<NonNullable<OperationExecutionOptions['onProgress']>>[0]) =>
|
|
46
|
+
extra.sendNotification({ method: 'notifications/progress', params: { ...progress, progressToken } }),
|
|
47
|
+
}
|
|
48
|
+
: {}),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
31
52
|
async function invokeMcpOperation(
|
|
32
53
|
host: OperationToolHost,
|
|
33
54
|
op: Operation,
|
|
34
55
|
opInput: Record<string, unknown>,
|
|
35
56
|
formatInput: Record<string, unknown>,
|
|
36
57
|
consumer: string | null,
|
|
58
|
+
options: OperationExecutionOptions,
|
|
37
59
|
) {
|
|
60
|
+
options.signal?.throwIfAborted();
|
|
38
61
|
const board = typeof opInput.board === 'string' && opInput.board.trim() ? opInput.board.trim() : undefined;
|
|
39
62
|
const readTarget = CONTEXT_READ_OPS.has(op.name) ? await host.prepareContextRead(board) : null;
|
|
40
63
|
if (readTarget?.boardId && 'board' in op.inputShape) {
|
|
41
64
|
opInput = { ...opInput, board: readTarget.boardId };
|
|
42
65
|
formatInput = { ...formatInput, board: readTarget.boardId };
|
|
43
66
|
}
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
67
|
+
options.signal?.throwIfAborted();
|
|
68
|
+
const invoker = host.invoker();
|
|
69
|
+
const result = await invoker.invoke(op.name, opInput, options);
|
|
70
|
+
// Preserve the host receiver, and bind options to formatter follow-up reads
|
|
71
|
+
// (history flags, snapshot summary, streaming node metadata).
|
|
72
|
+
const formatterHost: OperationMcpToolHost = {
|
|
73
|
+
getPinnedNodeIds: (target) => host.getPinnedNodeIds(target),
|
|
74
|
+
invoker: () => ({ invoke: (name, input, followUp) => invoker.invoke(name, input, { ...options, ...followUp }) }),
|
|
75
|
+
};
|
|
76
|
+
const formatted = structuredOperationResult(
|
|
77
|
+
op.name,
|
|
78
|
+
op.mcp?.formatResult
|
|
79
|
+
? await op.mcp.formatResult(result, formatInput, formatterHost)
|
|
80
|
+
: { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] },
|
|
81
|
+
);
|
|
82
|
+
// Do not throwIfAborted here: batch may already have returned a partial
|
|
83
|
+
// outcome, and cancellation must never erase completed work from the reply.
|
|
48
84
|
if (readTarget) {
|
|
49
85
|
const text = formatted.content
|
|
50
86
|
.filter((item): item is Extract<(typeof formatted.content)[number], { type: 'text' }> => item.type === 'text')
|
|
@@ -116,17 +152,24 @@ export function registerOperationTools(server: McpServer, getHost: () => Promise
|
|
|
116
152
|
// finding A). Loose parsing forwards unknown keys to the op layer,
|
|
117
153
|
// the single authority on dead-field rejection, HTTP and MCP alike.
|
|
118
154
|
inputSchema: z.looseObject({ ...op.inputShape, ...(tool.extraShape ?? {}) }),
|
|
155
|
+
outputSchema: operationOutputSchema([op.name]),
|
|
156
|
+
annotations: operationToolAnnotations(tool.toolName),
|
|
119
157
|
},
|
|
120
|
-
async (input: Record<string, unknown
|
|
158
|
+
async (input: Record<string, unknown>, extra) => {
|
|
121
159
|
try {
|
|
122
160
|
const host = await getHost();
|
|
161
|
+
extra.signal.throwIfAborted();
|
|
123
162
|
const opInput = tool.buildInput ? tool.buildInput(input) : input;
|
|
124
|
-
return await invokeMcpOperation(
|
|
163
|
+
return await invokeMcpOperation(
|
|
164
|
+
host,
|
|
165
|
+
op,
|
|
166
|
+
opInput,
|
|
167
|
+
input,
|
|
168
|
+
server.server.getClientVersion()?.name ?? null,
|
|
169
|
+
executionOptions(extra),
|
|
170
|
+
);
|
|
125
171
|
} catch (error) {
|
|
126
|
-
return
|
|
127
|
-
content: [{ type: 'text' as const, text: error instanceof Error ? error.message : String(error) }],
|
|
128
|
-
isError: true,
|
|
129
|
-
};
|
|
172
|
+
return operationToolError(error);
|
|
130
173
|
}
|
|
131
174
|
},
|
|
132
175
|
);
|
|
@@ -295,6 +338,88 @@ function requiredMcpFields(op: Operation): string[] {
|
|
|
295
338
|
return keys;
|
|
296
339
|
}
|
|
297
340
|
|
|
341
|
+
/** Registry metadata only: never boots a canvas or records a content read. */
|
|
342
|
+
export function registerOperationHelpTool(server: McpServer): void {
|
|
343
|
+
const folded = compositeFoldedOpNames();
|
|
344
|
+
server.registerTool(
|
|
345
|
+
'canvas_help',
|
|
346
|
+
{
|
|
347
|
+
description:
|
|
348
|
+
'Look up an MCP operation by registry name (e.g. node.add, ax.approval.request). Returns its public tool, argument schema and guidance. Omit op to list operation names. Does not execute operations or read canvas content.',
|
|
349
|
+
inputSchema: z.object({ op: z.string().min(1).optional() }),
|
|
350
|
+
outputSchema: z.object({
|
|
351
|
+
result: z.union([
|
|
352
|
+
z.strictObject({ operations: z.array(z.string()) }),
|
|
353
|
+
z.strictObject({
|
|
354
|
+
operation: z.string(),
|
|
355
|
+
tool: z.string(),
|
|
356
|
+
description: z.string(),
|
|
357
|
+
inputSchema: z.record(z.string(), z.unknown()),
|
|
358
|
+
}),
|
|
359
|
+
]),
|
|
360
|
+
}),
|
|
361
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
362
|
+
},
|
|
363
|
+
({ op: name }) => {
|
|
364
|
+
const operations = listOperations().filter((op) => op.mcp || folded.has(op.name));
|
|
365
|
+
if (name === undefined) {
|
|
366
|
+
const result = { operations: operations.map((op) => op.name).sort() };
|
|
367
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: { result } };
|
|
368
|
+
}
|
|
369
|
+
const op = operations.find((op) => op.name === name);
|
|
370
|
+
if (!op) {
|
|
371
|
+
return {
|
|
372
|
+
content: [{ type: 'text', text: `Unknown MCP operation "${name}". Omit op to list available operations.` }],
|
|
373
|
+
isError: true,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
const composite = compositeToolDefinitions.find((def) => compositeMemberOps(def).includes(name));
|
|
377
|
+
const publicName = (field: string) =>
|
|
378
|
+
Object.entries(composite?.fieldRemap ?? {}).find(([, original]) => original === field)?.[0] ?? field;
|
|
379
|
+
const shape = Object.fromEntries(
|
|
380
|
+
Object.entries({ ...op.inputShape, ...op.mcp?.extraShape }).map(([field, schema]) => [
|
|
381
|
+
publicName(field),
|
|
382
|
+
schema,
|
|
383
|
+
]),
|
|
384
|
+
);
|
|
385
|
+
if (composite?.resolveOp) {
|
|
386
|
+
// Resolve the same kind/action matrix used by composite dispatch.
|
|
387
|
+
const kinds = composite.extraDiscriminatorShape?.kind;
|
|
388
|
+
if (!(kinds instanceof z.ZodEnum)) throw new Error(`Missing kind enum for ${composite.toolName}.`);
|
|
389
|
+
for (const kind of kinds.options) {
|
|
390
|
+
for (const action of composite.actionEnum ?? []) {
|
|
391
|
+
if (composite.resolveOp({ kind: String(kind), action }) === name) {
|
|
392
|
+
shape.kind = z.literal(kind);
|
|
393
|
+
shape.action = z.literal(action);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
} else if (composite) {
|
|
398
|
+
const action = Object.entries(composite.actions).find(([, operation]) => operation === name)![0];
|
|
399
|
+
shape.action = z.literal(action);
|
|
400
|
+
}
|
|
401
|
+
const inputSchema = z.toJSONSchema(z.looseObject(shape), { target: 'draft-7', io: 'input' });
|
|
402
|
+
// Mirror dispatch's required-name alternatives rather than demanding both names.
|
|
403
|
+
for (const [field, aliases] of Object.entries(REQUIRED_FIELD_ALIASES[name] ?? {})) {
|
|
404
|
+
const key = publicName(field);
|
|
405
|
+
if (!inputSchema.required?.includes(key)) continue;
|
|
406
|
+
inputSchema.required = inputSchema.required.filter((required) => required !== key);
|
|
407
|
+
inputSchema.allOf = [
|
|
408
|
+
...(inputSchema.allOf ?? []),
|
|
409
|
+
{ anyOf: [key, ...aliases.map(publicName)].map((alternative) => ({ required: [alternative] })) },
|
|
410
|
+
];
|
|
411
|
+
}
|
|
412
|
+
const result = {
|
|
413
|
+
operation: name,
|
|
414
|
+
tool: composite?.toolName ?? op.mcp!.toolName,
|
|
415
|
+
description: op.mcp?.description ?? composite!.description,
|
|
416
|
+
inputSchema,
|
|
417
|
+
};
|
|
418
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: { result } };
|
|
419
|
+
},
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
298
423
|
/**
|
|
299
424
|
* Register composite (action-discriminated) MCP tools (plan-006). Each action
|
|
300
425
|
* dispatches to a registered operation, reusing that op's `mcp.buildInput` and
|
|
@@ -317,10 +442,16 @@ export function registerCompositeTools(
|
|
|
317
442
|
// must reach the op layer's dead-field rejection instead of being stripped.
|
|
318
443
|
server.registerTool(
|
|
319
444
|
def.toolName,
|
|
320
|
-
{
|
|
321
|
-
|
|
445
|
+
{
|
|
446
|
+
description: def.description,
|
|
447
|
+
inputSchema: z.looseObject(buildCompositeShape(def)),
|
|
448
|
+
outputSchema: operationOutputSchema(compositeMemberOps(def)),
|
|
449
|
+
annotations: operationToolAnnotations(def.toolName),
|
|
450
|
+
},
|
|
451
|
+
async (input: Record<string, unknown>, extra) => {
|
|
322
452
|
try {
|
|
323
453
|
const host = await getHost();
|
|
454
|
+
extra.signal.throwIfAborted();
|
|
324
455
|
const opName = resolveCompositeOp(def, input);
|
|
325
456
|
const op = getOperation(opName);
|
|
326
457
|
// Strip the composite discriminators (action + any extra, e.g. `kind`)
|
|
@@ -335,12 +466,16 @@ export function registerCompositeTools(
|
|
|
335
466
|
);
|
|
336
467
|
}
|
|
337
468
|
const opInput = op.mcp?.buildInput ? op.mcp.buildInput(rest) : rest;
|
|
338
|
-
return await invokeMcpOperation(
|
|
469
|
+
return await invokeMcpOperation(
|
|
470
|
+
host,
|
|
471
|
+
op,
|
|
472
|
+
opInput,
|
|
473
|
+
rest,
|
|
474
|
+
server.server.getClientVersion()?.name ?? null,
|
|
475
|
+
executionOptions(extra),
|
|
476
|
+
);
|
|
339
477
|
} catch (error) {
|
|
340
|
-
return
|
|
341
|
-
content: [{ type: 'text' as const, text: error instanceof Error ? error.message : String(error) }],
|
|
342
|
-
isError: true,
|
|
343
|
-
};
|
|
478
|
+
return operationToolError(error);
|
|
344
479
|
}
|
|
345
480
|
},
|
|
346
481
|
);
|
|
@@ -39,11 +39,7 @@
|
|
|
39
39
|
* the legacy error body — server-api only reads `missing.status`.
|
|
40
40
|
*
|
|
41
41
|
* Documented behavior change (accepted long-poll tradeoff, plan-007 sub-slice):
|
|
42
|
-
* -
|
|
43
|
-
* client disconnect aborted the wait early. The registry handler has no access
|
|
44
|
-
* to the HTTP Request, so the wait now runs to its (≤120s, subscription-based,
|
|
45
|
-
* cheap) timeout instead of aborting on disconnect. Resolution detection,
|
|
46
|
-
* timeout, and the `{ value, pending }` result are otherwise identical.
|
|
42
|
+
* - Request cancellation rejects the wait and disposes its subscription/timer.
|
|
47
43
|
* - Remote-MCP only (daemon mode, untested for these reads): the legacy
|
|
48
44
|
* RemoteCanvasAccess.await* methods special-cased a 404 → `{ <key>:null }`. The
|
|
49
45
|
* generic HttpOperationInvoker throws on 404, so an await-on-missing over a
|
|
@@ -70,9 +66,12 @@ function clampTimeoutMs(value: unknown): number {
|
|
|
70
66
|
return Math.min(ms, AX_WAIT_MAX_MS);
|
|
71
67
|
}
|
|
72
68
|
|
|
73
|
-
/**
|
|
69
|
+
/** Accept the operation's timeoutMs as well as the direct HTTP waitMs spelling. */
|
|
74
70
|
function readWaitMsInput(_req: Request, params: Record<string, string>, url: URL): Record<string, unknown> {
|
|
75
|
-
return {
|
|
71
|
+
return {
|
|
72
|
+
...params,
|
|
73
|
+
timeoutMs: clampTimeoutMs(url.searchParams.get('timeoutMs') ?? url.searchParams.get('waitMs') ?? ''),
|
|
74
|
+
};
|
|
76
75
|
}
|
|
77
76
|
|
|
78
77
|
/** MCP buildInput: forward id + timeoutMs (omitted ⇒ 30000), clamped to [0, max]. */
|
|
@@ -131,13 +130,16 @@ const axApprovalGetOperation = defineOperation<z.infer<typeof awaitInputSchema>,
|
|
|
131
130
|
};
|
|
132
131
|
},
|
|
133
132
|
},
|
|
134
|
-
handler: async (input) => {
|
|
133
|
+
handler: async (input, ctx) => {
|
|
135
134
|
const id = typeof input.id === 'string' ? input.id : '';
|
|
135
|
+
await ctx.onProgress?.({ progress: 0, total: 1, message: 'Waiting for approval' });
|
|
136
136
|
const { value, pending } = await waitForAxResolution<PmxAxApprovalGate>({
|
|
137
137
|
read: () => canvasState.getApproval(id),
|
|
138
138
|
isResolved: (g) => g.status !== 'pending',
|
|
139
139
|
timeoutMs: clampTimeoutMs(input.timeoutMs),
|
|
140
|
+
signal: ctx.signal,
|
|
140
141
|
});
|
|
142
|
+
await ctx.onProgress?.({ progress: 1, total: 1, message: pending ? 'Wait timed out' : 'Wait complete' });
|
|
141
143
|
return { ok: value !== null, approvalGate: value, pending } as unknown as Record<string, unknown>;
|
|
142
144
|
},
|
|
143
145
|
});
|
|
@@ -170,13 +172,16 @@ const axElicitationGetOperation = defineOperation<z.infer<typeof awaitInputSchem
|
|
|
170
172
|
};
|
|
171
173
|
},
|
|
172
174
|
},
|
|
173
|
-
handler: async (input) => {
|
|
175
|
+
handler: async (input, ctx) => {
|
|
174
176
|
const id = typeof input.id === 'string' ? input.id : '';
|
|
177
|
+
await ctx.onProgress?.({ progress: 0, total: 1, message: 'Waiting for elicitation' });
|
|
175
178
|
const { value, pending } = await waitForAxResolution<PmxAxElicitation>({
|
|
176
179
|
read: () => canvasState.getElicitation(id),
|
|
177
180
|
isResolved: (e) => e.status !== 'pending',
|
|
178
181
|
timeoutMs: clampTimeoutMs(input.timeoutMs),
|
|
182
|
+
signal: ctx.signal,
|
|
179
183
|
});
|
|
184
|
+
await ctx.onProgress?.({ progress: 1, total: 1, message: pending ? 'Wait timed out' : 'Wait complete' });
|
|
180
185
|
return { ok: value !== null, elicitation: value, pending } as unknown as Record<string, unknown>;
|
|
181
186
|
},
|
|
182
187
|
});
|
|
@@ -209,13 +214,16 @@ const axModeGetOperation = defineOperation<z.infer<typeof awaitInputSchema>, Rec
|
|
|
209
214
|
};
|
|
210
215
|
},
|
|
211
216
|
},
|
|
212
|
-
handler: async (input) => {
|
|
217
|
+
handler: async (input, ctx) => {
|
|
213
218
|
const id = typeof input.id === 'string' ? input.id : '';
|
|
219
|
+
await ctx.onProgress?.({ progress: 0, total: 1, message: 'Waiting for mode resolution' });
|
|
214
220
|
const { value, pending } = await waitForAxResolution<PmxAxModeRequest>({
|
|
215
221
|
read: () => canvasState.getModeRequest(id),
|
|
216
222
|
isResolved: (m) => m.status !== 'pending',
|
|
217
223
|
timeoutMs: clampTimeoutMs(input.timeoutMs),
|
|
224
|
+
signal: ctx.signal,
|
|
218
225
|
});
|
|
226
|
+
await ctx.onProgress?.({ progress: 1, total: 1, message: pending ? 'Wait timed out' : 'Wait complete' });
|
|
219
227
|
return { ok: value !== null, modeRequest: value, pending } as unknown as Record<string, unknown>;
|
|
220
228
|
},
|
|
221
229
|
});
|
|
@@ -335,6 +335,8 @@ const interactionOperation = defineOperation<z.infer<typeof interactionSchema>,
|
|
|
335
335
|
http: {
|
|
336
336
|
method: 'POST',
|
|
337
337
|
path: '/api/canvas/ax/interaction',
|
|
338
|
+
// MCP preserves the structured rejection code as well as its message.
|
|
339
|
+
errorBodyAsResult: true,
|
|
338
340
|
// Legacy wire: 200 when accepted, the envelope's own status when refused.
|
|
339
341
|
status: (result) =>
|
|
340
342
|
isRecord(result) && result.ok !== true && typeof result.status === 'number' ? result.status : 200,
|