pmx-canvas 0.9.3 → 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/runtime.json +1 -1
- package/.github/extensions/pmx-canvas/tools.json +9278 -2014
- package/CHANGELOG.md +22 -1
- package/dist/canvas/index.js +1 -1
- package/dist/canvas/mcp-app.html +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/index.d.ts +3 -3
- 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/webpage-node.d.ts +1 -0
- package/docs/api-reference.md +2 -2
- package/docs/cli.md +1 -1
- package/docs/http-api.md +31 -0
- package/docs/mcp.md +93 -21
- package/docs/plans/plan-019-post-0.9-roadmap.md +123 -6
- package/docs/sdk.md +5 -2
- package/package.json +1 -1
- 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 +9 -4
- package/skills/pmx-canvas/references/full-reference.md +35 -2
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +7 -7
- package/src/client/canvas/ToolRail.tsx +2 -0
- 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/index.ts +12 -4
- 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/nodes.ts +18 -3
- package/src/server/operations/registry.ts +24 -4
- package/src/server/operations/types.ts +16 -2
- package/src/server/server.ts +10 -0
- package/src/server/webpage-node.ts +9 -4
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { executeOperation, listOperations } from './registry.js';
|
|
10
10
|
import { SOURCE_LABEL_RE } from '../agent-presence.js';
|
|
11
|
-
import { OperationError } from './types.js';
|
|
11
|
+
import { OperationError, type OperationExecutionOptions, type OperationProgress } from './types.js';
|
|
12
12
|
import { isWorkbenchToken, refuseStaleWorkbenchWrite } from '../workbench-auth.js';
|
|
13
13
|
|
|
14
14
|
function responseJson(data: unknown, status = 200): Response {
|
|
@@ -20,6 +20,57 @@ function responseJson(data: unknown, status = 200): Response {
|
|
|
20
20
|
});
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/** Opt-in NDJSON; the terminal frame retains the ordinary HTTP status/body. */
|
|
24
|
+
export type OperationStreamFrame =
|
|
25
|
+
| { type: 'progress'; progress: OperationProgress }
|
|
26
|
+
| { type: 'result'; status: number; body: unknown };
|
|
27
|
+
|
|
28
|
+
function operationFailure(name: string, error: unknown, signal: AbortSignal): Response {
|
|
29
|
+
if (signal.aborted) return responseJson({ ok: false, cancelled: true, error: 'Operation cancelled.' }, 499);
|
|
30
|
+
if (error instanceof OperationError) {
|
|
31
|
+
return responseJson({ ok: false, error: error.message, ...(error.details ?? {}) }, error.status);
|
|
32
|
+
}
|
|
33
|
+
// Keep unexpected errors server-side, never leak a Bun development overlay.
|
|
34
|
+
console.error(`[operation] unhandled error dispatching ${name}:`, error);
|
|
35
|
+
return responseJson({ ok: false, error: 'Internal error processing the request.' }, 500);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function progressResponse(
|
|
39
|
+
req: Request,
|
|
40
|
+
name: string,
|
|
41
|
+
run: (options: OperationExecutionOptions) => Promise<Response>,
|
|
42
|
+
): Response {
|
|
43
|
+
const cancelled = new AbortController();
|
|
44
|
+
const signal = AbortSignal.any([req.signal, cancelled.signal]);
|
|
45
|
+
const encoder = new TextEncoder();
|
|
46
|
+
let closed = false;
|
|
47
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
48
|
+
async start(controller) {
|
|
49
|
+
const send = (frame: OperationStreamFrame): void => {
|
|
50
|
+
if (!closed) controller.enqueue(encoder.encode(`${JSON.stringify(frame)}\n`));
|
|
51
|
+
};
|
|
52
|
+
let response: Response;
|
|
53
|
+
try {
|
|
54
|
+
response = await run({ signal, onProgress: (progress) => send({ type: 'progress', progress }) });
|
|
55
|
+
} catch (error) {
|
|
56
|
+
response = operationFailure(name, error, signal);
|
|
57
|
+
}
|
|
58
|
+
if (!closed) {
|
|
59
|
+
send({ type: 'result', status: response.status, body: await response.json() });
|
|
60
|
+
closed = true;
|
|
61
|
+
controller.close();
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
cancel() {
|
|
65
|
+
closed = true;
|
|
66
|
+
cancelled.abort();
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
return new Response(stream, {
|
|
70
|
+
headers: { 'Content-Type': 'application/x-ndjson', 'Cache-Control': 'no-store', 'X-Accel-Buffering': 'no' },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
23
74
|
export function matchOperationPath(template: string, pathname: string): Record<string, string> | null {
|
|
24
75
|
const templateSegments = template.split('/');
|
|
25
76
|
const pathSegments = pathname.split('/');
|
|
@@ -83,6 +134,7 @@ export async function dispatchOperationRoute(req: Request, url: URL): Promise<Re
|
|
|
83
134
|
const params = matchOperationPath(route.path, url.pathname);
|
|
84
135
|
if (!params) continue;
|
|
85
136
|
try {
|
|
137
|
+
req.signal.throwIfAborted();
|
|
86
138
|
const input = route.readInput
|
|
87
139
|
? await route.readInput(req, params, url)
|
|
88
140
|
: await readOperationInput(req, params, url);
|
|
@@ -100,27 +152,23 @@ export async function dispatchOperationRoute(req: Request, url: URL): Promise<Re
|
|
|
100
152
|
// HTTP callers read as 'api'.
|
|
101
153
|
const sourceHeader = req.headers.get('x-pmx-source');
|
|
102
154
|
const source = sourceHeader && SOURCE_LABEL_RE.test(sourceHeader) ? sourceHeader : 'api';
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
155
|
+
const run = async (options: OperationExecutionOptions): Promise<Response> => {
|
|
156
|
+
const result = await executeOperation(op.name, input, {
|
|
157
|
+
...options,
|
|
158
|
+
// The legacy marker remains a UI-policy signal (ghost suppression).
|
|
159
|
+
// Only the unguessable boot token classifies authorship as human.
|
|
160
|
+
suppressAutoGhost: workbenchMarked,
|
|
161
|
+
fromWorkbench: workbenchMarked,
|
|
162
|
+
humanAuthor,
|
|
163
|
+
source,
|
|
164
|
+
proxiedRead: req.headers.get('x-pmx-proxied-read') === '1',
|
|
165
|
+
});
|
|
166
|
+
return responseJson(result, route.status ? route.status(result) : 200);
|
|
167
|
+
};
|
|
168
|
+
if (req.headers.get('accept') === 'application/x-ndjson') return progressResponse(req, op.name, run);
|
|
169
|
+
return await run({ signal: req.signal });
|
|
113
170
|
} catch (error) {
|
|
114
|
-
|
|
115
|
-
return responseJson({ ok: false, error: error.message, ...(error.details ?? {}) }, error.status);
|
|
116
|
-
}
|
|
117
|
-
// An unexpected (non-OperationError) throw from a handler MUST NOT escape the
|
|
118
|
-
// dispatcher: Bun.serve has no per-request boundary, so an escaped throw renders
|
|
119
|
-
// its dev error overlay (HTTP 500 text/html leaking the absolute server source
|
|
120
|
-
// path + stack). Return a clean JSON 500 with a generic message instead (the
|
|
121
|
-
// real error is logged server-side, never echoed to the client).
|
|
122
|
-
console.error(`[operation] unhandled error dispatching ${op.name}:`, error);
|
|
123
|
-
return responseJson({ ok: false, error: 'Internal error processing the request.' }, 500);
|
|
171
|
+
return operationFailure(op.name, error, req.signal);
|
|
124
172
|
}
|
|
125
173
|
}
|
|
126
174
|
return null;
|
|
@@ -77,7 +77,12 @@ export { runCanvasBatchOperation, type BatchEnvelope } from './ops/batch.js';
|
|
|
77
77
|
// OpenMcpAppCoreResult is the SDK's cast target for executeOperation('mcpapp.open').
|
|
78
78
|
export type { OpenMcpAppCoreResult } from './ops/app.js';
|
|
79
79
|
export { LocalOperationInvoker, HttpOperationInvoker, type OperationInvoker } from './invoker.js';
|
|
80
|
-
export {
|
|
80
|
+
export {
|
|
81
|
+
registerOperationTools,
|
|
82
|
+
registerCompositeTools,
|
|
83
|
+
registerOperationHelpTool,
|
|
84
|
+
type OperationToolHost,
|
|
85
|
+
} from './mcp.js';
|
|
81
86
|
export { compositeToolDefinitions, type CompositeToolDefinition } from './composites.js';
|
|
82
87
|
export {
|
|
83
88
|
OperationError,
|
|
@@ -85,5 +90,7 @@ export {
|
|
|
85
90
|
type Operation,
|
|
86
91
|
type OperationContext,
|
|
87
92
|
type OperationDefinition,
|
|
93
|
+
type OperationExecutionOptions,
|
|
88
94
|
type OperationErrorStatus,
|
|
95
|
+
type OperationProgress,
|
|
89
96
|
} from './types.js';
|
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { SOURCE_LABEL_RE } from '../agent-presence.js';
|
|
7
7
|
import { executeOperation, getOperation } from './registry.js';
|
|
8
|
-
import { OperationError, type OperationErrorStatus } from './types.js';
|
|
8
|
+
import { OperationError, type OperationErrorStatus, type OperationExecutionOptions } from './types.js';
|
|
9
|
+
import type { OperationStreamFrame } from './http.js';
|
|
9
10
|
|
|
10
11
|
export interface OperationInvoker {
|
|
11
|
-
invoke(name: string, input: Record<string, unknown
|
|
12
|
+
invoke(name: string, input: Record<string, unknown>, options?: OperationExecutionOptions): Promise<unknown>;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -40,9 +41,14 @@ export class LocalOperationInvoker implements OperationInvoker {
|
|
|
40
41
|
this.proxiedRead = source === 'mcp';
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
async invoke(
|
|
44
|
+
async invoke(
|
|
45
|
+
name: string,
|
|
46
|
+
input: Record<string, unknown>,
|
|
47
|
+
options: OperationExecutionOptions = {},
|
|
48
|
+
): Promise<unknown> {
|
|
44
49
|
const args = this.allowExternalMcp ? input : { ...input, allowExternalMcp: false };
|
|
45
50
|
return await executeOperation(name, defaultPresenceSource(name, args, this.source), {
|
|
51
|
+
...options,
|
|
46
52
|
source: this.source,
|
|
47
53
|
proxiedRead: this.proxiedRead,
|
|
48
54
|
});
|
|
@@ -53,6 +59,37 @@ function toOperationErrorStatus(status: number): OperationErrorStatus {
|
|
|
53
59
|
return status === 404 ? 404 : status === 409 ? 409 : status === 403 ? 403 : 400;
|
|
54
60
|
}
|
|
55
61
|
|
|
62
|
+
async function readProgressResponse(response: Response, options: OperationExecutionOptions): Promise<Response> {
|
|
63
|
+
if (!response.body) throw new OperationError('Missing operation progress stream.');
|
|
64
|
+
const reader = response.body.getReader();
|
|
65
|
+
const decoder = new TextDecoder();
|
|
66
|
+
let buffer = '';
|
|
67
|
+
try {
|
|
68
|
+
while (true) {
|
|
69
|
+
const { done, value } = await reader.read();
|
|
70
|
+
buffer += decoder.decode(value, { stream: !done });
|
|
71
|
+
let end = buffer.indexOf('\n');
|
|
72
|
+
while (end !== -1) {
|
|
73
|
+
const frame = JSON.parse(buffer.slice(0, end)) as OperationStreamFrame;
|
|
74
|
+
buffer = buffer.slice(end + 1);
|
|
75
|
+
if (frame.type === 'result') return Response.json(frame.body, { status: frame.status });
|
|
76
|
+
if (frame.type !== 'progress') throw new OperationError('Invalid operation progress frame.');
|
|
77
|
+
try {
|
|
78
|
+
await options.onProgress?.(frame.progress);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (!options.signal?.aborted) console.error('[operation] progress callback failed:', error);
|
|
81
|
+
}
|
|
82
|
+
end = buffer.indexOf('\n');
|
|
83
|
+
}
|
|
84
|
+
if (done) throw new OperationError('Operation progress stream ended without a result.');
|
|
85
|
+
}
|
|
86
|
+
} finally {
|
|
87
|
+
// A fetch abort already errors the reader; preserve that original failure.
|
|
88
|
+
await reader.cancel().catch(() => {});
|
|
89
|
+
reader.releaseLock();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
56
93
|
/** Builds the HTTP request from the op's route template (`:id` from input, GET flags to query). */
|
|
57
94
|
export class HttpOperationInvoker implements OperationInvoker {
|
|
58
95
|
private readonly baseUrl: string;
|
|
@@ -70,7 +107,12 @@ export class HttpOperationInvoker implements OperationInvoker {
|
|
|
70
107
|
this.proxiedRead = source === 'mcp';
|
|
71
108
|
}
|
|
72
109
|
|
|
73
|
-
async invoke(
|
|
110
|
+
async invoke(
|
|
111
|
+
name: string,
|
|
112
|
+
rawInput: Record<string, unknown>,
|
|
113
|
+
options: OperationExecutionOptions = {},
|
|
114
|
+
): Promise<unknown> {
|
|
115
|
+
options.signal?.throwIfAborted();
|
|
74
116
|
const args = this.allowExternalMcp ? rawInput : { ...rawInput, allowExternalMcp: false };
|
|
75
117
|
const input = defaultPresenceSource(name, args, this.source);
|
|
76
118
|
const op = getOperation(name);
|
|
@@ -96,8 +138,10 @@ export class HttpOperationInvoker implements OperationInvoker {
|
|
|
96
138
|
|
|
97
139
|
let url = `${this.baseUrl}${path}`;
|
|
98
140
|
const proxyHeaders: Record<string, string> = this.proxiedRead ? { 'x-pmx-proxied-read': '1' } : {};
|
|
141
|
+
if (options.onProgress) proxyHeaders.accept = 'application/x-ndjson';
|
|
99
142
|
const init: RequestInit = {
|
|
100
143
|
method: route.method,
|
|
144
|
+
signal: options.signal,
|
|
101
145
|
headers: { 'x-pmx-source': this.source, ...proxyHeaders },
|
|
102
146
|
};
|
|
103
147
|
if (route.method === 'GET' || route.method === 'DELETE') {
|
|
@@ -112,7 +156,10 @@ export class HttpOperationInvoker implements OperationInvoker {
|
|
|
112
156
|
init.body = JSON.stringify(rest);
|
|
113
157
|
}
|
|
114
158
|
|
|
115
|
-
|
|
159
|
+
let response = await fetch(url, init);
|
|
160
|
+
if (response.headers.get('content-type')?.startsWith('application/x-ndjson')) {
|
|
161
|
+
response = await readProgressResponse(response, options);
|
|
162
|
+
}
|
|
116
163
|
const text = await response.text();
|
|
117
164
|
let parsed: unknown = null;
|
|
118
165
|
let parsedAsJson = false;
|
|
@@ -125,6 +172,7 @@ export class HttpOperationInvoker implements OperationInvoker {
|
|
|
125
172
|
}
|
|
126
173
|
}
|
|
127
174
|
if (!response.ok) {
|
|
175
|
+
if (response.status === 499) throw new DOMException('Operation cancelled.', 'AbortError');
|
|
128
176
|
// errorBodyAsResult carries the route's STRUCTURED partial-failure
|
|
129
177
|
// envelope (e.g. canvas_batch { ok:false, failedIndex, ... }) through as
|
|
130
178
|
// a result. A non-JSON error body (an HTML 502 from a proxy, a foreign
|
|
@@ -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
|
+
}
|