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
package/src/mcp/server.ts
CHANGED
|
@@ -27,10 +27,14 @@ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mc
|
|
|
27
27
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
28
28
|
import { z } from 'zod';
|
|
29
29
|
import { AX_SOURCES } from '../server/operations/ops/ax-shared.js';
|
|
30
|
-
import {
|
|
30
|
+
import { describeCanvasSchema, stopCanvasServer } from '../server/index.js';
|
|
31
31
|
import { AX_INTERACTION_TYPES } from '../server/ax-interaction.js';
|
|
32
32
|
import { buildPendingAxActivity } from '../server/ax-state.js';
|
|
33
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
registerOperationTools,
|
|
35
|
+
registerCompositeTools,
|
|
36
|
+
registerOperationHelpTool,
|
|
37
|
+
} from '../server/operations/index.js';
|
|
34
38
|
import { agentSourceLabel } from '../server/operations/invoker.js';
|
|
35
39
|
import { contextReadFromPayload, pinnedBoardReads, graphReads } from '../server/context-reads.js';
|
|
36
40
|
import type { MemoryGraph } from '../shared/memory-graph.js';
|
|
@@ -45,11 +49,7 @@ import {
|
|
|
45
49
|
import { listBundledSkills, readBundledSkill } from '../server/bundled-skills.js';
|
|
46
50
|
import { registerSkillExtension } from './skills.js';
|
|
47
51
|
import { ContextUriTemplate } from './context-uri-template.js';
|
|
48
|
-
|
|
49
|
-
let canvas: CanvasAccess | null = null;
|
|
50
|
-
let resourceNotificationServer: McpServer | null = null;
|
|
51
|
-
let localResourceNotificationsStarted = false;
|
|
52
|
-
let remoteResourceNotificationsBaseUrl: string | null = null;
|
|
52
|
+
import { registerResourceSubscriptions } from './resource-subscriptions.js';
|
|
53
53
|
|
|
54
54
|
// Resolved from the sibling package.json so it stays accurate through bunx,
|
|
55
55
|
// global npm installs, and repo-local runs (no hard-coded string, no build
|
|
@@ -71,115 +71,6 @@ function structuredSchemaDescription(): string {
|
|
|
71
71
|
.join(', ');
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
async function ensureCanvas(): Promise<CanvasAccess> {
|
|
75
|
-
if (!canvas) {
|
|
76
|
-
canvas = await createCanvasAccess();
|
|
77
|
-
} else {
|
|
78
|
-
canvas = await refreshCanvasAccess(canvas);
|
|
79
|
-
}
|
|
80
|
-
startResourceNotifications(canvas);
|
|
81
|
-
return canvas;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function sleep(ms: number): Promise<void> {
|
|
85
|
-
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function sendCanvasResourceNotifications(type: 'nodes' | 'pins' | 'ax' | 'ax-timeline' = 'nodes'): void {
|
|
89
|
-
const server = resourceNotificationServer;
|
|
90
|
-
if (!server) return;
|
|
91
|
-
try {
|
|
92
|
-
if (type === 'pins' || type === 'nodes') {
|
|
93
|
-
server.server.sendResourceUpdated({ uri: 'canvas://pinned-context' });
|
|
94
|
-
}
|
|
95
|
-
if (type === 'pins' || type === 'ax') {
|
|
96
|
-
server.server.sendResourceUpdated({ uri: 'canvas://ax' });
|
|
97
|
-
server.server.sendResourceUpdated({ uri: 'canvas://ax-context' });
|
|
98
|
-
server.server.sendResourceUpdated({ uri: 'canvas://ax-timeline' });
|
|
99
|
-
server.server.sendResourceUpdated({ uri: 'canvas://ax-work' });
|
|
100
|
-
}
|
|
101
|
-
if (type === 'ax-timeline') {
|
|
102
|
-
server.server.sendResourceUpdated({ uri: 'canvas://ax-timeline' });
|
|
103
|
-
server.server.sendResourceUpdated({ uri: 'canvas://ax-context' });
|
|
104
|
-
server.server.sendResourceUpdated({ uri: 'canvas://ax-pending-steering' });
|
|
105
|
-
server.server.sendResourceUpdated({ uri: 'canvas://ax-delivery' });
|
|
106
|
-
}
|
|
107
|
-
server.server.sendResourceUpdated({ uri: 'canvas://layout' });
|
|
108
|
-
server.server.sendResourceUpdated({ uri: 'canvas://boards' });
|
|
109
|
-
server.server.sendResourceUpdated({ uri: 'canvas://summary' });
|
|
110
|
-
server.server.sendResourceUpdated({ uri: 'canvas://context' });
|
|
111
|
-
server.server.sendResourceUpdated({ uri: 'canvas://graph' });
|
|
112
|
-
server.server.sendResourceUpdated({ uri: 'canvas://spatial-context' });
|
|
113
|
-
server.server.sendResourceUpdated({ uri: 'canvas://history' });
|
|
114
|
-
server.server.sendResourceUpdated({ uri: 'canvas://code-graph' });
|
|
115
|
-
} catch (error) {
|
|
116
|
-
console.debug('[mcp] resource notification failed', error);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function handleRemoteSseFrame(frame: string): void {
|
|
121
|
-
const eventLine = frame.split('\n').find((line) => line.startsWith('event: '));
|
|
122
|
-
const event = eventLine?.slice('event: '.length).trim() ?? '';
|
|
123
|
-
if (!event || event === 'connected' || event === 'ping') return;
|
|
124
|
-
sendCanvasResourceNotifications(
|
|
125
|
-
// boards-changed covers board pins and switches, both of which change pinned context.
|
|
126
|
-
event === 'context-pins-changed' || event === 'boards-changed'
|
|
127
|
-
? 'pins'
|
|
128
|
-
: event === 'ax-state-changed'
|
|
129
|
-
? 'ax'
|
|
130
|
-
: event === 'ax-event-created'
|
|
131
|
-
? 'ax-timeline'
|
|
132
|
-
: 'nodes',
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
async function watchRemoteCanvasEvents(baseUrl: string): Promise<void> {
|
|
137
|
-
const decoder = new TextDecoder();
|
|
138
|
-
while (true) {
|
|
139
|
-
try {
|
|
140
|
-
const response = await fetch(`${baseUrl}/api/workbench/events`);
|
|
141
|
-
if (!response.ok || !response.body) {
|
|
142
|
-
await sleep(1_000);
|
|
143
|
-
continue;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const reader = response.body.getReader();
|
|
147
|
-
let buffer = '';
|
|
148
|
-
while (true) {
|
|
149
|
-
const { done, value } = await reader.read();
|
|
150
|
-
if (done) break;
|
|
151
|
-
buffer += decoder.decode(value, { stream: true });
|
|
152
|
-
const frames = buffer.split('\n\n');
|
|
153
|
-
buffer = frames.pop() ?? '';
|
|
154
|
-
for (const frame of frames) handleRemoteSseFrame(frame);
|
|
155
|
-
}
|
|
156
|
-
} catch (error) {
|
|
157
|
-
console.debug('[mcp] remote canvas event stream failed', error);
|
|
158
|
-
}
|
|
159
|
-
await sleep(1_000);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function startResourceNotifications(c: CanvasAccess): void {
|
|
164
|
-
const server = resourceNotificationServer;
|
|
165
|
-
if (!server) return;
|
|
166
|
-
|
|
167
|
-
if (c.remoteBaseUrl) {
|
|
168
|
-
if (remoteResourceNotificationsBaseUrl !== c.remoteBaseUrl) {
|
|
169
|
-
remoteResourceNotificationsBaseUrl = c.remoteBaseUrl;
|
|
170
|
-
void watchRemoteCanvasEvents(c.remoteBaseUrl);
|
|
171
|
-
}
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
if (localResourceNotificationsStarted) return;
|
|
176
|
-
localResourceNotificationsStarted = true;
|
|
177
|
-
|
|
178
|
-
canvasState.onChange((type) => {
|
|
179
|
-
sendCanvasResourceNotifications(type);
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
|
-
|
|
183
74
|
function encodeBase64(bytes: Uint8Array): string {
|
|
184
75
|
return Buffer.from(bytes).toString('base64');
|
|
185
76
|
}
|
|
@@ -219,7 +110,7 @@ function buildSummaryFromLayout(
|
|
|
219
110
|
};
|
|
220
111
|
}
|
|
221
112
|
|
|
222
|
-
export
|
|
113
|
+
export function createMcpServer(): McpServer {
|
|
223
114
|
const server = new McpServer(
|
|
224
115
|
{ name: 'pmx-canvas', version: readPackageVersion() },
|
|
225
116
|
{
|
|
@@ -227,7 +118,19 @@ export async function startMcpServer(): Promise<void> {
|
|
|
227
118
|
'Bundled workbench guidance: skill://pmx-canvas/SKILL.md. Discover skills via skills/list when supported, or canvas://skills; read files through resources/read. Skill delivery does not grant permission to execute bundled scripts.',
|
|
228
119
|
},
|
|
229
120
|
);
|
|
230
|
-
|
|
121
|
+
let canvas: Promise<CanvasAccess> | undefined;
|
|
122
|
+
const subscriptions = registerResourceSubscriptions(server, ensureCanvas);
|
|
123
|
+
async function ensureCanvas(): Promise<CanvasAccess> {
|
|
124
|
+
canvas = canvas ? canvas.then(refreshCanvasAccess) : createCanvasAccess();
|
|
125
|
+
try {
|
|
126
|
+
const access = await canvas;
|
|
127
|
+
subscriptions.attach(access);
|
|
128
|
+
return access;
|
|
129
|
+
} catch (error) {
|
|
130
|
+
canvas = undefined;
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
231
134
|
registerSkillExtension(server);
|
|
232
135
|
|
|
233
136
|
/**
|
|
@@ -269,6 +172,7 @@ export async function startMcpServer(): Promise<void> {
|
|
|
269
172
|
// as standalone tools in v0.3.0; they remain reachable via their composite
|
|
270
173
|
// and canvas_batch. Remaining standalone tool names and compact/full payload
|
|
271
174
|
// behavior are frozen (tests/unit/mcp-tool-freeze.test.ts, operation-parity.test.ts).
|
|
175
|
+
registerOperationHelpTool(server);
|
|
272
176
|
registerOperationTools(server, ensureCanvas);
|
|
273
177
|
|
|
274
178
|
// ── Composite (action-discriminated) tools (plan-006) ───────────
|
|
@@ -282,36 +186,58 @@ export async function startMcpServer(): Promise<void> {
|
|
|
282
186
|
// tool; the 6 snapshot standalones are deprecated in v0.3.0 and fold into it.)
|
|
283
187
|
registerCompositeTools(server, ensureCanvas);
|
|
284
188
|
|
|
285
|
-
|
|
189
|
+
const interactionResult = z.looseObject({
|
|
190
|
+
ok: z.literal(true),
|
|
191
|
+
type: z.enum(AX_INTERACTION_TYPES),
|
|
192
|
+
sourceNodeId: z.string(),
|
|
193
|
+
primitive: z.unknown(),
|
|
194
|
+
});
|
|
195
|
+
server.registerTool(
|
|
286
196
|
'canvas_ax_interaction',
|
|
287
|
-
'Submit a node-originated AX interaction: a capability-gated, validated event from an eligible node that maps onto an AX operation (work item, evidence, approval, review, focus, steering, event). Returns { ok: false, code } if the node type/metadata does not allow the interaction type or the payload is invalid.',
|
|
288
197
|
{
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
198
|
+
description:
|
|
199
|
+
'Submit a capability-gated node AX interaction (work, evidence, approval, review, focus, steering or event). Rejected interactions return isError with { ok: false, code, error }.',
|
|
200
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
201
|
+
outputSchema: interactionResult,
|
|
202
|
+
inputSchema: {
|
|
203
|
+
type: z
|
|
204
|
+
.enum(AX_INTERACTION_TYPES)
|
|
205
|
+
.describe('Interaction type, e.g. ax.work.create, ax.evidence.add, ax.focus.set.'),
|
|
206
|
+
sourceNodeId: z.string().describe('The node emitting the interaction.'),
|
|
207
|
+
payload: z
|
|
208
|
+
.record(z.string(), z.unknown())
|
|
209
|
+
.optional()
|
|
210
|
+
.describe('Type-specific payload, e.g. {"title":"..."} for ax.work.create.'),
|
|
211
|
+
sourceSurface: z.enum(['native-node', 'json-render', 'html-node', 'mcp-app', 'adapter']).optional(),
|
|
212
|
+
correlationId: z.string().optional(),
|
|
213
|
+
source: z.enum(AX_SOURCES).optional().describe('Optional host/source label. Defaults to mcp.'),
|
|
214
|
+
},
|
|
300
215
|
},
|
|
301
|
-
async ({ type, sourceNodeId, payload, sourceSurface, correlationId, source }) => {
|
|
216
|
+
async ({ type, sourceNodeId, payload, sourceSurface, correlationId, source }, extra) => {
|
|
302
217
|
try {
|
|
303
218
|
const c = await ensureCanvas();
|
|
304
|
-
|
|
219
|
+
extra.signal.throwIfAborted();
|
|
220
|
+
const result = await c.invoker().invoke(
|
|
221
|
+
'ax.interaction.submit',
|
|
305
222
|
{
|
|
306
223
|
type,
|
|
307
224
|
sourceNodeId,
|
|
308
225
|
...(payload ? { payload } : {}),
|
|
309
226
|
...(sourceSurface ? { sourceSurface } : {}),
|
|
310
227
|
...(correlationId ? { correlationId } : {}),
|
|
228
|
+
source: source ?? 'mcp',
|
|
311
229
|
},
|
|
312
|
-
{
|
|
230
|
+
{ signal: extra.signal },
|
|
313
231
|
);
|
|
314
|
-
|
|
232
|
+
const failure = z
|
|
233
|
+
.object({ ok: z.literal(false), status: z.number(), code: z.string(), error: z.string() })
|
|
234
|
+
.safeParse(result);
|
|
235
|
+
const body = failure.success ? failure.data : interactionResult.parse(result);
|
|
236
|
+
return {
|
|
237
|
+
content: [{ type: 'text', text: JSON.stringify(body) }],
|
|
238
|
+
// SDK clients validate structuredContent even on isError results.
|
|
239
|
+
...(failure.success ? { isError: true } : { structuredContent: body }),
|
|
240
|
+
};
|
|
315
241
|
} catch (error) {
|
|
316
242
|
return {
|
|
317
243
|
content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
|
|
@@ -328,69 +254,85 @@ export async function startMcpServer(): Promise<void> {
|
|
|
328
254
|
// canvas_resolve_mode migrated to the operation registry (plan-007 Slice B
|
|
329
255
|
// wave 2): src/server/operations/ops/ax-work.ts.
|
|
330
256
|
|
|
331
|
-
|
|
257
|
+
const activityResult = z.object({
|
|
258
|
+
ok: z.literal(true),
|
|
259
|
+
event: z.looseObject({ id: z.string() }),
|
|
260
|
+
workItem: z.looseObject({ id: z.string() }).nullable(),
|
|
261
|
+
evidence: z.looseObject({ id: z.string() }).nullable(),
|
|
262
|
+
review: z.looseObject({ id: z.string() }).nullable(),
|
|
263
|
+
});
|
|
264
|
+
server.registerTool(
|
|
332
265
|
'canvas_ingest_activity',
|
|
333
|
-
'Ingest a normalized agent activity (a tool/session event your harness forwards) so the board reacts automatically — primitive A, makes AX bidirectional. Always records a timeline event; kind-driven default reactions (overridable per call via `reactions`): failure/error → work item (blocked) + review finding + evidence (logs); tool-result + outcome:"success" → evidence (tool-result); everything else (tool-start, session-*, command, note) → event only. Set any reaction to false to suppress it, or to an object to override its fields. Returns { event, workItem, evidence, review }.',
|
|
334
266
|
{
|
|
335
|
-
|
|
336
|
-
'tool-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
.
|
|
351
|
-
.
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
.
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
267
|
+
description:
|
|
268
|
+
'Ingest a harness activity. Always records an event. failure/error also creates blocked work, a review and log evidence; successful tool-result creates evidence. Override or suppress defaults with reactions. Returns IDs and records for event, workItem, evidence and review.',
|
|
269
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
270
|
+
outputSchema: activityResult,
|
|
271
|
+
inputSchema: {
|
|
272
|
+
kind: z.enum([
|
|
273
|
+
'tool-start',
|
|
274
|
+
'tool-result',
|
|
275
|
+
'failure',
|
|
276
|
+
'error',
|
|
277
|
+
'session-start',
|
|
278
|
+
'session-end',
|
|
279
|
+
'command',
|
|
280
|
+
'note',
|
|
281
|
+
]),
|
|
282
|
+
title: z.string(),
|
|
283
|
+
summary: z.string().optional(),
|
|
284
|
+
outcome: z.enum(['success', 'failure']).optional(),
|
|
285
|
+
ref: z
|
|
286
|
+
.string()
|
|
287
|
+
.optional()
|
|
288
|
+
.describe(
|
|
289
|
+
'A file path, URL, or commit the activity refers to (used as the review file anchor for failures).',
|
|
290
|
+
),
|
|
291
|
+
nodeIds: z.array(z.string()).optional(),
|
|
292
|
+
data: z.record(z.string(), z.unknown()).optional(),
|
|
293
|
+
reactions: z
|
|
294
|
+
.object({
|
|
295
|
+
workItem: z
|
|
296
|
+
.union([
|
|
297
|
+
z.literal(false),
|
|
298
|
+
z.object({
|
|
299
|
+
status: z.enum(['todo', 'in-progress', 'blocked', 'done', 'cancelled']).optional(),
|
|
300
|
+
detail: z.string().nullable().optional(),
|
|
301
|
+
}),
|
|
302
|
+
])
|
|
303
|
+
.optional(),
|
|
304
|
+
evidence: z
|
|
305
|
+
.union([
|
|
306
|
+
z.literal(false),
|
|
307
|
+
z.object({
|
|
308
|
+
kind: z.enum(['logs', 'tool-result', 'screenshot', 'file', 'diff', 'test-output']).optional(),
|
|
309
|
+
body: z.string().nullable().optional(),
|
|
310
|
+
}),
|
|
311
|
+
])
|
|
312
|
+
.optional(),
|
|
313
|
+
review: z
|
|
314
|
+
.union([
|
|
315
|
+
z.literal(false),
|
|
316
|
+
z.object({
|
|
317
|
+
severity: z.enum(['info', 'warning', 'error']).optional(),
|
|
318
|
+
kind: z.enum(['comment', 'finding']).optional(),
|
|
319
|
+
anchorType: z.enum(['node', 'file', 'region']).optional(),
|
|
320
|
+
nodeId: z.string().nullable().optional(),
|
|
321
|
+
}),
|
|
322
|
+
])
|
|
323
|
+
.optional(),
|
|
324
|
+
})
|
|
325
|
+
.optional()
|
|
326
|
+
.describe('Override or suppress the kind-driven default reactions.'),
|
|
327
|
+
source: z.enum(AX_SOURCES).optional(),
|
|
328
|
+
},
|
|
389
329
|
},
|
|
390
|
-
async ({ kind, title, summary, outcome, ref, nodeIds, data, reactions, source }) => {
|
|
330
|
+
async ({ kind, title, summary, outcome, ref, nodeIds, data, reactions, source }, extra) => {
|
|
391
331
|
try {
|
|
392
332
|
const c = await ensureCanvas();
|
|
393
|
-
|
|
333
|
+
extra.signal.throwIfAborted();
|
|
334
|
+
const result = await c.invoker().invoke(
|
|
335
|
+
'ax.activity.ingest',
|
|
394
336
|
{
|
|
395
337
|
kind,
|
|
396
338
|
title,
|
|
@@ -400,10 +342,12 @@ export async function startMcpServer(): Promise<void> {
|
|
|
400
342
|
...(nodeIds !== undefined ? { nodeIds } : {}),
|
|
401
343
|
...(data !== undefined ? { data } : {}),
|
|
402
344
|
...(reactions !== undefined ? { reactions } : {}),
|
|
345
|
+
source: source ?? 'mcp',
|
|
403
346
|
},
|
|
404
|
-
{
|
|
347
|
+
{ signal: extra.signal },
|
|
405
348
|
);
|
|
406
|
-
|
|
349
|
+
const body = activityResult.parse(result);
|
|
350
|
+
return { content: [{ type: 'text', text: JSON.stringify(body) }], structuredContent: body };
|
|
407
351
|
} catch (error) {
|
|
408
352
|
return {
|
|
409
353
|
content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
|
|
@@ -430,25 +374,50 @@ export async function startMcpServer(): Promise<void> {
|
|
|
430
374
|
// binary image payload, which the registry's JSON wire shape does not model.
|
|
431
375
|
|
|
432
376
|
// ── canvas_screenshot ─────────────────────────────────────────
|
|
433
|
-
|
|
377
|
+
const screenshotResult = z.object({
|
|
378
|
+
bytes: z.number().int().nonnegative(),
|
|
379
|
+
backend: z.enum(['webkit', 'chrome']).nullable(),
|
|
380
|
+
width: z.number().nullable(),
|
|
381
|
+
height: z.number().nullable(),
|
|
382
|
+
mimeType: z.enum(['image/png', 'image/jpeg', 'image/webp']),
|
|
383
|
+
});
|
|
384
|
+
server.registerTool(
|
|
434
385
|
'canvas_screenshot',
|
|
435
|
-
'Capture a screenshot from the active Bun.WebView automation session. Returns both an MCP image payload and JSON metadata. Requires an active automation session started via canvas_webview with action "start".',
|
|
436
386
|
{
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
387
|
+
description:
|
|
388
|
+
'Capture an image and metadata from the active Bun.WebView session. Start it with canvas_webview action "start" first.',
|
|
389
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
390
|
+
outputSchema: screenshotResult,
|
|
391
|
+
inputSchema: {
|
|
392
|
+
format: z
|
|
393
|
+
.enum(['png', 'jpeg', 'webp'])
|
|
394
|
+
.optional()
|
|
395
|
+
.describe('Screenshot format (default depends on Bun; png recommended)'),
|
|
396
|
+
quality: z.number().optional().describe('Optional quality for lossy formats'),
|
|
397
|
+
},
|
|
442
398
|
},
|
|
443
|
-
async ({ format, quality }) => {
|
|
444
|
-
const c = await ensureCanvas();
|
|
399
|
+
async ({ format, quality }, extra) => {
|
|
445
400
|
try {
|
|
446
|
-
const
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
401
|
+
const c = await ensureCanvas();
|
|
402
|
+
extra.signal.throwIfAborted();
|
|
403
|
+
const bytes = await c.screenshotAutomationWebView(
|
|
404
|
+
{
|
|
405
|
+
...(format ? { format } : {}),
|
|
406
|
+
...(typeof quality === 'number' ? { quality } : {}),
|
|
407
|
+
},
|
|
408
|
+
extra.signal,
|
|
409
|
+
);
|
|
410
|
+
extra.signal.throwIfAborted();
|
|
450
411
|
const status = await c.getAutomationWebViewStatus();
|
|
412
|
+
const metadata = screenshotResult.parse({
|
|
413
|
+
bytes: bytes.byteLength,
|
|
414
|
+
backend: status.backend,
|
|
415
|
+
width: status.width,
|
|
416
|
+
height: status.height,
|
|
417
|
+
mimeType: format === 'jpeg' ? 'image/jpeg' : format === 'webp' ? 'image/webp' : 'image/png',
|
|
418
|
+
});
|
|
451
419
|
return {
|
|
420
|
+
structuredContent: metadata,
|
|
452
421
|
content: [
|
|
453
422
|
{
|
|
454
423
|
type: 'image',
|
|
@@ -457,17 +426,7 @@ export async function startMcpServer(): Promise<void> {
|
|
|
457
426
|
},
|
|
458
427
|
{
|
|
459
428
|
type: 'text',
|
|
460
|
-
text: JSON.stringify(
|
|
461
|
-
{
|
|
462
|
-
bytes: bytes.byteLength,
|
|
463
|
-
backend: status.backend,
|
|
464
|
-
width: status.width,
|
|
465
|
-
height: status.height,
|
|
466
|
-
mimeType: format === 'jpeg' ? 'image/jpeg' : format === 'webp' ? 'image/webp' : 'image/png',
|
|
467
|
-
},
|
|
468
|
-
null,
|
|
469
|
-
2,
|
|
470
|
-
),
|
|
429
|
+
text: JSON.stringify(metadata),
|
|
471
430
|
},
|
|
472
431
|
],
|
|
473
432
|
};
|
|
@@ -1015,6 +974,11 @@ export async function startMcpServer(): Promise<void> {
|
|
|
1015
974
|
// canvas_validate migrated to the operation registry (plan-008 Wave 1):
|
|
1016
975
|
// src/server/operations/ops/validate.ts.
|
|
1017
976
|
|
|
977
|
+
return server;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
export async function startMcpServer(): Promise<void> {
|
|
981
|
+
const server = createMcpServer();
|
|
1018
982
|
// Connect via stdio
|
|
1019
983
|
const transport = new StdioServerTransport();
|
|
1020
984
|
await server.connect(transport);
|
|
@@ -1024,7 +988,9 @@ export async function startMcpServer(): Promise<void> {
|
|
|
1024
988
|
// orphaned — the auto-started canvas server and its timers keep the event
|
|
1025
989
|
// loop alive forever. stopCanvasServer() checkpoints the SQLite DB before
|
|
1026
990
|
// exit and is a no-op when this process only attached to a remote daemon.
|
|
991
|
+
const disposeSubscriptions = server.server.onclose;
|
|
1027
992
|
const shutdown = (): void => {
|
|
993
|
+
disposeSubscriptions?.();
|
|
1028
994
|
try {
|
|
1029
995
|
stopCanvasServer();
|
|
1030
996
|
process.exit(0);
|
package/src/server/ax-wait.ts
CHANGED
|
@@ -23,27 +23,32 @@ export async function waitForAxResolution<T extends { status: string }>(opts: {
|
|
|
23
23
|
timeoutMs: number;
|
|
24
24
|
signal?: AbortSignal;
|
|
25
25
|
}): Promise<AxWaitResult<T>> {
|
|
26
|
+
opts.signal?.throwIfAborted();
|
|
26
27
|
const { read, isResolved } = opts;
|
|
27
28
|
const timeoutMs = Math.max(0, Math.min(opts.timeoutMs, AX_WAIT_MAX_MS));
|
|
28
29
|
const pendingOf = (v: T | null): boolean => (v ? !isResolved(v) : false);
|
|
29
30
|
|
|
30
31
|
const current = read();
|
|
31
|
-
if (!current || isResolved(current) || timeoutMs === 0
|
|
32
|
+
if (!current || isResolved(current) || timeoutMs === 0) {
|
|
32
33
|
return { value: current, pending: pendingOf(current) };
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
return new Promise<AxWaitResult<T>>((resolve) => {
|
|
36
|
+
return new Promise<AxWaitResult<T>>((resolve, reject) => {
|
|
36
37
|
let done = false;
|
|
37
38
|
const finish = (): void => {
|
|
38
39
|
if (done) return;
|
|
39
40
|
done = true;
|
|
40
41
|
clearTimeout(timer);
|
|
41
42
|
dispose();
|
|
42
|
-
opts.signal?.removeEventListener('abort',
|
|
43
|
+
opts.signal?.removeEventListener('abort', onAbort);
|
|
44
|
+
if (opts.signal?.aborted) {
|
|
45
|
+
reject(opts.signal.reason);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
43
48
|
const v = read();
|
|
44
49
|
resolve({ value: v, pending: pendingOf(v) });
|
|
45
50
|
};
|
|
46
|
-
const
|
|
51
|
+
const onAbort = finish;
|
|
47
52
|
const check = (type: string): void => {
|
|
48
53
|
if (type !== 'ax') return;
|
|
49
54
|
const v = read();
|
|
@@ -51,7 +56,8 @@ export async function waitForAxResolution<T extends { status: string }>(opts: {
|
|
|
51
56
|
};
|
|
52
57
|
const timer = setTimeout(finish, timeoutMs);
|
|
53
58
|
const dispose = canvasState.onChange(check);
|
|
54
|
-
opts.signal?.addEventListener('abort',
|
|
59
|
+
opts.signal?.addEventListener('abort', onAbort, { once: true });
|
|
60
|
+
if (opts.signal?.aborted) finish();
|
|
55
61
|
});
|
|
56
62
|
}
|
|
57
63
|
|
|
@@ -67,17 +73,24 @@ export async function waitForAxCondition(opts: {
|
|
|
67
73
|
ready: () => boolean;
|
|
68
74
|
timeoutMs: number;
|
|
69
75
|
channel?: string;
|
|
76
|
+
signal?: AbortSignal;
|
|
70
77
|
}): Promise<void> {
|
|
78
|
+
opts.signal?.throwIfAborted();
|
|
71
79
|
const timeoutMs = Math.max(0, Math.min(opts.timeoutMs, AX_WAIT_MAX_MS));
|
|
72
80
|
if (timeoutMs === 0 || opts.ready()) return;
|
|
73
81
|
const channel = opts.channel ?? 'ax-timeline';
|
|
74
|
-
await new Promise<void>((resolve) => {
|
|
82
|
+
await new Promise<void>((resolve, reject) => {
|
|
75
83
|
let done = false;
|
|
76
84
|
const finish = (): void => {
|
|
77
85
|
if (done) return;
|
|
78
86
|
done = true;
|
|
79
87
|
clearTimeout(timer);
|
|
80
88
|
dispose();
|
|
89
|
+
opts.signal?.removeEventListener('abort', finish);
|
|
90
|
+
if (opts.signal?.aborted) {
|
|
91
|
+
reject(opts.signal.reason);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
81
94
|
resolve();
|
|
82
95
|
};
|
|
83
96
|
const check = (type: string): void => {
|
|
@@ -85,5 +98,7 @@ export async function waitForAxCondition(opts: {
|
|
|
85
98
|
};
|
|
86
99
|
const timer = setTimeout(finish, timeoutMs);
|
|
87
100
|
const dispose = canvasState.onChange(check);
|
|
101
|
+
opts.signal?.addEventListener('abort', finish, { once: true });
|
|
102
|
+
if (opts.signal?.aborted) finish();
|
|
88
103
|
});
|
|
89
104
|
}
|
|
@@ -1059,8 +1059,9 @@ export function resolveCanvasNode(nodeRef: CanvasNodeLookupInput):
|
|
|
1059
1059
|
|
|
1060
1060
|
export async function refreshCanvasWebpageNode(
|
|
1061
1061
|
id: string,
|
|
1062
|
-
options: { url?: string } = {},
|
|
1062
|
+
options: { url?: string; signal?: AbortSignal } = {},
|
|
1063
1063
|
): Promise<{ ok: boolean; id: string; error?: string }> {
|
|
1064
|
+
options.signal?.throwIfAborted();
|
|
1064
1065
|
const existing = canvasState.getNode(id);
|
|
1065
1066
|
if (!existing || existing.type !== 'webpage') {
|
|
1066
1067
|
return { ok: false, id, error: `Webpage node "${id}" not found.` };
|
|
@@ -1094,7 +1095,8 @@ export async function refreshCanvasWebpageNode(
|
|
|
1094
1095
|
}
|
|
1095
1096
|
|
|
1096
1097
|
try {
|
|
1097
|
-
const snapshot = await fetchWebpageSnapshot(normalizedUrl);
|
|
1098
|
+
const snapshot = await fetchWebpageSnapshot(normalizedUrl, { signal: options.signal });
|
|
1099
|
+
options.signal?.throwIfAborted();
|
|
1098
1100
|
if (canvasState.boardGeneration !== boardGeneration) {
|
|
1099
1101
|
return { ok: false, id, error: 'The board changed during webpage refresh.' };
|
|
1100
1102
|
}
|
|
@@ -1131,6 +1133,8 @@ export async function refreshCanvasWebpageNode(
|
|
|
1131
1133
|
canvasState.updateNode(id, { data: nextData });
|
|
1132
1134
|
return { ok: true, id };
|
|
1133
1135
|
} catch (error) {
|
|
1136
|
+
// Cancellation leaves the previous content/status intact, not an error card.
|
|
1137
|
+
options.signal?.throwIfAborted();
|
|
1134
1138
|
if (canvasState.boardGeneration !== boardGeneration) {
|
|
1135
1139
|
return { ok: false, id, error: 'The board changed during webpage refresh.' };
|
|
1136
1140
|
}
|
|
@@ -2863,6 +2863,16 @@ class CanvasStateManager {
|
|
|
2863
2863
|
return node ? structuredClone(this.nodeForRead(node)) : undefined;
|
|
2864
2864
|
}
|
|
2865
2865
|
|
|
2866
|
+
/** Camera targets need geometry, not hydrated documents, frames or image blobs. */
|
|
2867
|
+
nodeGeometry(): Array<Pick<CanvasNodeState, 'id' | 'type' | 'position' | 'size'>> {
|
|
2868
|
+
return Array.from(this.nodes.values(), ({ id, type, position, size }) => ({
|
|
2869
|
+
id,
|
|
2870
|
+
type,
|
|
2871
|
+
position: { ...position },
|
|
2872
|
+
size: { ...size },
|
|
2873
|
+
}));
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2866
2876
|
/** Who made and last edited each card, and its content revision — no content, no copies. */
|
|
2867
2877
|
nodeStamps(): Array<Pick<CanvasNodeState, 'id' | 'createdBy' | 'lastEditedBy' | 'contentRevision'>> {
|
|
2868
2878
|
return [...this.nodes.values()].map(({ id, createdBy, lastEditedBy, contentRevision }) => ({
|