drafted 1.0.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/mcp/server.mjs ADDED
@@ -0,0 +1,1024 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Drafted MCP Server
5
+ * Filesystem-style tools for agents to read/write/edit frames on the canvas.
6
+ * Calls the Drafted HTTP API directly (no CLI subprocess).
7
+ */
8
+
9
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
11
+ import { execFile } from 'child_process';
12
+ import { readFileSync, existsSync } from 'fs';
13
+ import { join, dirname, basename, extname, resolve } from 'path';
14
+ import { homedir } from 'os';
15
+ import { fileURLToPath } from 'url';
16
+ import { z } from 'zod';
17
+ import { LAYERS } from '../shared/constants.mjs';
18
+
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+
21
+ const server = new McpServer({
22
+ name: 'drafted',
23
+ version: '2.3.0',
24
+ description: `Multi-tenant design workspace. Structure: Organization → Projects → Layers → Lanes → Frames.
25
+
26
+ An org contains projects. Each project has a zoomable canvas with frames (HTML files) organized as /{layer}/{lane}/{filename}. Layers are predefined categories (wireframes, designs, brand-assets, etc.), lanes are groups within a layer, and frames are the individual design files.
27
+
28
+ WORKFLOW: list_projects → open_project → ls / → read/write/edit. Every response includes a "project" field showing which project you're operating on — always verify it matches your intent before writing.
29
+
30
+ IMPORTANT: Any URL containing /f/{uuid} is a Drafted frame link — ALWAYS use read(path=URL) to get frame content, focus(target=URL) to pan the canvas to it. Never curl or WebFetch Drafted URLs.`,
31
+ });
32
+
33
+ const layerKeys = Object.keys(LAYERS);
34
+
35
+ // ── Config ────────────────────────────────────────────────────────
36
+
37
+ const AUTH_FILE = process.env.DRAFTED_AUTH_FILE || join(homedir(), '.drafted', 'auth.json');
38
+
39
+ function getServerUrl() {
40
+ if (process.env.DRAFTED_SERVER) return process.env.DRAFTED_SERVER.replace(/\/$/, '');
41
+ // Read from config.json next to auth.json (written by install-mcp.sh)
42
+ try {
43
+ const cfgPath = join(homedir(), '.drafted', 'config.json');
44
+ if (existsSync(cfgPath)) {
45
+ const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
46
+ if (cfg.server) return cfg.server.replace(/\/$/, '');
47
+ }
48
+ } catch { /* fall through */ }
49
+ return `http://localhost:${process.env.DRAFTED_PORT || 3477}`;
50
+ }
51
+
52
+ // ── Per-instance session ──────────────────────────────────────────
53
+ // Each MCP instance clones its own session from the bootstrap session in auth.json.
54
+ // This ensures multiple Claude Code instances don't share state.
55
+
56
+ let instanceSessionId = null; // cloned session, in-memory only
57
+ let agentActiveProjectId = null; // in-memory only, never persisted
58
+
59
+ function getBootstrapSessionId() {
60
+ try {
61
+ if (existsSync(AUTH_FILE)) {
62
+ const auth = JSON.parse(readFileSync(AUTH_FILE, 'utf8'));
63
+ return auth.sessionId || null;
64
+ }
65
+ } catch { /* no auth */ }
66
+ return null;
67
+ }
68
+
69
+ function getAuthHeaders() {
70
+ const sid = instanceSessionId || getBootstrapSessionId();
71
+ if (sid) return { Cookie: `gc_session=${sid}` };
72
+ return {};
73
+ }
74
+
75
+ async function cloneSession() {
76
+ const bootstrapId = getBootstrapSessionId();
77
+ if (!bootstrapId) return;
78
+
79
+ try {
80
+ const url = `${getServerUrl()}/auth/session/clone`;
81
+ const res = await fetch(url, {
82
+ method: 'POST',
83
+ headers: { Cookie: `gc_session=${bootstrapId}` },
84
+ });
85
+ if (!res.ok) return;
86
+ const data = await res.json();
87
+ if (data.sessionId) {
88
+ instanceSessionId = data.sessionId;
89
+ }
90
+ } catch { /* server may not be ready yet, will retry on first API call */ }
91
+ }
92
+
93
+ async function ensureSession() {
94
+ if (instanceSessionId) return;
95
+ await cloneSession();
96
+ }
97
+
98
+ async function api(method, path, body, _retried) {
99
+ await ensureSession();
100
+ const pid = agentActiveProjectId;
101
+ const sep = path.includes('?') ? '&' : '?';
102
+ const scopedPath = pid ? `${path}${sep}projectId=${pid}` : path;
103
+ const url = `${getServerUrl()}${scopedPath}`;
104
+ const headers = { ...getAuthHeaders() };
105
+ const opts = { method, headers };
106
+
107
+ if (body !== undefined) {
108
+ headers['Content-Type'] = 'application/json';
109
+ opts.body = JSON.stringify(body);
110
+ }
111
+
112
+ const res = await fetch(url, opts);
113
+ const text = await res.text();
114
+
115
+ // Session expired after server restart — re-clone and retry once
116
+ if (res.status === 401 && !_retried) {
117
+ instanceSessionId = null;
118
+ await cloneSession();
119
+ return api(method, path, body, true);
120
+ }
121
+
122
+ let data;
123
+ try {
124
+ data = JSON.parse(text);
125
+ } catch {
126
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`);
127
+ data = { text };
128
+ }
129
+
130
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
131
+ return data;
132
+ }
133
+
134
+ function ok(text) {
135
+ return { content: [{ type: 'text', text: typeof text === 'string' ? text : JSON.stringify(text, null, 2) }] };
136
+ }
137
+
138
+ // ── Agent WebSocket presence ──────────────────────────────────────
139
+ import WebSocket from 'ws';
140
+
141
+ let agentWs = null;
142
+ let agentWsReconnectTimer = null;
143
+
144
+ function setMcpActiveProject(projectId) {
145
+ agentActiveProjectId = projectId;
146
+ }
147
+
148
+ async function connectAgentWs() {
149
+ await ensureSession();
150
+ const auth = getAuthHeaders();
151
+ if (!auth.Cookie) return;
152
+
153
+ const serverUrl = getServerUrl().replace(/^http/, 'ws');
154
+ try {
155
+ agentWs = new WebSocket(serverUrl, { headers: auth });
156
+ } catch { return; }
157
+
158
+ agentWs.on('open', () => {
159
+ console.error('[MCP-WS] Connected');
160
+ if (agentActiveProjectId) {
161
+ agentWs.send(JSON.stringify({ type: 'join', projectId: agentActiveProjectId, agent: true }));
162
+ }
163
+ });
164
+
165
+ agentWs.on('close', () => {
166
+ console.error('[MCP-WS] Disconnected, reconnecting in 5s...');
167
+ agentWs = null;
168
+ // Server may have restarted — invalidate session so ensureSession re-clones
169
+ instanceSessionId = null;
170
+ clearTimeout(agentWsReconnectTimer);
171
+ agentWsReconnectTimer = setTimeout(() => {
172
+ connectAgentWs().catch((e) => {
173
+ console.error('[MCP-WS] Reconnect failed:', e?.message || e);
174
+ });
175
+ }, 5000);
176
+ });
177
+
178
+ agentWs.on('error', () => {
179
+ // close handler will fire after this
180
+ });
181
+ }
182
+
183
+ function joinAgentWsRoom(projectId) {
184
+ if (!agentWs) return;
185
+ const msg = JSON.stringify({ type: 'join', projectId, agent: true });
186
+ if (agentWs.readyState === WebSocket.OPEN) {
187
+ agentWs.send(msg);
188
+ } else if (agentWs.readyState === WebSocket.CONNECTING) {
189
+ agentWs.once('open', () => agentWs.send(msg));
190
+ }
191
+ }
192
+
193
+ // Clone session and connect WebSocket on startup (delayed to let server be ready)
194
+ setTimeout(() => {
195
+ cloneSession().then(() => connectAgentWs()).catch(() => {});
196
+ }, 1000);
197
+
198
+ function err(error) {
199
+ return { content: [{ type: 'text', text: error.message || String(error) }], isError: true };
200
+ }
201
+
202
+ // ── Anchor enforcement ────────────────────────────────────────────
203
+ // Track which frames this session has fully read (no line range = full read)
204
+ const readFrameIds = new Set();
205
+
206
+ // Cache anchored frames (refreshed on each check)
207
+ let anchoredCache = null;
208
+ let anchoredCacheTime = 0;
209
+
210
+ async function getAnchoredFrames() {
211
+ // Cache for 10 seconds to avoid hammering the API
212
+ if (anchoredCache && Date.now() - anchoredCacheTime < 10000) return anchoredCache;
213
+ try {
214
+ anchoredCache = await api('GET', '/api/designs/anchored');
215
+ anchoredCacheTime = Date.now();
216
+ return anchoredCache;
217
+ } catch {
218
+ return [];
219
+ }
220
+ }
221
+
222
+ function parseLayer(path) {
223
+ return path.replace(/^\/+/, '').split('/')[0];
224
+ }
225
+
226
+ async function checkAnchors(layer) {
227
+ const anchored = await getAnchoredFrames();
228
+ if (!Array.isArray(anchored)) return null;
229
+
230
+ const layerAnchors = anchored.filter(f => f.layer === layer);
231
+ if (layerAnchors.length === 0) return null;
232
+
233
+ const unread = layerAnchors.filter(f => !readFrameIds.has(f.id));
234
+ if (unread.length === 0) return null;
235
+
236
+ const paths = unread.map(f => `/${f.layer}/${f.lane}/${f.label}`);
237
+ return `This layer has ${layerAnchors.length} anchored frame(s) that must be read before making changes. ` +
238
+ `Unread anchors:\n${paths.map(p => ' read path="' + p + '"').join('\n')}\n\n` +
239
+ `Read all anchored frames first, then retry your operation.`;
240
+ }
241
+
242
+ // ── CLI passthrough (for login/start/stop only) ───────────────────
243
+
244
+ function runCLI(command, args = [], options = {}) {
245
+ const timeout = options.timeout || 30000;
246
+ return new Promise((resolve, reject) => {
247
+ execFile('drafted', [command, ...args, '--json'], { timeout }, (error, stdout, stderr) => {
248
+ if (error) {
249
+ if (error.code === 'ENOENT') return reject(new Error('drafted CLI not found on PATH'));
250
+ if (error.killed) return reject(new Error('Command timed out'));
251
+ try {
252
+ const result = JSON.parse(stdout);
253
+ return reject(new Error(result.error || 'Command failed'));
254
+ } catch {
255
+ return reject(new Error(stderr || error.message));
256
+ }
257
+ }
258
+ try { resolve(JSON.parse(stdout)); }
259
+ catch { reject(new Error('Failed to parse CLI output')); }
260
+ });
261
+ });
262
+ }
263
+
264
+ // ── Server management tools (still via CLI) ───────────────────────
265
+
266
+ // ── Login tools ─────────────────────────────────────────────────────
267
+
268
+ // Shared pending device code — get_login_link stores it, login reuses it
269
+ let pendingDeviceCode = null;
270
+
271
+ server.tool('get_login_link', 'Get a sign-in URL without blocking. Use this before login when the browser may not open (SSH, headless, tmux). Returns the URL immediately for the user to open manually. Then call login to complete the flow.', {}, async () => {
272
+ try {
273
+ const codeRes = await fetch(`${getServerUrl()}/auth/device/code`, { method: 'POST' });
274
+ if (!codeRes.ok) throw new Error(`Failed to start device authorization (HTTP ${codeRes.status})`);
275
+ const data = await codeRes.json();
276
+ pendingDeviceCode = data;
277
+ return ok(data.verificationUrl);
278
+ } catch (error) { return err(error); }
279
+ });
280
+
281
+ server.tool('login', 'Authenticate with Drafted. Opens a browser for the user to sign in. Run this if other tools return auth errors or "fetch failed". If get_login_link was called first, polls for that approval instead of opening a new browser.', {}, async () => {
282
+ try {
283
+ // Check if already authenticated
284
+ const existing = getBootstrapSessionId();
285
+ if (existing) {
286
+ try {
287
+ const res = await fetch(`${getServerUrl()}/auth/me`, {
288
+ headers: { Cookie: `gc_session=${existing}` },
289
+ });
290
+ if (res.ok) {
291
+ const me = await res.json();
292
+ return ok({ status: 'already_authenticated', userId: me.userId, email: me.userEmail, org: me.currentOrg?.name });
293
+ }
294
+ } catch { /* session invalid, proceed with login */ }
295
+ }
296
+
297
+ let deviceCode, verificationUrl, expiresIn;
298
+ let reusingPending = false;
299
+
300
+ // Reuse pending device code from get_login_link if available
301
+ if (pendingDeviceCode) {
302
+ ({ deviceCode, verificationUrl, expiresIn } = pendingDeviceCode);
303
+ pendingDeviceCode = null;
304
+ reusingPending = true;
305
+ } else {
306
+ // Request a new device code
307
+ const codeRes = await fetch(`${getServerUrl()}/auth/device/code`, { method: 'POST' });
308
+ if (!codeRes.ok) throw new Error(`Failed to start device authorization (HTTP ${codeRes.status})`);
309
+ ({ deviceCode, verificationUrl, expiresIn } = await codeRes.json());
310
+ }
311
+
312
+ // Generate QR code for the verification URL
313
+ let qrText = '';
314
+ try {
315
+ const qrcode = await import('qrcode-terminal');
316
+ qrText = await new Promise((resolve) => {
317
+ qrcode.default.generate(verificationUrl, { small: true }, (code) => resolve(code));
318
+ });
319
+ } catch { /* QR generation failed, continue without it */ }
320
+
321
+ // Log the URL to stderr so it's visible even if MCP response is delayed
322
+ console.error(`\n[MCP] Sign in at: ${verificationUrl}\n${qrText ? qrText + '\n' : ''}[MCP] Waiting for approval...`);
323
+
324
+ // Open browser only if we're not reusing a pending code (user already has the URL)
325
+ if (!reusingPending) {
326
+ const { exec } = await import('child_process');
327
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
328
+ exec(`${cmd} ${JSON.stringify(verificationUrl)}`);
329
+ }
330
+
331
+ // Poll for approval
332
+ const deadline = Date.now() + (expiresIn * 1000);
333
+ while (Date.now() < deadline) {
334
+ await new Promise(r => setTimeout(r, 4000));
335
+ const res = await fetch(`${getServerUrl()}/auth/device/token`, {
336
+ method: 'POST',
337
+ headers: { 'Content-Type': 'application/json' },
338
+ body: JSON.stringify({ deviceCode }),
339
+ });
340
+ if (!res.ok) throw new Error(`Token poll failed (HTTP ${res.status})`);
341
+ const data = await res.json();
342
+
343
+ if (data.status === 'approved') {
344
+ // Write auth file
345
+ const { writeFileSync, mkdirSync } = await import('fs');
346
+ const { dirname } = await import('path');
347
+ mkdirSync(dirname(AUTH_FILE), { recursive: true });
348
+ writeFileSync(AUTH_FILE, JSON.stringify({
349
+ sessionId: data.sessionId,
350
+ userId: data.userId || null,
351
+ orgId: data.orgId || null,
352
+ server: getServerUrl(),
353
+ updatedAt: new Date().toISOString(),
354
+ }, null, 2));
355
+
356
+ // Re-clone session for this instance
357
+ instanceSessionId = null;
358
+ await cloneSession();
359
+ connectAgentWs();
360
+
361
+ return ok({ status: 'logged_in', sessionId: data.sessionId, userId: data.userId });
362
+ }
363
+
364
+ if (data.status === 'expired') throw new Error('Device code expired. Try again.');
365
+ }
366
+
367
+ throw new Error(`Login timed out. If the browser didn't open, visit: ${verificationUrl}`);
368
+ } catch (error) { return err(error); }
369
+ });
370
+
371
+ // ── Project management tools (direct HTTP) ────────────────────────
372
+
373
+ server.tool('list_projects', 'START HERE. Lists all projects in the org. Use this first to find the project you need, then open_project to switch to it.', {}, async () => {
374
+ try {
375
+ const data = await api('GET', '/api/projects');
376
+ data.agentProject = agentActiveProjectId || null;
377
+ return ok(data);
378
+ } catch (error) { return err(error); }
379
+ });
380
+
381
+ server.tool('create_project', {
382
+ name: z.string().describe('Project name'),
383
+ description: z.string().optional().describe('Project description'),
384
+ templateSlug: z.string().optional().describe('Template slug (e.g. "web-design", "mobile-app", "landing-page")'),
385
+ }, async ({ name, description, templateSlug }) => {
386
+ try {
387
+ const body = { name };
388
+ if (description) body.description = description;
389
+ if (templateSlug) body.templateSlug = templateSlug;
390
+ return ok(await api('POST', '/api/projects', body));
391
+ } catch (error) { return err(error); }
392
+ });
393
+
394
+ server.tool('list_templates', {}, async () => {
395
+ try { return ok(await api('GET', '/api/templates')); }
396
+ catch (error) { return err(error); }
397
+ });
398
+
399
+ server.tool('create_template', {
400
+ name: z.string().describe('Template name'),
401
+ description: z.string().describe('Template description'),
402
+ layers: z.array(z.object({}).passthrough()).describe('Array of layer definitions'),
403
+ visibility: z.string().optional().describe('Visibility: "org" or "public"'),
404
+ }, async ({ name, description, layers, visibility }) => {
405
+ try {
406
+ const body = { name, description, layers };
407
+ if (visibility) body.visibility = visibility;
408
+ return ok(await api('POST', '/api/templates', body));
409
+ } catch (error) { return err(error); }
410
+ });
411
+
412
+ server.tool('update_template', {
413
+ templateId: z.string().describe('Template ID to update'),
414
+ name: z.string().optional().describe('Template name'),
415
+ description: z.string().optional().describe('Template description'),
416
+ layers: z.array(z.object({}).passthrough()).optional().describe('Array of layer definitions'),
417
+ visibility: z.string().optional().describe('Visibility: "org" or "public"'),
418
+ }, async ({ templateId, name, description, layers, visibility }) => {
419
+ try {
420
+ const body = {};
421
+ if (name) body.name = name;
422
+ if (description) body.description = description;
423
+ if (layers) body.layers = layers;
424
+ if (visibility) body.visibility = visibility;
425
+ return ok(await api('PUT', `/api/templates/${templateId}`, body));
426
+ } catch (error) { return err(error); }
427
+ });
428
+
429
+ server.tool('delete_template', {
430
+ templateId: z.string().describe('Template ID to delete'),
431
+ }, async ({ templateId }) => {
432
+ try { return ok(await api('DELETE', `/api/templates/${templateId}`)); }
433
+ catch (error) { return err(error); }
434
+ });
435
+
436
+ server.tool('fork_template', {
437
+ templateId: z.string().describe('Template ID to fork'),
438
+ name: z.string().optional().describe('Name for the forked copy'),
439
+ }, async ({ templateId, name }) => {
440
+ try {
441
+ const body = {};
442
+ if (name) body.name = name;
443
+ return ok(await api('POST', `/api/templates/${templateId}/fork`, body));
444
+ } catch (error) { return err(error); }
445
+ });
446
+
447
+ server.tool('open_project', 'Switch active project. REQUIRED before reading or writing — all fs tools (ls, read, write, edit, rm, mv, batch) operate on the active project. Get project IDs from list_projects.', {
448
+ projectId: z.string().describe('Project ID to switch to and open in browser'),
449
+ }, async ({ projectId }) => {
450
+ try {
451
+ const result = await api('POST', '/api/project/switch', { projectId });
452
+ setMcpActiveProject(projectId);
453
+ joinAgentWsRoom(projectId);
454
+ const base = getServerUrl();
455
+ // Resolve project slug for URL
456
+ let projectSlug = projectId;
457
+ try {
458
+ const data = await api('GET', '/api/projects');
459
+ const proj = (data.projects || []).find(p => p.id === projectId);
460
+ if (proj?.slug) projectSlug = proj.slug;
461
+ } catch { /* fall back to projectId */ }
462
+ const url = `${base}/project/${projectSlug}`;
463
+ const { exec } = await import('child_process');
464
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
465
+ exec(`${cmd} ${JSON.stringify(url)}`);
466
+ return ok({ ...result, url, opened: true });
467
+ } catch (error) { return err(error); }
468
+ });
469
+
470
+ server.tool('focus', {
471
+ target: z.string().describe('Frame URL (any URL containing /f/{uuid}), frame ID (UUID), or file path (/{layer}/{lane}/{filename}) to pan the canvas viewport to. When a user shares a Drafted frame link, pass it directly here.'),
472
+ }, async ({ target }) => {
473
+ try {
474
+ // Resolve target to a frame ID
475
+ const frameUrlMatch = target.match(/\/f\/([a-f0-9-]{36})/);
476
+ const uuidMatch = target.match(/^[a-f0-9-]{36}$/);
477
+ let frameId = frameUrlMatch?.[1] || (uuidMatch ? target : null);
478
+
479
+ if (!frameId) {
480
+ // Path — resolve via read to get the frame ID
481
+ const parts = target.replace(/^\/+/, '').split('/');
482
+ if (parts.length !== 3) throw new Error('Target must be a frame URL, frame ID, or path /{layer}/{lane}/{filename}');
483
+ const frame = await api('GET', `/api/fs/${parts[0]}/${parts[1]}/${parts[2]}`);
484
+ if (!frame.id) throw new Error('Frame not found');
485
+ frameId = frame.id;
486
+ }
487
+
488
+ const result = await api('POST', `/api/focus/${frameId}`);
489
+ return ok(result);
490
+ } catch (error) { return err(error); }
491
+ });
492
+
493
+ server.tool('screenshot', {
494
+ target: z.string().describe('Frame URL (any URL containing /f/{uuid}), frame ID (UUID), or file path (/{layer}/{lane}/{filename}) to screenshot.'),
495
+ width: z.number().optional().default(1440).describe('Viewport width in pixels (default 1440)'),
496
+ height: z.number().optional().default(900).describe('Viewport height in pixels (default 900)'),
497
+ fullPage: z.boolean().optional().default(true).describe('Capture full page or just viewport (default true)'),
498
+ }, async ({ target, width, height, fullPage }) => {
499
+ try {
500
+ // Resolve target to a frame ID
501
+ const frameUrlMatch = target.match(/\/f\/([a-f0-9-]{36})/);
502
+ const uuidMatch = target.match(/^[a-f0-9-]{36}$/);
503
+ let frameId = frameUrlMatch?.[1] || (uuidMatch ? target : null);
504
+
505
+ if (!frameId) {
506
+ const parts = target.replace(/^\/+/, '').split('/');
507
+ if (parts.length !== 3) throw new Error('Target must be a frame URL, frame ID, or path /{layer}/{lane}/{filename}');
508
+ const frame = await api('GET', `/api/fs/${parts[0]}/${parts[1]}/${parts[2]}`);
509
+ if (!frame.id) throw new Error('Frame not found');
510
+ frameId = frame.id;
511
+ }
512
+
513
+ const url = `${getServerUrl()}/api/screenshot/${frameId}?width=${width}&height=${height}&fullPage=${fullPage}`;
514
+ const res = await fetch(url, { headers: getAuthHeaders() });
515
+ if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
516
+
517
+ const buffer = await res.arrayBuffer();
518
+ const base64 = Buffer.from(buffer).toString('base64');
519
+
520
+ return {
521
+ content: [{
522
+ type: 'image',
523
+ data: base64,
524
+ mimeType: 'image/png',
525
+ }],
526
+ };
527
+ } catch (error) { return err(error); }
528
+ });
529
+
530
+ server.tool('update_project', {
531
+ projectId: z.string().describe('Project ID to update'),
532
+ name: z.string().optional().describe('New project name'),
533
+ folder: z.string().nullable().optional().describe('Folder name (null to remove from folder)'),
534
+ description: z.string().nullable().optional().describe('Project description'),
535
+ layers: z.array(z.object({}).passthrough()).optional().describe('Full layers array replacement. Use ls / to read current layers first.'),
536
+ }, async ({ projectId, name, folder, description, layers }) => {
537
+ try {
538
+ const body = {};
539
+ if (name !== undefined) body.name = name;
540
+ if (folder !== undefined) body.folder = folder;
541
+ if (description !== undefined) body.description = description;
542
+ if (layers) body.layers = layers;
543
+ if (Object.keys(body).length === 0) throw new Error('At least one field (name, folder, description, layers) is required');
544
+ return ok(await api('PATCH', `/api/project/${projectId}`, body));
545
+ } catch (error) { return err(error); }
546
+ });
547
+
548
+ server.tool('add_layer', {
549
+ projectId: z.string().describe('Project ID to add the layer to'),
550
+ key: z.string().describe('Unique layer key (e.g. "research", "prototypes")'),
551
+ label: z.string().describe('Display label for the layer'),
552
+ type: z.string().describe('Layer type (e.g. "html", "image", "text")'),
553
+ width: z.number().describe('Default frame width in pixels'),
554
+ height: z.number().describe('Default frame height in pixels'),
555
+ description: z.string().optional().describe('Layer description'),
556
+ prompt: z.string().optional().describe('Prompt hint for AI agents working in this layer'),
557
+ }, async ({ projectId, key, label, type, width, height, description, prompt }) => {
558
+ try {
559
+ const project = await api('GET', `/api/project/${projectId}`);
560
+ const layers = project.layers || [];
561
+ if (layers.some(l => l.key === key)) {
562
+ throw new Error(`Layer with key "${key}" already exists in this project`);
563
+ }
564
+ const newLayer = { key, label, type, width, height };
565
+ if (description !== undefined) newLayer.description = description;
566
+ if (prompt !== undefined) newLayer.prompt = prompt;
567
+ layers.push(newLayer);
568
+ return ok(await api('PATCH', `/api/project/${projectId}`, { layers }));
569
+ } catch (error) { return err(error); }
570
+ });
571
+
572
+ server.tool('remove_layer', {
573
+ projectId: z.string().describe('Project ID to remove the layer from'),
574
+ key: z.string().describe('Layer key to remove (e.g. "research", "prototypes")'),
575
+ force: z.boolean().optional().default(false).describe('Force removal even if the layer contains frames'),
576
+ }, async ({ projectId, key, force }) => {
577
+ try {
578
+ const project = await api('GET', `/api/project/${projectId}`);
579
+ const layers = project.layers || [];
580
+ if (!layers.some(l => l.key === key)) {
581
+ throw new Error(`Layer with key "${key}" does not exist in this project`);
582
+ }
583
+ // Check for existing frames in the layer
584
+ if (!force) {
585
+ const listing = await api('GET', `/api/fs?path=/${key}&projectId=${projectId}&recursive=true`);
586
+ const entries = listing.entries || [];
587
+ const frames = entries.filter(e => e.type === 'frame');
588
+ // Don't count the auto-generated _context.md as real content
589
+ const realFrames = frames.filter(f => !f.path.endsWith('/_meta/_context.md'));
590
+ const frameCount = realFrames.length;
591
+ if (frameCount > 0) {
592
+ throw new Error(`Layer "${key}" contains ${frameCount} frame(s). Use force: true to confirm deletion.`);
593
+ }
594
+ }
595
+ const filtered = layers.filter(l => l.key !== key);
596
+ return ok(await api('PATCH', `/api/project/${projectId}`, { layers: filtered }));
597
+ } catch (error) { return err(error); }
598
+ });
599
+
600
+ server.tool('reorder_layers', {
601
+ projectId: z.string().describe('Project ID to reorder layers for'),
602
+ keys: z.array(z.string()).describe('Ordered array of ALL layer keys. Must contain exactly the same keys as current layers — no additions, removals, or duplicates.'),
603
+ }, async ({ projectId, keys }) => {
604
+ try {
605
+ const project = await api('GET', `/api/project/${projectId}`);
606
+ const currentLayers = project.layers || [];
607
+ const currentKeys = currentLayers.map(l => l.key);
608
+
609
+ // Check for duplicates
610
+ const uniqueKeys = new Set(keys);
611
+ if (uniqueKeys.size !== keys.length) {
612
+ const dupes = keys.filter((k, i) => keys.indexOf(k) !== i);
613
+ throw new Error(`Duplicate keys: ${[...new Set(dupes)].join(', ')}`);
614
+ }
615
+
616
+ // Check for missing keys
617
+ const missing = currentKeys.filter(k => !uniqueKeys.has(k));
618
+ if (missing.length > 0) {
619
+ throw new Error(`Missing keys: ${missing.join(', ')}. You must include all existing layer keys.`);
620
+ }
621
+
622
+ // Check for extra keys
623
+ const currentSet = new Set(currentKeys);
624
+ const extra = keys.filter(k => !currentSet.has(k));
625
+ if (extra.length > 0) {
626
+ throw new Error(`Unknown keys: ${extra.join(', ')}. Only existing layer keys are allowed.`);
627
+ }
628
+
629
+ const reorderedLayers = keys.map(k => currentLayers.find(l => l.key === k));
630
+ return ok(await api('PATCH', `/api/project/${projectId}`, { layers: reorderedLayers }));
631
+ } catch (error) { return err(error); }
632
+ });
633
+
634
+ server.tool('update_layer', {
635
+ projectId: z.string().describe('Project ID containing the layer'),
636
+ key: z.string().describe('Layer key to update (e.g. "wireframes", "designs")'),
637
+ label: z.string().optional().describe('Display label'),
638
+ type: z.string().optional().describe('Layer type'),
639
+ width: z.number().optional().describe('Default frame width'),
640
+ height: z.number().optional().describe('Default frame height'),
641
+ description: z.string().optional().describe('Layer description'),
642
+ prompt: z.string().optional().describe('Layer prompt'),
643
+ }, async ({ projectId, key, label, type, width, height, description, prompt }) => {
644
+ try {
645
+ const project = await api('GET', `/api/project/${projectId}`);
646
+ const layers = project.layers || project.project?.layers;
647
+ if (!Array.isArray(layers)) throw new Error('Project has no layers array');
648
+
649
+ const idx = layers.findIndex(l => l.key === key);
650
+ if (idx === -1) throw new Error(`Layer with key "${key}" not found`);
651
+
652
+ const updates = { label, type, width, height, description, prompt };
653
+ const filtered = Object.fromEntries(Object.entries(updates).filter(([, v]) => v !== undefined));
654
+ if (Object.keys(filtered).length === 0) throw new Error('At least one field (label, type, width, height, description, prompt) is required');
655
+
656
+ layers[idx] = { ...layers[idx], ...filtered };
657
+ return ok(await api('PATCH', `/api/project/${projectId}`, { layers }));
658
+ } catch (error) { return err(error); }
659
+ });
660
+
661
+ server.tool('get_org', {}, async () => {
662
+ try {
663
+ const data = await api('GET', '/api/orgs');
664
+ const orgs = data.orgs || data || [];
665
+ // Get members for the active org
666
+ const auth = JSON.parse(readFileSync(AUTH_FILE, 'utf8'));
667
+ let members = [];
668
+ let activeOrg = null;
669
+ if (auth.sessionId) {
670
+ // Find the active org from the session
671
+ const projectData = await api('GET', '/api/projects');
672
+ const orgId = projectData.projects?.[0]?.orgId;
673
+ if (orgId) {
674
+ activeOrg = orgs.find(o => (o.orgId || o.id) === orgId);
675
+ try {
676
+ const memberData = await api('GET', `/api/orgs/${orgId}/members`);
677
+ members = memberData.members || memberData || [];
678
+ } catch { /* no members */ }
679
+ }
680
+ }
681
+ return ok({
682
+ activeOrg: activeOrg ? { id: activeOrg.orgId || activeOrg.id, name: activeOrg.orgName || activeOrg.name } : null,
683
+ orgs: orgs.map(o => ({ id: o.orgId || o.id, name: o.orgName || o.name })),
684
+ members: members.map(m => ({ id: m.userId, name: m.username, email: m.email, role: m.role })),
685
+ });
686
+ } catch (error) { return err(error); }
687
+ });
688
+
689
+ server.tool('search', {
690
+ query: z.string().describe('Search term to match against frame names'),
691
+ projectId: z.string().optional().describe('Limit search to a specific project (optional)'),
692
+ }, async ({ query, projectId }) => {
693
+ try {
694
+ const params = new URLSearchParams({ q: query });
695
+ if (projectId) params.set('projectId', projectId);
696
+ await ensureSession();
697
+ const url = `${getServerUrl()}/api/search?${params.toString()}`;
698
+ const res = await fetch(url, { headers: getAuthHeaders() });
699
+ if (!res.ok) throw new Error(`Search failed: ${res.status}`);
700
+ const results = await res.json();
701
+ return ok(results.map(r => ({
702
+ id: r.id,
703
+ path: `/${r.layer}/${r.lane}/${r.label}`,
704
+ project: r.projectName,
705
+ projectId: r.projectId,
706
+ frameUrl: `${getServerUrl()}/f/${r.id}`,
707
+ contentType: r.contentType,
708
+ updatedAt: r.updatedAt,
709
+ })));
710
+ } catch (error) { return err(error); }
711
+ });
712
+
713
+ // ── Filesystem tools (direct HTTP to /api/fs) ─────────────────────
714
+
715
+ server.tool('write', 'Write a frame to the ACTIVE PROJECT. Check the "project" field in the response to confirm it went to the right place. Use open_project first if needed.\n\n**Content or file:** Provide `content` (HTML/markdown/text) OR `file_path` (absolute path to a local file like a PNG screenshot). For binary files (images, PDFs), use `file_path` — the file is uploaded to storage and displayed as an asset frame.\n\n**Dimensions:** By default, frames use the layer\'s default size (e.g., 1440×900 for designs, 1440×3000 for wireframes). This is often too large for small content like a brief or a single component. Use `autoSize: true` to automatically measure the HTML content and size the frame to fit. You can also pass explicit `width`/`height` for precise control. Choose dimensions that match your content — a short text document should be ~800×400, not 1440×3000.', {
716
+ path: z.string().describe('File path: /{layer}/{lane}/{filename} (e.g. /wireframes/analyse/variant-a.html, /images/tests/login.png). To write MULTIPLE files at once, use the batch tool instead. Returns frameUrl (canvas deep link to view the frame in the browser) and id (frame UUID).'),
717
+ content: z.string().optional().describe('File content (HTML, markdown, or text). Mutually exclusive with file_path — provide one or the other.'),
718
+ file_path: z.string().optional().describe('Absolute path to a local file to upload (e.g. /tmp/screenshot.png). The file is read from disk and uploaded to storage. Mutually exclusive with content.'),
719
+ autoSize: z.boolean().optional().describe('Automatically measure HTML content and size the frame to fit. Recommended for most content. Only applies to content, not file_path.'),
720
+ width: z.number().optional().describe('Explicit frame width in pixels. Overrides layer default. Ignored if autoSize is true.'),
721
+ height: z.number().optional().describe('Explicit frame height in pixels. Overrides layer default. Ignored if autoSize is true.'),
722
+ color: z.string().optional().describe('CSS color for frame border (e.g. #ff0000, red)'),
723
+ }, async ({ path, content, file_path, autoSize, width, height, color }) => {
724
+ try {
725
+ // Validate: exactly one of content or file_path
726
+ if (content != null && file_path) throw new Error('Provide content OR file_path, not both');
727
+ if (content == null && !file_path) throw new Error('Provide content or file_path');
728
+
729
+ const anchorErr = await checkAnchors(parseLayer(path));
730
+ if (anchorErr) return err(new Error(anchorErr));
731
+ const parts = path.replace(/^\/+/, '').split('/');
732
+ if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
733
+
734
+ let body;
735
+ if (file_path) {
736
+ // Binary upload: read file from disk, send as base64
737
+ const resolved = resolve(file_path);
738
+ if (!existsSync(resolved)) throw new Error(`File not found: ${resolved}`);
739
+ const buffer = readFileSync(resolved);
740
+ const ext = extname(resolved).toLowerCase();
741
+ const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.pdf': 'application/pdf' };
742
+ body = { base64: buffer.toString('base64'), contentType: MIME[ext] || 'application/octet-stream' };
743
+ } else {
744
+ body = { content };
745
+ if (autoSize) body.autoSize = true;
746
+ }
747
+ if (width) body.width = width;
748
+ if (height) body.height = height;
749
+ if (color) body.color = color;
750
+ const result = await api('PUT', `/api/fs/${parts[0]}/${parts[1]}/${parts[2]}`, body);
751
+ return ok(result);
752
+ } catch (error) { return err(error); }
753
+ });
754
+
755
+ server.tool('read', 'Read a frame from the ACTIVE PROJECT. Response includes "project" field confirming which project was read.', {
756
+ path: z.string().describe('File path /{layer}/{lane}/{filename}, frame URL (any URL containing /f/{uuid}), or bare frame ID (UUID). When a user shares a Drafted frame link, pass it directly here. Use ls to discover paths.'),
757
+ lines: z.string().optional().describe('Line range (e.g. "1-50", "80-120"). Omit to read all.'),
758
+ }, async ({ path, lines }) => {
759
+ try {
760
+ const query = lines ? `?lines=${encodeURIComponent(lines)}` : '';
761
+
762
+ // Detect frame URL (e.g. http://host/f/{uuid}) or bare UUID
763
+ const frameUrlMatch = path.match(/\/f\/([a-f0-9-]{36})/);
764
+ const uuidMatch = path.match(/^[a-f0-9-]{36}$/);
765
+ const frameId = frameUrlMatch?.[1] || (uuidMatch ? path : null);
766
+
767
+ let result;
768
+ if (frameId) {
769
+ result = await api('GET', `/api/fs/by-id/${frameId}${query}`);
770
+ } else {
771
+ const parts = path.replace(/^\/+/, '').split('/');
772
+ if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}, a frame URL, or a frame ID');
773
+ result = await api('GET', `/api/fs/${parts[0]}/${parts[1]}/${parts[2]}${query}`);
774
+ }
775
+
776
+ // Track full reads (no line range) for anchor enforcement
777
+ if (!lines && result.ok && result.id) {
778
+ readFrameIds.add(result.id);
779
+ }
780
+
781
+ return ok(result.content || result);
782
+ } catch (error) { return err(error); }
783
+ });
784
+
785
+ server.tool('edit', 'Edit a frame in the ACTIVE PROJECT using hashline operations. Response includes "project" field. Use open_project first if needed.', {
786
+ path: z.string().describe('File path: /{layer}/{lane}/{filename}. To edit MULTIPLE files at once, use the batch tool instead — it sends one notification instead of many.'),
787
+ operations: z.array(z.object({
788
+ type: z.enum(['replace', 'delete', 'insertAfter', 'insertBefore']).describe('Edit type'),
789
+ lineHash: z.string().describe('4-char hash of the target line (from read output). Each line has a unique hash — even identical lines get different hashes.'),
790
+ newContent: z.string().optional().describe('New content (for replace, insertAfter, insertBefore)'),
791
+ })).describe('Hashline edit operations'),
792
+ }, async ({ path, operations }) => {
793
+ try {
794
+ const anchorErr = await checkAnchors(parseLayer(path));
795
+ if (anchorErr) return err(new Error(anchorErr));
796
+ const result = await api('POST', '/api/fs/edit', { path, operations });
797
+ return ok(result);
798
+ } catch (error) { return err(error); }
799
+ });
800
+
801
+ server.tool('ls', 'List contents of the ACTIVE PROJECT. Use ls / after open_project to see layers, workflow, and confirm you\'re in the right project.', {
802
+ path: z.string().optional().default('/').describe('Directory path: / (layers), /{layer} (lanes), /{layer}/{lane} (frames). Frame entries include frameUrl (canvas deep link) and id (frame UUID).'),
803
+ recursive: z.boolean().optional().describe('List contents of subdirectories'),
804
+ summary: z.boolean().optional().describe('Include size, updatedAt, title for frames'),
805
+ pattern: z.string().optional().describe('Glob pattern to filter filenames (e.g. "*.html")'),
806
+ }, async ({ path, recursive, summary, pattern }) => {
807
+ try {
808
+ const params = new URLSearchParams();
809
+ params.set('path', path);
810
+ if (recursive) params.set('recursive', 'true');
811
+ if (summary) params.set('summary', 'true');
812
+ if (pattern) params.set('pattern', pattern);
813
+ const result = await api('GET', `/api/fs/?${params.toString()}`);
814
+ return ok(result);
815
+ } catch (error) { return err(error); }
816
+ });
817
+
818
+ server.tool('anchor', 'Mark a frame as a context anchor. Anchored frames MUST be read before writing or editing any frame in the same layer. Use this for briefs, style guides, or constraints that should inform all work in this layer.', {
819
+ path: z.string().describe('File path: /{layer}/{lane}/{filename}'),
820
+ anchored: z.boolean().describe('true to anchor, false to unanchor'),
821
+ }, async ({ path, anchored }) => {
822
+ try {
823
+ const result = await api('POST', '/api/fs/anchor', { path, anchored });
824
+ return ok(result);
825
+ } catch (error) { return err(error); }
826
+ });
827
+
828
+ server.tool('rm', 'Delete a frame or lane from the ACTIVE PROJECT. Response includes "project" field.', {
829
+ path: z.string().describe('Path to delete: /{layer}/{lane}/{filename} or /{layer}/{lane} (deletes entire lane). To delete MULTIPLE files, use the batch tool instead.'),
830
+ }, async ({ path }) => {
831
+ try {
832
+ const anchorErr = await checkAnchors(parseLayer(path));
833
+ if (anchorErr) return err(new Error(anchorErr));
834
+ const clean = path.replace(/^\/+|\/+$/g, '');
835
+ const result = await api('DELETE', `/api/fs/${clean}`);
836
+ return ok(result);
837
+ } catch (error) { return err(error); }
838
+ });
839
+
840
+ server.tool('mv', 'Move/rename a frame within the ACTIVE PROJECT. Response includes "project" field.', {
841
+ from: z.string().describe('Source path: /{layer}/{lane}/{filename}'),
842
+ to: z.string().describe('Destination path: /{layer}/{lane}/{filename}'),
843
+ }, async ({ from, to }) => {
844
+ try {
845
+ const fromErr = await checkAnchors(parseLayer(from));
846
+ if (fromErr) return err(new Error(fromErr));
847
+ const toErr = await checkAnchors(parseLayer(to));
848
+ if (toErr) return err(new Error(toErr));
849
+ const result = await api('POST', '/api/fs/mv', { from, to });
850
+ return ok(result);
851
+ } catch (error) { return err(error); }
852
+ });
853
+
854
+ server.tool('batch', 'Batch operations on the ACTIVE PROJECT. Response includes "project" field. Use open_project first if needed.', {
855
+ operations: z.array(z.object({
856
+ tool: z.enum(['write', 'rm', 'mv', 'edit']).describe('Tool to execute'),
857
+ path: z.string().optional().describe('Path (for write, rm, edit)'),
858
+ content: z.string().optional().describe('Content (for write). Mutually exclusive with file_path.'),
859
+ file_path: z.string().optional().describe('Absolute path to a local file to upload (for write). Mutually exclusive with content.'),
860
+ color: z.string().optional().describe('CSS color for frame border (for write)'),
861
+ from: z.string().optional().describe('Source path (for mv)'),
862
+ to: z.string().optional().describe('Destination path (for mv)'),
863
+ operations: z.array(z.object({
864
+ type: z.enum(['replace', 'delete', 'insertAfter', 'insertBefore']),
865
+ lineHash: z.string(),
866
+ newContent: z.string().optional(),
867
+ })).optional().describe('Edit operations (for edit). Always include lineNum.'),
868
+ })).describe('ALWAYS use batch instead of multiple individual tool calls. Applies the same change to many files, writes multiple frames, or combines writes+edits+deletes. One canvas refresh instead of many. Example: editing 5 wireframes to remove a section = one batch with 5 edit operations.'),
869
+ }, async ({ operations }) => {
870
+ try {
871
+ // Collect all layers involved in the batch
872
+ const layers = new Set();
873
+ for (const op of operations) {
874
+ if (op.path) layers.add(parseLayer(op.path));
875
+ if (op.from) layers.add(parseLayer(op.from));
876
+ if (op.to) layers.add(parseLayer(op.to));
877
+ }
878
+ for (const layer of layers) {
879
+ const anchorErr = await checkAnchors(layer);
880
+ if (anchorErr) return err(new Error(anchorErr));
881
+ }
882
+
883
+ // Resolve file_path → base64 for write operations before sending to server
884
+ const resolvedOps = operations.map(op => {
885
+ if (op.tool === 'write' && op.file_path) {
886
+ const resolved = resolve(op.file_path);
887
+ if (!existsSync(resolved)) throw new Error(`File not found: ${resolved}`);
888
+ const buffer = readFileSync(resolved);
889
+ const ext = extname(resolved).toLowerCase();
890
+ const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.pdf': 'application/pdf' };
891
+ const { file_path: _, ...rest } = op;
892
+ return { ...rest, base64: buffer.toString('base64'), contentType: MIME[ext] || 'application/octet-stream' };
893
+ }
894
+ return op;
895
+ });
896
+
897
+ const result = await api('POST', '/api/fs/batch', { operations: resolvedOps });
898
+ return ok(result);
899
+ } catch (error) { return err(error); }
900
+ });
901
+
902
+ // ── Connector tools ───────────────────────────────────────────────
903
+
904
+ server.tool('connect', 'Create a connector (arrow) between two frames on the surface.', {
905
+ source: z.string().describe('Source frame path or ID'),
906
+ target: z.string().describe('Target frame path or ID'),
907
+ label: z.string().optional().describe('Connector label text'),
908
+ type: z.string().optional().default('arrow-forward').describe('Connector type (default: arrow-forward)'),
909
+ color: z.string().optional().describe('Connector color (CSS color string)'),
910
+ }, async ({ source, target, label, type, color }) => {
911
+ try {
912
+ const body = { source, target };
913
+ if (label) body.label = label;
914
+ if (type) body.type = type;
915
+ if (color) body.color = color;
916
+ const result = await api('POST', '/api/connectors', body);
917
+ return ok(result);
918
+ } catch (error) { return err(error); }
919
+ });
920
+
921
+ server.tool('disconnect', 'Remove a connector between frames. Provide either connectorId directly, or source+target to find and remove it.', {
922
+ source: z.string().optional().describe('Source frame path or ID'),
923
+ target: z.string().optional().describe('Target frame path or ID'),
924
+ connectorId: z.string().optional().describe('Connector ID to delete directly'),
925
+ }, async ({ source, target, connectorId }) => {
926
+ try {
927
+ if (connectorId) {
928
+ const result = await api('DELETE', `/api/connectors/${connectorId}`);
929
+ return ok(result);
930
+ }
931
+ if (source && target) {
932
+ // Resolve source/target paths to frame IDs
933
+ const sourceFrame = await api('GET', `/api/fs/${source.replace(/^\/+/, '')}`);
934
+ const targetFrame = await api('GET', `/api/fs/${target.replace(/^\/+/, '')}`);
935
+ const sourceId = sourceFrame.id;
936
+ const targetId = targetFrame.id;
937
+ if (!sourceId || !targetId) throw new Error('Could not resolve source or target frame');
938
+
939
+ const connectors = await api('GET', '/api/connectors');
940
+ const match = (connectors.connectors || connectors || []).find(
941
+ c => c.sourceDesignId === sourceId && c.targetDesignId === targetId
942
+ );
943
+ if (!match) throw new Error(`No connector found from ${source} to ${target}`);
944
+ const result = await api('DELETE', `/api/connectors/${match.id}`);
945
+ return ok(result);
946
+ }
947
+ throw new Error('Provide either connectorId, or both source and target');
948
+ } catch (error) { return err(error); }
949
+ });
950
+
951
+ // ── Layout tools ──────────────────────────────────────────────────
952
+
953
+ server.tool('layout', 'Auto-arrange frames using graph layout algorithm. Positions connected frames as a directed graph.', {
954
+ direction: z.enum(['TB', 'LR', 'BT', 'RL']).optional().default('TB').describe('Layout direction: TB (top-bottom), LR (left-right), BT (bottom-top), RL (right-left)')
955
+ }, async ({ direction }) => {
956
+ try {
957
+ const result = await api('POST', '/api/layout', { direction });
958
+ return ok(result);
959
+ } catch (error) { return err(error); }
960
+ });
961
+
962
+ // ── Resource: canvas info ─────────────────────────────────────────
963
+
964
+ server.resource('info', 'drafted://info', {
965
+ description: 'Canvas vocabulary: layers with default dimensions',
966
+ mimeType: 'application/json',
967
+ }, async (uri) => {
968
+ return {
969
+ contents: [{
970
+ uri: uri.href,
971
+ mimeType: 'application/json',
972
+ text: JSON.stringify({
973
+ layers: LAYERS,
974
+ pathFormat: '/{layer}/{lane}/{filename}',
975
+ tools: ['write', 'read', 'edit', 'ls', 'rm', 'mv', 'batch'],
976
+ }, null, 2),
977
+ }],
978
+ };
979
+ });
980
+
981
+ // ── Transport ─────────────────────────────────────────────────────
982
+
983
+ const args = process.argv.slice(2);
984
+
985
+ async function main() {
986
+ if (args.includes('--http')) {
987
+ const { createServer } = await import('node:http');
988
+ const { StreamableHTTPServerTransport } = await import('@modelcontextprotocol/sdk/server/streamableHttp.js');
989
+ const { randomUUID } = await import('node:crypto');
990
+
991
+ const transport = new StreamableHTTPServerTransport({
992
+ sessionIdGenerator: () => randomUUID(),
993
+ });
994
+
995
+ await server.connect(transport);
996
+
997
+ const httpServer = createServer((req, res) => {
998
+ if (req.url === '/mcp') {
999
+ transport.handleRequest(req, res);
1000
+ } else {
1001
+ res.writeHead(404);
1002
+ res.end('Not found');
1003
+ }
1004
+ });
1005
+
1006
+ const port = parseInt(process.env.MCP_HTTP_PORT || '3100', 10);
1007
+ httpServer.listen(port, () => {
1008
+ console.error(`MCP HTTP server listening on port ${port}`);
1009
+ });
1010
+ } else {
1011
+ const transport = new StdioServerTransport();
1012
+ await server.connect(transport);
1013
+ }
1014
+ }
1015
+
1016
+ // Prevent unhandled rejections from killing the stdio transport
1017
+ process.on('unhandledRejection', (err) => {
1018
+ console.error('[MCP] Unhandled rejection:', err?.message || err);
1019
+ });
1020
+
1021
+ main().catch((err) => {
1022
+ console.error('Fatal:', err.message);
1023
+ process.exit(1);
1024
+ });