drafted 1.1.1 → 1.1.3

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 CHANGED
@@ -9,15 +9,45 @@
9
9
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
10
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
11
11
  import { execFile } from 'child_process';
12
- import { readFileSync, existsSync } from 'fs';
12
+ import { createHash } from 'node:crypto';
13
+ import { readFileSync, existsSync, realpathSync } from 'fs';
13
14
  import { join, dirname, basename, extname, resolve } from 'path';
14
15
  import { homedir } from 'os';
15
16
  import { fileURLToPath } from 'url';
17
+ import { AsyncLocalStorage } from 'node:async_hooks';
16
18
  import { z } from 'zod';
19
+ import { registerAppResource, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server';
20
+ import WebSocket from 'ws';
17
21
  import { LAYERS } from '../src/shared/constants.mjs';
18
22
 
19
23
  const __dirname = dirname(fileURLToPath(import.meta.url));
20
24
 
25
+ // ── Per-instance session ──────────────────────────────────────────
26
+ // State is per-request via AsyncLocalStorage. Standalone modes (stdio, --http)
27
+ // share a process-wide default frame so the existing single-tenant behaviour
28
+ // is preserved. The mounted /mcp route in server/server.mjs wraps each
29
+ // request in runWithRequestState({...}, handler) so concurrent OAuth users
30
+ // never see each other's session or active project.
31
+
32
+ const requestState = new AsyncLocalStorage();
33
+ const standaloneState = { sessionId: null, projectId: null };
34
+
35
+ function getState() {
36
+ return requestState.getStore() || standaloneState;
37
+ }
38
+
39
+ export function runWithRequestState(initial, fn) {
40
+ return requestState.run({ ...standaloneState, ...initial }, fn);
41
+ }
42
+
43
+ // ── MCP server factory ────────────────────────────────────────────
44
+ // Streamable-HTTP requires a fresh McpServer per request — the SDK only
45
+ // permits one transport connection per server instance, so a singleton
46
+ // crashes on the second request. All tool + resource registrations live
47
+ // inside the factory so each HTTP request gets its own isolated server.
48
+ // Stdio mode uses the `mcpServer` singleton (built once at module load).
49
+
50
+ export function createMcpServer() {
21
51
  const server = new McpServer({
22
52
  name: 'drafted',
23
53
  version: '2.4.0',
@@ -25,13 +55,151 @@ const server = new McpServer({
25
55
 
26
56
  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
57
 
28
- WORKFLOW: list_projectsopen_project → ls / → read/write/edit. Projects span all orgs -- open_project auto-switches org context. Every response includes a "project" field showing which project you're operating on -- always verify it matches your intent before writing.
58
+ WORKFLOW: project(action="list")project(action="open") → ls / → read/write/edit. Projects span all orgs -- opening a project auto-switches org context. Every response includes a "project" field showing which project you're operating on -- always verify it matches your intent before writing.
59
+
60
+ SKILLS: Drafted has a skill library -- reusable agent instructions stored as SKILL.md files. When a user says "use the X skill", call skill(action="search") to find it, then skill(action="load") to get its instructions. Skills can cover anything: UX guidelines, copywriting rules, brand voice, coding standards, review checklists, etc.
61
+
62
+ BREADCRUMBS: When a frame you write or read corresponds to a file in the user's codebase (a component spec, wireframe for a route, design doc for a module), leave a comment in that code file using the canonical token "drafted:<frameId>" wrapped in the file's comment syntax (e.g. "// drafted:abc-123..." for JS/TS, "# drafted:abc-123..." for Python/YAML, "<!-- drafted:abc-123... -->" for HTML/Markdown). Project-level references use "drafted-project:<projectId>" in the project README or CLAUDE.md. Future agents grepping for "drafted:" will discover the link and can pull the frame via read(<frameId>). One line per related frame. Skip for throwaway or exploratory frames.
29
63
 
30
64
  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
65
  });
32
66
 
33
67
  const layerKeys = Object.keys(LAYERS);
34
68
 
69
+ // ── Tool annotations ──────────────────────────────────────────────
70
+ // Required by Claude (readOnlyHint, destructiveHint) and ChatGPT Apps SDK
71
+ // (openWorldHint). All tools are registered via tool() below — never call
72
+ // server.tool / server.registerTool directly. New tools must be added here.
73
+ //
74
+ // Semantics (per MCP spec):
75
+ // readOnlyHint = tool does not modify any state
76
+ // destructiveHint = tool may delete or overwrite (only meaningful when readOnly=false)
77
+ // openWorldHint = tool affects state outside Drafted (browser, email, public web)
78
+
79
+ const TOOL_ANNOTATIONS = {
80
+ // Auth — initiates external browser / email flows
81
+ auth: { title: 'Sign in', readOnlyHint: false, destructiveHint: false, openWorldHint: true, description: 'Sign in to Drafted. `action=get_link` returns a URL for manual sign-in (SSH/headless); `action=login` opens a browser and polls for approval.' },
82
+
83
+ // Projects
84
+ project: { title: 'Projects', readOnlyHint: false, destructiveHint: false, openWorldHint: false, widgetUri: 'ui://widget/drafted-canvas-overview.html', description: 'Manage projects: list (start here), open (switch active project), create, update, move to another org.' },
85
+ get_org: { title: 'Get organization', readOnlyHint: true, destructiveHint: false, openWorldHint: false, description: 'Get the current organization and membership info.' },
86
+
87
+ // Templates
88
+ template: { title: 'Templates', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Manage project templates: list, create, update, delete, fork.' },
89
+
90
+ // Layers
91
+ layer: { title: 'Layers', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Manage layers in a project: add, update, remove, reorder.' },
92
+
93
+ // Frames — filesystem
94
+ ls: { title: 'List frames', readOnlyHint: true, destructiveHint: false, openWorldHint: false, widgetUri: 'ui://widget/drafted-canvas-overview.html' },
95
+ frame: { title: 'Frames', readOnlyHint: false, destructiveHint: true, openWorldHint: false, widgetUri: 'ui://widget/drafted-frame-preview.html', description: 'Read, write, edit, move, anchor, or search frames in the ACTIVE PROJECT. Dispatch by `action`. Use `ls` to browse, `rm` to delete.' },
96
+ rm: { title: 'Delete frame', readOnlyHint: false, destructiveHint: true, openWorldHint: false },
97
+ // batch: { title: 'Batch operations', readOnlyHint: false, destructiveHint: true, openWorldHint: false },
98
+
99
+ // Canvas / view
100
+ focus: { title: 'Focus on target', readOnlyHint: false, destructiveHint: false, openWorldHint: false, description: 'Pan the canvas viewport for connected clients to a frame, lane, or layer.' },
101
+ screenshot: { title: 'Screenshot', readOnlyHint: true, destructiveHint: false, openWorldHint: false, description: 'Render a PNG via headless browser. `scope=frame` for a single frame, `scope=canvas` for a region of the project surface.' },
102
+
103
+ // Assets
104
+ asset: { title: 'Assets', readOnlyHint: false, destructiveHint: false, openWorldHint: false, description: 'Manage project assets (CSS/JS/images/fonts referenced by frames). `action=upload` or `action=list`.' },
105
+
106
+ // Shapes / connectors / groups / layout
107
+ shape: { title: 'Add shape', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
108
+ group: { title: 'Create group', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
109
+ connector: { title: 'Connectors', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Connect or disconnect frames with arrows on the surface. `action=connect` or `action=disconnect`.' },
110
+ layout: { title: 'Auto-layout', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
111
+
112
+ // Skills
113
+ skill: { title: 'Skills', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Manage the Drafted skill library: search, load, add, update, remove, attach/detach from projects, favorite, and edit skill files.' },
114
+ };
115
+
116
+ function tool(name, descOrSchema, schemaOrHandler, handler) {
117
+ const ann = TOOL_ANNOTATIONS[name];
118
+ if (!ann) throw new Error(`MCP tool "${name}" missing entry in TOOL_ANNOTATIONS`);
119
+ let description, inputSchema, cb;
120
+ if (typeof descOrSchema === 'string') {
121
+ description = descOrSchema;
122
+ inputSchema = schemaOrHandler;
123
+ cb = handler;
124
+ } else {
125
+ description = ann.description || '';
126
+ inputSchema = descOrSchema;
127
+ cb = schemaOrHandler;
128
+ }
129
+ const config = {
130
+ title: ann.title,
131
+ description,
132
+ inputSchema,
133
+ annotations: {
134
+ readOnlyHint: ann.readOnlyHint,
135
+ destructiveHint: ann.destructiveHint,
136
+ openWorldHint: ann.openWorldHint,
137
+ },
138
+ };
139
+ // ChatGPT Apps SDK: if this tool has a widget, wire its UI template URI
140
+ // so ChatGPT renders the widget instead of text-only tool results.
141
+ if (ann.widgetUri) {
142
+ config._meta = { 'ui': { resourceUri: ann.widgetUri } };
143
+ }
144
+ return server.registerTool(name, config, cb);
145
+ }
146
+
147
+ // ── ChatGPT Apps SDK widgets ──────────────────────────────────────
148
+ // Register UI resource templates that tools link to via _meta.ui.resourceUri.
149
+ // ChatGPT and Claude render these HTML files in a sandboxed iframe when the
150
+ // linked tool is called. The `ui.domain` field is required and must match
151
+ // Claude's format: `{sha256(mcp_url)[:32]}.claudemcpcontent.com`. ChatGPT
152
+ // only requires uniqueness per app, so the sha256-derived subdomain satisfies
153
+ // both clients.
154
+
155
+ const MCP_URL = 'https://drafted.live/mcp';
156
+ const WIDGET_DOMAIN =
157
+ createHash('sha256').update(MCP_URL).digest('hex').slice(0, 32) +
158
+ '.claudemcpcontent.com';
159
+
160
+ function makeWidgetResource(uri, htmlPath) {
161
+ const html = readFileSync(join(__dirname, 'widgets', htmlPath), 'utf8');
162
+ return () => ({
163
+ contents: [
164
+ {
165
+ uri,
166
+ mimeType: RESOURCE_MIME_TYPE,
167
+ text: html,
168
+ _meta: {
169
+ ui: {
170
+ prefersBorder: true,
171
+ domain: WIDGET_DOMAIN,
172
+ csp: {
173
+ connectDomains: ['https://drafted.live'],
174
+ resourceDomains: [
175
+ 'https://drafted.live',
176
+ 'https://fonts.googleapis.com',
177
+ 'https://fonts.gstatic.com',
178
+ ],
179
+ },
180
+ },
181
+ },
182
+ },
183
+ ],
184
+ });
185
+ }
186
+
187
+ registerAppResource(
188
+ server,
189
+ 'drafted-frame-preview',
190
+ 'ui://widget/drafted-frame-preview.html',
191
+ {},
192
+ makeWidgetResource('ui://widget/drafted-frame-preview.html', 'frame-preview.html')
193
+ );
194
+
195
+ registerAppResource(
196
+ server,
197
+ 'drafted-canvas-overview',
198
+ 'ui://widget/drafted-canvas-overview.html',
199
+ {},
200
+ makeWidgetResource('ui://widget/drafted-canvas-overview.html', 'canvas-overview.html')
201
+ );
202
+
35
203
  // ── Config ────────────────────────────────────────────────────────
36
204
 
37
205
  const AUTH_FILE = process.env.DRAFTED_AUTH_FILE || join(homedir(), '.drafted', 'auth.json');
@@ -49,13 +217,6 @@ function getServerUrl() {
49
217
  return `http://localhost:${process.env.DRAFTED_PORT || 3477}`;
50
218
  }
51
219
 
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
220
  function getBootstrapSessionId() {
60
221
  try {
61
222
  if (existsSync(AUTH_FILE)) {
@@ -67,7 +228,7 @@ function getBootstrapSessionId() {
67
228
  }
68
229
 
69
230
  function getAuthHeaders() {
70
- const sid = instanceSessionId || getBootstrapSessionId();
231
+ const sid = getState().sessionId || getBootstrapSessionId();
71
232
  if (sid) return { Cookie: `gc_session=${sid}` };
72
233
  return {};
73
234
  }
@@ -85,13 +246,13 @@ async function cloneSession() {
85
246
  if (!res.ok) return;
86
247
  const data = await res.json();
87
248
  if (data.sessionId) {
88
- instanceSessionId = data.sessionId;
249
+ getState().sessionId = data.sessionId;
89
250
  }
90
251
  } catch { /* server may not be ready yet, will retry on first API call */ }
91
252
  }
92
253
 
93
254
  async function ensureSession() {
94
- if (instanceSessionId) return;
255
+ if (getState().sessionId) return;
95
256
  await cloneSession();
96
257
  }
97
258
 
@@ -107,7 +268,7 @@ function mimeFromExt(ext) { return MIME_MAP[ext?.toLowerCase()] || 'application/
107
268
 
108
269
  async function api(method, path, body, _retried) {
109
270
  await ensureSession();
110
- const pid = agentActiveProjectId;
271
+ const pid = getState().projectId;
111
272
  const sep = path.includes('?') ? '&' : '?';
112
273
  const scopedPath = pid ? `${path}${sep}projectId=${pid}` : path;
113
274
  const url = `${getServerUrl()}${scopedPath}`;
@@ -124,7 +285,7 @@ async function api(method, path, body, _retried) {
124
285
 
125
286
  // Session expired after server restart — re-clone and retry once
126
287
  if (res.status === 401 && !_retried) {
127
- instanceSessionId = null;
288
+ getState().sessionId = null;
128
289
  await cloneSession();
129
290
  return api(method, path, body, true);
130
291
  }
@@ -141,18 +302,77 @@ async function api(method, path, body, _retried) {
141
302
  return data;
142
303
  }
143
304
 
144
- function ok(text) {
145
- return { content: [{ type: 'text', text: typeof text === 'string' ? text : JSON.stringify(text, null, 2) }] };
305
+ // MCP spec caps tool results at 25k tokens. Text averages ~4 chars/token,
306
+ // so 90k chars 22.5k tokens safe margin under the cap. Image content
307
+ // (screenshot scope=frame/canvas) goes through a separate path and is
308
+ // token-counted by Claude vision, not by char length, so no need to cap here.
309
+ const MAX_TOOL_RESULT_CHARS = 90_000;
310
+
311
+ function enforceTokenBudget(text, max = MAX_TOOL_RESULT_CHARS) {
312
+ if (text.length <= max) return text;
313
+ const head = text.slice(0, max - 240);
314
+ const tail = `\n\n[TRUNCATED: result was ${text.length} chars, capped at ${max} to stay under the MCP 25k-token tool-result limit. Use a more specific query, pagination params (limit/offset), or frame(action="read") with lines to fetch in chunks.]`;
315
+ return head + tail;
316
+ }
317
+
318
+ function ok(text, opts) {
319
+ const str = typeof text === 'string' ? text : JSON.stringify(text, null, 2);
320
+ const result = { content: [{ type: 'text', text: enforceTokenBudget(str) }] };
321
+ // ChatGPT Apps SDK: widgets read structuredContent (also visible to model —
322
+ // keep it tight) and _meta (widget-only, never reaches the model).
323
+ if (opts?.structuredContent) result.structuredContent = opts.structuredContent;
324
+ if (opts?._meta) result._meta = opts._meta;
325
+ return result;
326
+ }
327
+
328
+ // Build the structuredContent shape that the frame-preview widget reads.
329
+ // Tools that produce or return a frame (read/write/edit) call this so the
330
+ // model and widget see the same metadata view.
331
+ function frameStructuredContent(result) {
332
+ return {
333
+ id: result.id,
334
+ path: result.path || (result.layer && result.lane && result.label
335
+ ? `/${result.layer}/${result.lane}/${result.label}`
336
+ : undefined),
337
+ frameId: result.id,
338
+ contentType: result.contentType,
339
+ layer: result.layer,
340
+ lane: result.lane,
341
+ width: result.width,
342
+ height: result.height,
343
+ frameUrl: result.id ? `${getServerUrl()}/f/${result.id}` : undefined,
344
+ };
345
+ }
346
+
347
+ // Attach a canonical breadcrumb token (+ optional hint) to a response that
348
+ // references a frame or project by UUID. Agents should paste the token as a
349
+ // comment in any codebase file that corresponds to the frame/project so
350
+ // future agents grepping for "drafted:" discover the link.
351
+ function withFrameBreadcrumb(result, { hint = false } = {}) {
352
+ if (!result || typeof result !== 'object' || !result.id) return result;
353
+ const out = { ...result, breadcrumb: `drafted:${result.id}` };
354
+ if (hint) {
355
+ out.breadcrumbHint = `If this frame maps to a file in the user's codebase, paste "drafted:${result.id}" as a comment in that file so future agents discover the link.`;
356
+ }
357
+ return out;
358
+ }
359
+
360
+ function withProjectBreadcrumb(result) {
361
+ if (!result || typeof result !== 'object' || !result.id) return result;
362
+ return {
363
+ ...result,
364
+ breadcrumb: `drafted-project:${result.id}`,
365
+ breadcrumbHint: `Consider pasting "drafted-project:${result.id}" in the project README or CLAUDE.md so future agents discover the linked Drafted project.`,
366
+ };
146
367
  }
147
368
 
148
369
  // ── Agent WebSocket presence ──────────────────────────────────────
149
- import WebSocket from 'ws';
150
370
 
151
371
  let agentWs = null;
152
372
  let agentWsReconnectTimer = null;
153
373
 
154
374
  function setMcpActiveProject(projectId) {
155
- agentActiveProjectId = projectId;
375
+ getState().projectId = projectId;
156
376
  }
157
377
 
158
378
  async function connectAgentWs() {
@@ -167,8 +387,8 @@ async function connectAgentWs() {
167
387
 
168
388
  agentWs.on('open', () => {
169
389
  console.error('[MCP-WS] Connected');
170
- if (agentActiveProjectId) {
171
- agentWs.send(JSON.stringify({ type: 'join', projectId: agentActiveProjectId, agent: true }));
390
+ if (getState().projectId) {
391
+ agentWs.send(JSON.stringify({ type: 'join', projectId: getState().projectId, agent: true }));
172
392
  }
173
393
  });
174
394
 
@@ -176,7 +396,7 @@ async function connectAgentWs() {
176
396
  console.error('[MCP-WS] Disconnected, reconnecting in 5s...');
177
397
  agentWs = null;
178
398
  // Server may have restarted — invalidate session so ensureSession re-clones
179
- instanceSessionId = null;
399
+ getState().sessionId = null;
180
400
  clearTimeout(agentWsReconnectTimer);
181
401
  agentWsReconnectTimer = setTimeout(() => {
182
402
  connectAgentWs().catch((e) => {
@@ -190,7 +410,11 @@ async function connectAgentWs() {
190
410
  });
191
411
  }
192
412
 
193
- function joinAgentWsRoom(projectId) {
413
+ async function joinAgentWsRoom(projectId) {
414
+ // Ensure WS is connected (may not be after restart/session re-clone)
415
+ if (!agentWs || agentWs.readyState > WebSocket.OPEN) {
416
+ await connectAgentWs();
417
+ }
194
418
  if (!agentWs) return;
195
419
  const msg = JSON.stringify({ type: 'join', projectId, agent: true });
196
420
  if (agentWs.readyState === WebSocket.OPEN) {
@@ -200,10 +424,15 @@ function joinAgentWsRoom(projectId) {
200
424
  }
201
425
  }
202
426
 
203
- // Clone session and connect WebSocket on startup (delayed to let server be ready)
204
- setTimeout(() => {
205
- cloneSession().then(() => connectAgentWs()).catch(() => {});
206
- }, 1000);
427
+ // Clone session and connect WebSocket on startup (delayed to let server be ready).
428
+ // Guarded because createMcpServer() runs per HTTP request — the bootstrap must
429
+ // fire exactly once per process, not per request.
430
+ if (!globalThis.__draftedAgentWsBootstrapped) {
431
+ globalThis.__draftedAgentWsBootstrapped = true;
432
+ setTimeout(() => {
433
+ cloneSession().then(() => connectAgentWs()).catch(() => {});
434
+ }, 1000);
435
+ }
207
436
 
208
437
  function err(error) {
209
438
  return { content: [{ type: 'text', text: error.message || String(error) }], isError: true };
@@ -275,218 +504,271 @@ function runCLI(command, args = [], options = {}) {
275
504
 
276
505
  // ── Login tools ─────────────────────────────────────────────────────
277
506
 
278
- // Shared pending device code — get_login_link stores it, login reuses it
507
+ // Shared pending device code — auth(action=get_link) stores it, auth(action=login) reuses it
279
508
  let pendingDeviceCode = null;
280
509
 
281
- 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 () => {
510
+ tool('auth', 'Sign in to Drafted. `action=get_link` returns a verification URL immediately (use for SSH/headless/tmux where a browser may not open). `action=login` opens a browser and polls for approval run this if other tools return auth errors or "fetch failed". If get_link was called first, login reuses that pending code instead of opening a new browser.', {
511
+ action: z.enum(['get_link', 'login']).describe('Operation to perform.'),
512
+ }, async ({ action }) => {
282
513
  try {
283
- const codeRes = await fetch(`${getServerUrl()}/auth/device/code`, { method: 'POST' });
284
- if (!codeRes.ok) throw new Error(`Failed to start device authorization (HTTP ${codeRes.status})`);
285
- const data = await codeRes.json();
286
- pendingDeviceCode = data;
287
- return ok(data.verificationUrl);
288
- } catch (error) { return err(error); }
289
- });
290
-
291
- 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 () => {
292
- try {
293
- // Check if already authenticated
294
- const existing = getBootstrapSessionId();
295
- if (existing) {
296
- try {
297
- const res = await fetch(`${getServerUrl()}/auth/me`, {
298
- headers: { Cookie: `gc_session=${existing}` },
299
- });
300
- if (res.ok) {
301
- const me = await res.json();
302
- return ok({ status: 'already_authenticated', userId: me.userId, email: me.userEmail, org: me.currentOrg?.name });
303
- }
304
- } catch { /* session invalid, proceed with login */ }
305
- }
306
-
307
- let deviceCode, verificationUrl, expiresIn;
308
- let reusingPending = false;
309
-
310
- // Reuse pending device code from get_login_link if available
311
- if (pendingDeviceCode) {
312
- ({ deviceCode, verificationUrl, expiresIn } = pendingDeviceCode);
313
- pendingDeviceCode = null;
314
- reusingPending = true;
315
- } else {
316
- // Request a new device code
514
+ if (action === 'get_link') {
317
515
  const codeRes = await fetch(`${getServerUrl()}/auth/device/code`, { method: 'POST' });
318
516
  if (!codeRes.ok) throw new Error(`Failed to start device authorization (HTTP ${codeRes.status})`);
319
- ({ deviceCode, verificationUrl, expiresIn } = await codeRes.json());
517
+ const data = await codeRes.json();
518
+ pendingDeviceCode = data;
519
+ return ok(data.verificationUrl);
320
520
  }
321
-
322
- // Generate QR code for the verification URL
323
- let qrText = '';
324
- try {
325
- const qrcode = await import('qrcode-terminal');
326
- qrText = await new Promise((resolve) => {
327
- qrcode.default.generate(verificationUrl, { small: true }, (code) => resolve(code));
328
- });
329
- } catch { /* QR generation failed, continue without it */ }
330
-
331
- // Log the URL to stderr so it's visible even if MCP response is delayed
332
- console.error(`\n[MCP] Sign in at: ${verificationUrl}\n${qrText ? qrText + '\n' : ''}[MCP] Waiting for approval...`);
333
-
334
- // Open browser only if we're not reusing a pending code (user already has the URL)
335
- if (!reusingPending) {
336
- const { exec } = await import('child_process');
337
- const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
338
- exec(`${cmd} ${JSON.stringify(verificationUrl)}`);
339
- }
340
-
341
- // Poll for approval
342
- const deadline = Date.now() + (expiresIn * 1000);
343
- while (Date.now() < deadline) {
344
- await new Promise(r => setTimeout(r, 4000));
345
- const res = await fetch(`${getServerUrl()}/auth/device/token`, {
346
- method: 'POST',
347
- headers: { 'Content-Type': 'application/json' },
348
- body: JSON.stringify({ deviceCode }),
349
- });
350
- if (!res.ok) throw new Error(`Token poll failed (HTTP ${res.status})`);
351
- const data = await res.json();
352
-
353
- if (data.status === 'approved') {
354
- // Write auth file
355
- const { writeFileSync, mkdirSync } = await import('fs');
356
- const { dirname } = await import('path');
357
- mkdirSync(dirname(AUTH_FILE), { recursive: true });
358
- writeFileSync(AUTH_FILE, JSON.stringify({
359
- sessionId: data.sessionId,
360
- userId: data.userId || null,
361
- orgId: data.orgId || null,
362
- server: getServerUrl(),
363
- updatedAt: new Date().toISOString(),
364
- }, null, 2));
365
-
366
- // Re-clone session for this instance
367
- instanceSessionId = null;
368
- await cloneSession();
369
- connectAgentWs();
370
-
371
- return ok({ status: 'logged_in', sessionId: data.sessionId, userId: data.userId });
521
+ if (action === 'login') {
522
+ const existing = getBootstrapSessionId();
523
+ if (existing) {
524
+ try {
525
+ const res = await fetch(`${getServerUrl()}/auth/me`, {
526
+ headers: { Cookie: `gc_session=${existing}` },
527
+ });
528
+ if (res.ok) {
529
+ const me = await res.json();
530
+ return ok({ status: 'already_authenticated', userId: me.userId, email: me.userEmail, org: me.currentOrg?.name });
531
+ }
532
+ } catch { /* session invalid, proceed with login */ }
372
533
  }
373
534
 
374
- if (data.status === 'expired') throw new Error('Device code expired. Try again.');
375
- }
535
+ let deviceCode, verificationUrl, expiresIn;
536
+ let reusingPending = false;
376
537
 
377
- throw new Error(`Login timed out. If the browser didn't open, visit: ${verificationUrl}`);
378
- } catch (error) { return err(error); }
379
- });
538
+ if (pendingDeviceCode) {
539
+ ({ deviceCode, verificationUrl, expiresIn } = pendingDeviceCode);
540
+ pendingDeviceCode = null;
541
+ reusingPending = true;
542
+ } else {
543
+ const codeRes = await fetch(`${getServerUrl()}/auth/device/code`, { method: 'POST' });
544
+ if (!codeRes.ok) throw new Error(`Failed to start device authorization (HTTP ${codeRes.status})`);
545
+ ({ deviceCode, verificationUrl, expiresIn } = await codeRes.json());
546
+ }
380
547
 
381
- // ── Project management tools (direct HTTP) ────────────────────────
548
+ let qrText = '';
549
+ try {
550
+ const qrcode = await import('qrcode-terminal');
551
+ qrText = await new Promise((resolve) => {
552
+ qrcode.default.generate(verificationUrl, { small: true }, (code) => resolve(code));
553
+ });
554
+ } catch { /* QR generation failed, continue without it */ }
382
555
 
383
- server.tool('list_projects', 'START HERE. Lists all projects across all orgs. Use this first to find the project you need, then open_project to switch to it (org switches automatically).', {}, async () => {
384
- try {
385
- const data = await api('GET', '/api/projects');
386
- data.agentProject = agentActiveProjectId || null;
387
- return ok(data);
388
- } catch (error) { return err(error); }
389
- });
556
+ console.error(`\n[MCP] Sign in at: ${verificationUrl}\n${qrText ? qrText + '\n' : ''}[MCP] Waiting for approval...`);
390
557
 
391
- server.tool('create_project', {
392
- name: z.string().describe('Project name'),
393
- description: z.string().optional().describe('Project description'),
394
- templateSlug: z.string().optional().describe('Template slug (e.g. "web-design", "mobile-app", "landing-page")'),
395
- }, async ({ name, description, templateSlug }) => {
396
- try {
397
- const body = { name };
398
- if (description) body.description = description;
399
- if (templateSlug) body.templateSlug = templateSlug;
400
- return ok(await api('POST', '/api/projects', body));
401
- } catch (error) { return err(error); }
402
- });
558
+ if (!reusingPending) {
559
+ const { exec } = await import('child_process');
560
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
561
+ exec(`${cmd} ${JSON.stringify(verificationUrl)}`);
562
+ }
403
563
 
404
- server.tool('list_templates', {}, async () => {
405
- try { return ok(await api('GET', '/api/templates')); }
406
- catch (error) { return err(error); }
407
- });
564
+ const deadline = Date.now() + (expiresIn * 1000);
565
+ while (Date.now() < deadline) {
566
+ await new Promise(r => setTimeout(r, 4000));
567
+ const res = await fetch(`${getServerUrl()}/auth/device/token`, {
568
+ method: 'POST',
569
+ headers: { 'Content-Type': 'application/json' },
570
+ body: JSON.stringify({ deviceCode }),
571
+ });
572
+ if (!res.ok) throw new Error(`Token poll failed (HTTP ${res.status})`);
573
+ const data = await res.json();
574
+
575
+ if (data.status === 'approved') {
576
+ const { writeFileSync, mkdirSync } = await import('fs');
577
+ const { dirname } = await import('path');
578
+ mkdirSync(dirname(AUTH_FILE), { recursive: true });
579
+ writeFileSync(AUTH_FILE, JSON.stringify({
580
+ sessionId: data.sessionId,
581
+ userId: data.userId || null,
582
+ orgId: data.orgId || null,
583
+ server: getServerUrl(),
584
+ updatedAt: new Date().toISOString(),
585
+ }, null, 2));
586
+
587
+ getState().sessionId = null;
588
+ await cloneSession();
589
+ connectAgentWs();
590
+
591
+ return ok({ status: 'logged_in', sessionId: data.sessionId, userId: data.userId });
592
+ }
408
593
 
409
- server.tool('create_template', {
410
- name: z.string().describe('Template name'),
411
- description: z.string().describe('Template description'),
412
- layers: z.array(z.object({}).passthrough()).describe('Array of layer definitions'),
413
- visibility: z.string().optional().describe('Visibility: "org" or "public"'),
414
- }, async ({ name, description, layers, visibility }) => {
415
- try {
416
- const body = { name, description, layers };
417
- if (visibility) body.visibility = visibility;
418
- return ok(await api('POST', '/api/templates', body));
419
- } catch (error) { return err(error); }
420
- });
594
+ if (data.status === 'expired') throw new Error('Device code expired. Try again.');
595
+ }
421
596
 
422
- server.tool('update_template', {
423
- templateId: z.string().describe('Template ID to update'),
424
- name: z.string().optional().describe('Template name'),
425
- description: z.string().optional().describe('Template description'),
426
- layers: z.array(z.object({}).passthrough()).optional().describe('Array of layer definitions'),
427
- visibility: z.string().optional().describe('Visibility: "org" or "public"'),
428
- }, async ({ templateId, name, description, layers, visibility }) => {
429
- try {
430
- const body = {};
431
- if (name) body.name = name;
432
- if (description) body.description = description;
433
- if (layers) body.layers = layers;
434
- if (visibility) body.visibility = visibility;
435
- return ok(await api('PUT', `/api/templates/${templateId}`, body));
597
+ throw new Error(`Login timed out. If the browser didn't open, visit: ${verificationUrl}`);
598
+ }
599
+ throw new Error(`Unknown auth action: ${action}`);
436
600
  } catch (error) { return err(error); }
437
601
  });
438
602
 
439
- server.tool('delete_template', {
440
- templateId: z.string().describe('Template ID to delete'),
441
- }, async ({ templateId }) => {
442
- try { return ok(await api('DELETE', `/api/templates/${templateId}`)); }
443
- catch (error) { return err(error); }
444
- });
603
+ // ── Project management tools (direct HTTP) ────────────────────────
445
604
 
446
- server.tool('fork_template', {
447
- templateId: z.string().describe('Template ID to fork'),
448
- name: z.string().optional().describe('Name for the forked copy'),
449
- }, async ({ templateId, name }) => {
605
+ tool('project', 'START HERE for project management. Dispatch by `action`: list (lists all projects across all orgs — always call first), open (switch the active project; required before reading/writing frames), create (new project, optionally from a template), update (change name/folder/description/layers), move (transfer to another org). The org switches automatically when you open a project.', {
606
+ action: z.enum(['list', 'open', 'create', 'update', 'move']).describe('Operation to perform.'),
607
+ projectId: z.string().optional().describe('[open|update|move] project ID. Get IDs from action=list.'),
608
+ name: z.string().optional().describe('[create|update] project name'),
609
+ description: z.string().nullable().optional().describe('[create|update] project description'),
610
+ templateSlug: z.string().optional().describe('[create] template slug (e.g. "web-design", "mobile-app", "landing-page")'),
611
+ folder: z.string().nullable().optional().describe('[update] folder name (null to remove from folder)'),
612
+ layers: z.array(z.object({}).passthrough()).optional().describe('[update] full layers array replacement. Use ls / to read current layers first.'),
613
+ targetOrgId: z.string().optional().describe('[move] destination organization ID. Get org IDs from action=list (each project has an orgId field) or get_org. Both source and target org must include the current user.'),
614
+ skipBrowser: z.boolean().optional().describe('[open] skip opening/navigating a browser tab (use when the user already has the project open, e.g. from an invite snippet)'),
615
+ }, async (args) => {
450
616
  try {
451
- const body = {};
452
- if (name) body.name = name;
453
- return ok(await api('POST', `/api/templates/${templateId}/fork`, body));
617
+ const { action } = args;
618
+ switch (action) {
619
+ case 'list': {
620
+ const data = await api('GET', '/api/projects');
621
+ data.agentProject = getState().projectId || null;
622
+ try {
623
+ const favData = await api('GET', '/api/skills/favorites');
624
+ const favs = favData.skills || [];
625
+ if (favs.length > 0) {
626
+ data.favoritedSkills = favs.map(s => ({
627
+ id: s.id,
628
+ name: s.name,
629
+ slug: s.slug,
630
+ description: s.description,
631
+ tags: s.tags,
632
+ }));
633
+ }
634
+ } catch { /* skills not available */ }
635
+
636
+ const structuredContent = {
637
+ projects: (data.projects || []).map(p => ({
638
+ id: p.id,
639
+ name: p.name,
640
+ slug: p.slug,
641
+ description: p.description,
642
+ orgId: p.orgId,
643
+ })),
644
+ activeProject: data.agentProject,
645
+ };
646
+ return ok(data, { structuredContent });
647
+ }
648
+ case 'open': {
649
+ const { projectId, skipBrowser } = args;
650
+ if (!projectId) throw new Error('projectId required for action=open');
651
+ const result = await api('POST', '/api/project/switch', { projectId });
652
+ setMcpActiveProject(projectId);
653
+ joinAgentWsRoom(projectId);
654
+ const base = getServerUrl();
655
+ let projectSlug = projectId;
656
+ try {
657
+ const data = await api('GET', '/api/projects');
658
+ const proj = (data.projects || []).find(p => p.id === projectId);
659
+ if (proj?.slug) projectSlug = proj.slug;
660
+ } catch { /* fall back to projectId */ }
661
+ const url = `${base}/project/${projectSlug}`;
662
+ let navigated = 0;
663
+ if (!skipBrowser) {
664
+ try {
665
+ const nav = await api('POST', '/api/project/navigate', { projectId });
666
+ navigated = nav.navigated || 0;
667
+ } catch { /* server may not support navigate yet */ }
668
+ if (navigated === 0) {
669
+ const { exec } = await import('child_process');
670
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
671
+ exec(`${cmd} ${JSON.stringify(url)}`);
672
+ }
673
+ }
674
+ let projectSkillsList = [];
675
+ try {
676
+ const skillData = await api('GET', `/api/projects/${projectId}/skills`);
677
+ projectSkillsList = skillData.skills || [];
678
+ } catch { /* skills not available yet */ }
679
+
680
+ if (projectSkillsList.length > 0 && projectSkillsList.length <= 3) {
681
+ for (const s of projectSkillsList) {
682
+ try {
683
+ const full = await api('GET', `/api/skills/${s.id}`);
684
+ s.content = full.content;
685
+ s.files = full.files || [];
686
+ } catch { /* skip */ }
687
+ }
688
+ }
689
+
690
+ return ok({ ...result, url, opened: true, navigated, skills: projectSkillsList });
691
+ }
692
+ case 'create': {
693
+ const { name, description, templateSlug } = args;
694
+ if (!name) throw new Error('name required for action=create');
695
+ const body = { name };
696
+ if (description) body.description = description;
697
+ if (templateSlug) body.templateSlug = templateSlug;
698
+ return ok(withProjectBreadcrumb(await api('POST', '/api/projects', body)));
699
+ }
700
+ case 'update': {
701
+ const { projectId, name, folder, description, layers } = args;
702
+ if (!projectId) throw new Error('projectId required for action=update');
703
+ const body = {};
704
+ if (name !== undefined) body.name = name;
705
+ if (folder !== undefined) body.folder = folder;
706
+ if (description !== undefined) body.description = description;
707
+ if (layers) body.layers = layers;
708
+ if (Object.keys(body).length === 0) throw new Error('At least one field (name, folder, description, layers) is required for action=update');
709
+ return ok(await api('PATCH', `/api/project/${projectId}`, body));
710
+ }
711
+ case 'move': {
712
+ const { projectId, targetOrgId } = args;
713
+ if (!projectId || !targetOrgId) throw new Error('projectId and targetOrgId required for action=move');
714
+ return ok(await api('POST', `/api/project/${projectId}/move`, { targetOrgId }));
715
+ }
716
+ default:
717
+ throw new Error(`Unknown project action: ${action}`);
718
+ }
454
719
  } catch (error) { return err(error); }
455
720
  });
456
721
 
457
- 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.', {
458
- projectId: z.string().describe('Project ID to switch to and open in browser'),
459
- }, async ({ projectId }) => {
722
+ tool('template', 'Manage project templates in the org. Dispatch by `action`: list/create/update/delete/fork. A template bundles layer definitions that new projects can be created from.', {
723
+ action: z.enum(['list', 'create', 'update', 'delete', 'fork']).describe('Operation to perform.'),
724
+ templateId: z.string().optional().describe('[update|delete|fork] template ID'),
725
+ name: z.string().optional().describe('[create|update|fork] template name (required for create; optional rename for fork)'),
726
+ description: z.string().optional().describe('[create|update] template description'),
727
+ layers: z.array(z.object({}).passthrough()).optional().describe('[create|update] array of layer definitions'),
728
+ visibility: z.string().optional().describe('[create|update] "org" or "public"'),
729
+ }, async (args) => {
460
730
  try {
461
- const result = await api('POST', '/api/project/switch', { projectId });
462
- setMcpActiveProject(projectId);
463
- joinAgentWsRoom(projectId);
464
- const base = getServerUrl();
465
- // Resolve project slug for URL
466
- let projectSlug = projectId;
467
- try {
468
- const data = await api('GET', '/api/projects');
469
- const proj = (data.projects || []).find(p => p.id === projectId);
470
- if (proj?.slug) projectSlug = proj.slug;
471
- } catch { /* fall back to projectId */ }
472
- const url = `${base}/project/${projectSlug}`;
473
- // Navigate existing browser tabs instead of opening new ones
474
- let navigated = 0;
475
- try {
476
- const nav = await api('POST', '/api/project/navigate', { projectId });
477
- navigated = nav.navigated || 0;
478
- } catch { /* server may not support navigate yet */ }
479
- // Only open a new tab if no browser tabs were reached
480
- if (navigated === 0) {
481
- const { exec } = await import('child_process');
482
- const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
483
- exec(`${cmd} ${JSON.stringify(url)}`);
731
+ const { action } = args;
732
+ switch (action) {
733
+ case 'list':
734
+ return ok(await api('GET', '/api/templates'));
735
+ case 'create': {
736
+ const { name, description, layers, visibility } = args;
737
+ if (!name || !description || !layers) throw new Error('name, description, layers required for action=create');
738
+ const body = { name, description, layers };
739
+ if (visibility) body.visibility = visibility;
740
+ return ok(await api('POST', '/api/templates', body));
741
+ }
742
+ case 'update': {
743
+ const { templateId, name, description, layers, visibility } = args;
744
+ if (!templateId) throw new Error('templateId required for action=update');
745
+ const body = {};
746
+ if (name) body.name = name;
747
+ if (description) body.description = description;
748
+ if (layers) body.layers = layers;
749
+ if (visibility) body.visibility = visibility;
750
+ if (Object.keys(body).length === 0) throw new Error('At least one field is required for action=update');
751
+ return ok(await api('PUT', `/api/templates/${templateId}`, body));
752
+ }
753
+ case 'delete': {
754
+ const { templateId } = args;
755
+ if (!templateId) throw new Error('templateId required for action=delete');
756
+ return ok(await api('DELETE', `/api/templates/${templateId}`));
757
+ }
758
+ case 'fork': {
759
+ const { templateId, name } = args;
760
+ if (!templateId) throw new Error('templateId required for action=fork');
761
+ const body = {};
762
+ if (name) body.name = name;
763
+ return ok(await api('POST', `/api/templates/${templateId}/fork`, body));
764
+ }
765
+ default:
766
+ throw new Error(`Unknown template action: ${action}`);
484
767
  }
485
- return ok({ ...result, url, opened: true, navigated });
486
768
  } catch (error) { return err(error); }
487
769
  });
488
770
 
489
- server.tool('focus', {
771
+ tool('focus', {
490
772
  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.'),
491
773
  }, async ({ target }) => {
492
774
  try {
@@ -509,175 +791,159 @@ server.tool('focus', {
509
791
  } catch (error) { return err(error); }
510
792
  });
511
793
 
512
- server.tool('screenshot', {
513
- target: z.string().describe('Frame URL (any URL containing /f/{uuid}), frame ID (UUID), or file path (/{layer}/{lane}/{filename}) to screenshot.'),
514
- width: z.number().optional().default(1440).describe('Viewport width in pixels (default 1440)'),
515
- height: z.number().optional().default(900).describe('Viewport height in pixels (default 900)'),
516
- fullPage: z.boolean().optional().default(true).describe('Capture full page or just viewport (default true)'),
517
- }, async ({ target, width, height, fullPage }) => {
518
- try {
519
- // Resolve target to a frame ID
520
- const frameUrlMatch = target.match(/\/f\/([a-f0-9-]{36})/);
521
- const uuidMatch = target.match(/^[a-f0-9-]{36}$/);
522
- let frameId = frameUrlMatch?.[1] || (uuidMatch ? target : null);
523
-
524
- if (!frameId) {
525
- const parts = target.replace(/^\/+/, '').split('/');
526
- if (parts.length !== 3) throw new Error('Target must be a frame URL, frame ID, or path /{layer}/{lane}/{filename}');
527
- const frame = await api('GET', `/api/fs/${parts[0]}/${parts[1]}/${parts[2]}`);
528
- if (!frame.id) throw new Error('Frame not found');
529
- frameId = frame.id;
530
- }
531
-
532
- const url = `${getServerUrl()}/api/screenshot/${frameId}?width=${width}&height=${height}&fullPage=${fullPage}`;
533
- const res = await fetch(url, { headers: getAuthHeaders() });
534
- if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
535
-
536
- const buffer = await res.arrayBuffer();
537
- const base64 = Buffer.from(buffer).toString('base64');
538
-
539
- return {
540
- content: [{
541
- type: 'image',
542
- data: base64,
543
- mimeType: 'image/png',
544
- }],
545
- };
546
- } catch (error) { return err(error); }
547
- });
548
-
549
- server.tool('update_project', {
550
- projectId: z.string().describe('Project ID to update'),
551
- name: z.string().optional().describe('New project name'),
552
- folder: z.string().nullable().optional().describe('Folder name (null to remove from folder)'),
553
- description: z.string().nullable().optional().describe('Project description'),
554
- layers: z.array(z.object({}).passthrough()).optional().describe('Full layers array replacement. Use ls / to read current layers first.'),
555
- }, async ({ projectId, name, folder, description, layers }) => {
556
- try {
557
- const body = {};
558
- if (name !== undefined) body.name = name;
559
- if (folder !== undefined) body.folder = folder;
560
- if (description !== undefined) body.description = description;
561
- if (layers) body.layers = layers;
562
- if (Object.keys(body).length === 0) throw new Error('At least one field (name, folder, description, layers) is required');
563
- return ok(await api('PATCH', `/api/project/${projectId}`, body));
564
- } catch (error) { return err(error); }
565
- });
566
-
567
- server.tool('add_layer', {
568
- projectId: z.string().describe('Project ID to add the layer to'),
569
- key: z.string().describe('Unique layer key (e.g. "research", "prototypes")'),
570
- label: z.string().describe('Display label for the layer'),
571
- type: z.string().describe('Layer type (e.g. "html", "image", "text")'),
572
- width: z.number().describe('Default frame width in pixels'),
573
- height: z.number().describe('Default frame height in pixels'),
574
- description: z.string().optional().describe('Layer description'),
575
- prompt: z.string().optional().describe('Prompt hint for AI agents working in this layer'),
576
- }, async ({ projectId, key, label, type, width, height, description, prompt }) => {
794
+ tool('screenshot', 'Render a PNG via headless browser. `scope=frame` captures a single frame (default 1440×900, fullPage). `scope=canvas` captures a region of the project surface (default 1600×1200, typically the "plans" layer where shapes live).', {
795
+ scope: z.enum(['frame', 'canvas']).describe('What to capture.'),
796
+ target: z.string().optional().describe('[scope=frame] frame URL, UUID, or /{layer}/{lane}/{filename} path.'),
797
+ slug: z.string().optional().describe('[scope=canvas] project slug. Defaults to the currently active project.'),
798
+ layer: z.string().optional().describe('[scope=canvas] layer key to capture (default: plans).'),
799
+ width: z.number().optional().describe('Viewport width in pixels (frame default 1440, canvas default 1600).'),
800
+ height: z.number().optional().describe('Viewport height in pixels (frame default 900, canvas default 1200).'),
801
+ fullPage: z.boolean().optional().describe('[scope=frame] capture full page or just viewport (default true).'),
802
+ }, async (args) => {
577
803
  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}" already exists in this project`);
582
- }
583
- const newLayer = { key, label, type, width, height };
584
- if (description !== undefined) newLayer.description = description;
585
- if (prompt !== undefined) newLayer.prompt = prompt;
586
- layers.push(newLayer);
587
- return ok(await api('PATCH', `/api/project/${projectId}`, { layers }));
588
- } catch (error) { return err(error); }
589
- });
590
-
591
- server.tool('remove_layer', {
592
- projectId: z.string().describe('Project ID to remove the layer from'),
593
- key: z.string().describe('Layer key to remove (e.g. "research", "prototypes")'),
594
- force: z.boolean().optional().default(false).describe('Force removal even if the layer contains frames'),
595
- }, async ({ projectId, key, force }) => {
596
- try {
597
- const project = await api('GET', `/api/project/${projectId}`);
598
- const layers = project.layers || [];
599
- if (!layers.some(l => l.key === key)) {
600
- throw new Error(`Layer with key "${key}" does not exist in this project`);
804
+ const { scope } = args;
805
+ if (scope === 'frame') {
806
+ const { target, width = 1440, height = 900, fullPage = true } = args;
807
+ if (!target) throw new Error('target required for scope=frame');
808
+ const frameUrlMatch = target.match(/\/f\/([a-f0-9-]{36})/);
809
+ const uuidMatch = target.match(/^[a-f0-9-]{36}$/);
810
+ let frameId = frameUrlMatch?.[1] || (uuidMatch ? target : null);
811
+ if (!frameId) {
812
+ const parts = target.replace(/^\/+/, '').split('/');
813
+ if (parts.length !== 3) throw new Error('Target must be a frame URL, frame ID, or path /{layer}/{lane}/{filename}');
814
+ const frame = await api('GET', `/api/fs/${parts[0]}/${parts[1]}/${parts[2]}`);
815
+ if (!frame.id) throw new Error('Frame not found');
816
+ frameId = frame.id;
817
+ }
818
+ const url = `${getServerUrl()}/api/screenshot/${frameId}?width=${width}&height=${height}&fullPage=${fullPage}`;
819
+ const res = await fetch(url, { headers: getAuthHeaders() });
820
+ if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
821
+ const buffer = await res.arrayBuffer();
822
+ const base64 = Buffer.from(buffer).toString('base64');
823
+ return {
824
+ content: [{ type: 'image', data: base64, mimeType: 'image/png' }],
825
+ };
601
826
  }
602
- // Check for existing frames in the layer
603
- if (!force) {
604
- const listing = await api('GET', `/api/fs?path=/${key}&projectId=${projectId}&recursive=true`);
605
- const entries = listing.entries || [];
606
- const frames = entries.filter(e => e.type === 'frame');
607
- // Don't count the auto-generated _context.md as real content
608
- const realFrames = frames.filter(f => !f.path.endsWith('/_meta/_context.md'));
609
- const frameCount = realFrames.length;
610
- if (frameCount > 0) {
611
- throw new Error(`Layer "${key}" contains ${frameCount} frame(s). Use force: true to confirm deletion.`);
827
+ if (scope === 'canvas') {
828
+ const { slug, layer = 'plans', width = 1600, height = 1200 } = args;
829
+ let targetSlug = slug;
830
+ if (!targetSlug) {
831
+ const list = await api('GET', '/api/projects');
832
+ const active = (list.projects || []).find(p => p.id === list.activeProject);
833
+ if (!active) throw new Error('No active project — pass slug explicitly.');
834
+ targetSlug = active.slug;
612
835
  }
836
+ const url = `${getServerUrl()}/api/canvas-screenshot/${encodeURIComponent(targetSlug)}?layer=${encodeURIComponent(layer)}&width=${width}&height=${height}`;
837
+ const res = await fetch(url, { headers: getAuthHeaders() });
838
+ if (!res.ok) throw new Error(`Canvas screenshot failed: ${res.status} ${await res.text()}`);
839
+ const buffer = await res.arrayBuffer();
840
+ const base64 = Buffer.from(buffer).toString('base64');
841
+ return {
842
+ content: [{ type: 'image', data: base64, mimeType: 'image/png' }],
843
+ };
613
844
  }
614
- const filtered = layers.filter(l => l.key !== key);
615
- return ok(await api('PATCH', `/api/project/${projectId}`, { layers: filtered }));
845
+ throw new Error(`Unknown screenshot scope: ${scope}`);
616
846
  } catch (error) { return err(error); }
617
847
  });
618
848
 
619
- server.tool('reorder_layers', {
620
- projectId: z.string().describe('Project ID to reorder layers for'),
621
- 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.'),
622
- }, async ({ projectId, keys }) => {
849
+ tool('layer', 'Manage layers in a project. Dispatch by `action`: add/update/remove/reorder. Layers are the horizontal bands of a Drafted canvas (e.g. wireframes, designs, brand-assets). All actions take projectId; add/update/remove also take the layer `key`.', {
850
+ action: z.enum(['add', 'update', 'remove', 'reorder']).describe('Operation to perform.'),
851
+ projectId: z.string().describe('Project ID (all actions require this)'),
852
+ key: z.string().optional().describe('[add|update|remove] unique layer key (e.g. "research", "prototypes")'),
853
+ label: z.string().optional().describe('[add|update] display label'),
854
+ type: z.string().optional().describe('[add|update] layer type (e.g. "html", "image", "text")'),
855
+ width: z.number().optional().describe('[add|update] default frame width in pixels'),
856
+ height: z.number().optional().describe('[add|update] default frame height in pixels'),
857
+ description: z.string().optional().describe('[add|update] layer description'),
858
+ prompt: z.string().optional().describe('[add|update] prompt hint for AI agents working in this layer'),
859
+ force: z.boolean().optional().describe('[remove] force removal even if the layer contains frames'),
860
+ keys: z.array(z.string()).optional().describe('[reorder] ordered array of ALL existing layer keys — no additions, removals, or duplicates'),
861
+ }, async (args) => {
623
862
  try {
624
- const project = await api('GET', `/api/project/${projectId}`);
625
- const currentLayers = project.layers || [];
626
- const currentKeys = currentLayers.map(l => l.key);
627
-
628
- // Check for duplicates
629
- const uniqueKeys = new Set(keys);
630
- if (uniqueKeys.size !== keys.length) {
631
- const dupes = keys.filter((k, i) => keys.indexOf(k) !== i);
632
- throw new Error(`Duplicate keys: ${[...new Set(dupes)].join(', ')}`);
633
- }
634
-
635
- // Check for missing keys
636
- const missing = currentKeys.filter(k => !uniqueKeys.has(k));
637
- if (missing.length > 0) {
638
- throw new Error(`Missing keys: ${missing.join(', ')}. You must include all existing layer keys.`);
639
- }
640
-
641
- // Check for extra keys
642
- const currentSet = new Set(currentKeys);
643
- const extra = keys.filter(k => !currentSet.has(k));
644
- if (extra.length > 0) {
645
- throw new Error(`Unknown keys: ${extra.join(', ')}. Only existing layer keys are allowed.`);
863
+ const { action, projectId } = args;
864
+ if (!projectId) throw new Error('projectId is required');
865
+ switch (action) {
866
+ case 'add': {
867
+ const { key, label, type, width, height, description, prompt } = args;
868
+ if (!key || !label || !type || width == null || height == null) {
869
+ throw new Error('key, label, type, width, height are required for action=add');
870
+ }
871
+ const project = await api('GET', `/api/project/${projectId}`);
872
+ const layers = project.layers || [];
873
+ if (layers.some(l => l.key === key)) {
874
+ throw new Error(`Layer with key "${key}" already exists in this project`);
875
+ }
876
+ const newLayer = { key, label, type, width, height };
877
+ if (description !== undefined) newLayer.description = description;
878
+ if (prompt !== undefined) newLayer.prompt = prompt;
879
+ layers.push(newLayer);
880
+ return ok(await api('PATCH', `/api/project/${projectId}`, { layers }));
881
+ }
882
+ case 'update': {
883
+ const { key, label, type, width, height, description, prompt } = args;
884
+ if (!key) throw new Error('key is required for action=update');
885
+ const project = await api('GET', `/api/project/${projectId}`);
886
+ const layers = project.layers || project.project?.layers;
887
+ if (!Array.isArray(layers)) throw new Error('Project has no layers array');
888
+ const idx = layers.findIndex(l => l.key === key);
889
+ if (idx === -1) throw new Error(`Layer with key "${key}" not found`);
890
+ const updates = { label, type, width, height, description, prompt };
891
+ const filtered = Object.fromEntries(Object.entries(updates).filter(([, v]) => v !== undefined));
892
+ if (Object.keys(filtered).length === 0) throw new Error('At least one field (label, type, width, height, description, prompt) is required for action=update');
893
+ layers[idx] = { ...layers[idx], ...filtered };
894
+ return ok(await api('PATCH', `/api/project/${projectId}`, { layers }));
895
+ }
896
+ case 'remove': {
897
+ const { key, force = false } = args;
898
+ if (!key) throw new Error('key is required for action=remove');
899
+ const project = await api('GET', `/api/project/${projectId}`);
900
+ const layers = project.layers || [];
901
+ if (!layers.some(l => l.key === key)) {
902
+ throw new Error(`Layer with key "${key}" does not exist in this project`);
903
+ }
904
+ if (!force) {
905
+ const listing = await api('GET', `/api/fs?path=/${key}&projectId=${projectId}&recursive=true`);
906
+ const entries = listing.entries || [];
907
+ const frames = entries.filter(e => e.type === 'frame');
908
+ const realFrames = frames.filter(f => !f.path.endsWith('/_meta/_context.md') && !f.path.endsWith('/instructions/context.md'));
909
+ const frameCount = realFrames.length;
910
+ if (frameCount > 0) {
911
+ throw new Error(`Layer "${key}" contains ${frameCount} frame(s). Use force: true to confirm deletion.`);
912
+ }
913
+ }
914
+ const filtered = layers.filter(l => l.key !== key);
915
+ return ok(await api('PATCH', `/api/project/${projectId}`, { layers: filtered }));
916
+ }
917
+ case 'reorder': {
918
+ const { keys } = args;
919
+ if (!Array.isArray(keys)) throw new Error('keys (array) is required for action=reorder');
920
+ const project = await api('GET', `/api/project/${projectId}`);
921
+ const currentLayers = project.layers || [];
922
+ const currentKeys = currentLayers.map(l => l.key);
923
+ const uniqueKeys = new Set(keys);
924
+ if (uniqueKeys.size !== keys.length) {
925
+ const dupes = keys.filter((k, i) => keys.indexOf(k) !== i);
926
+ throw new Error(`Duplicate keys: ${[...new Set(dupes)].join(', ')}`);
927
+ }
928
+ const missing = currentKeys.filter(k => !uniqueKeys.has(k));
929
+ if (missing.length > 0) {
930
+ throw new Error(`Missing keys: ${missing.join(', ')}. You must include all existing layer keys.`);
931
+ }
932
+ const currentSet = new Set(currentKeys);
933
+ const extra = keys.filter(k => !currentSet.has(k));
934
+ if (extra.length > 0) {
935
+ throw new Error(`Unknown keys: ${extra.join(', ')}. Only existing layer keys are allowed.`);
936
+ }
937
+ const reorderedLayers = keys.map(k => currentLayers.find(l => l.key === k));
938
+ return ok(await api('PATCH', `/api/project/${projectId}`, { layers: reorderedLayers }));
939
+ }
940
+ default:
941
+ throw new Error(`Unknown layer action: ${action}`);
646
942
  }
647
-
648
- const reorderedLayers = keys.map(k => currentLayers.find(l => l.key === k));
649
- return ok(await api('PATCH', `/api/project/${projectId}`, { layers: reorderedLayers }));
650
943
  } catch (error) { return err(error); }
651
944
  });
652
945
 
653
- server.tool('update_layer', {
654
- projectId: z.string().describe('Project ID containing the layer'),
655
- key: z.string().describe('Layer key to update (e.g. "wireframes", "designs")'),
656
- label: z.string().optional().describe('Display label'),
657
- type: z.string().optional().describe('Layer type'),
658
- width: z.number().optional().describe('Default frame width'),
659
- height: z.number().optional().describe('Default frame height'),
660
- description: z.string().optional().describe('Layer description'),
661
- prompt: z.string().optional().describe('Layer prompt'),
662
- }, async ({ projectId, key, label, type, width, height, description, prompt }) => {
663
- try {
664
- const project = await api('GET', `/api/project/${projectId}`);
665
- const layers = project.layers || project.project?.layers;
666
- if (!Array.isArray(layers)) throw new Error('Project has no layers array');
667
-
668
- const idx = layers.findIndex(l => l.key === key);
669
- if (idx === -1) throw new Error(`Layer with key "${key}" not found`);
670
-
671
- const updates = { label, type, width, height, description, prompt };
672
- const filtered = Object.fromEntries(Object.entries(updates).filter(([, v]) => v !== undefined));
673
- if (Object.keys(filtered).length === 0) throw new Error('At least one field (label, type, width, height, description, prompt) is required');
674
-
675
- layers[idx] = { ...layers[idx], ...filtered };
676
- return ok(await api('PATCH', `/api/project/${projectId}`, { layers }));
677
- } catch (error) { return err(error); }
678
- });
679
-
680
- server.tool('get_org', {}, async () => {
946
+ tool('get_org', {}, async () => {
681
947
  try {
682
948
  const data = await api('GET', '/api/orgs');
683
949
  const orgs = data.orgs || data || [];
@@ -705,154 +971,207 @@ server.tool('get_org', {}, async () => {
705
971
  } catch (error) { return err(error); }
706
972
  });
707
973
 
708
- server.tool('search', {
709
- query: z.string().describe('Search term to match against frame names'),
710
- projectId: z.string().optional().describe('Limit search to a specific project (optional)'),
711
- }, async ({ query, projectId }) => {
712
- try {
713
- const params = new URLSearchParams({ q: query });
714
- if (projectId) params.set('projectId', projectId);
715
- await ensureSession();
716
- const url = `${getServerUrl()}/api/search?${params.toString()}`;
717
- const res = await fetch(url, { headers: getAuthHeaders() });
718
- if (!res.ok) throw new Error(`Search failed: ${res.status}`);
719
- const results = await res.json();
720
- return ok(results.map(r => ({
721
- id: r.id,
722
- path: `/${r.layer}/${r.lane}/${r.label}`,
723
- project: r.projectName,
724
- projectId: r.projectId,
725
- frameUrl: `${getServerUrl()}/f/${r.id}`,
726
- contentType: r.contentType,
727
- updatedAt: r.updatedAt,
728
- })));
729
- } catch (error) { return err(error); }
730
- });
731
-
732
974
  // ── Filesystem tools (direct HTTP to /api/fs) ─────────────────────
733
975
 
734
- 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.', {
735
- 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).'),
736
- content: z.string().optional().describe('File content (HTML, markdown, or text). Mutually exclusive with file_path — provide one or the other.'),
737
- 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.'),
738
- 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.'),
739
- width: z.number().optional().describe('Explicit frame width in pixels. Overrides layer default. Ignored if autoSize is true.'),
740
- height: z.number().optional().describe('Explicit frame height in pixels. Overrides layer default. Ignored if autoSize is true.'),
741
- color: z.string().optional().describe('CSS color for frame border (e.g. #ff0000, red)'),
742
- }, async ({ path, content, file_path, autoSize, width, height, color }) => {
743
- try {
744
- // Validate: exactly one of content or file_path
745
- if (content != null && file_path) throw new Error('Provide content OR file_path, not both');
746
- if (content == null && !file_path) throw new Error('Provide content or file_path');
747
-
748
- const anchorErr = await checkAnchors(parseLayer(path));
749
- if (anchorErr) return err(new Error(anchorErr));
750
- const parts = path.replace(/^\/+/, '').split('/');
751
- if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
752
-
753
- let body;
754
- if (file_path) {
755
- const resolved = resolve(file_path);
756
- if (!existsSync(resolved)) throw new Error(`File not found: ${resolved}`);
757
- const ext = extname(resolved).toLowerCase();
758
- const TEXT_EXTS = ['.html', '.htm', '.svg', '.md', '.markdown', '.txt', '.css', '.js', '.mjs', '.json', '.xml'];
759
- if (TEXT_EXTS.includes(ext)) {
760
- // Text file: read as content so it's stored inline (enables base tag injection for HTML)
761
- body = { content: readFileSync(resolved, 'utf8') };
762
- if (autoSize) body.autoSize = true;
763
- } else {
764
- // Binary upload: read file from disk, send as base64
765
- const buffer = readFileSync(resolved);
766
- const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.pdf': 'application/pdf' };
767
- body = { base64: buffer.toString('base64'), contentType: MIME[ext] || 'application/octet-stream' };
768
- }
769
- } else {
770
- body = { content };
771
- if (autoSize) body.autoSize = true;
772
- }
773
- if (width) body.width = width;
774
- if (height) body.height = height;
775
- if (color) body.color = color;
776
- const result = await api('PUT', `/api/fs/${parts[0]}/${parts[1]}/${parts[2]}`, body);
777
- return ok(result);
778
- } catch (error) { return err(error); }
779
- });
780
-
781
- server.tool('read', 'Read a frame from the ACTIVE PROJECT. Response includes "project" field confirming which project was read.', {
782
- 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.'),
783
- lines: z.string().optional().describe('Line range (e.g. "1-50", "80-120"). Omit to read all.'),
784
- }, async ({ path, lines }) => {
785
- try {
786
- const query = lines ? `?lines=${encodeURIComponent(lines)}` : '';
787
-
788
- // Detect frame URL (e.g. http://host/f/{uuid}) or bare UUID
789
- const frameUrlMatch = path.match(/\/f\/([a-f0-9-]{36})/);
790
- const uuidMatch = path.match(/^[a-f0-9-]{36}$/);
791
- const frameId = frameUrlMatch?.[1] || (uuidMatch ? path : null);
792
-
793
- let result;
794
- if (frameId) {
795
- result = await api('GET', `/api/fs/by-id/${frameId}${query}`);
796
- } else {
797
- const parts = path.replace(/^\/+/, '').split('/');
798
- if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}, a frame URL, or a frame ID');
799
- result = await api('GET', `/api/fs/${parts[0]}/${parts[1]}/${parts[2]}${query}`);
800
- }
801
-
802
- // Track full reads (no line range) for anchor enforcement
803
- if (!lines && result.ok && result.id) {
804
- readFrameIds.add(result.id);
805
- }
806
-
807
- return ok(result.content || result);
808
- } catch (error) { return err(error); }
809
- });
810
-
811
- server.tool('edit', 'Edit a frame in the ACTIVE PROJECT using hashline operations. Response includes "project" field. Use open_project first if needed.', {
812
- 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.'),
976
+ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by path, frame URL, or UUID), write (new frame or overwrite), edit (hashline ops), mv (rename/move), anchor (mark as required-read for the layer), search (match frame names). Use project(action="open") first. For listing use `ls`, for deletion use `rm`.\n\n**Write — 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` — uploaded to storage and displayed as an asset frame.\n\n**Write — dimensions:** By default, frames use the layer\'s default size (e.g. 1440×900 for designs, 1440×3000 for wireframes). Often too large for small content. Use `autoSize: true` to measure HTML content and size to fit, or pass explicit `width`/`height`.', {
977
+ action: z.enum(['read', 'write', 'edit', 'mv', 'anchor', 'search']).describe('Operation to perform.'),
978
+ path: z.string().optional().describe('[read] /{layer}/{lane}/{filename}, frame URL, or UUID. [write|edit|anchor] /{layer}/{lane}/{filename}.'),
979
+ lines: z.string().optional().describe('[read] line range (e.g. "1-50"). Omit to read all.'),
980
+ content: z.string().optional().describe('[write] HTML/markdown/text. Mutually exclusive with file_path.'),
981
+ file_path: z.string().optional().describe('[write] absolute path to a local file to upload. Mutually exclusive with content.'),
982
+ autoSize: z.boolean().optional().describe('[write] measure HTML content and size frame to fit. Content only, not file_path.'),
983
+ width: z.number().optional().describe('[write] explicit width in pixels. Overrides layer default. Ignored if autoSize=true.'),
984
+ height: z.number().optional().describe('[write] explicit height in pixels. Overrides layer default. Ignored if autoSize=true.'),
985
+ color: z.string().optional().describe('[write] CSS color for frame border (e.g. #ff0000, red).'),
813
986
  operations: z.array(z.object({
814
987
  type: z.enum(['replace', 'delete', 'insertAfter', 'insertBefore']).describe('Edit type'),
815
- 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.'),
988
+ lineHash: z.string().describe('4-char hash of the target line (from read output). Each line has a unique hash.'),
816
989
  newContent: z.string().optional().describe('New content (for replace, insertAfter, insertBefore)'),
817
- })).describe('Hashline edit operations'),
818
- }, async ({ path, operations }) => {
990
+ })).optional().describe('[edit] hashline edit operations'),
991
+ from: z.string().optional().describe('[mv] source path /{layer}/{lane}/{filename}'),
992
+ to: z.string().optional().describe('[mv] destination path /{layer}/{lane}/{filename}'),
993
+ anchored: z.boolean().optional().describe('[anchor] true to anchor, false to unanchor. Anchored frames MUST be read before writing/editing in the same layer.'),
994
+ query: z.string().optional().describe('[search] term to match against frame names'),
995
+ projectId: z.string().optional().describe('[search] limit to a specific project (optional)'),
996
+ limit: z.number().optional().describe('[search] max results (default 50, max 200)'),
997
+ }, async (args) => {
819
998
  try {
820
- const anchorErr = await checkAnchors(parseLayer(path));
821
- if (anchorErr) return err(new Error(anchorErr));
822
- const result = await api('POST', '/api/fs/edit', { path, operations });
823
- return ok(result);
999
+ const { action } = args;
1000
+ switch (action) {
1001
+ case 'read': {
1002
+ const { path, lines } = args;
1003
+ if (!path) throw new Error('path required for action=read');
1004
+ const query = lines ? `?lines=${encodeURIComponent(lines)}` : '';
1005
+ const frameUrlMatch = path.match(/\/f\/([a-f0-9-]{36})/);
1006
+ const uuidMatch = path.match(/^[a-f0-9-]{36}$/);
1007
+ const frameId = frameUrlMatch?.[1] || (uuidMatch ? path : null);
1008
+ let result;
1009
+ if (frameId) {
1010
+ result = await api('GET', `/api/fs/by-id/${frameId}${query}`);
1011
+ } else {
1012
+ const parts = path.replace(/^\/+/, '').split('/');
1013
+ if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}, a frame URL, or a frame ID');
1014
+ result = await api('GET', `/api/fs/${parts[0]}/${parts[1]}/${parts[2]}${query}`);
1015
+ }
1016
+ if (!lines && result.ok && result.id) {
1017
+ readFrameIds.add(result.id);
1018
+ }
1019
+ return ok(result.content || result, {
1020
+ structuredContent: frameStructuredContent(result),
1021
+ _meta: { frameHtml: result.content },
1022
+ });
1023
+ }
1024
+ case 'write': {
1025
+ const { path, content, file_path, autoSize, width, height, color } = args;
1026
+ if (!path) throw new Error('path required for action=write');
1027
+ if (content != null && file_path) throw new Error('Provide content OR file_path, not both');
1028
+ if (content == null && !file_path) throw new Error('Provide content or file_path');
1029
+ const anchorErr = await checkAnchors(parseLayer(path));
1030
+ if (anchorErr) return err(new Error(anchorErr));
1031
+ const parts = path.replace(/^\/+/, '').split('/');
1032
+ if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
1033
+ let body;
1034
+ if (file_path) {
1035
+ const resolved = resolve(file_path);
1036
+ if (!existsSync(resolved)) throw new Error(`File not found: ${resolved}`);
1037
+ const ext = extname(resolved).toLowerCase();
1038
+ const TEXT_EXTS = ['.html', '.htm', '.svg', '.md', '.markdown', '.txt', '.css', '.js', '.mjs', '.json', '.xml'];
1039
+ if (TEXT_EXTS.includes(ext)) {
1040
+ body = { content: readFileSync(resolved, 'utf8') };
1041
+ if (autoSize) body.autoSize = true;
1042
+ } else {
1043
+ const buffer = readFileSync(resolved);
1044
+ const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.pdf': 'application/pdf' };
1045
+ body = { base64: buffer.toString('base64'), contentType: MIME[ext] || 'application/octet-stream' };
1046
+ }
1047
+ } else {
1048
+ body = { content };
1049
+ if (autoSize) body.autoSize = true;
1050
+ }
1051
+ if (width) body.width = width;
1052
+ if (height) body.height = height;
1053
+ if (color) body.color = color;
1054
+ const result = await api('PUT', `/api/fs/${parts[0]}/${parts[1]}/${parts[2]}`, body);
1055
+ return ok(withFrameBreadcrumb(result, { hint: true }), {
1056
+ structuredContent: frameStructuredContent(result),
1057
+ _meta: body.content ? { frameHtml: body.content } : undefined,
1058
+ });
1059
+ }
1060
+ case 'edit': {
1061
+ const { path, operations } = args;
1062
+ if (!path) throw new Error('path required for action=edit');
1063
+ if (!Array.isArray(operations) || operations.length === 0) throw new Error('operations (array) required for action=edit');
1064
+ const anchorErr = await checkAnchors(parseLayer(path));
1065
+ if (anchorErr) return err(new Error(anchorErr));
1066
+ const result = await api('POST', '/api/fs/edit', { path, operations });
1067
+ return ok(result, {
1068
+ structuredContent: frameStructuredContent(result),
1069
+ _meta: result.content ? { frameHtml: result.content } : undefined,
1070
+ });
1071
+ }
1072
+ case 'mv': {
1073
+ const { from, to } = args;
1074
+ if (!from || !to) throw new Error('from and to required for action=mv');
1075
+ const fromErr = await checkAnchors(parseLayer(from));
1076
+ if (fromErr) return err(new Error(fromErr));
1077
+ const toErr = await checkAnchors(parseLayer(to));
1078
+ if (toErr) return err(new Error(toErr));
1079
+ return ok(await api('POST', '/api/fs/mv', { from, to }));
1080
+ }
1081
+ case 'anchor': {
1082
+ const { path, anchored } = args;
1083
+ if (!path) throw new Error('path required for action=anchor');
1084
+ if (typeof anchored !== 'boolean') throw new Error('anchored (boolean) required for action=anchor');
1085
+ return ok(await api('POST', '/api/fs/anchor', { path, anchored }));
1086
+ }
1087
+ case 'search': {
1088
+ const { query, projectId, limit = 50 } = args;
1089
+ if (!query) throw new Error('query required for action=search');
1090
+ const params = new URLSearchParams({ q: query });
1091
+ if (projectId) params.set('projectId', projectId);
1092
+ await ensureSession();
1093
+ const url = `${getServerUrl()}/api/search?${params.toString()}`;
1094
+ const res = await fetch(url, { headers: getAuthHeaders() });
1095
+ if (!res.ok) throw new Error(`Search failed: ${res.status}`);
1096
+ const results = await res.json();
1097
+ const cap = Math.min(Math.max(1, limit || 50), 200);
1098
+ const slice = results.slice(0, cap);
1099
+ return ok({
1100
+ results: slice.map(r => ({
1101
+ id: r.id,
1102
+ path: `/${r.layer}/${r.lane}/${r.label}`,
1103
+ project: r.projectName,
1104
+ projectId: r.projectId,
1105
+ frameUrl: `${getServerUrl()}/f/${r.id}`,
1106
+ contentType: r.contentType,
1107
+ updatedAt: r.updatedAt,
1108
+ })),
1109
+ totalAvailable: results.length,
1110
+ truncated: results.length > cap,
1111
+ });
1112
+ }
1113
+ default:
1114
+ throw new Error(`Unknown frame action: ${action}`);
1115
+ }
824
1116
  } catch (error) { return err(error); }
825
1117
  });
826
1118
 
827
- 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.', {
1119
+ tool('ls', 'List contents of the ACTIVE PROJECT. Use ls / after project(action="open") to see layers, workflow, and confirm you\'re in the right project.', {
828
1120
  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).'),
829
- recursive: z.boolean().optional().describe('List contents of subdirectories'),
1121
+ recursive: z.boolean().optional().describe('List contents of subdirectories. When true, forces summary mode (metadata only, no full content) to keep results under the 25k token cap.'),
830
1122
  summary: z.boolean().optional().describe('Include size, updatedAt, title for frames'),
831
1123
  pattern: z.string().optional().describe('Glob pattern to filter filenames (e.g. "*.html")'),
832
- }, async ({ path, recursive, summary, pattern }) => {
1124
+ limit: z.number().optional().default(500).describe('Max entries to return (default 500, max 2000). Use pattern or path to scope further.'),
1125
+ }, async ({ path, recursive, summary, pattern, limit }) => {
833
1126
  try {
834
1127
  const params = new URLSearchParams();
835
1128
  params.set('path', path);
836
- if (recursive) params.set('recursive', 'true');
837
- if (summary) params.set('summary', 'true');
1129
+ // Recursive walks can return huge content payloads — force summary mode
1130
+ // so each entry stays small. Agents that need full content can read individual frames.
1131
+ if (recursive) {
1132
+ params.set('recursive', 'true');
1133
+ params.set('summary', 'true');
1134
+ } else if (summary) {
1135
+ params.set('summary', 'true');
1136
+ }
838
1137
  if (pattern) params.set('pattern', pattern);
839
1138
  const result = await api('GET', `/api/fs/?${params.toString()}`);
840
- return ok(result);
841
- } catch (error) { return err(error); }
842
- });
843
1139
 
844
- 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.', {
845
- path: z.string().describe('File path: /{layer}/{lane}/{filename}'),
846
- anchored: z.boolean().describe('true to anchor, false to unanchor'),
847
- }, async ({ path, anchored }) => {
848
- try {
849
- const result = await api('POST', '/api/fs/anchor', { path, anchored });
850
- return ok(result);
1140
+ // Cap entries client-side as a safety net
1141
+ const cap = Math.min(Math.max(1, limit || 500), 2000);
1142
+ if (Array.isArray(result?.entries) && result.entries.length > cap) {
1143
+ result.totalAvailable = result.entries.length;
1144
+ result.truncated = true;
1145
+ result.entries = result.entries.slice(0, cap);
1146
+ }
1147
+
1148
+ // ChatGPT Apps SDK: canvas-overview widget renders byLayer.
1149
+ const entries = Array.isArray(result?.entries) ? result.entries : [];
1150
+ const byLayer = {};
1151
+ for (const e of entries) {
1152
+ const layer = e.layer || (e.type === 'directory' ? e.name : 'unsorted');
1153
+ (byLayer[layer] ??= []).push({
1154
+ label: e.label || e.name,
1155
+ filename: e.filename || e.name,
1156
+ layer: e.layer,
1157
+ lane: e.lane,
1158
+ frameUrl: e.id ? `${getServerUrl()}/f/${e.id}` : undefined,
1159
+ });
1160
+ }
1161
+ const projectId = getState().projectId;
1162
+ const structuredContent = {
1163
+ project: result?.project || result?.projectName,
1164
+ canvasUrl: result?.projectSlug ? `${getServerUrl()}/project/${result.projectSlug}` : undefined,
1165
+ byLayer,
1166
+ truncated: result?.truncated || false,
1167
+ totalAvailable: result?.totalAvailable,
1168
+ };
1169
+ return ok(result, { structuredContent });
851
1170
  } catch (error) { return err(error); }
852
1171
  });
853
1172
 
854
- server.tool('rm', 'Delete a frame or lane from the ACTIVE PROJECT. Response includes "project" field.', {
855
- 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.'),
1173
+ tool('rm', 'Delete a frame or lane from the ACTIVE PROJECT. Response includes "project" field.', {
1174
+ path: z.string().describe('Path to delete: /{layer}/{lane}/{filename} or /{layer}/{lane} (deletes entire lane).'),
856
1175
  }, async ({ path }) => {
857
1176
  try {
858
1177
  const anchorErr = await checkAnchors(parseLayer(path));
@@ -863,21 +1182,9 @@ server.tool('rm', 'Delete a frame or lane from the ACTIVE PROJECT. Response incl
863
1182
  } catch (error) { return err(error); }
864
1183
  });
865
1184
 
866
- server.tool('mv', 'Move/rename a frame within the ACTIVE PROJECT. Response includes "project" field.', {
867
- from: z.string().describe('Source path: /{layer}/{lane}/{filename}'),
868
- to: z.string().describe('Destination path: /{layer}/{lane}/{filename}'),
869
- }, async ({ from, to }) => {
870
- try {
871
- const fromErr = await checkAnchors(parseLayer(from));
872
- if (fromErr) return err(new Error(fromErr));
873
- const toErr = await checkAnchors(parseLayer(to));
874
- if (toErr) return err(new Error(toErr));
875
- const result = await api('POST', '/api/fs/mv', { from, to });
876
- return ok(result);
877
- } catch (error) { return err(error); }
878
- });
879
-
880
- server.tool('batch', 'Batch operations on the ACTIVE PROJECT. Response includes "project" field. Use open_project first if needed.', {
1185
+ // batch tool temporarily disabled keep code intact for re-enablement later.
1186
+ /*
1187
+ tool('batch', 'Batch operations on the ACTIVE PROJECT. Response includes "project" field. Use project(action="open") first if needed.', {
881
1188
  operations: z.array(z.object({
882
1189
  tool: z.enum(['write', 'rm', 'mv', 'edit', 'upload_asset']).describe('Tool to execute'),
883
1190
  path: z.string().optional().describe('Path (for write, rm, edit)'),
@@ -939,8 +1246,8 @@ server.tool('batch', 'Batch operations on the ACTIVE PROJECT. Response includes
939
1246
  const body = { base64: b64, contentType: ct };
940
1247
  if (op.frame_id) body.frameId = op.frame_id;
941
1248
 
942
- const projectId = agentActiveProjectId;
943
- if (!projectId) throw new Error('No active project. Call open_project first.');
1249
+ const projectId = getState().projectId;
1250
+ if (!projectId) throw new Error('No active project. Call project(action="open") first.');
944
1251
  const r = await api('PUT', `/api/projects/${projectId}/assets/${op.asset_path}`, body);
945
1252
  results.push({ ok: true, tool: 'upload_asset', asset_path: op.asset_path, ...r });
946
1253
  } catch (e) {
@@ -968,127 +1275,292 @@ server.tool('batch', 'Batch operations on the ACTIVE PROJECT. Response includes
968
1275
  });
969
1276
 
970
1277
  const batchResult = await api('POST', '/api/fs/batch', { operations: resolvedOps });
971
- if (batchResult.results) results.push(...batchResult.results);
1278
+ if (batchResult.results) {
1279
+ for (const r of batchResult.results) {
1280
+ results.push(withFrameBreadcrumb(r));
1281
+ }
1282
+ }
972
1283
  }
973
1284
 
974
1285
  return ok({ ok: true, results });
975
1286
  } catch (error) { return err(error); }
976
1287
  });
1288
+ */
977
1289
 
978
1290
  // ── Asset tools ──────────────────────────────────────────────────
979
1291
 
980
- server.tool('upload_asset', 'Upload a supporting file (CSS, JS, image, font) to the ACTIVE PROJECT. Assets are referenced by frames via relative paths — e.g., if your HTML has <link href="css/styles.css">, upload the asset with asset_path="css/styles.css". Assets are NOT frames — they don\'t appear on the canvas. Use batch with upload_asset operations for bulk uploads.', {
981
- asset_path: z.string().describe('Relative path for the asset (e.g., "css/styles.css", "js/app.js", "img/logo.png"). This must match the path used in HTML references.'),
982
- file_path: z.string().optional().describe('Absolute path to a local file to upload. Mutually exclusive with content/base64.'),
983
- content: z.string().optional().describe('Text content (for CSS/JS files). Mutually exclusive with file_path.'),
984
- base64: z.string().optional().describe('Base64-encoded binary content. Mutually exclusive with file_path and content.'),
985
- content_type: z.string().optional().describe('MIME type (auto-detected from extension if omitted)'),
986
- frame_id: z.string().optional().describe('Frame ID to associate this asset with (for cleanup when the frame is deleted). Get this from the write tool response.'),
987
- }, async ({ asset_path, file_path, content, base64: rawBase64, content_type, frame_id }) => {
1292
+ tool('asset', 'Manage supporting files (CSS, JS, images, fonts) in the ACTIVE PROJECT. Assets are referenced by frames via relative paths — e.g., if your HTML has <link href="css/styles.css">, upload with asset_path="css/styles.css". Assets are NOT frames — they don\'t appear on the canvas. `action=upload` to add/replace, `action=list` to browse.', {
1293
+ action: z.enum(['upload', 'list']).describe('Operation to perform.'),
1294
+ asset_path: z.string().optional().describe('[upload] relative asset path (e.g. "css/styles.css"). Must match the path used in HTML references.'),
1295
+ file_path: z.string().optional().describe('[upload] absolute path to a local file. Mutually exclusive with content/base64.'),
1296
+ content: z.string().optional().describe('[upload] text content (for CSS/JS). Mutually exclusive with file_path/base64.'),
1297
+ base64: z.string().optional().describe('[upload] base64-encoded binary content. Mutually exclusive with file_path/content.'),
1298
+ content_type: z.string().optional().describe('[upload] MIME type (auto-detected from extension if omitted).'),
1299
+ frame_id: z.string().optional().describe('[upload|list] associate with / filter by a specific frame. Get frame IDs from frame(action="write") or ls.'),
1300
+ }, async (args) => {
988
1301
  try {
989
- if (!asset_path) throw new Error('asset_path is required');
990
- if (asset_path.includes('..')) throw new Error('asset_path must not contain ".."');
991
-
992
- let b64, ct;
993
- if (file_path) {
994
- const resolved = resolve(file_path);
995
- if (!existsSync(resolved)) throw new Error(`File not found: ${resolved}`);
996
- const buffer = readFileSync(resolved);
997
- b64 = buffer.toString('base64');
998
- ct = content_type || mimeFromExt(extname(resolved));
999
- } else if (content != null) {
1000
- b64 = Buffer.from(content).toString('base64');
1001
- ct = content_type || mimeFromExt(extname(asset_path));
1002
- } else if (rawBase64) {
1003
- b64 = rawBase64;
1004
- ct = content_type || mimeFromExt(extname(asset_path));
1005
- } else {
1006
- throw new Error('Provide file_path, content, or base64');
1302
+ const { action } = args;
1303
+ const projectId = getState().projectId;
1304
+ if (!projectId) throw new Error('No active project. Call project(action="open") first.');
1305
+ if (action === 'upload') {
1306
+ const { asset_path, file_path, content, base64: rawBase64, content_type, frame_id } = args;
1307
+ if (!asset_path) throw new Error('asset_path is required for action=upload');
1308
+ if (asset_path.includes('..')) throw new Error('asset_path must not contain ".."');
1309
+ let b64, ct;
1310
+ if (file_path) {
1311
+ const resolved = resolve(file_path);
1312
+ if (!existsSync(resolved)) throw new Error(`File not found: ${resolved}`);
1313
+ const buffer = readFileSync(resolved);
1314
+ b64 = buffer.toString('base64');
1315
+ ct = content_type || mimeFromExt(extname(resolved));
1316
+ } else if (content != null) {
1317
+ b64 = Buffer.from(content).toString('base64');
1318
+ ct = content_type || mimeFromExt(extname(asset_path));
1319
+ } else if (rawBase64) {
1320
+ b64 = rawBase64;
1321
+ ct = content_type || mimeFromExt(extname(asset_path));
1322
+ } else {
1323
+ throw new Error('Provide file_path, content, or base64');
1324
+ }
1325
+ const body = { base64: b64, contentType: ct };
1326
+ if (frame_id) body.frameId = frame_id;
1327
+ return ok(await api('PUT', `/api/projects/${projectId}/assets/${asset_path}`, body));
1007
1328
  }
1008
-
1009
- const body = { base64: b64, contentType: ct };
1010
- if (frame_id) body.frameId = frame_id;
1011
-
1012
- const projectId = agentActiveProjectId;
1013
- if (!projectId) throw new Error('No active project. Call open_project first.');
1014
-
1015
- const result = await api('PUT', `/api/projects/${projectId}/assets/${asset_path}`, body);
1016
- return ok(result);
1329
+ if (action === 'list') {
1330
+ const { frame_id } = args;
1331
+ const query = frame_id ? `?frameId=${frame_id}` : '';
1332
+ return ok(await api('GET', `/api/projects/${projectId}/assets${query}`));
1333
+ }
1334
+ throw new Error(`Unknown asset action: ${action}`);
1017
1335
  } catch (error) { return err(error); }
1018
1336
  });
1019
1337
 
1020
- server.tool('list_assets', 'List all assets in the ACTIVE PROJECT, optionally filtered by frame.', {
1021
- frame_id: z.string().optional().describe('Filter assets by frame ID'),
1022
- }, async ({ frame_id }) => {
1338
+ // ── Shape tool ───────────────────────────────────────────────────────
1339
+
1340
+ tool('shape', 'Create a flowchart shape on the surface. Shapes are lightweight text nodes — use them for flowcharts, process diagrams, and decision trees. Connect shapes with connector(action="connect"), then call layout to auto-arrange.', {
1341
+ text: z.string().describe('Text to display inside the shape. Supports multi-line with \\n.'),
1342
+ shape: z.enum(['rectangle', 'diamond', 'oval', 'pill']).optional().default('rectangle').describe('Shape type: rectangle (process/action), diamond (decision), oval (start/end), pill (rounded step)'),
1343
+ layer: z.string().optional().describe('Layer to place the shape in (default: plans)'),
1344
+ lane: z.string().optional().describe('Lane within the layer (default: default)'),
1345
+ color: z.string().optional().describe('Border color (CSS color string)'),
1346
+ fill: z.string().optional().describe('Background fill color (CSS color string)'),
1347
+ textColor: z.string().optional().describe('Text color (CSS color string)'),
1348
+ group: z.string().optional().describe('Group ID to assign this shape to (for swim lane clustering). Use the ID returned by the group tool.'),
1349
+ width: z.number().optional().describe('Explicit width in pixels (overrides auto-sizing from text)'),
1350
+ height: z.number().optional().describe('Explicit height in pixels (overrides auto-sizing from text)'),
1351
+ layoutIgnore: z.boolean().optional().describe('Exclude this shape from auto-layout. Use for annotations, labels, or manually positioned elements.'),
1352
+ }, async ({ text, shape, layer, lane, color, fill, textColor, group, width, height, layoutIgnore }) => {
1023
1353
  try {
1024
- const projectId = agentActiveProjectId;
1025
- if (!projectId) throw new Error('No active project. Call open_project first.');
1026
- const query = frame_id ? `?frameId=${frame_id}` : '';
1027
- const result = await api('GET', `/api/projects/${projectId}/assets${query}`);
1354
+ const body = { text, shape };
1355
+ if (layer) body.layer = layer;
1356
+ if (lane) body.lane = lane;
1357
+ if (color) body.color = color;
1358
+ if (fill) body.fill = fill;
1359
+ if (textColor) body.textColor = textColor;
1360
+ if (group) body.group = group;
1361
+ if (width) body.width = width;
1362
+ if (height) body.height = height;
1363
+ if (layoutIgnore) body.layoutIgnore = true;
1364
+ const result = await api('POST', '/api/fs/shape', body);
1028
1365
  return ok(result);
1029
1366
  } catch (error) { return err(error); }
1030
1367
  });
1031
1368
 
1032
- // ── Connector tools ───────────────────────────────────────────────
1033
-
1034
- server.tool('connect', 'Create a connector (arrow) between two frames on the surface.', {
1035
- source: z.string().describe('Source frame path or ID'),
1036
- target: z.string().describe('Target frame path or ID'),
1037
- label: z.string().optional().describe('Connector label text'),
1038
- type: z.string().optional().default('arrow-forward').describe('Connector type (default: arrow-forward)'),
1039
- color: z.string().optional().describe('Connector color (CSS color string)'),
1040
- }, async ({ source, target, label, type, color }) => {
1369
+ tool('group', 'Create a group (swim lane / region) on the surface. Groups are labeled, colored background areas that visually contain shapes. Assign shapes to a group using the group parameter on the shape tool. Use layout with groups=true to auto-cluster.', {
1370
+ label: z.string().describe('Group label text (displayed in the header bar)'),
1371
+ color: z.string().optional().describe('Header bar and border color (CSS color string)'),
1372
+ fill: z.string().optional().describe('Background fill color (CSS color string). Defaults to a tinted version of color.'),
1373
+ layer: z.string().optional().describe('Layer to place the group in (default: plans)'),
1374
+ lane: z.string().optional().describe('Lane within the layer (default: default)'),
1375
+ order: z.number().optional().describe('Sort order for group positioning (lower = first). Default: 0'),
1376
+ }, async ({ label, color, fill, layer, lane, order }) => {
1041
1377
  try {
1042
- const body = { source, target };
1043
- if (label) body.label = label;
1044
- if (type) body.type = type;
1378
+ const body = { label };
1045
1379
  if (color) body.color = color;
1046
- const result = await api('POST', '/api/connectors', body);
1380
+ if (fill) body.fill = fill;
1381
+ if (layer) body.layer = layer;
1382
+ if (lane) body.lane = lane;
1383
+ if (order != null) body.order = order;
1384
+ const result = await api('POST', '/api/fs/group', body);
1047
1385
  return ok(result);
1048
1386
  } catch (error) { return err(error); }
1049
1387
  });
1388
+ // ── Connector tools ───────────────────────────────────────────────
1050
1389
 
1051
- server.tool('disconnect', 'Remove a connector between frames. Provide either connectorId directly, or source+target to find and remove it.', {
1052
- source: z.string().optional().describe('Source frame path or ID'),
1053
- target: z.string().optional().describe('Target frame path or ID'),
1054
- connectorId: z.string().optional().describe('Connector ID to delete directly'),
1055
- }, async ({ source, target, connectorId }) => {
1390
+ tool('connector', 'Create or remove connectors (arrows) between frames on the surface. `action=connect` adds an arrow from source to target. `action=disconnect` removes one — pass either connectorId directly or source+target to find and delete.', {
1391
+ action: z.enum(['connect', 'disconnect']).describe('Operation to perform.'),
1392
+ source: z.string().optional().describe('[connect|disconnect] source frame path or ID'),
1393
+ target: z.string().optional().describe('[connect|disconnect] target frame path or ID'),
1394
+ label: z.string().optional().describe('[connect] connector label text'),
1395
+ type: z.string().optional().describe('[connect] connector type (default: arrow-forward)'),
1396
+ color: z.string().optional().describe('[connect] connector color (CSS color string)'),
1397
+ connectorId: z.string().optional().describe('[disconnect] connector ID to delete directly'),
1398
+ }, async (args) => {
1056
1399
  try {
1057
- if (connectorId) {
1058
- const result = await api('DELETE', `/api/connectors/${connectorId}`);
1059
- return ok(result);
1400
+ const { action } = args;
1401
+ if (action === 'connect') {
1402
+ const { source, target, label, type = 'arrow-forward', color } = args;
1403
+ if (!source || !target) throw new Error('source and target required for action=connect');
1404
+ const body = { source, target };
1405
+ if (label) body.label = label;
1406
+ if (type) body.type = type;
1407
+ if (color) body.color = color;
1408
+ return ok(await api('POST', '/api/connectors', body));
1060
1409
  }
1061
- if (source && target) {
1062
- // Resolve source/target paths to frame IDs
1063
- const sourceFrame = await api('GET', `/api/fs/${source.replace(/^\/+/, '')}`);
1064
- const targetFrame = await api('GET', `/api/fs/${target.replace(/^\/+/, '')}`);
1065
- const sourceId = sourceFrame.id;
1066
- const targetId = targetFrame.id;
1067
- if (!sourceId || !targetId) throw new Error('Could not resolve source or target frame');
1068
-
1069
- const connectors = await api('GET', '/api/connectors');
1070
- const match = (connectors.connectors || connectors || []).find(
1071
- c => c.sourceDesignId === sourceId && c.targetDesignId === targetId
1072
- );
1073
- if (!match) throw new Error(`No connector found from ${source} to ${target}`);
1074
- const result = await api('DELETE', `/api/connectors/${match.id}`);
1075
- return ok(result);
1410
+ if (action === 'disconnect') {
1411
+ const { source, target, connectorId } = args;
1412
+ if (connectorId) {
1413
+ return ok(await api('DELETE', `/api/connectors/${connectorId}`));
1414
+ }
1415
+ if (source && target) {
1416
+ const sourceFrame = await api('GET', `/api/fs/${source.replace(/^\/+/, '')}`);
1417
+ const targetFrame = await api('GET', `/api/fs/${target.replace(/^\/+/, '')}`);
1418
+ const sourceId = sourceFrame.id;
1419
+ const targetId = targetFrame.id;
1420
+ if (!sourceId || !targetId) throw new Error('Could not resolve source or target frame');
1421
+ const connectors = await api('GET', '/api/connectors');
1422
+ const match = (connectors.connectors || connectors || []).find(
1423
+ c => c.sourceDesignId === sourceId && c.targetDesignId === targetId
1424
+ );
1425
+ if (!match) throw new Error(`No connector found from ${source} to ${target}`);
1426
+ return ok(await api('DELETE', `/api/connectors/${match.id}`));
1427
+ }
1428
+ throw new Error('Provide either connectorId, or both source and target');
1076
1429
  }
1077
- throw new Error('Provide either connectorId, or both source and target');
1430
+ throw new Error(`Unknown connector action: ${action}`);
1078
1431
  } catch (error) { return err(error); }
1079
1432
  });
1080
1433
 
1081
1434
  // ── Layout tools ──────────────────────────────────────────────────
1082
1435
 
1083
- server.tool('layout', 'Auto-arrange frames using graph layout algorithm. Positions connected frames as a directed graph.', {
1084
- 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)')
1085
- }, async ({ direction }) => {
1436
+ tool('layout', 'Auto-arrange frames using graph layout algorithm. Positions connected frames as a directed graph.', {
1437
+ 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)'),
1438
+ groups: z.boolean().optional().default(false).describe('Enable group clustering. When true, shapes assigned to a group (via the group param) are clustered together, and group frames are resized to enclose their members.'),
1439
+ }, async ({ direction, groups }) => {
1086
1440
  try {
1087
- const result = await api('POST', '/api/layout', { direction });
1441
+ const body = { direction };
1442
+ if (groups) body.groups = true;
1443
+ const result = await api('POST', '/api/layout', body);
1088
1444
  return ok(result);
1089
1445
  } catch (error) { return err(error); }
1090
1446
  });
1091
1447
 
1448
+ // ── Skill library tool ───────────────────────────────────────────
1449
+
1450
+ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/guidelines agents can load and follow. Dispatch by `action`: search/load/list for discovery; add/update/remove for org skills; attach/detach for project binding; favorite/unfavorite for personal pins; read_file/update_file for supporting files inside a skill directory.', {
1451
+ action: z.enum([
1452
+ 'search', 'load', 'list',
1453
+ 'add', 'update', 'remove',
1454
+ 'attach', 'detach',
1455
+ 'favorite', 'unfavorite',
1456
+ 'read_file', 'update_file',
1457
+ ]).describe('Operation to perform.'),
1458
+ query: z.string().optional().describe('[search] term to match against name/description/content'),
1459
+ tags: z.array(z.string()).optional().describe('[search] filter by tags; [add|update] tag list'),
1460
+ scope: z.enum(['all', 'org', 'global']).optional().describe('[search] scope (default: all)'),
1461
+ limit: z.number().optional().describe('[search] max results (default 25, max 100)'),
1462
+ skill: z.string().optional().describe('[load] skill ID (UUID) or slug'),
1463
+ skillId: z.string().optional().describe('[update|remove|attach|detach|favorite|unfavorite|read_file|update_file] skill ID'),
1464
+ name: z.string().optional().describe('[add|update] skill name'),
1465
+ description: z.string().optional().describe('[add|update] one-line description'),
1466
+ content: z.string().optional().describe('[add|update] root SKILL.md content; [update_file] file content'),
1467
+ triggerPatterns: z.array(z.string()).optional().describe('[add|update] patterns that suggest this skill'),
1468
+ path: z.string().optional().describe('[read_file|update_file] relative path inside skill directory (e.g. "examples/react.md")'),
1469
+ }, async (args) => {
1470
+ try {
1471
+ const { action } = args;
1472
+ switch (action) {
1473
+ case 'search': {
1474
+ const { query, tags, scope = 'all', limit = 25 } = args;
1475
+ const params = new URLSearchParams();
1476
+ if (query) params.set('q', query);
1477
+ if (tags?.length) params.set('tags', tags.join(','));
1478
+ if (scope) params.set('scope', scope);
1479
+ const qs = params.toString();
1480
+ const endpoint = query ? '/api/skills/search' : '/api/skills';
1481
+ const result = await api('GET', `${endpoint}${qs ? '?' + qs : ''}`);
1482
+ const cap = Math.min(Math.max(1, limit || 25), 100);
1483
+ if (Array.isArray(result?.skills) && result.skills.length > cap) {
1484
+ result.totalAvailable = result.skills.length;
1485
+ result.truncated = true;
1486
+ result.skills = result.skills.slice(0, cap);
1487
+ }
1488
+ return ok(result);
1489
+ }
1490
+ case 'load': {
1491
+ const { skill } = args;
1492
+ if (!skill) throw new Error('skill (ID or slug) is required for action=load');
1493
+ const isUuid = /^[a-f0-9-]{36}$/.test(skill);
1494
+ const endpoint = isUuid ? `/api/skills/${skill}` : `/api/skills/slug/${skill}`;
1495
+ return ok(await api('GET', endpoint));
1496
+ }
1497
+ case 'list': {
1498
+ if (!getState().projectId) throw new Error('No active project. Call project(action="open") first.');
1499
+ return ok(await api('GET', `/api/projects/${getState().projectId}/skills`));
1500
+ }
1501
+ case 'add': {
1502
+ const { name, description, content, tags, triggerPatterns } = args;
1503
+ if (!name || !description || !content) throw new Error('name, description, content required for action=add');
1504
+ const body = { name, description, content };
1505
+ if (tags) body.tags = tags;
1506
+ if (triggerPatterns) body.triggerPatterns = triggerPatterns;
1507
+ return ok(await api('POST', '/api/skills', body));
1508
+ }
1509
+ case 'update': {
1510
+ const { skillId, name, description, content, tags, triggerPatterns } = args;
1511
+ if (!skillId) throw new Error('skillId required for action=update');
1512
+ const body = {};
1513
+ if (name !== undefined) body.name = name;
1514
+ if (description !== undefined) body.description = description;
1515
+ if (content !== undefined) body.content = content;
1516
+ if (tags !== undefined) body.tags = tags;
1517
+ if (triggerPatterns !== undefined) body.triggerPatterns = triggerPatterns;
1518
+ if (Object.keys(body).length === 0) throw new Error('At least one field is required for action=update');
1519
+ return ok(await api('PUT', `/api/skills/${skillId}`, body));
1520
+ }
1521
+ case 'remove': {
1522
+ const { skillId } = args;
1523
+ if (!skillId) throw new Error('skillId required for action=remove');
1524
+ return ok(await api('DELETE', `/api/skills/${skillId}`));
1525
+ }
1526
+ case 'attach': {
1527
+ const { skillId } = args;
1528
+ if (!skillId) throw new Error('skillId required for action=attach');
1529
+ if (!getState().projectId) throw new Error('No active project. Call project(action="open") first.');
1530
+ return ok(await api('POST', `/api/projects/${getState().projectId}/skills`, { skillId }));
1531
+ }
1532
+ case 'detach': {
1533
+ const { skillId } = args;
1534
+ if (!skillId) throw new Error('skillId required for action=detach');
1535
+ if (!getState().projectId) throw new Error('No active project. Call project(action="open") first.');
1536
+ return ok(await api('DELETE', `/api/projects/${getState().projectId}/skills/${skillId}`));
1537
+ }
1538
+ case 'favorite': {
1539
+ const { skillId } = args;
1540
+ if (!skillId) throw new Error('skillId required for action=favorite');
1541
+ return ok(await api('POST', `/api/skills/favorites/${skillId}`));
1542
+ }
1543
+ case 'unfavorite': {
1544
+ const { skillId } = args;
1545
+ if (!skillId) throw new Error('skillId required for action=unfavorite');
1546
+ return ok(await api('DELETE', `/api/skills/favorites/${skillId}`));
1547
+ }
1548
+ case 'read_file': {
1549
+ const { skillId, path } = args;
1550
+ if (!skillId || !path) throw new Error('skillId and path required for action=read_file');
1551
+ return ok(await api('GET', `/api/skills/${skillId}/files/${path}`));
1552
+ }
1553
+ case 'update_file': {
1554
+ const { skillId, path, content } = args;
1555
+ if (!skillId || !path || content == null) throw new Error('skillId, path, content required for action=update_file');
1556
+ return ok(await api('PUT', `/api/skills/${skillId}/files/${path}`, { content }));
1557
+ }
1558
+ default:
1559
+ throw new Error(`Unknown skill action: ${action}`);
1560
+ }
1561
+ } catch (error) { return err(error); }
1562
+ });
1563
+
1092
1564
  // ── Resource: canvas info ─────────────────────────────────────────
1093
1565
 
1094
1566
  server.resource('info', 'drafted://info', {
@@ -1102,13 +1574,20 @@ server.resource('info', 'drafted://info', {
1102
1574
  text: JSON.stringify({
1103
1575
  layers: LAYERS,
1104
1576
  pathFormat: '/{layer}/{lane}/{filename}',
1105
- tools: ['write', 'read', 'edit', 'ls', 'rm', 'mv', 'batch'],
1577
+ tools: ['write', 'read', 'edit', 'ls', 'rm', 'mv'],
1106
1578
  }, null, 2),
1107
1579
  }],
1108
1580
  };
1109
1581
  });
1110
1582
 
1583
+ return server;
1584
+ }
1585
+
1111
1586
  // ── Transport ─────────────────────────────────────────────────────
1587
+ // Default singleton for stdio mode. HTTP mode in server/server.mjs calls
1588
+ // createMcpServer() per request so each transport gets its own server.
1589
+
1590
+ export const mcpServer = createMcpServer();
1112
1591
 
1113
1592
  const args = process.argv.slice(2);
1114
1593
 
@@ -1143,12 +1622,27 @@ async function main() {
1143
1622
  }
1144
1623
  }
1145
1624
 
1146
- // Prevent unhandled rejections from killing the stdio transport
1147
- process.on('unhandledRejection', (err) => {
1148
- console.error('[MCP] Unhandled rejection:', err?.message || err);
1149
- });
1625
+ // Only auto-start transports when invoked as a script (drafted-mcp / node mcp/server.mjs).
1626
+ // When imported by server/server.mjs for the mounted OAuth route, callers connect their
1627
+ // own transport. realpathSync handles the case where drafted-mcp is a symlink (the
1628
+ // global npm bin always is) — without it the equality check fails and stdio never starts.
1629
+ const isMain = (() => {
1630
+ if (!process.argv[1]) return false;
1631
+ try {
1632
+ return fileURLToPath(import.meta.url) === realpathSync(process.argv[1]);
1633
+ } catch {
1634
+ return false;
1635
+ }
1636
+ })();
1150
1637
 
1151
- main().catch((err) => {
1152
- console.error('Fatal:', err.message);
1153
- process.exit(1);
1154
- });
1638
+ if (isMain) {
1639
+ // Prevent unhandled rejections from killing the stdio transport
1640
+ process.on('unhandledRejection', (err) => {
1641
+ console.error('[MCP] Unhandled rejection:', err?.message || err);
1642
+ });
1643
+
1644
+ main().catch((err) => {
1645
+ console.error('Fatal:', err.message);
1646
+ process.exit(1);
1647
+ });
1648
+ }