drafted 1.1.2 → 1.1.4
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/cli/drafted.mjs +0 -9
- package/mcp/server.mjs +1076 -784
- package/mcp/widgets/canvas-overview.html +213 -0
- package/mcp/widgets/frame-preview.html +162 -0
- package/package.json +5 -1
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 {
|
|
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,15 +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:
|
|
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.
|
|
29
59
|
|
|
30
|
-
SKILLS: Drafted has a skill library -- reusable agent instructions stored as SKILL.md files. When a user says "use the X skill",
|
|
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.
|
|
31
63
|
|
|
32
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.`,
|
|
33
65
|
});
|
|
34
66
|
|
|
35
67
|
const layerKeys = Object.keys(LAYERS);
|
|
36
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
|
+
|
|
37
203
|
// ── Config ────────────────────────────────────────────────────────
|
|
38
204
|
|
|
39
205
|
const AUTH_FILE = process.env.DRAFTED_AUTH_FILE || join(homedir(), '.drafted', 'auth.json');
|
|
@@ -51,13 +217,6 @@ function getServerUrl() {
|
|
|
51
217
|
return `http://localhost:${process.env.DRAFTED_PORT || 3477}`;
|
|
52
218
|
}
|
|
53
219
|
|
|
54
|
-
// ── Per-instance session ──────────────────────────────────────────
|
|
55
|
-
// Each MCP instance clones its own session from the bootstrap session in auth.json.
|
|
56
|
-
// This ensures multiple Claude Code instances don't share state.
|
|
57
|
-
|
|
58
|
-
let instanceSessionId = null; // cloned session, in-memory only
|
|
59
|
-
let agentActiveProjectId = null; // in-memory only, never persisted
|
|
60
|
-
|
|
61
220
|
function getBootstrapSessionId() {
|
|
62
221
|
try {
|
|
63
222
|
if (existsSync(AUTH_FILE)) {
|
|
@@ -69,7 +228,7 @@ function getBootstrapSessionId() {
|
|
|
69
228
|
}
|
|
70
229
|
|
|
71
230
|
function getAuthHeaders() {
|
|
72
|
-
const sid =
|
|
231
|
+
const sid = getState().sessionId || getBootstrapSessionId();
|
|
73
232
|
if (sid) return { Cookie: `gc_session=${sid}` };
|
|
74
233
|
return {};
|
|
75
234
|
}
|
|
@@ -87,13 +246,13 @@ async function cloneSession() {
|
|
|
87
246
|
if (!res.ok) return;
|
|
88
247
|
const data = await res.json();
|
|
89
248
|
if (data.sessionId) {
|
|
90
|
-
|
|
249
|
+
getState().sessionId = data.sessionId;
|
|
91
250
|
}
|
|
92
251
|
} catch { /* server may not be ready yet, will retry on first API call */ }
|
|
93
252
|
}
|
|
94
253
|
|
|
95
254
|
async function ensureSession() {
|
|
96
|
-
if (
|
|
255
|
+
if (getState().sessionId) return;
|
|
97
256
|
await cloneSession();
|
|
98
257
|
}
|
|
99
258
|
|
|
@@ -109,7 +268,7 @@ function mimeFromExt(ext) { return MIME_MAP[ext?.toLowerCase()] || 'application/
|
|
|
109
268
|
|
|
110
269
|
async function api(method, path, body, _retried) {
|
|
111
270
|
await ensureSession();
|
|
112
|
-
const pid =
|
|
271
|
+
const pid = getState().projectId;
|
|
113
272
|
const sep = path.includes('?') ? '&' : '?';
|
|
114
273
|
const scopedPath = pid ? `${path}${sep}projectId=${pid}` : path;
|
|
115
274
|
const url = `${getServerUrl()}${scopedPath}`;
|
|
@@ -126,7 +285,7 @@ async function api(method, path, body, _retried) {
|
|
|
126
285
|
|
|
127
286
|
// Session expired after server restart — re-clone and retry once
|
|
128
287
|
if (res.status === 401 && !_retried) {
|
|
129
|
-
|
|
288
|
+
getState().sessionId = null;
|
|
130
289
|
await cloneSession();
|
|
131
290
|
return api(method, path, body, true);
|
|
132
291
|
}
|
|
@@ -143,18 +302,77 @@ async function api(method, path, body, _retried) {
|
|
|
143
302
|
return data;
|
|
144
303
|
}
|
|
145
304
|
|
|
146
|
-
|
|
147
|
-
|
|
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
|
+
};
|
|
148
367
|
}
|
|
149
368
|
|
|
150
369
|
// ── Agent WebSocket presence ──────────────────────────────────────
|
|
151
|
-
import WebSocket from 'ws';
|
|
152
370
|
|
|
153
371
|
let agentWs = null;
|
|
154
372
|
let agentWsReconnectTimer = null;
|
|
155
373
|
|
|
156
374
|
function setMcpActiveProject(projectId) {
|
|
157
|
-
|
|
375
|
+
getState().projectId = projectId;
|
|
158
376
|
}
|
|
159
377
|
|
|
160
378
|
async function connectAgentWs() {
|
|
@@ -169,8 +387,8 @@ async function connectAgentWs() {
|
|
|
169
387
|
|
|
170
388
|
agentWs.on('open', () => {
|
|
171
389
|
console.error('[MCP-WS] Connected');
|
|
172
|
-
if (
|
|
173
|
-
agentWs.send(JSON.stringify({ type: 'join', projectId:
|
|
390
|
+
if (getState().projectId) {
|
|
391
|
+
agentWs.send(JSON.stringify({ type: 'join', projectId: getState().projectId, agent: true }));
|
|
174
392
|
}
|
|
175
393
|
});
|
|
176
394
|
|
|
@@ -178,7 +396,7 @@ async function connectAgentWs() {
|
|
|
178
396
|
console.error('[MCP-WS] Disconnected, reconnecting in 5s...');
|
|
179
397
|
agentWs = null;
|
|
180
398
|
// Server may have restarted — invalidate session so ensureSession re-clones
|
|
181
|
-
|
|
399
|
+
getState().sessionId = null;
|
|
182
400
|
clearTimeout(agentWsReconnectTimer);
|
|
183
401
|
agentWsReconnectTimer = setTimeout(() => {
|
|
184
402
|
connectAgentWs().catch((e) => {
|
|
@@ -206,10 +424,15 @@ async function joinAgentWsRoom(projectId) {
|
|
|
206
424
|
}
|
|
207
425
|
}
|
|
208
426
|
|
|
209
|
-
// Clone session and connect WebSocket on startup (delayed to let server be ready)
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
+
}
|
|
213
436
|
|
|
214
437
|
function err(error) {
|
|
215
438
|
return { content: [{ type: 'text', text: error.message || String(error) }], isError: true };
|
|
@@ -281,253 +504,271 @@ function runCLI(command, args = [], options = {}) {
|
|
|
281
504
|
|
|
282
505
|
// ── Login tools ─────────────────────────────────────────────────────
|
|
283
506
|
|
|
284
|
-
// Shared pending device code —
|
|
507
|
+
// Shared pending device code — auth(action=get_link) stores it, auth(action=login) reuses it
|
|
285
508
|
let pendingDeviceCode = null;
|
|
286
509
|
|
|
287
|
-
|
|
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 }) => {
|
|
288
513
|
try {
|
|
289
|
-
|
|
290
|
-
if (!codeRes.ok) throw new Error(`Failed to start device authorization (HTTP ${codeRes.status})`);
|
|
291
|
-
const data = await codeRes.json();
|
|
292
|
-
pendingDeviceCode = data;
|
|
293
|
-
return ok(data.verificationUrl);
|
|
294
|
-
} catch (error) { return err(error); }
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
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 () => {
|
|
298
|
-
try {
|
|
299
|
-
// Check if already authenticated
|
|
300
|
-
const existing = getBootstrapSessionId();
|
|
301
|
-
if (existing) {
|
|
302
|
-
try {
|
|
303
|
-
const res = await fetch(`${getServerUrl()}/auth/me`, {
|
|
304
|
-
headers: { Cookie: `gc_session=${existing}` },
|
|
305
|
-
});
|
|
306
|
-
if (res.ok) {
|
|
307
|
-
const me = await res.json();
|
|
308
|
-
return ok({ status: 'already_authenticated', userId: me.userId, email: me.userEmail, org: me.currentOrg?.name });
|
|
309
|
-
}
|
|
310
|
-
} catch { /* session invalid, proceed with login */ }
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
let deviceCode, verificationUrl, expiresIn;
|
|
314
|
-
let reusingPending = false;
|
|
315
|
-
|
|
316
|
-
// Reuse pending device code from get_login_link if available
|
|
317
|
-
if (pendingDeviceCode) {
|
|
318
|
-
({ deviceCode, verificationUrl, expiresIn } = pendingDeviceCode);
|
|
319
|
-
pendingDeviceCode = null;
|
|
320
|
-
reusingPending = true;
|
|
321
|
-
} else {
|
|
322
|
-
// Request a new device code
|
|
514
|
+
if (action === 'get_link') {
|
|
323
515
|
const codeRes = await fetch(`${getServerUrl()}/auth/device/code`, { method: 'POST' });
|
|
324
516
|
if (!codeRes.ok) throw new Error(`Failed to start device authorization (HTTP ${codeRes.status})`);
|
|
325
|
-
|
|
517
|
+
const data = await codeRes.json();
|
|
518
|
+
pendingDeviceCode = data;
|
|
519
|
+
return ok(data.verificationUrl);
|
|
326
520
|
}
|
|
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 */ }
|
|
533
|
+
}
|
|
327
534
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
try {
|
|
331
|
-
const qrcode = await import('qrcode-terminal');
|
|
332
|
-
qrText = await new Promise((resolve) => {
|
|
333
|
-
qrcode.default.generate(verificationUrl, { small: true }, (code) => resolve(code));
|
|
334
|
-
});
|
|
335
|
-
} catch { /* QR generation failed, continue without it */ }
|
|
336
|
-
|
|
337
|
-
// Log the URL to stderr so it's visible even if MCP response is delayed
|
|
338
|
-
console.error(`\n[MCP] Sign in at: ${verificationUrl}\n${qrText ? qrText + '\n' : ''}[MCP] Waiting for approval...`);
|
|
339
|
-
|
|
340
|
-
// Open browser only if we're not reusing a pending code (user already has the URL)
|
|
341
|
-
if (!reusingPending) {
|
|
342
|
-
const { exec } = await import('child_process');
|
|
343
|
-
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
344
|
-
exec(`${cmd} ${JSON.stringify(verificationUrl)}`);
|
|
345
|
-
}
|
|
535
|
+
let deviceCode, verificationUrl, expiresIn;
|
|
536
|
+
let reusingPending = false;
|
|
346
537
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
method: 'POST'
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
});
|
|
356
|
-
if (!res.ok) throw new Error(`Token poll failed (HTTP ${res.status})`);
|
|
357
|
-
const data = await res.json();
|
|
358
|
-
|
|
359
|
-
if (data.status === 'approved') {
|
|
360
|
-
// Write auth file
|
|
361
|
-
const { writeFileSync, mkdirSync } = await import('fs');
|
|
362
|
-
const { dirname } = await import('path');
|
|
363
|
-
mkdirSync(dirname(AUTH_FILE), { recursive: true });
|
|
364
|
-
writeFileSync(AUTH_FILE, JSON.stringify({
|
|
365
|
-
sessionId: data.sessionId,
|
|
366
|
-
userId: data.userId || null,
|
|
367
|
-
orgId: data.orgId || null,
|
|
368
|
-
server: getServerUrl(),
|
|
369
|
-
updatedAt: new Date().toISOString(),
|
|
370
|
-
}, null, 2));
|
|
371
|
-
|
|
372
|
-
// Re-clone session for this instance
|
|
373
|
-
instanceSessionId = null;
|
|
374
|
-
await cloneSession();
|
|
375
|
-
connectAgentWs();
|
|
376
|
-
|
|
377
|
-
return ok({ status: 'logged_in', sessionId: data.sessionId, userId: data.userId });
|
|
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());
|
|
378
546
|
}
|
|
379
547
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
});
|
|
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 */ }
|
|
386
555
|
|
|
387
|
-
|
|
556
|
+
console.error(`\n[MCP] Sign in at: ${verificationUrl}\n${qrText ? qrText + '\n' : ''}[MCP] Waiting for approval...`);
|
|
388
557
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
// Inject favorited skills so the agent knows about them from the first call
|
|
394
|
-
try {
|
|
395
|
-
const favData = await api('GET', '/api/skills/favorites');
|
|
396
|
-
const favs = favData.skills || [];
|
|
397
|
-
if (favs.length > 0) {
|
|
398
|
-
data.favoritedSkills = favs.map(s => ({
|
|
399
|
-
id: s.id,
|
|
400
|
-
name: s.name,
|
|
401
|
-
slug: s.slug,
|
|
402
|
-
description: s.description,
|
|
403
|
-
tags: s.tags,
|
|
404
|
-
}));
|
|
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)}`);
|
|
405
562
|
}
|
|
406
|
-
} catch { /* skills not available */ }
|
|
407
|
-
return ok(data);
|
|
408
|
-
} catch (error) { return err(error); }
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
server.tool('create_project', {
|
|
412
|
-
name: z.string().describe('Project name'),
|
|
413
|
-
description: z.string().optional().describe('Project description'),
|
|
414
|
-
templateSlug: z.string().optional().describe('Template slug (e.g. "web-design", "mobile-app", "landing-page")'),
|
|
415
|
-
}, async ({ name, description, templateSlug }) => {
|
|
416
|
-
try {
|
|
417
|
-
const body = { name };
|
|
418
|
-
if (description) body.description = description;
|
|
419
|
-
if (templateSlug) body.templateSlug = templateSlug;
|
|
420
|
-
return ok(await api('POST', '/api/projects', body));
|
|
421
|
-
} catch (error) { return err(error); }
|
|
422
|
-
});
|
|
423
563
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
}
|
|
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
|
+
}
|
|
428
593
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
description: z.string().describe('Template description'),
|
|
432
|
-
layers: z.array(z.object({}).passthrough()).describe('Array of layer definitions'),
|
|
433
|
-
visibility: z.string().optional().describe('Visibility: "org" or "public"'),
|
|
434
|
-
}, async ({ name, description, layers, visibility }) => {
|
|
435
|
-
try {
|
|
436
|
-
const body = { name, description, layers };
|
|
437
|
-
if (visibility) body.visibility = visibility;
|
|
438
|
-
return ok(await api('POST', '/api/templates', body));
|
|
439
|
-
} catch (error) { return err(error); }
|
|
440
|
-
});
|
|
594
|
+
if (data.status === 'expired') throw new Error('Device code expired. Try again.');
|
|
595
|
+
}
|
|
441
596
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
description: z.string().optional().describe('Template description'),
|
|
446
|
-
layers: z.array(z.object({}).passthrough()).optional().describe('Array of layer definitions'),
|
|
447
|
-
visibility: z.string().optional().describe('Visibility: "org" or "public"'),
|
|
448
|
-
}, async ({ templateId, name, description, layers, visibility }) => {
|
|
449
|
-
try {
|
|
450
|
-
const body = {};
|
|
451
|
-
if (name) body.name = name;
|
|
452
|
-
if (description) body.description = description;
|
|
453
|
-
if (layers) body.layers = layers;
|
|
454
|
-
if (visibility) body.visibility = visibility;
|
|
455
|
-
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}`);
|
|
456
600
|
} catch (error) { return err(error); }
|
|
457
601
|
});
|
|
458
602
|
|
|
459
|
-
|
|
460
|
-
templateId: z.string().describe('Template ID to delete'),
|
|
461
|
-
}, async ({ templateId }) => {
|
|
462
|
-
try { return ok(await api('DELETE', `/api/templates/${templateId}`)); }
|
|
463
|
-
catch (error) { return err(error); }
|
|
464
|
-
});
|
|
603
|
+
// ── Project management tools (direct HTTP) ────────────────────────
|
|
465
604
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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) => {
|
|
470
616
|
try {
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
|
|
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
|
+
}
|
|
474
719
|
} catch (error) { return err(error); }
|
|
475
720
|
});
|
|
476
721
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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) => {
|
|
481
730
|
try {
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
} catch { /* fall back to projectId */ }
|
|
493
|
-
const url = `${base}/project/${projectSlug}`;
|
|
494
|
-
let navigated = 0;
|
|
495
|
-
if (!skipBrowser) {
|
|
496
|
-
// Navigate existing browser tabs instead of opening new ones
|
|
497
|
-
try {
|
|
498
|
-
const nav = await api('POST', '/api/project/navigate', { projectId });
|
|
499
|
-
navigated = nav.navigated || 0;
|
|
500
|
-
} catch { /* server may not support navigate yet */ }
|
|
501
|
-
// Only open a new tab if no browser tabs were reached
|
|
502
|
-
if (navigated === 0) {
|
|
503
|
-
const { exec } = await import('child_process');
|
|
504
|
-
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
505
|
-
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));
|
|
506
741
|
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
for (const s of projectSkillsList) {
|
|
518
|
-
try {
|
|
519
|
-
const full = await api('GET', `/api/skills/${s.id}`);
|
|
520
|
-
s.content = full.content;
|
|
521
|
-
s.files = full.files || [];
|
|
522
|
-
} catch { /* skip */ }
|
|
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));
|
|
523
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}`);
|
|
524
767
|
}
|
|
525
|
-
|
|
526
|
-
return ok({ ...result, url, opened: true, navigated, skills: projectSkillsList });
|
|
527
768
|
} catch (error) { return err(error); }
|
|
528
769
|
});
|
|
529
770
|
|
|
530
|
-
|
|
771
|
+
tool('focus', {
|
|
531
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.'),
|
|
532
773
|
}, async ({ target }) => {
|
|
533
774
|
try {
|
|
@@ -550,175 +791,159 @@ server.tool('focus', {
|
|
|
550
791
|
} catch (error) { return err(error); }
|
|
551
792
|
});
|
|
552
793
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
const uuidMatch = target.match(/^[a-f0-9-]{36}$/);
|
|
563
|
-
let frameId = frameUrlMatch?.[1] || (uuidMatch ? target : null);
|
|
564
|
-
|
|
565
|
-
if (!frameId) {
|
|
566
|
-
const parts = target.replace(/^\/+/, '').split('/');
|
|
567
|
-
if (parts.length !== 3) throw new Error('Target must be a frame URL, frame ID, or path /{layer}/{lane}/{filename}');
|
|
568
|
-
const frame = await api('GET', `/api/fs/${parts[0]}/${parts[1]}/${parts[2]}`);
|
|
569
|
-
if (!frame.id) throw new Error('Frame not found');
|
|
570
|
-
frameId = frame.id;
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
const url = `${getServerUrl()}/api/screenshot/${frameId}?width=${width}&height=${height}&fullPage=${fullPage}`;
|
|
574
|
-
const res = await fetch(url, { headers: getAuthHeaders() });
|
|
575
|
-
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
|
|
576
|
-
|
|
577
|
-
const buffer = await res.arrayBuffer();
|
|
578
|
-
const base64 = Buffer.from(buffer).toString('base64');
|
|
579
|
-
|
|
580
|
-
return {
|
|
581
|
-
content: [{
|
|
582
|
-
type: 'image',
|
|
583
|
-
data: base64,
|
|
584
|
-
mimeType: 'image/png',
|
|
585
|
-
}],
|
|
586
|
-
};
|
|
587
|
-
} catch (error) { return err(error); }
|
|
588
|
-
});
|
|
589
|
-
|
|
590
|
-
server.tool('update_project', {
|
|
591
|
-
projectId: z.string().describe('Project ID to update'),
|
|
592
|
-
name: z.string().optional().describe('New project name'),
|
|
593
|
-
folder: z.string().nullable().optional().describe('Folder name (null to remove from folder)'),
|
|
594
|
-
description: z.string().nullable().optional().describe('Project description'),
|
|
595
|
-
layers: z.array(z.object({}).passthrough()).optional().describe('Full layers array replacement. Use ls / to read current layers first.'),
|
|
596
|
-
}, async ({ projectId, name, folder, description, layers }) => {
|
|
597
|
-
try {
|
|
598
|
-
const body = {};
|
|
599
|
-
if (name !== undefined) body.name = name;
|
|
600
|
-
if (folder !== undefined) body.folder = folder;
|
|
601
|
-
if (description !== undefined) body.description = description;
|
|
602
|
-
if (layers) body.layers = layers;
|
|
603
|
-
if (Object.keys(body).length === 0) throw new Error('At least one field (name, folder, description, layers) is required');
|
|
604
|
-
return ok(await api('PATCH', `/api/project/${projectId}`, body));
|
|
605
|
-
} catch (error) { return err(error); }
|
|
606
|
-
});
|
|
607
|
-
|
|
608
|
-
server.tool('add_layer', {
|
|
609
|
-
projectId: z.string().describe('Project ID to add the layer to'),
|
|
610
|
-
key: z.string().describe('Unique layer key (e.g. "research", "prototypes")'),
|
|
611
|
-
label: z.string().describe('Display label for the layer'),
|
|
612
|
-
type: z.string().describe('Layer type (e.g. "html", "image", "text")'),
|
|
613
|
-
width: z.number().describe('Default frame width in pixels'),
|
|
614
|
-
height: z.number().describe('Default frame height in pixels'),
|
|
615
|
-
description: z.string().optional().describe('Layer description'),
|
|
616
|
-
prompt: z.string().optional().describe('Prompt hint for AI agents working in this layer'),
|
|
617
|
-
}, async ({ projectId, key, label, type, width, height, description, prompt }) => {
|
|
618
|
-
try {
|
|
619
|
-
const project = await api('GET', `/api/project/${projectId}`);
|
|
620
|
-
const layers = project.layers || [];
|
|
621
|
-
if (layers.some(l => l.key === key)) {
|
|
622
|
-
throw new Error(`Layer with key "${key}" already exists in this project`);
|
|
623
|
-
}
|
|
624
|
-
const newLayer = { key, label, type, width, height };
|
|
625
|
-
if (description !== undefined) newLayer.description = description;
|
|
626
|
-
if (prompt !== undefined) newLayer.prompt = prompt;
|
|
627
|
-
layers.push(newLayer);
|
|
628
|
-
return ok(await api('PATCH', `/api/project/${projectId}`, { layers }));
|
|
629
|
-
} catch (error) { return err(error); }
|
|
630
|
-
});
|
|
631
|
-
|
|
632
|
-
server.tool('remove_layer', {
|
|
633
|
-
projectId: z.string().describe('Project ID to remove the layer from'),
|
|
634
|
-
key: z.string().describe('Layer key to remove (e.g. "research", "prototypes")'),
|
|
635
|
-
force: z.boolean().optional().default(false).describe('Force removal even if the layer contains frames'),
|
|
636
|
-
}, async ({ projectId, key, force }) => {
|
|
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) => {
|
|
637
803
|
try {
|
|
638
|
-
const
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
throw new Error(
|
|
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
|
+
};
|
|
642
826
|
}
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
if (frameCount > 0) {
|
|
652
|
-
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;
|
|
653
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
|
+
};
|
|
654
844
|
}
|
|
655
|
-
|
|
656
|
-
return ok(await api('PATCH', `/api/project/${projectId}`, { layers: filtered }));
|
|
845
|
+
throw new Error(`Unknown screenshot scope: ${scope}`);
|
|
657
846
|
} catch (error) { return err(error); }
|
|
658
847
|
});
|
|
659
848
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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) => {
|
|
664
862
|
try {
|
|
665
|
-
const
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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}`);
|
|
687
942
|
}
|
|
688
|
-
|
|
689
|
-
const reorderedLayers = keys.map(k => currentLayers.find(l => l.key === k));
|
|
690
|
-
return ok(await api('PATCH', `/api/project/${projectId}`, { layers: reorderedLayers }));
|
|
691
943
|
} catch (error) { return err(error); }
|
|
692
944
|
});
|
|
693
945
|
|
|
694
|
-
|
|
695
|
-
projectId: z.string().describe('Project ID containing the layer'),
|
|
696
|
-
key: z.string().describe('Layer key to update (e.g. "wireframes", "designs")'),
|
|
697
|
-
label: z.string().optional().describe('Display label'),
|
|
698
|
-
type: z.string().optional().describe('Layer type'),
|
|
699
|
-
width: z.number().optional().describe('Default frame width'),
|
|
700
|
-
height: z.number().optional().describe('Default frame height'),
|
|
701
|
-
description: z.string().optional().describe('Layer description'),
|
|
702
|
-
prompt: z.string().optional().describe('Layer prompt'),
|
|
703
|
-
}, async ({ projectId, key, label, type, width, height, description, prompt }) => {
|
|
704
|
-
try {
|
|
705
|
-
const project = await api('GET', `/api/project/${projectId}`);
|
|
706
|
-
const layers = project.layers || project.project?.layers;
|
|
707
|
-
if (!Array.isArray(layers)) throw new Error('Project has no layers array');
|
|
708
|
-
|
|
709
|
-
const idx = layers.findIndex(l => l.key === key);
|
|
710
|
-
if (idx === -1) throw new Error(`Layer with key "${key}" not found`);
|
|
711
|
-
|
|
712
|
-
const updates = { label, type, width, height, description, prompt };
|
|
713
|
-
const filtered = Object.fromEntries(Object.entries(updates).filter(([, v]) => v !== undefined));
|
|
714
|
-
if (Object.keys(filtered).length === 0) throw new Error('At least one field (label, type, width, height, description, prompt) is required');
|
|
715
|
-
|
|
716
|
-
layers[idx] = { ...layers[idx], ...filtered };
|
|
717
|
-
return ok(await api('PATCH', `/api/project/${projectId}`, { layers }));
|
|
718
|
-
} catch (error) { return err(error); }
|
|
719
|
-
});
|
|
720
|
-
|
|
721
|
-
server.tool('get_org', {}, async () => {
|
|
946
|
+
tool('get_org', {}, async () => {
|
|
722
947
|
try {
|
|
723
948
|
const data = await api('GET', '/api/orgs');
|
|
724
949
|
const orgs = data.orgs || data || [];
|
|
@@ -746,154 +971,207 @@ server.tool('get_org', {}, async () => {
|
|
|
746
971
|
} catch (error) { return err(error); }
|
|
747
972
|
});
|
|
748
973
|
|
|
749
|
-
server.tool('search', {
|
|
750
|
-
query: z.string().describe('Search term to match against frame names'),
|
|
751
|
-
projectId: z.string().optional().describe('Limit search to a specific project (optional)'),
|
|
752
|
-
}, async ({ query, projectId }) => {
|
|
753
|
-
try {
|
|
754
|
-
const params = new URLSearchParams({ q: query });
|
|
755
|
-
if (projectId) params.set('projectId', projectId);
|
|
756
|
-
await ensureSession();
|
|
757
|
-
const url = `${getServerUrl()}/api/search?${params.toString()}`;
|
|
758
|
-
const res = await fetch(url, { headers: getAuthHeaders() });
|
|
759
|
-
if (!res.ok) throw new Error(`Search failed: ${res.status}`);
|
|
760
|
-
const results = await res.json();
|
|
761
|
-
return ok(results.map(r => ({
|
|
762
|
-
id: r.id,
|
|
763
|
-
path: `/${r.layer}/${r.lane}/${r.label}`,
|
|
764
|
-
project: r.projectName,
|
|
765
|
-
projectId: r.projectId,
|
|
766
|
-
frameUrl: `${getServerUrl()}/f/${r.id}`,
|
|
767
|
-
contentType: r.contentType,
|
|
768
|
-
updatedAt: r.updatedAt,
|
|
769
|
-
})));
|
|
770
|
-
} catch (error) { return err(error); }
|
|
771
|
-
});
|
|
772
|
-
|
|
773
974
|
// ── Filesystem tools (direct HTTP to /api/fs) ─────────────────────
|
|
774
975
|
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
// Validate: exactly one of content or file_path
|
|
786
|
-
if (content != null && file_path) throw new Error('Provide content OR file_path, not both');
|
|
787
|
-
if (content == null && !file_path) throw new Error('Provide content or file_path');
|
|
788
|
-
|
|
789
|
-
const anchorErr = await checkAnchors(parseLayer(path));
|
|
790
|
-
if (anchorErr) return err(new Error(anchorErr));
|
|
791
|
-
const parts = path.replace(/^\/+/, '').split('/');
|
|
792
|
-
if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
|
|
793
|
-
|
|
794
|
-
let body;
|
|
795
|
-
if (file_path) {
|
|
796
|
-
const resolved = resolve(file_path);
|
|
797
|
-
if (!existsSync(resolved)) throw new Error(`File not found: ${resolved}`);
|
|
798
|
-
const ext = extname(resolved).toLowerCase();
|
|
799
|
-
const TEXT_EXTS = ['.html', '.htm', '.svg', '.md', '.markdown', '.txt', '.css', '.js', '.mjs', '.json', '.xml'];
|
|
800
|
-
if (TEXT_EXTS.includes(ext)) {
|
|
801
|
-
// Text file: read as content so it's stored inline (enables base tag injection for HTML)
|
|
802
|
-
body = { content: readFileSync(resolved, 'utf8') };
|
|
803
|
-
if (autoSize) body.autoSize = true;
|
|
804
|
-
} else {
|
|
805
|
-
// Binary upload: read file from disk, send as base64
|
|
806
|
-
const buffer = readFileSync(resolved);
|
|
807
|
-
const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.pdf': 'application/pdf' };
|
|
808
|
-
body = { base64: buffer.toString('base64'), contentType: MIME[ext] || 'application/octet-stream' };
|
|
809
|
-
}
|
|
810
|
-
} else {
|
|
811
|
-
body = { content };
|
|
812
|
-
if (autoSize) body.autoSize = true;
|
|
813
|
-
}
|
|
814
|
-
if (width) body.width = width;
|
|
815
|
-
if (height) body.height = height;
|
|
816
|
-
if (color) body.color = color;
|
|
817
|
-
const result = await api('PUT', `/api/fs/${parts[0]}/${parts[1]}/${parts[2]}`, body);
|
|
818
|
-
return ok(result);
|
|
819
|
-
} catch (error) { return err(error); }
|
|
820
|
-
});
|
|
821
|
-
|
|
822
|
-
server.tool('read', 'Read a frame from the ACTIVE PROJECT. Response includes "project" field confirming which project was read.', {
|
|
823
|
-
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.'),
|
|
824
|
-
lines: z.string().optional().describe('Line range (e.g. "1-50", "80-120"). Omit to read all.'),
|
|
825
|
-
}, async ({ path, lines }) => {
|
|
826
|
-
try {
|
|
827
|
-
const query = lines ? `?lines=${encodeURIComponent(lines)}` : '';
|
|
828
|
-
|
|
829
|
-
// Detect frame URL (e.g. http://host/f/{uuid}) or bare UUID
|
|
830
|
-
const frameUrlMatch = path.match(/\/f\/([a-f0-9-]{36})/);
|
|
831
|
-
const uuidMatch = path.match(/^[a-f0-9-]{36}$/);
|
|
832
|
-
const frameId = frameUrlMatch?.[1] || (uuidMatch ? path : null);
|
|
833
|
-
|
|
834
|
-
let result;
|
|
835
|
-
if (frameId) {
|
|
836
|
-
result = await api('GET', `/api/fs/by-id/${frameId}${query}`);
|
|
837
|
-
} else {
|
|
838
|
-
const parts = path.replace(/^\/+/, '').split('/');
|
|
839
|
-
if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}, a frame URL, or a frame ID');
|
|
840
|
-
result = await api('GET', `/api/fs/${parts[0]}/${parts[1]}/${parts[2]}${query}`);
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
// Track full reads (no line range) for anchor enforcement
|
|
844
|
-
if (!lines && result.ok && result.id) {
|
|
845
|
-
readFrameIds.add(result.id);
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
return ok(result.content || result);
|
|
849
|
-
} catch (error) { return err(error); }
|
|
850
|
-
});
|
|
851
|
-
|
|
852
|
-
server.tool('edit', 'Edit a frame in the ACTIVE PROJECT using hashline operations. Response includes "project" field. Use open_project first if needed.', {
|
|
853
|
-
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).'),
|
|
854
986
|
operations: z.array(z.object({
|
|
855
987
|
type: z.enum(['replace', 'delete', 'insertAfter', 'insertBefore']).describe('Edit type'),
|
|
856
|
-
lineHash: z.string().describe('4-char hash of the target line (from read output). Each line has a unique hash
|
|
988
|
+
lineHash: z.string().describe('4-char hash of the target line (from read output). Each line has a unique hash.'),
|
|
857
989
|
newContent: z.string().optional().describe('New content (for replace, insertAfter, insertBefore)'),
|
|
858
|
-
})).describe('
|
|
859
|
-
|
|
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) => {
|
|
860
998
|
try {
|
|
861
|
-
const
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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
|
+
}
|
|
865
1116
|
} catch (error) { return err(error); }
|
|
866
1117
|
});
|
|
867
1118
|
|
|
868
|
-
|
|
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.', {
|
|
869
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).'),
|
|
870
|
-
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.'),
|
|
871
1122
|
summary: z.boolean().optional().describe('Include size, updatedAt, title for frames'),
|
|
872
1123
|
pattern: z.string().optional().describe('Glob pattern to filter filenames (e.g. "*.html")'),
|
|
873
|
-
|
|
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 }) => {
|
|
874
1126
|
try {
|
|
875
1127
|
const params = new URLSearchParams();
|
|
876
1128
|
params.set('path', path);
|
|
877
|
-
|
|
878
|
-
|
|
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
|
+
}
|
|
879
1137
|
if (pattern) params.set('pattern', pattern);
|
|
880
1138
|
const result = await api('GET', `/api/fs/?${params.toString()}`);
|
|
881
|
-
return ok(result);
|
|
882
|
-
} catch (error) { return err(error); }
|
|
883
|
-
});
|
|
884
1139
|
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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 });
|
|
892
1170
|
} catch (error) { return err(error); }
|
|
893
1171
|
});
|
|
894
1172
|
|
|
895
|
-
|
|
896
|
-
path: z.string().describe('Path to delete: /{layer}/{lane}/{filename} or /{layer}/{lane} (deletes entire lane).
|
|
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).'),
|
|
897
1175
|
}, async ({ path }) => {
|
|
898
1176
|
try {
|
|
899
1177
|
const anchorErr = await checkAnchors(parseLayer(path));
|
|
@@ -904,21 +1182,9 @@ server.tool('rm', 'Delete a frame or lane from the ACTIVE PROJECT. Response incl
|
|
|
904
1182
|
} catch (error) { return err(error); }
|
|
905
1183
|
});
|
|
906
1184
|
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
}, async ({ from, to }) => {
|
|
911
|
-
try {
|
|
912
|
-
const fromErr = await checkAnchors(parseLayer(from));
|
|
913
|
-
if (fromErr) return err(new Error(fromErr));
|
|
914
|
-
const toErr = await checkAnchors(parseLayer(to));
|
|
915
|
-
if (toErr) return err(new Error(toErr));
|
|
916
|
-
const result = await api('POST', '/api/fs/mv', { from, to });
|
|
917
|
-
return ok(result);
|
|
918
|
-
} catch (error) { return err(error); }
|
|
919
|
-
});
|
|
920
|
-
|
|
921
|
-
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.', {
|
|
922
1188
|
operations: z.array(z.object({
|
|
923
1189
|
tool: z.enum(['write', 'rm', 'mv', 'edit', 'upload_asset']).describe('Tool to execute'),
|
|
924
1190
|
path: z.string().optional().describe('Path (for write, rm, edit)'),
|
|
@@ -980,8 +1246,8 @@ server.tool('batch', 'Batch operations on the ACTIVE PROJECT. Response includes
|
|
|
980
1246
|
const body = { base64: b64, contentType: ct };
|
|
981
1247
|
if (op.frame_id) body.frameId = op.frame_id;
|
|
982
1248
|
|
|
983
|
-
const projectId =
|
|
984
|
-
if (!projectId) throw new Error('No active project. Call
|
|
1249
|
+
const projectId = getState().projectId;
|
|
1250
|
+
if (!projectId) throw new Error('No active project. Call project(action="open") first.');
|
|
985
1251
|
const r = await api('PUT', `/api/projects/${projectId}/assets/${op.asset_path}`, body);
|
|
986
1252
|
results.push({ ok: true, tool: 'upload_asset', asset_path: op.asset_path, ...r });
|
|
987
1253
|
} catch (e) {
|
|
@@ -1009,285 +1275,289 @@ server.tool('batch', 'Batch operations on the ACTIVE PROJECT. Response includes
|
|
|
1009
1275
|
});
|
|
1010
1276
|
|
|
1011
1277
|
const batchResult = await api('POST', '/api/fs/batch', { operations: resolvedOps });
|
|
1012
|
-
if (batchResult.results)
|
|
1278
|
+
if (batchResult.results) {
|
|
1279
|
+
for (const r of batchResult.results) {
|
|
1280
|
+
results.push(withFrameBreadcrumb(r));
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1013
1283
|
}
|
|
1014
1284
|
|
|
1015
1285
|
return ok({ ok: true, results });
|
|
1016
1286
|
} catch (error) { return err(error); }
|
|
1017
1287
|
});
|
|
1288
|
+
*/
|
|
1018
1289
|
|
|
1019
1290
|
// ── Asset tools ──────────────────────────────────────────────────
|
|
1020
1291
|
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
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) => {
|
|
1029
1301
|
try {
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
if (
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
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));
|
|
1048
1328
|
}
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
const result = await api('PUT', `/api/projects/${projectId}/assets/${asset_path}`, body);
|
|
1057
|
-
return ok(result);
|
|
1058
|
-
} catch (error) { return err(error); }
|
|
1059
|
-
});
|
|
1060
|
-
|
|
1061
|
-
server.tool('list_assets', 'List all assets in the ACTIVE PROJECT, optionally filtered by frame.', {
|
|
1062
|
-
frame_id: z.string().optional().describe('Filter assets by frame ID'),
|
|
1063
|
-
}, async ({ frame_id }) => {
|
|
1064
|
-
try {
|
|
1065
|
-
const projectId = agentActiveProjectId;
|
|
1066
|
-
if (!projectId) throw new Error('No active project. Call open_project first.');
|
|
1067
|
-
const query = frame_id ? `?frameId=${frame_id}` : '';
|
|
1068
|
-
const result = await api('GET', `/api/projects/${projectId}/assets${query}`);
|
|
1069
|
-
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}`);
|
|
1070
1335
|
} catch (error) { return err(error); }
|
|
1071
1336
|
});
|
|
1072
1337
|
|
|
1073
1338
|
// ── Shape tool ───────────────────────────────────────────────────────
|
|
1074
1339
|
|
|
1075
|
-
|
|
1076
|
-
text: z.string().describe('Text to display inside the shape'),
|
|
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.'),
|
|
1077
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)'),
|
|
1078
1343
|
layer: z.string().optional().describe('Layer to place the shape in (default: plans)'),
|
|
1079
1344
|
lane: z.string().optional().describe('Lane within the layer (default: default)'),
|
|
1080
1345
|
color: z.string().optional().describe('Border color (CSS color string)'),
|
|
1081
|
-
|
|
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 }) => {
|
|
1082
1353
|
try {
|
|
1083
1354
|
const body = { text, shape };
|
|
1084
1355
|
if (layer) body.layer = layer;
|
|
1085
1356
|
if (lane) body.lane = lane;
|
|
1086
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;
|
|
1087
1364
|
const result = await api('POST', '/api/fs/shape', body);
|
|
1088
1365
|
return ok(result);
|
|
1089
1366
|
} catch (error) { return err(error); }
|
|
1090
1367
|
});
|
|
1091
|
-
// ── Connector tools ───────────────────────────────────────────────
|
|
1092
1368
|
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
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 }) => {
|
|
1100
1377
|
try {
|
|
1101
|
-
const body = {
|
|
1102
|
-
if (label) body.label = label;
|
|
1103
|
-
if (type) body.type = type;
|
|
1378
|
+
const body = { label };
|
|
1104
1379
|
if (color) body.color = color;
|
|
1105
|
-
|
|
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);
|
|
1106
1385
|
return ok(result);
|
|
1107
1386
|
} catch (error) { return err(error); }
|
|
1108
1387
|
});
|
|
1388
|
+
// ── Connector tools ───────────────────────────────────────────────
|
|
1109
1389
|
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
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) => {
|
|
1115
1399
|
try {
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
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));
|
|
1119
1409
|
}
|
|
1120
|
-
if (
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
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');
|
|
1135
1429
|
}
|
|
1136
|
-
throw new Error(
|
|
1430
|
+
throw new Error(`Unknown connector action: ${action}`);
|
|
1137
1431
|
} catch (error) { return err(error); }
|
|
1138
1432
|
});
|
|
1139
1433
|
|
|
1140
1434
|
// ── Layout tools ──────────────────────────────────────────────────
|
|
1141
1435
|
|
|
1142
|
-
|
|
1143
|
-
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)')
|
|
1144
|
-
|
|
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 }) => {
|
|
1145
1440
|
try {
|
|
1146
|
-
const
|
|
1441
|
+
const body = { direction };
|
|
1442
|
+
if (groups) body.groups = true;
|
|
1443
|
+
const result = await api('POST', '/api/layout', body);
|
|
1147
1444
|
return ok(result);
|
|
1148
1445
|
} catch (error) { return err(error); }
|
|
1149
1446
|
});
|
|
1150
1447
|
|
|
1151
|
-
// ── Skill library
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
}, async (
|
|
1173
|
-
try {
|
|
1174
|
-
const isUuid = /^[a-f0-9-]{36}$/.test(skill);
|
|
1175
|
-
const endpoint = isUuid ? `/api/skills/${skill}` : `/api/skills/slug/${skill}`;
|
|
1176
|
-
const result = await api('GET', endpoint);
|
|
1177
|
-
return ok(result);
|
|
1178
|
-
} catch (error) { return err(error); }
|
|
1179
|
-
});
|
|
1180
|
-
|
|
1181
|
-
server.tool('read_skill_file', 'Read a supporting file from a skill directory (e.g. an example, template, or config). Use load_skill first to see available files.', {
|
|
1182
|
-
skillId: z.string().describe('Skill ID'),
|
|
1183
|
-
path: z.string().describe('Relative file path within the skill (e.g. "examples/react.md")'),
|
|
1184
|
-
}, async ({ skillId, path }) => {
|
|
1185
|
-
try {
|
|
1186
|
-
const result = await api('GET', `/api/skills/${skillId}/files/${path}`);
|
|
1187
|
-
return ok(result);
|
|
1188
|
-
} catch (error) { return err(error); }
|
|
1189
|
-
});
|
|
1190
|
-
|
|
1191
|
-
server.tool('add_skill', 'Create a new skill in the org skill library. Creates a skill directory with a root SKILL.md. Use update_skill_file to add supporting files afterward.', {
|
|
1192
|
-
name: z.string().describe('Skill name'),
|
|
1193
|
-
description: z.string().describe('One-line description of what the skill does'),
|
|
1194
|
-
content: z.string().describe('Root SKILL.md content (markdown with instructions/prompts)'),
|
|
1195
|
-
tags: z.array(z.string()).optional().describe('Tags for discovery'),
|
|
1196
|
-
triggerPatterns: z.array(z.string()).optional().describe('Patterns that suggest this skill (e.g. "designing a landing page", "writing tests")'),
|
|
1197
|
-
}, async ({ name, description, content, tags, triggerPatterns }) => {
|
|
1198
|
-
try {
|
|
1199
|
-
const body = { name, description, content };
|
|
1200
|
-
if (tags) body.tags = tags;
|
|
1201
|
-
if (triggerPatterns) body.triggerPatterns = triggerPatterns;
|
|
1202
|
-
const result = await api('POST', '/api/skills', body);
|
|
1203
|
-
return ok(result);
|
|
1204
|
-
} catch (error) { return err(error); }
|
|
1205
|
-
});
|
|
1206
|
-
|
|
1207
|
-
server.tool('update_skill', 'Update an existing org skill. Can change name, description, content (SKILL.md), tags, or trigger patterns. Cannot edit global skills.', {
|
|
1208
|
-
skillId: z.string().describe('Skill ID to update'),
|
|
1209
|
-
name: z.string().optional().describe('New skill name'),
|
|
1210
|
-
description: z.string().optional().describe('New description'),
|
|
1211
|
-
content: z.string().optional().describe('New root SKILL.md content'),
|
|
1212
|
-
tags: z.array(z.string()).optional().describe('Replace tags'),
|
|
1213
|
-
triggerPatterns: z.array(z.string()).optional().describe('Replace trigger patterns'),
|
|
1214
|
-
}, async ({ skillId, name, description, content, tags, triggerPatterns }) => {
|
|
1215
|
-
try {
|
|
1216
|
-
const body = {};
|
|
1217
|
-
if (name !== undefined) body.name = name;
|
|
1218
|
-
if (description !== undefined) body.description = description;
|
|
1219
|
-
if (content !== undefined) body.content = content;
|
|
1220
|
-
if (tags !== undefined) body.tags = tags;
|
|
1221
|
-
if (triggerPatterns !== undefined) body.triggerPatterns = triggerPatterns;
|
|
1222
|
-
if (Object.keys(body).length === 0) throw new Error('At least one field is required');
|
|
1223
|
-
const result = await api('PUT', `/api/skills/${skillId}`, body);
|
|
1224
|
-
return ok(result);
|
|
1225
|
-
} catch (error) { return err(error); }
|
|
1226
|
-
});
|
|
1227
|
-
|
|
1228
|
-
server.tool('update_skill_file', 'Add or update a supporting file in a skill directory. Use for examples, templates, configs -- anything beyond the root SKILL.md.', {
|
|
1229
|
-
skillId: z.string().describe('Skill ID'),
|
|
1230
|
-
path: z.string().describe('Relative file path (e.g. "examples/react.md", "templates/component.html")'),
|
|
1231
|
-
content: z.string().describe('File content'),
|
|
1232
|
-
}, async ({ skillId, path, content }) => {
|
|
1233
|
-
try {
|
|
1234
|
-
const result = await api('PUT', `/api/skills/${skillId}/files/${path}`, { content });
|
|
1235
|
-
return ok(result);
|
|
1236
|
-
} catch (error) { return err(error); }
|
|
1237
|
-
});
|
|
1238
|
-
|
|
1239
|
-
server.tool('remove_skill', 'Delete a skill from the org library. Also detaches it from all projects. Cannot delete global skills.', {
|
|
1240
|
-
skillId: z.string().describe('Skill ID to delete'),
|
|
1241
|
-
}, async ({ skillId }) => {
|
|
1242
|
-
try {
|
|
1243
|
-
const result = await api('DELETE', `/api/skills/${skillId}`);
|
|
1244
|
-
return ok(result);
|
|
1245
|
-
} catch (error) { return err(error); }
|
|
1246
|
-
});
|
|
1247
|
-
|
|
1248
|
-
server.tool('list_project_skills', 'List skills attached to the active project. These are the skills recommended for this project context.', {}, async () => {
|
|
1249
|
-
try {
|
|
1250
|
-
if (!agentActiveProjectId) throw new Error('No active project. Call open_project first.');
|
|
1251
|
-
const result = await api('GET', `/api/projects/${agentActiveProjectId}/skills`);
|
|
1252
|
-
return ok(result);
|
|
1253
|
-
} catch (error) { return err(error); }
|
|
1254
|
-
});
|
|
1255
|
-
|
|
1256
|
-
server.tool('attach_skill', 'Attach a skill to the active project. Attached skills are auto-loaded when agents open this project.', {
|
|
1257
|
-
skillId: z.string().describe('Skill ID to attach'),
|
|
1258
|
-
}, async ({ skillId }) => {
|
|
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) => {
|
|
1259
1470
|
try {
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
}
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
}
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
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
|
+
}
|
|
1291
1561
|
} catch (error) { return err(error); }
|
|
1292
1562
|
});
|
|
1293
1563
|
|
|
@@ -1304,13 +1574,20 @@ server.resource('info', 'drafted://info', {
|
|
|
1304
1574
|
text: JSON.stringify({
|
|
1305
1575
|
layers: LAYERS,
|
|
1306
1576
|
pathFormat: '/{layer}/{lane}/{filename}',
|
|
1307
|
-
tools: ['write', 'read', 'edit', 'ls', 'rm', 'mv'
|
|
1577
|
+
tools: ['write', 'read', 'edit', 'ls', 'rm', 'mv'],
|
|
1308
1578
|
}, null, 2),
|
|
1309
1579
|
}],
|
|
1310
1580
|
};
|
|
1311
1581
|
});
|
|
1312
1582
|
|
|
1583
|
+
return server;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1313
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();
|
|
1314
1591
|
|
|
1315
1592
|
const args = process.argv.slice(2);
|
|
1316
1593
|
|
|
@@ -1324,7 +1601,7 @@ async function main() {
|
|
|
1324
1601
|
sessionIdGenerator: () => randomUUID(),
|
|
1325
1602
|
});
|
|
1326
1603
|
|
|
1327
|
-
await
|
|
1604
|
+
await mcpServer.connect(transport);
|
|
1328
1605
|
|
|
1329
1606
|
const httpServer = createServer((req, res) => {
|
|
1330
1607
|
if (req.url === '/mcp') {
|
|
@@ -1341,16 +1618,31 @@ async function main() {
|
|
|
1341
1618
|
});
|
|
1342
1619
|
} else {
|
|
1343
1620
|
const transport = new StdioServerTransport();
|
|
1344
|
-
await
|
|
1621
|
+
await mcpServer.connect(transport);
|
|
1345
1622
|
}
|
|
1346
1623
|
}
|
|
1347
1624
|
|
|
1348
|
-
//
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
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
|
+
})();
|
|
1352
1637
|
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
process.
|
|
1356
|
-
|
|
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
|
+
}
|