bytespost-canvas 0.1.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/LICENSE +21 -0
- package/README.md +102 -0
- package/dist/cli.d.ts +17 -0
- package/dist/cli.js +747 -0
- package/dist/commandRegistry.d.ts +44 -0
- package/dist/commandRegistry.js +301 -0
- package/dist/daemon.d.ts +22 -0
- package/dist/daemon.js +475 -0
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +143 -0
- package/dist/protocol.d.ts +212 -0
- package/dist/protocol.js +9 -0
- package/package.json +58 -0
package/dist/daemon.js
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent-bridge daemon (SLICE 1).
|
|
3
|
+
*
|
|
4
|
+
* Standalone Node process. It does two things:
|
|
5
|
+
* 1. Serves a tiny localhost-only HTTP API (health + screenshot).
|
|
6
|
+
* 2. Hosts a WebSocket hub at `/agent` that the browser canvas connects OUT
|
|
7
|
+
* to at boot (a tab cannot listen for sockets, so it dials us).
|
|
8
|
+
*
|
|
9
|
+
* Each HTTP request is relayed to the connected canvas as a `BridgeCommand`
|
|
10
|
+
* over WS; we await the id-correlated `BridgeReply` and turn it into the HTTP
|
|
11
|
+
* response. localhost-only and opt-in by design.
|
|
12
|
+
*/
|
|
13
|
+
import { createServer } from 'node:http';
|
|
14
|
+
import { randomUUID } from 'node:crypto';
|
|
15
|
+
import { WebSocketServer } from 'ws';
|
|
16
|
+
import { PROTOCOL_VERSION, } from './protocol.js';
|
|
17
|
+
const HOST = '127.0.0.1';
|
|
18
|
+
const DEFAULT_PORT = 7777;
|
|
19
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
20
|
+
const WS_PATH = '/agent';
|
|
21
|
+
export function resolvePort() {
|
|
22
|
+
const raw = process.env.CANVAS_BRIDGE_PORT;
|
|
23
|
+
if (!raw) {
|
|
24
|
+
return DEFAULT_PORT;
|
|
25
|
+
}
|
|
26
|
+
const parsed = Number.parseInt(raw, 10);
|
|
27
|
+
return Number.isFinite(parsed) && parsed > 0 && parsed < 65_536 ? parsed : DEFAULT_PORT;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Start the daemon. Resolves once HTTP is listening. The returned handle can
|
|
31
|
+
* close both the HTTP and WS servers.
|
|
32
|
+
*/
|
|
33
|
+
export function startDaemon(port = resolvePort()) {
|
|
34
|
+
const tabs = new Map();
|
|
35
|
+
const socketTabIds = new Map();
|
|
36
|
+
const pending = new Map();
|
|
37
|
+
const settle = (id, run) => {
|
|
38
|
+
const req = pending.get(id);
|
|
39
|
+
if (!req) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
pending.delete(id);
|
|
43
|
+
clearTimeout(req.timer);
|
|
44
|
+
run(req);
|
|
45
|
+
};
|
|
46
|
+
const sendCommand = (tab, command) => {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
if (!tab.socket || tab.socket.readyState !== tab.socket.OPEN) {
|
|
49
|
+
reject(new Error('NO_CANVAS'));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const timer = setTimeout(() => {
|
|
53
|
+
settle(command.id, (req) => req.reject(new Error('TIMEOUT')));
|
|
54
|
+
}, REQUEST_TIMEOUT_MS);
|
|
55
|
+
pending.set(command.id, { resolve, reject, timer });
|
|
56
|
+
try {
|
|
57
|
+
tab.info.lastSeenAt = new Date().toISOString();
|
|
58
|
+
tab.socket.send(JSON.stringify(command));
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
settle(command.id, (req) => req.reject(error instanceof Error ? error : new Error(String(error))));
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
const httpServer = createServer((req, res) => {
|
|
66
|
+
// Permit the in-browser chat agent to fetch us. The daemon is bound to
|
|
67
|
+
// 127.0.0.1 so reflecting the origin (vs `*`) lets us keep cookies-allowed
|
|
68
|
+
// semantics if a future tool needs them. Preflight is short-circuited.
|
|
69
|
+
const origin = req.headers.origin;
|
|
70
|
+
if (origin) {
|
|
71
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
72
|
+
res.setHeader('Vary', 'Origin');
|
|
73
|
+
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
77
|
+
}
|
|
78
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, PUT, DELETE, OPTIONS');
|
|
79
|
+
res.setHeader('Access-Control-Allow-Headers', 'content-type, x-canvas-tab');
|
|
80
|
+
res.setHeader('Access-Control-Max-Age', '86400');
|
|
81
|
+
if (req.method === 'OPTIONS') {
|
|
82
|
+
res.writeHead(204).end();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
handleHttp(req, res, {
|
|
86
|
+
listTabs: () => listOpenTabs(tabs),
|
|
87
|
+
findTab: (tabId) => {
|
|
88
|
+
const tab = tabs.get(tabId);
|
|
89
|
+
return tab && tab.socket.readyState === tab.socket.OPEN ? tab : null;
|
|
90
|
+
},
|
|
91
|
+
sendCommand,
|
|
92
|
+
}).catch((error) => {
|
|
93
|
+
const maybeRelay = error;
|
|
94
|
+
if (typeof maybeRelay.status === 'number') {
|
|
95
|
+
sendJson(res, maybeRelay.status, maybeRelay.body ?? { error: { code: 'ERROR', message: 'request failed' } });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
sendError(res, 500, 'INTERNAL_ERROR', error instanceof Error ? error.message : String(error));
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
const wss = new WebSocketServer({ server: httpServer, path: WS_PATH });
|
|
102
|
+
wss.on('connection', (socket) => {
|
|
103
|
+
const fallbackTab = registerTab(tabs, socketTabIds, socket, {
|
|
104
|
+
id: `tab_${randomUUID().slice(0, 8)}`,
|
|
105
|
+
title: 'Canvas',
|
|
106
|
+
ready: false,
|
|
107
|
+
capabilities: [],
|
|
108
|
+
});
|
|
109
|
+
console.log('[agent-bridge] canvas connected', fallbackTab.info.id);
|
|
110
|
+
socket.on('message', (raw) => {
|
|
111
|
+
let message;
|
|
112
|
+
try {
|
|
113
|
+
message = JSON.parse(typeof raw === 'string' ? raw : raw.toString());
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (isBrowserHello(message)) {
|
|
119
|
+
const tab = registerTab(tabs, socketTabIds, socket, message.tab);
|
|
120
|
+
console.log('[agent-bridge] tab registered', tab.info.id);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (!message || typeof message.id !== 'string') {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
settle(message.id, (request) => request.resolve(message));
|
|
127
|
+
});
|
|
128
|
+
socket.on('close', () => {
|
|
129
|
+
const tabId = socketTabIds.get(socket);
|
|
130
|
+
if (tabId) {
|
|
131
|
+
socketTabIds.delete(socket);
|
|
132
|
+
tabs.delete(tabId);
|
|
133
|
+
console.log('[agent-bridge] canvas disconnected', tabId);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
socket.on('error', () => {
|
|
137
|
+
/* swallow — close handler clears the ref */
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
return new Promise((resolve, reject) => {
|
|
141
|
+
httpServer.once('error', reject);
|
|
142
|
+
httpServer.listen(port, HOST, () => {
|
|
143
|
+
httpServer.off('error', reject);
|
|
144
|
+
console.log(`[agent-bridge] daemon listening on http://${HOST}:${port} (ws ${WS_PATH})`);
|
|
145
|
+
resolve({
|
|
146
|
+
port,
|
|
147
|
+
close: () => new Promise((done) => {
|
|
148
|
+
for (const [, req] of pending) {
|
|
149
|
+
clearTimeout(req.timer);
|
|
150
|
+
req.reject(new Error('SHUTDOWN'));
|
|
151
|
+
}
|
|
152
|
+
pending.clear();
|
|
153
|
+
wss.close();
|
|
154
|
+
httpServer.close(() => done());
|
|
155
|
+
}),
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
async function handleHttp(req, res, deps) {
|
|
161
|
+
const url = new URL(req.url ?? '/', `http://${HOST}`);
|
|
162
|
+
const pathname = url.pathname;
|
|
163
|
+
if (req.method === 'GET' && pathname === '/v1/health') {
|
|
164
|
+
const tabs = deps.listTabs();
|
|
165
|
+
const body = {
|
|
166
|
+
canvasConnected: tabs.length > 0,
|
|
167
|
+
tabCount: tabs.length,
|
|
168
|
+
version: PROTOCOL_VERSION,
|
|
169
|
+
};
|
|
170
|
+
sendJson(res, 200, body);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (req.method === 'GET' && pathname === '/v1/tabs') {
|
|
174
|
+
sendJson(res, 200, { tabs: deps.listTabs().map((tab) => serializeTab(tab)) });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const tabInfoMatch = pathname.match(/^\/v1\/tabs\/([^/]+)$/);
|
|
178
|
+
if (req.method === 'GET' && tabInfoMatch) {
|
|
179
|
+
const tab = deps.findTab(decodeURIComponent(tabInfoMatch[1]));
|
|
180
|
+
if (!tab) {
|
|
181
|
+
sendError(res, 404, 'TAB_NOT_FOUND', `No connected tab ${tabInfoMatch[1]}`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
sendJson(res, 200, { tab: serializeTab(tab) });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (req.method === 'GET' && pathname === '/v1/screenshot') {
|
|
188
|
+
const reply = await relayToSelectedTab(url, deps, { type: 'screenshot' });
|
|
189
|
+
if (reply.type !== 'screenshot') {
|
|
190
|
+
sendError(res, 502, 'UNEXPECTED_REPLY', 'unexpected reply type');
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const png = Buffer.from(reply.data.pngBase64, 'base64');
|
|
194
|
+
res.writeHead(200, {
|
|
195
|
+
'content-type': 'image/png',
|
|
196
|
+
'content-length': String(png.byteLength),
|
|
197
|
+
'cache-control': 'no-store',
|
|
198
|
+
});
|
|
199
|
+
res.end(png);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (req.method === 'POST' && pathname === '/v1/draw') {
|
|
203
|
+
let body;
|
|
204
|
+
try {
|
|
205
|
+
const raw = await readBody(req);
|
|
206
|
+
const parsed = JSON.parse(raw);
|
|
207
|
+
body = validateDrawRequest(parsed);
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
sendError(res, 400, 'BAD_REQUEST', error instanceof Error ? error.message : 'invalid JSON body');
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const reply = await relayToSelectedTab(url, deps, { type: 'draw', body: { nodes: body.nodes } });
|
|
214
|
+
if (reply.type !== 'draw') {
|
|
215
|
+
sendError(res, 502, 'UNEXPECTED_REPLY', 'unexpected reply type');
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
sendJson(res, 200, { nodeIds: reply.data.nodeIds });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (req.method === 'GET' && pathname === '/v1/document') {
|
|
222
|
+
const reply = await relayToSelectedTab(url, deps, { type: 'document_get' });
|
|
223
|
+
sendJson(res, 200, reply.data);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (req.method === 'GET' && pathname === '/v1/document/nodes') {
|
|
227
|
+
const reply = await relayToSelectedTab(url, deps, { type: 'document_nodes' });
|
|
228
|
+
sendJson(res, 200, reply.data);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const nodeMatch = pathname.match(/^\/v1\/node\/([^/]+)$/);
|
|
232
|
+
if (nodeMatch && req.method === 'GET') {
|
|
233
|
+
const reply = await relayToSelectedTab(url, deps, {
|
|
234
|
+
type: 'node_get',
|
|
235
|
+
body: { nodeId: decodeURIComponent(nodeMatch[1]) },
|
|
236
|
+
});
|
|
237
|
+
sendJson(res, 200, reply.data);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (nodeMatch && req.method === 'DELETE') {
|
|
241
|
+
const reply = await relayToSelectedTab(url, deps, {
|
|
242
|
+
type: 'node_delete',
|
|
243
|
+
body: { nodeId: decodeURIComponent(nodeMatch[1]) },
|
|
244
|
+
});
|
|
245
|
+
sendJson(res, 200, reply.data);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (nodeMatch && (req.method === 'PATCH' || req.method === 'PUT')) {
|
|
249
|
+
const raw = await readBody(req);
|
|
250
|
+
const parsed = parseJsonBody(raw);
|
|
251
|
+
const reply = await relayToSelectedTab(url, deps, {
|
|
252
|
+
type: 'node_update',
|
|
253
|
+
body: { nodeId: decodeURIComponent(nodeMatch[1]), patch: asObject(parsed.patch ?? parsed) },
|
|
254
|
+
});
|
|
255
|
+
sendJson(res, 200, reply.data);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (req.method === 'POST' && pathname === '/v1/select') {
|
|
259
|
+
const parsed = parseJsonBody(await readBody(req));
|
|
260
|
+
const nodeIds = Array.isArray(parsed.nodeIds)
|
|
261
|
+
? parsed.nodeIds.filter((id) => typeof id === 'string')
|
|
262
|
+
: typeof parsed.nodeId === 'string'
|
|
263
|
+
? [parsed.nodeId]
|
|
264
|
+
: [];
|
|
265
|
+
const reply = await relayToSelectedTab(url, deps, { type: 'select_set', body: { nodeIds } });
|
|
266
|
+
sendJson(res, 200, reply.data);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (req.method === 'POST' && pathname === '/v1/select/clear') {
|
|
270
|
+
const reply = await relayToSelectedTab(url, deps, { type: 'select_clear' });
|
|
271
|
+
sendJson(res, 200, reply.data);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (req.method === 'POST' && pathname === '/v1/viewport/fit') {
|
|
275
|
+
const reply = await relayToSelectedTab(url, deps, { type: 'viewport_fit' });
|
|
276
|
+
sendJson(res, 200, reply.data);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (req.method === 'POST' && pathname === '/v1/viewport/zoom') {
|
|
280
|
+
const parsed = parseJsonBody(await readBody(req));
|
|
281
|
+
const queryZoom = url.searchParams.get('zoom');
|
|
282
|
+
const zoom = typeof parsed.zoom === 'number' ? parsed.zoom : queryZoom ? Number(queryZoom) : undefined;
|
|
283
|
+
const reply = await relayToSelectedTab(url, deps, { type: 'viewport_zoom', body: { zoom } });
|
|
284
|
+
sendJson(res, 200, reply.data);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (req.method === 'POST' && pathname === '/v1/building') {
|
|
288
|
+
const parsed = parseJsonBody(await readBody(req));
|
|
289
|
+
const buildingType = typeof parsed.buildingType === 'string' ? parsed.buildingType : undefined;
|
|
290
|
+
const input = asObject(parsed.input ?? parsed);
|
|
291
|
+
const reply = await relayToSelectedTab(url, deps, { type: 'building_create', body: { buildingType, input } });
|
|
292
|
+
sendJson(res, 200, reply.data);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (req.method === 'POST' && pathname === '/v1/command') {
|
|
296
|
+
const parsed = parseJsonBody(await readBody(req));
|
|
297
|
+
if (typeof parsed.type !== 'string') {
|
|
298
|
+
sendError(res, 400, 'BAD_REQUEST', 'command type is required');
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const reply = await relayToSelectedTab(url, deps, {
|
|
302
|
+
type: parsed.type,
|
|
303
|
+
body: asObject(parsed),
|
|
304
|
+
});
|
|
305
|
+
sendJson(res, 200, reply.data);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
sendError(res, 404, 'NOT_FOUND', 'not found');
|
|
309
|
+
}
|
|
310
|
+
/** Read a request body with a small cap so a stray client cannot OOM the daemon. */
|
|
311
|
+
function readBody(req, limitBytes = 1_000_000) {
|
|
312
|
+
return new Promise((resolve, reject) => {
|
|
313
|
+
const chunks = [];
|
|
314
|
+
let size = 0;
|
|
315
|
+
req.on('data', (chunk) => {
|
|
316
|
+
size += chunk.length;
|
|
317
|
+
if (size > limitBytes) {
|
|
318
|
+
reject(new Error('request body too large'));
|
|
319
|
+
req.destroy();
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
chunks.push(chunk);
|
|
323
|
+
});
|
|
324
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
325
|
+
req.on('error', reject);
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Shallow-validate the draw body. The browser side does the authoritative
|
|
330
|
+
* per-type validation when constructing NodeIR; here we only guarantee a
|
|
331
|
+
* well-formed `{nodes: [...]}` envelope so bad input fails fast as 400.
|
|
332
|
+
*/
|
|
333
|
+
function validateDrawRequest(value) {
|
|
334
|
+
if (!value || typeof value !== 'object') {
|
|
335
|
+
throw new Error('body must be a JSON object');
|
|
336
|
+
}
|
|
337
|
+
const nodes = value.nodes;
|
|
338
|
+
if (!Array.isArray(nodes) || nodes.length === 0) {
|
|
339
|
+
throw new Error('body.nodes must be a non-empty array');
|
|
340
|
+
}
|
|
341
|
+
for (const node of nodes) {
|
|
342
|
+
if (!node || typeof node !== 'object' || typeof node.type !== 'string') {
|
|
343
|
+
throw new Error('each node must be an object with a string "type"');
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return { nodes: nodes };
|
|
347
|
+
}
|
|
348
|
+
function listOpenTabs(tabs) {
|
|
349
|
+
return Array.from(tabs.values())
|
|
350
|
+
.filter((tab) => tab.socket.readyState === tab.socket.OPEN)
|
|
351
|
+
.sort((a, b) => a.info.id.localeCompare(b.info.id));
|
|
352
|
+
}
|
|
353
|
+
function serializeTab(tab) {
|
|
354
|
+
return {
|
|
355
|
+
id: tab.info.id,
|
|
356
|
+
title: tab.info.title,
|
|
357
|
+
app: tab.info.app,
|
|
358
|
+
url: tab.info.url,
|
|
359
|
+
canvasType: tab.info.canvasType,
|
|
360
|
+
ready: tab.info.ready,
|
|
361
|
+
document: tab.info.document,
|
|
362
|
+
capabilities: tab.info.capabilities ?? [],
|
|
363
|
+
connectedAt: tab.info.connectedAt,
|
|
364
|
+
lastSeenAt: tab.info.lastSeenAt,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
function registerTab(tabs, socketTabIds, socket, input) {
|
|
368
|
+
const now = new Date().toISOString();
|
|
369
|
+
const previousId = socketTabIds.get(socket);
|
|
370
|
+
const previous = previousId ? tabs.get(previousId) : null;
|
|
371
|
+
const id = input.id || previousId || `tab_${randomUUID().slice(0, 8)}`;
|
|
372
|
+
if (previousId && previousId !== id) {
|
|
373
|
+
tabs.delete(previousId);
|
|
374
|
+
}
|
|
375
|
+
const info = {
|
|
376
|
+
...(previous?.info ?? {}),
|
|
377
|
+
...input,
|
|
378
|
+
id,
|
|
379
|
+
connectedAt: previous?.info.connectedAt ?? now,
|
|
380
|
+
lastSeenAt: now,
|
|
381
|
+
capabilities: input.capabilities ?? previous?.info.capabilities ?? [],
|
|
382
|
+
};
|
|
383
|
+
const tab = { socket, info };
|
|
384
|
+
tabs.set(id, tab);
|
|
385
|
+
socketTabIds.set(socket, id);
|
|
386
|
+
return tab;
|
|
387
|
+
}
|
|
388
|
+
function isBrowserHello(message) {
|
|
389
|
+
return Boolean(message &&
|
|
390
|
+
message.type === 'hello' &&
|
|
391
|
+
message.tab &&
|
|
392
|
+
typeof message.tab === 'object');
|
|
393
|
+
}
|
|
394
|
+
async function relayToSelectedTab(url, deps, route) {
|
|
395
|
+
const tab = selectTab(url.searchParams.get('tab'), deps);
|
|
396
|
+
let reply;
|
|
397
|
+
try {
|
|
398
|
+
reply = await deps.sendCommand(tab, {
|
|
399
|
+
id: randomUUID(),
|
|
400
|
+
type: route.type,
|
|
401
|
+
...route.body,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
catch (error) {
|
|
405
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
406
|
+
if (message === 'TIMEOUT') {
|
|
407
|
+
throw relayError(504, 'TIMEOUT', 'canvas did not reply within 10s');
|
|
408
|
+
}
|
|
409
|
+
if (message === 'NO_CANVAS') {
|
|
410
|
+
throw relayError(503, 'NO_TABS', 'no canvas connected');
|
|
411
|
+
}
|
|
412
|
+
throw relayError(502, 'RELAY_ERROR', message);
|
|
413
|
+
}
|
|
414
|
+
if (!reply.ok) {
|
|
415
|
+
throw relayError(502, 'TAB_COMMAND_FAILED', reply.error);
|
|
416
|
+
}
|
|
417
|
+
const expectedType = route.expectedType ?? route.type;
|
|
418
|
+
if (reply.type !== expectedType) {
|
|
419
|
+
throw relayError(502, 'UNEXPECTED_REPLY', `expected ${expectedType}, got ${reply.type}`);
|
|
420
|
+
}
|
|
421
|
+
return reply;
|
|
422
|
+
}
|
|
423
|
+
function selectTab(tabId, deps) {
|
|
424
|
+
if (tabId) {
|
|
425
|
+
const tab = deps.findTab(tabId);
|
|
426
|
+
if (!tab) {
|
|
427
|
+
throw relayError(404, 'TAB_NOT_FOUND', `No connected tab ${tabId}`);
|
|
428
|
+
}
|
|
429
|
+
return tab;
|
|
430
|
+
}
|
|
431
|
+
const tabs = deps.listTabs();
|
|
432
|
+
if (tabs.length === 0) {
|
|
433
|
+
throw relayError(503, 'NO_TABS', 'no canvas connected');
|
|
434
|
+
}
|
|
435
|
+
if (tabs.length > 1) {
|
|
436
|
+
throw relayError(409, 'TAB_REQUIRED', 'Multiple tabs are connected; pass ?tab=<id>.', {
|
|
437
|
+
availableTabs: tabs.map((tab) => tab.info.id),
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
return tabs[0];
|
|
441
|
+
}
|
|
442
|
+
function relayError(status, code, message, extras = {}) {
|
|
443
|
+
return {
|
|
444
|
+
status,
|
|
445
|
+
body: {
|
|
446
|
+
error: {
|
|
447
|
+
code,
|
|
448
|
+
message,
|
|
449
|
+
...extras,
|
|
450
|
+
},
|
|
451
|
+
},
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
function parseJsonBody(raw) {
|
|
455
|
+
if (!raw.trim()) {
|
|
456
|
+
return {};
|
|
457
|
+
}
|
|
458
|
+
const parsed = JSON.parse(raw);
|
|
459
|
+
return asObject(parsed);
|
|
460
|
+
}
|
|
461
|
+
function asObject(value) {
|
|
462
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
463
|
+
}
|
|
464
|
+
function sendError(res, status, code, message) {
|
|
465
|
+
sendJson(res, status, { error: { code, message } });
|
|
466
|
+
}
|
|
467
|
+
function sendJson(res, status, body) {
|
|
468
|
+
const text = JSON.stringify(body);
|
|
469
|
+
res.writeHead(status, {
|
|
470
|
+
'content-type': 'application/json',
|
|
471
|
+
'content-length': String(Buffer.byteLength(text)),
|
|
472
|
+
'cache-control': 'no-store',
|
|
473
|
+
});
|
|
474
|
+
res.end(text);
|
|
475
|
+
}
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runMcpServer(): Promise<void>;
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { resolvePort } from './daemon.js';
|
|
5
|
+
const HOST = '127.0.0.1';
|
|
6
|
+
function baseUrl() {
|
|
7
|
+
return `http://${HOST}:${resolvePort()}`;
|
|
8
|
+
}
|
|
9
|
+
function tabQuery(tab) {
|
|
10
|
+
return tab ? `?tab=${encodeURIComponent(tab)}` : '';
|
|
11
|
+
}
|
|
12
|
+
async function requestJson(path, init) {
|
|
13
|
+
const res = await fetch(`${baseUrl()}${path}`, init);
|
|
14
|
+
const text = await res.text();
|
|
15
|
+
const body = text ? JSON.parse(text) : {};
|
|
16
|
+
if (!res.ok) {
|
|
17
|
+
throw new Error(JSON.stringify(body));
|
|
18
|
+
}
|
|
19
|
+
return body;
|
|
20
|
+
}
|
|
21
|
+
async function requestPngBase64(path) {
|
|
22
|
+
const res = await fetch(`${baseUrl()}${path}`);
|
|
23
|
+
if (!res.ok) {
|
|
24
|
+
const text = await res.text().catch(() => '');
|
|
25
|
+
throw new Error(text || `HTTP ${res.status}`);
|
|
26
|
+
}
|
|
27
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
28
|
+
return bytes.toString('base64');
|
|
29
|
+
}
|
|
30
|
+
function textResult(value) {
|
|
31
|
+
return {
|
|
32
|
+
content: [
|
|
33
|
+
{
|
|
34
|
+
type: 'text',
|
|
35
|
+
text: typeof value === 'string' ? value : JSON.stringify(value, null, 2),
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export async function runMcpServer() {
|
|
41
|
+
const server = new McpServer({
|
|
42
|
+
name: 'canvas-control-plane',
|
|
43
|
+
version: '0.1.0',
|
|
44
|
+
});
|
|
45
|
+
server.registerTool('canvas_ls', {
|
|
46
|
+
description: 'List every open Canvas editor tab connected to the local daemon.',
|
|
47
|
+
inputSchema: {},
|
|
48
|
+
}, async () => textResult(await requestJson('/v1/tabs')));
|
|
49
|
+
server.registerTool('canvas_tab_info', {
|
|
50
|
+
description: 'Get metadata for one connected Canvas tab.',
|
|
51
|
+
inputSchema: { tab: z.string() },
|
|
52
|
+
}, async ({ tab }) => textResult(await requestJson(`/v1/tabs/${encodeURIComponent(tab)}`)));
|
|
53
|
+
server.registerTool('canvas_document_get', {
|
|
54
|
+
description: 'Read the active document from a Canvas tab.',
|
|
55
|
+
inputSchema: { tab: z.string().optional() },
|
|
56
|
+
}, async ({ tab }) => textResult(await requestJson(`/v1/document${tabQuery(tab)}`)));
|
|
57
|
+
server.registerTool('canvas_document_nodes', {
|
|
58
|
+
description: 'List flattened nodes in the active document.',
|
|
59
|
+
inputSchema: { tab: z.string().optional() },
|
|
60
|
+
}, async ({ tab }) => textResult(await requestJson(`/v1/document/nodes${tabQuery(tab)}`)));
|
|
61
|
+
server.registerTool('canvas_draw', {
|
|
62
|
+
description: 'Create one or more 2D nodes in a Canvas tab. Supports rect, frame, text, and line specs.',
|
|
63
|
+
inputSchema: {
|
|
64
|
+
tab: z.string().optional(),
|
|
65
|
+
nodes: z.array(z.record(z.string(), z.unknown())).min(1),
|
|
66
|
+
},
|
|
67
|
+
}, async ({ tab, nodes }) => textResult(await requestJson(`/v1/draw${tabQuery(tab)}`, {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: { 'content-type': 'application/json' },
|
|
70
|
+
body: JSON.stringify({ nodes }),
|
|
71
|
+
})));
|
|
72
|
+
server.registerTool('canvas_node_get', {
|
|
73
|
+
description: 'Get one node by id.',
|
|
74
|
+
inputSchema: { tab: z.string().optional(), nodeId: z.string() },
|
|
75
|
+
}, async ({ tab, nodeId }) => textResult(await requestJson(`/v1/node/${encodeURIComponent(nodeId)}${tabQuery(tab)}`)));
|
|
76
|
+
server.registerTool('canvas_node_update', {
|
|
77
|
+
description: 'Apply a shallow patch to one node.',
|
|
78
|
+
inputSchema: {
|
|
79
|
+
tab: z.string().optional(),
|
|
80
|
+
nodeId: z.string(),
|
|
81
|
+
patch: z.record(z.string(), z.unknown()),
|
|
82
|
+
},
|
|
83
|
+
}, async ({ tab, nodeId, patch }) => textResult(await requestJson(`/v1/node/${encodeURIComponent(nodeId)}${tabQuery(tab)}`, {
|
|
84
|
+
method: 'PATCH',
|
|
85
|
+
headers: { 'content-type': 'application/json' },
|
|
86
|
+
body: JSON.stringify({ patch }),
|
|
87
|
+
})));
|
|
88
|
+
server.registerTool('canvas_node_delete', {
|
|
89
|
+
description: 'Delete one node by id.',
|
|
90
|
+
inputSchema: { tab: z.string().optional(), nodeId: z.string() },
|
|
91
|
+
}, async ({ tab, nodeId }) => textResult(await requestJson(`/v1/node/${encodeURIComponent(nodeId)}${tabQuery(tab)}`, {
|
|
92
|
+
method: 'DELETE',
|
|
93
|
+
})));
|
|
94
|
+
server.registerTool('canvas_select_set', {
|
|
95
|
+
description: 'Set the current selection to a list of node ids.',
|
|
96
|
+
inputSchema: { tab: z.string().optional(), nodeIds: z.array(z.string()) },
|
|
97
|
+
}, async ({ tab, nodeIds }) => textResult(await requestJson(`/v1/select${tabQuery(tab)}`, {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: { 'content-type': 'application/json' },
|
|
100
|
+
body: JSON.stringify({ nodeIds }),
|
|
101
|
+
})));
|
|
102
|
+
server.registerTool('canvas_select_clear', {
|
|
103
|
+
description: 'Clear the current selection.',
|
|
104
|
+
inputSchema: { tab: z.string().optional() },
|
|
105
|
+
}, async ({ tab }) => textResult(await requestJson(`/v1/select/clear${tabQuery(tab)}`, { method: 'POST' })));
|
|
106
|
+
server.registerTool('canvas_viewport_screenshot', {
|
|
107
|
+
description: 'Return a PNG screenshot from a Canvas tab as MCP image content.',
|
|
108
|
+
inputSchema: { tab: z.string().optional() },
|
|
109
|
+
}, async ({ tab }) => ({
|
|
110
|
+
content: [
|
|
111
|
+
{
|
|
112
|
+
type: 'image',
|
|
113
|
+
data: await requestPngBase64(`/v1/screenshot${tabQuery(tab)}`),
|
|
114
|
+
mimeType: 'image/png',
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
}));
|
|
118
|
+
server.registerTool('canvas_viewport_fit', {
|
|
119
|
+
description: 'Fit the viewport to the document.',
|
|
120
|
+
inputSchema: { tab: z.string().optional() },
|
|
121
|
+
}, async ({ tab }) => textResult(await requestJson(`/v1/viewport/fit${tabQuery(tab)}`, { method: 'POST' })));
|
|
122
|
+
server.registerTool('canvas_viewport_zoom', {
|
|
123
|
+
description: 'Set the viewport zoom.',
|
|
124
|
+
inputSchema: { tab: z.string().optional(), zoom: z.number() },
|
|
125
|
+
}, async ({ tab, zoom }) => textResult(await requestJson(`/v1/viewport/zoom${tabQuery(tab)}`, {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
headers: { 'content-type': 'application/json' },
|
|
128
|
+
body: JSON.stringify({ zoom }),
|
|
129
|
+
})));
|
|
130
|
+
server.registerTool('canvas_building_create', {
|
|
131
|
+
description: 'Call a building-design creation tool such as wall, room, slab, roof, door, window, zone, or furniture.',
|
|
132
|
+
inputSchema: {
|
|
133
|
+
tab: z.string().optional(),
|
|
134
|
+
buildingType: z.string(),
|
|
135
|
+
input: z.record(z.string(), z.unknown()).default({}),
|
|
136
|
+
},
|
|
137
|
+
}, async ({ tab, buildingType, input }) => textResult(await requestJson(`/v1/building${tabQuery(tab)}`, {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
headers: { 'content-type': 'application/json' },
|
|
140
|
+
body: JSON.stringify({ buildingType, input }),
|
|
141
|
+
})));
|
|
142
|
+
await server.connect(new StdioServerTransport());
|
|
143
|
+
}
|