drafted 1.2.1 → 1.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/mcp/server.mjs +421 -33
- package/package.json +2 -1
package/mcp/server.mjs
CHANGED
|
@@ -30,7 +30,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
30
30
|
// never see each other's session or active project.
|
|
31
31
|
|
|
32
32
|
const requestState = new AsyncLocalStorage();
|
|
33
|
-
const standaloneState = { sessionId: null, projectId: null };
|
|
33
|
+
const standaloneState = { sessionId: null, projectId: null, projectMeta: null };
|
|
34
34
|
|
|
35
35
|
function getState() {
|
|
36
36
|
return requestState.getStore() || standaloneState;
|
|
@@ -111,6 +111,7 @@ const TOOL_ANNOTATIONS = {
|
|
|
111
111
|
|
|
112
112
|
// Skills
|
|
113
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
|
+
wiki: { title: 'Wiki', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Per-org wiki. Markdown pages with paths as hierarchy. Dispatch by `action`.' },
|
|
114
115
|
};
|
|
115
116
|
|
|
116
117
|
function tool(name, descOrSchema, schemaOrHandler, handler) {
|
|
@@ -328,7 +329,7 @@ function ok(text, opts) {
|
|
|
328
329
|
// Build the structuredContent shape that the frame-preview widget reads.
|
|
329
330
|
// Tools that produce or return a frame (read/write/edit) call this so the
|
|
330
331
|
// model and widget see the same metadata view.
|
|
331
|
-
function frameStructuredContent(result) {
|
|
332
|
+
function frameStructuredContent(result, project = null) {
|
|
332
333
|
return {
|
|
333
334
|
id: result.id,
|
|
334
335
|
path: result.path || (result.layer && result.lane && result.label
|
|
@@ -341,6 +342,7 @@ function frameStructuredContent(result) {
|
|
|
341
342
|
width: result.width,
|
|
342
343
|
height: result.height,
|
|
343
344
|
frameUrl: result.id ? `${getServerUrl()}/f/${result.id}` : undefined,
|
|
345
|
+
project, // {id, slug, name, orgId} of the active project — visible in client UI
|
|
344
346
|
};
|
|
345
347
|
}
|
|
346
348
|
|
|
@@ -371,8 +373,19 @@ function withProjectBreadcrumb(result) {
|
|
|
371
373
|
let agentWs = null;
|
|
372
374
|
let agentWsReconnectTimer = null;
|
|
373
375
|
|
|
374
|
-
function setMcpActiveProject(projectId) {
|
|
375
|
-
getState()
|
|
376
|
+
function setMcpActiveProject(projectId, meta = null) {
|
|
377
|
+
const s = getState();
|
|
378
|
+
s.projectId = projectId;
|
|
379
|
+
s.projectMeta = meta; // { id, slug, name, orgId } — for echo on mutation responses
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Returns { id, slug, name, orgId } for the project this MCP session most
|
|
383
|
+
// recently opened — what frame mutations will actually target. Echoed on
|
|
384
|
+
// every mutation so silent cross-project drift is visible.
|
|
385
|
+
function getCurrentProjectContext() {
|
|
386
|
+
const s = getState();
|
|
387
|
+
if (!s.projectId) return null;
|
|
388
|
+
return s.projectMeta || { id: s.projectId, slug: null, name: null, orgId: null };
|
|
376
389
|
}
|
|
377
390
|
|
|
378
391
|
async function connectAgentWs() {
|
|
@@ -502,19 +515,108 @@ function invalidateProjectSkillsCache(projectId) {
|
|
|
502
515
|
if (projectId) projectSkillsCache.delete(projectId);
|
|
503
516
|
}
|
|
504
517
|
|
|
505
|
-
async function checkProjectSkills(projectId) {
|
|
518
|
+
async function checkProjectSkills(projectId, operation) {
|
|
506
519
|
if (!projectId) return null;
|
|
507
520
|
const skills = await getProjectSkills(projectId);
|
|
508
521
|
if (skills.length === 0) return null;
|
|
509
522
|
const unloaded = skills.filter(s => !loadedSkillIds.has(s.id));
|
|
510
523
|
if (unloaded.length === 0) return null;
|
|
511
524
|
|
|
525
|
+
// Fire-and-forget: tell the server so any open browser shows a toast.
|
|
526
|
+
api('POST', `/api/projects/${projectId}/skill-gate-events`, {
|
|
527
|
+
unloadedSkills: unloaded.map(s => ({ id: s.id, name: s.name, slug: s.slug })),
|
|
528
|
+
operation: operation || 'mutation',
|
|
529
|
+
agent: process.env.DRAFTED_AGENT_NAME || null,
|
|
530
|
+
}).catch(() => { /* non-fatal */ });
|
|
531
|
+
|
|
512
532
|
const lines = unloaded.map(s => ` skill(action="load", skill="${s.slug || s.id}") -- ${s.name}`);
|
|
513
533
|
return `This project has ${skills.length} attached skill(s) that must be loaded before making changes. ` +
|
|
514
534
|
`Unloaded skills:\n${lines.join('\n')}\n\n` +
|
|
515
535
|
`Load all attached skills first, then retry your operation. Skills tell you HOW to do the work — they're not optional.`;
|
|
516
536
|
}
|
|
517
537
|
|
|
538
|
+
// ── Org skill enforcement ────────────────────────────────────────
|
|
539
|
+
// Same shape as project skill enforcement but for org-attached skills.
|
|
540
|
+
// Wiki mutations require org skills to be loaded first.
|
|
541
|
+
|
|
542
|
+
let cachedOrgId = null;
|
|
543
|
+
let cachedOrgIdTime = 0;
|
|
544
|
+
|
|
545
|
+
async function getCurrentOrgId() {
|
|
546
|
+
const ctx = await getCurrentOrgContext();
|
|
547
|
+
return ctx?.id || null;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Returns { id, name } for the org bound to THIS MCP session. Each session is
|
|
551
|
+
// independent on purpose — parallel agents can run on different orgs.
|
|
552
|
+
async function getCurrentOrgContext() {
|
|
553
|
+
if (cachedOrgId && Date.now() - cachedOrgIdTime < 30000) return cachedOrgId;
|
|
554
|
+
try {
|
|
555
|
+
const data = await api('GET', '/auth/me');
|
|
556
|
+
if (!data?.orgId) return null;
|
|
557
|
+
cachedOrgId = { id: data.orgId, name: data.currentOrg?.name || null };
|
|
558
|
+
cachedOrgIdTime = Date.now();
|
|
559
|
+
return cachedOrgId;
|
|
560
|
+
} catch { return null; }
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async function getOrgSkills(orgId) {
|
|
564
|
+
if (!orgId) return [];
|
|
565
|
+
// No cache: gate freshness > the ~5ms HTTP roundtrip. Otherwise an attach
|
|
566
|
+
// performed mid-session won't take effect until the cached [] expires.
|
|
567
|
+
try {
|
|
568
|
+
const result = await api('GET', `/api/orgs/${orgId}/skills`);
|
|
569
|
+
return Array.isArray(result?.skills) ? result.skills : [];
|
|
570
|
+
} catch { return []; }
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function checkOrgSkills(orgId, operation) {
|
|
574
|
+
if (!orgId) return null;
|
|
575
|
+
const skills = await getOrgSkills(orgId);
|
|
576
|
+
if (skills.length === 0) return null;
|
|
577
|
+
const unloaded = skills.filter(s => !loadedSkillIds.has(s.id));
|
|
578
|
+
if (unloaded.length === 0) return null;
|
|
579
|
+
const lines = unloaded.map(s => ` skill(action="load", skill="${s.slug || s.id}") -- ${s.name}`);
|
|
580
|
+
return `This org has ${skills.length} attached skill(s) that must be loaded before making changes. ` +
|
|
581
|
+
`Unloaded skills:\n${lines.join('\n')}\n\n` +
|
|
582
|
+
`Load all attached skills first, then retry your operation. Skills tell you HOW to do the work — they're not optional.`;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ── Wiki helpers ─────────────────────────────────────────────────
|
|
586
|
+
|
|
587
|
+
function normalizeWikiPath(input) {
|
|
588
|
+
if (!input) return input;
|
|
589
|
+
return input.replace(/^\/+/, '').replace(/\.md$/, '');
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function applyHashlineOps(content, operations) {
|
|
593
|
+
let lines = content.split('\n');
|
|
594
|
+
const lineToHash = {};
|
|
595
|
+
for (let i = 0; i < lines.length; i++) {
|
|
596
|
+
lineToHash[i] = createHash('sha256').update(lines[i] || '').digest('hex').slice(0, 4);
|
|
597
|
+
}
|
|
598
|
+
const sorted = [...operations].reverse();
|
|
599
|
+
for (const op of sorted) {
|
|
600
|
+
const matches = Object.entries(lineToHash).filter(([, h]) => h === op.lineHash).map(([idx]) => parseInt(idx));
|
|
601
|
+
if (matches.length === 0) throw new Error(`Line with hash "${op.lineHash}" not found`);
|
|
602
|
+
if (matches.length > 1) throw new Error(`Ambiguous hash "${op.lineHash}" matches ${matches.length} lines`);
|
|
603
|
+
const idx = matches[0];
|
|
604
|
+
if (op.type === 'replace') { lines[idx] = op.newContent; }
|
|
605
|
+
else if (op.type === 'delete') { lines.splice(idx, 1); }
|
|
606
|
+
else if (op.type === 'insertAfter') { lines.splice(idx + 1, 0, op.newContent); }
|
|
607
|
+
else if (op.type === 'insertBefore') { lines.splice(idx, 0, op.newContent); }
|
|
608
|
+
}
|
|
609
|
+
return lines.join('\n');
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async function getTreeAsMap() {
|
|
613
|
+
const tree = await api('GET', '/api/wiki/tree');
|
|
614
|
+
const pages = tree.pages || [];
|
|
615
|
+
const pathToPage = {};
|
|
616
|
+
for (const p of pages) { pathToPage[p.path] = p; }
|
|
617
|
+
return { pages, pathToPage };
|
|
618
|
+
}
|
|
619
|
+
|
|
518
620
|
// ── CLI passthrough (for login/start/stop only) ───────────────────
|
|
519
621
|
|
|
520
622
|
function runCLI(command, args = [], options = {}) {
|
|
@@ -686,15 +788,19 @@ tool('project', 'START HERE for project management. Dispatch by `action`: list (
|
|
|
686
788
|
const { projectId, skipBrowser } = args;
|
|
687
789
|
if (!projectId) throw new Error('projectId required for action=open');
|
|
688
790
|
const result = await api('POST', '/api/project/switch', { projectId });
|
|
689
|
-
setMcpActiveProject(projectId);
|
|
690
791
|
joinAgentWsRoom(projectId);
|
|
691
792
|
const base = getServerUrl();
|
|
692
793
|
let projectSlug = projectId;
|
|
794
|
+
let projectMeta = { id: projectId, slug: null, name: null, orgId: null };
|
|
693
795
|
try {
|
|
694
796
|
const data = await api('GET', '/api/projects');
|
|
695
797
|
const proj = (data.projects || []).find(p => p.id === projectId);
|
|
696
|
-
if (proj
|
|
798
|
+
if (proj) {
|
|
799
|
+
projectSlug = proj.slug || projectId;
|
|
800
|
+
projectMeta = { id: proj.id, slug: proj.slug || null, name: proj.name || null, orgId: proj.orgId || null };
|
|
801
|
+
}
|
|
697
802
|
} catch { /* fall back to projectId */ }
|
|
803
|
+
setMcpActiveProject(projectId, projectMeta);
|
|
698
804
|
const url = `${base}/project/${projectSlug}`;
|
|
699
805
|
let navigated = 0;
|
|
700
806
|
if (!skipBrowser) {
|
|
@@ -1000,28 +1106,28 @@ tool('layer', 'Manage layers in a project. Dispatch by `action`: add/update/remo
|
|
|
1000
1106
|
|
|
1001
1107
|
tool('get_org', {}, async () => {
|
|
1002
1108
|
try {
|
|
1109
|
+
// Source of truth = THIS MCP session's bound org (what mutations will actually
|
|
1110
|
+
// hit). Each session is independent on purpose — multiple agents can run in
|
|
1111
|
+
// parallel against different orgs. /auth/me reads sessions.org_id directly.
|
|
1112
|
+
const me = await api('GET', '/auth/me');
|
|
1113
|
+
const sessionOrgId = me?.orgId || null;
|
|
1114
|
+
|
|
1003
1115
|
const data = await api('GET', '/api/orgs');
|
|
1004
|
-
const orgs = data.orgs || data || [];
|
|
1005
|
-
|
|
1006
|
-
|
|
1116
|
+
const orgs = (data.orgs || data || []).map(o => ({ id: o.orgId || o.id, name: o.orgName || o.name }));
|
|
1117
|
+
const activeOrg = sessionOrgId ? (orgs.find(o => o.id === sessionOrgId) || null) : null;
|
|
1118
|
+
|
|
1007
1119
|
let members = [];
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
if (orgId) {
|
|
1014
|
-
activeOrg = orgs.find(o => (o.orgId || o.id) === orgId);
|
|
1015
|
-
try {
|
|
1016
|
-
const memberData = await api('GET', `/api/orgs/${orgId}/members`);
|
|
1017
|
-
members = memberData.members || memberData || [];
|
|
1018
|
-
} catch { /* no members */ }
|
|
1019
|
-
}
|
|
1120
|
+
if (sessionOrgId) {
|
|
1121
|
+
try {
|
|
1122
|
+
const memberData = await api('GET', `/api/orgs/${sessionOrgId}/members`);
|
|
1123
|
+
members = memberData.members || memberData || [];
|
|
1124
|
+
} catch { /* no members */ }
|
|
1020
1125
|
}
|
|
1021
1126
|
return ok({
|
|
1022
|
-
activeOrg
|
|
1023
|
-
orgs
|
|
1127
|
+
activeOrg,
|
|
1128
|
+
orgs,
|
|
1024
1129
|
members: members.map(m => ({ id: m.userId, name: m.username, email: m.email, role: m.role })),
|
|
1130
|
+
note: "activeOrg is the org bound to THIS MCP session — what mutations will actually target. Browser tabs and other MCP sessions for the same user can be on different orgs.",
|
|
1025
1131
|
});
|
|
1026
1132
|
} catch (error) { return err(error); }
|
|
1027
1133
|
});
|
|
@@ -1052,6 +1158,10 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
1052
1158
|
}, async (args) => {
|
|
1053
1159
|
try {
|
|
1054
1160
|
const { action } = args;
|
|
1161
|
+
// Echo project context on every mutation so the agent sees where the
|
|
1162
|
+
// write landed before it can develop a wrong assumption.
|
|
1163
|
+
const projectCtx = getCurrentProjectContext();
|
|
1164
|
+
const withProject = (result) => ({ ...result, project: projectCtx });
|
|
1055
1165
|
switch (action) {
|
|
1056
1166
|
case 'read': {
|
|
1057
1167
|
const { path, lines } = args;
|
|
@@ -1075,7 +1185,7 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
1075
1185
|
// structuredContent over text when both are present and structured looks
|
|
1076
1186
|
// "complete" — so include content in BOTH places to ensure the agent
|
|
1077
1187
|
// can always see the frame's actual content, not just metadata.
|
|
1078
|
-
const structured = frameStructuredContent(result);
|
|
1188
|
+
const structured = frameStructuredContent(result, projectCtx);
|
|
1079
1189
|
structured.content = result.content ?? '';
|
|
1080
1190
|
structured.size = result.size;
|
|
1081
1191
|
structured.totalLines = result.totalLines;
|
|
@@ -1124,8 +1234,8 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
1124
1234
|
if (height) body.height = height;
|
|
1125
1235
|
if (color) body.color = color;
|
|
1126
1236
|
const result = await api('PUT', `/api/fs/${parts[0]}/${parts[1]}/${parts[2]}`, body);
|
|
1127
|
-
return ok(withFrameBreadcrumb(result, { hint: true }), {
|
|
1128
|
-
structuredContent: frameStructuredContent(result),
|
|
1237
|
+
return ok(withProject(withFrameBreadcrumb(result, { hint: true })), {
|
|
1238
|
+
structuredContent: frameStructuredContent(result, projectCtx),
|
|
1129
1239
|
_meta: body.content ? { frameHtml: body.content } : undefined,
|
|
1130
1240
|
});
|
|
1131
1241
|
}
|
|
@@ -1138,8 +1248,8 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
1138
1248
|
const anchorErr = await checkAnchors(parseLayer(path));
|
|
1139
1249
|
if (anchorErr) return err(new Error(anchorErr));
|
|
1140
1250
|
const result = await api('POST', '/api/fs/edit', { path, operations });
|
|
1141
|
-
return ok(result, {
|
|
1142
|
-
structuredContent: frameStructuredContent(result),
|
|
1251
|
+
return ok(withProject(result), {
|
|
1252
|
+
structuredContent: frameStructuredContent(result, projectCtx),
|
|
1143
1253
|
_meta: result.content ? { frameHtml: result.content } : undefined,
|
|
1144
1254
|
});
|
|
1145
1255
|
}
|
|
@@ -1152,7 +1262,7 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
1152
1262
|
if (fromErr) return err(new Error(fromErr));
|
|
1153
1263
|
const toErr = await checkAnchors(parseLayer(to));
|
|
1154
1264
|
if (toErr) return err(new Error(toErr));
|
|
1155
|
-
return ok(await api('POST', '/api/fs/mv', { from, to }));
|
|
1265
|
+
return ok(withProject(await api('POST', '/api/fs/mv', { from, to })));
|
|
1156
1266
|
}
|
|
1157
1267
|
case 'anchor': {
|
|
1158
1268
|
const { path, anchored } = args;
|
|
@@ -1160,7 +1270,7 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
1160
1270
|
if (typeof anchored !== 'boolean') throw new Error('anchored (boolean) required for action=anchor');
|
|
1161
1271
|
const skillErr = await checkProjectSkills(getState().projectId);
|
|
1162
1272
|
if (skillErr) return err(new Error(skillErr));
|
|
1163
|
-
return ok(await api('POST', '/api/fs/anchor', { path, anchored }));
|
|
1273
|
+
return ok(withProject(await api('POST', '/api/fs/anchor', { path, anchored })));
|
|
1164
1274
|
}
|
|
1165
1275
|
case 'search': {
|
|
1166
1276
|
const { query, projectId, limit = 50 } = args;
|
|
@@ -1248,7 +1358,7 @@ tool('ls', 'List contents of the ACTIVE PROJECT. Use ls / after project(action="
|
|
|
1248
1358
|
} catch (error) { return err(error); }
|
|
1249
1359
|
});
|
|
1250
1360
|
|
|
1251
|
-
tool('rm', 'Delete a frame or lane from the ACTIVE PROJECT. Response includes "project" field.', {
|
|
1361
|
+
tool('rm', 'Delete a frame or lane from the ACTIVE PROJECT. Response includes "project" field so you see where the deletion landed.', {
|
|
1252
1362
|
path: z.string().describe('Path to delete: /{layer}/{lane}/{filename} or /{layer}/{lane} (deletes entire lane).'),
|
|
1253
1363
|
}, async ({ path }) => {
|
|
1254
1364
|
try {
|
|
@@ -1258,7 +1368,7 @@ tool('rm', 'Delete a frame or lane from the ACTIVE PROJECT. Response includes "p
|
|
|
1258
1368
|
if (anchorErr) return err(new Error(anchorErr));
|
|
1259
1369
|
const clean = path.replace(/^\/+|\/+$/g, '');
|
|
1260
1370
|
const result = await api('DELETE', `/api/fs/${clean}`);
|
|
1261
|
-
return ok(result);
|
|
1371
|
+
return ok({ ...result, project: getCurrentProjectContext() });
|
|
1262
1372
|
} catch (error) { return err(error); }
|
|
1263
1373
|
});
|
|
1264
1374
|
|
|
@@ -1657,6 +1767,284 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
|
|
|
1657
1767
|
} catch (error) { return err(error); }
|
|
1658
1768
|
});
|
|
1659
1769
|
|
|
1770
|
+
// ── Wiki tool (org-scoped, no project needed) ─────────────────────
|
|
1771
|
+
// All 11 actions dispatch from one tool. Read-only actions skip the
|
|
1772
|
+
// skill gate; mutations require org-level wiki-maintainer skills loaded.
|
|
1773
|
+
|
|
1774
|
+
tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and other agents/humans share maintenance — every edit broadcasts live, and edits from others appear in `recent` and on `read`.\n\nBefore mutating: check `recent` and `search` for relevant existing pages. Before mv/rm: check `links` (or pass `dryRun=true`). After completing a logical session of work, append a `log` entry.\n\nThe tool handles bookkeeping you\'d otherwise forget: `mv` rewrites inbound references via the link index, `read` shows who edited last and when. Use `health` to find unlinked pages and broken links.\n\n**Skill gate:** the org may attach a `wiki-maintainer` skill that you MUST load before mutations. If you get a skill-gate error, run skill(action="load", skill="wiki-maintainer") then retry.', {
|
|
1775
|
+
action: z.enum(['ls', 'recent', 'read', 'search', 'links', 'log', 'health', 'write', 'edit', 'mv', 'rm']).describe('Operation to perform.'),
|
|
1776
|
+
path: z.string().optional().describe('[ls|read|links] wiki path. For ls: default / (root). For read: required. For links: required.'),
|
|
1777
|
+
recursive: z.boolean().optional().describe('[ls] list recursively with depth indicators'),
|
|
1778
|
+
limit: z.number().optional().describe('[recent|search] max results (recent default 10, search default 25)'),
|
|
1779
|
+
query: z.string().optional().describe('[search] term to search in title, path, and content'),
|
|
1780
|
+
lines: z.string().optional().describe('[read] line range (e.g. "1-50"). Omit to read all.'),
|
|
1781
|
+
message: z.string().optional().describe('[log] message to append to log.md page'),
|
|
1782
|
+
title: z.string().optional().describe('[write] page title (required for write)'),
|
|
1783
|
+
content: z.string().optional().describe('[write|edit] page content (write: full content; edit: hashline content not used — use operations)'),
|
|
1784
|
+
type: z.string().optional().describe('[write] page type (default "page")'),
|
|
1785
|
+
frontmatter: z.any().optional().describe('[write] frontmatter object'),
|
|
1786
|
+
operations: z.array(z.object({
|
|
1787
|
+
type: z.enum(['replace', 'delete', 'insertAfter', 'insertBefore']).describe('Edit type'),
|
|
1788
|
+
lineHash: z.string().describe('4-char hash of the target line (from read output)'),
|
|
1789
|
+
newContent: z.string().optional().describe('New content (for replace, insertAfter, insertBefore)'),
|
|
1790
|
+
})).optional().describe('[edit] hashline edit operations — same shape as frame.edit'),
|
|
1791
|
+
from: z.string().optional().describe('[mv] source path'),
|
|
1792
|
+
to: z.string().optional().describe('[mv] destination path'),
|
|
1793
|
+
dryRun: z.boolean().optional().describe('[mv|rm] preview impact without applying changes'),
|
|
1794
|
+
}, async (args) => {
|
|
1795
|
+
try {
|
|
1796
|
+
const { action } = args;
|
|
1797
|
+
|
|
1798
|
+
// Resolve org context once for the wiki tool. Each MCP session is bound
|
|
1799
|
+
// to one org (parallel agents on different orgs is supported by design).
|
|
1800
|
+
const orgCtx = await getCurrentOrgContext();
|
|
1801
|
+
const orgId = orgCtx?.id || null;
|
|
1802
|
+
// Mutation responses include `org` so the agent always sees where the
|
|
1803
|
+
// write landed — eliminates silent cross-org confusion.
|
|
1804
|
+
const withOrg = (result) => ({ ...result, org: orgCtx });
|
|
1805
|
+
|
|
1806
|
+
// ── Skill gate: all mutation actions ──────────────────────────
|
|
1807
|
+
const MUTATING = new Set(['write', 'edit', 'mv', 'rm', 'log']);
|
|
1808
|
+
if (MUTATING.has(action)) {
|
|
1809
|
+
const skillErr = await checkOrgSkills(orgId, action);
|
|
1810
|
+
if (skillErr) return err(new Error(skillErr));
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
switch (action) {
|
|
1814
|
+
|
|
1815
|
+
// ── ls ──────────────────────────────────────────────────────
|
|
1816
|
+
case 'ls': {
|
|
1817
|
+
const { path: lsPath = '/', recursive: lsRecursive = false } = args;
|
|
1818
|
+
const { pages, pathToPage } = await getTreeAsMap();
|
|
1819
|
+
const parent = normalizeWikiPath(lsPath);
|
|
1820
|
+
|
|
1821
|
+
if (lsRecursive) {
|
|
1822
|
+
// Full tree under path with depth
|
|
1823
|
+
const prefix = parent ? parent + '/' : '';
|
|
1824
|
+
const filtered = !parent ? pages : pages.filter(p => p.path === parent || p.path.startsWith(prefix));
|
|
1825
|
+
const tree = filtered.map(p => ({
|
|
1826
|
+
depth: !parent ? p.path.split('/').length - 1 : p.path.split('/').length - parent.split('/').length - (p.path === parent ? 1 : 0),
|
|
1827
|
+
path: p.path,
|
|
1828
|
+
title: p.title,
|
|
1829
|
+
type: p.type,
|
|
1830
|
+
id: p.id,
|
|
1831
|
+
}));
|
|
1832
|
+
return ok({ tree });
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
// Non-recursive: children of parent path
|
|
1836
|
+
if (!parent) {
|
|
1837
|
+
// Root: show top-level directories + root pages
|
|
1838
|
+
const dirs = new Set();
|
|
1839
|
+
const rootPages = [];
|
|
1840
|
+
for (const p of pages) {
|
|
1841
|
+
if (!p.path.includes('/')) { rootPages.push(p); }
|
|
1842
|
+
else { dirs.add(p.path.split('/')[0]); }
|
|
1843
|
+
}
|
|
1844
|
+
return ok({
|
|
1845
|
+
tree: [
|
|
1846
|
+
...Array.from(dirs).sort().map(d => ({ type: 'directory', name: d })),
|
|
1847
|
+
...rootPages.map(p => ({ type: 'page', path: p.path, title: p.title, id: p.id })),
|
|
1848
|
+
],
|
|
1849
|
+
});
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
// Has parent: show immediate children
|
|
1853
|
+
const children = [];
|
|
1854
|
+
const seenDirs = new Set();
|
|
1855
|
+
const prefix = parent + '/';
|
|
1856
|
+
for (const p of pages) {
|
|
1857
|
+
if (p.path === parent) {
|
|
1858
|
+
children.push({ type: 'page', path: p.path, title: p.title, id: p.id });
|
|
1859
|
+
} else if (p.path.startsWith(prefix)) {
|
|
1860
|
+
const rest = p.path.slice(prefix.length);
|
|
1861
|
+
if (rest.includes('/')) {
|
|
1862
|
+
const sub = rest.split('/')[0];
|
|
1863
|
+
if (!seenDirs.has(sub)) { seenDirs.add(sub); children.push({ type: 'directory', name: parent + '/' + sub }); }
|
|
1864
|
+
} else {
|
|
1865
|
+
children.push({ type: 'page', path: p.path, title: p.title, id: p.id });
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
return ok({ tree: children });
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
// ── recent ──────────────────────────────────────────────────
|
|
1873
|
+
case 'recent': {
|
|
1874
|
+
const { limit: recentLimit = 10 } = args;
|
|
1875
|
+
const tree = await api('GET', '/api/wiki/tree');
|
|
1876
|
+
const recent = (tree.pages || [])
|
|
1877
|
+
.filter(p => p.updatedAt)
|
|
1878
|
+
.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt))
|
|
1879
|
+
.slice(0, Math.max(1, recentLimit))
|
|
1880
|
+
.map(p => ({ path: p.path, title: p.title, updatedAt: p.updatedAt }));
|
|
1881
|
+
return ok({ pages: recent });
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
// ── read ────────────────────────────────────────────────────
|
|
1885
|
+
case 'read': {
|
|
1886
|
+
const { path: readPath, lines: readLines } = args;
|
|
1887
|
+
if (!readPath) throw new Error('path required for action=read');
|
|
1888
|
+
const normalized = normalizeWikiPath(readPath);
|
|
1889
|
+
const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`);
|
|
1890
|
+
// Client-side line slice — server returns full content; we trim if `lines` was passed.
|
|
1891
|
+
let outContent = page.content;
|
|
1892
|
+
if (readLines && typeof outContent === 'string') {
|
|
1893
|
+
const m = readLines.match(/^(\d+)-(\d+)$/);
|
|
1894
|
+
if (!m) throw new Error(`lines must be "N-M" (e.g. "10-50"), got: ${readLines}`);
|
|
1895
|
+
const [start, end] = [parseInt(m[1]), parseInt(m[2])];
|
|
1896
|
+
const allLines = outContent.split('\n');
|
|
1897
|
+
outContent = allLines.slice(Math.max(0, start - 1), end).join('\n');
|
|
1898
|
+
}
|
|
1899
|
+
// Get backlink count via search (approximate)
|
|
1900
|
+
let backlinkCount = 0;
|
|
1901
|
+
try {
|
|
1902
|
+
const searchRes = await api('GET', `/api/wiki/search?q=${encodeURIComponent(normalized)}`);
|
|
1903
|
+
backlinkCount = (searchRes.hits || []).length;
|
|
1904
|
+
} catch { /* best-effort */ }
|
|
1905
|
+
return ok({
|
|
1906
|
+
path: page.path,
|
|
1907
|
+
title: page.title,
|
|
1908
|
+
type: page.type,
|
|
1909
|
+
frontmatter: page.frontmatter,
|
|
1910
|
+
content: outContent,
|
|
1911
|
+
lastEditedBy: page.updatedBy,
|
|
1912
|
+
lastEditedAt: page.updatedAt,
|
|
1913
|
+
backlinkCount,
|
|
1914
|
+
});
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
// ── search ──────────────────────────────────────────────────
|
|
1918
|
+
case 'search': {
|
|
1919
|
+
const { query: searchQuery, limit: searchLimit = 25 } = args;
|
|
1920
|
+
if (!searchQuery) throw new Error('query required for action=search');
|
|
1921
|
+
const result = await api('GET', `/api/wiki/search?q=${encodeURIComponent(searchQuery)}&limit=${searchLimit}`);
|
|
1922
|
+
const hits = (result.hits || []).map(h => ({ path: h.path, title: h.title }));
|
|
1923
|
+
return ok({ hits });
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
// ── links ───────────────────────────────────────────────────
|
|
1927
|
+
case 'links': {
|
|
1928
|
+
const { path: linksPath } = args;
|
|
1929
|
+
if (!linksPath) throw new Error('path required for action=links');
|
|
1930
|
+
const normalized = normalizeWikiPath(linksPath);
|
|
1931
|
+
const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`);
|
|
1932
|
+
return ok(await api('GET', `/api/wiki/pages/${page.id}/links`));
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
// ── log ─────────────────────────────────────────────────────
|
|
1936
|
+
case 'log': {
|
|
1937
|
+
const { message: logMessage } = args;
|
|
1938
|
+
if (!logMessage) throw new Error('message required for action=log');
|
|
1939
|
+
const agentName = process.env.DRAFTED_AGENT_NAME || 'mcp';
|
|
1940
|
+
const dateStr = new Date().toISOString().replace('T', ' ').slice(0, 19) + 'Z';
|
|
1941
|
+
const entry = `## ${dateStr} note by ${agentName} | ${logMessage}`;
|
|
1942
|
+
|
|
1943
|
+
// Try to read existing log page
|
|
1944
|
+
let existingContent = '';
|
|
1945
|
+
let existingId = null;
|
|
1946
|
+
try {
|
|
1947
|
+
const logPage = await api('GET', '/api/wiki/page?path=log');
|
|
1948
|
+
existingContent = logPage.content || '';
|
|
1949
|
+
existingId = logPage.id;
|
|
1950
|
+
} catch {
|
|
1951
|
+
// Create new log page
|
|
1952
|
+
const created = await api('POST', '/api/wiki/pages', {
|
|
1953
|
+
path: 'log',
|
|
1954
|
+
title: 'Log',
|
|
1955
|
+
content: entry + '\n',
|
|
1956
|
+
});
|
|
1957
|
+
return ok(withOrg({ appended: true, created: true, pageId: created.id }));
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
// Append to existing log
|
|
1961
|
+
const updatedContent = (existingContent.endsWith('\n') ? existingContent : existingContent + '\n') + entry + '\n';
|
|
1962
|
+
await api('PATCH', `/api/wiki/pages/${existingId}`, { content: updatedContent });
|
|
1963
|
+
return ok(withOrg({ appended: true }));
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
// ── health ──────────────────────────────────────────────────
|
|
1967
|
+
case 'health': {
|
|
1968
|
+
return ok(await api('GET', '/api/wiki/health'));
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
// ── write ───────────────────────────────────────────────────
|
|
1972
|
+
case 'write': {
|
|
1973
|
+
const { path: writePath, title: writeTitle, content: writeContent, type: writeType, frontmatter } = args;
|
|
1974
|
+
if (!writePath) throw new Error('path required for action=write');
|
|
1975
|
+
if (!writeTitle) throw new Error('title required for action=write');
|
|
1976
|
+
const normalized = normalizeWikiPath(writePath);
|
|
1977
|
+
const body = { path: normalized, title: writeTitle };
|
|
1978
|
+
if (writeContent !== undefined) body.content = writeContent;
|
|
1979
|
+
if (writeType) body.type = writeType;
|
|
1980
|
+
if (frontmatter !== undefined) body.frontmatter = frontmatter;
|
|
1981
|
+
|
|
1982
|
+
// Check if page exists — if so, update; otherwise create
|
|
1983
|
+
try {
|
|
1984
|
+
const existing = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`);
|
|
1985
|
+
const result = await api('PATCH', `/api/wiki/pages/${existing.id}`, body);
|
|
1986
|
+
return ok(withOrg({ path: result.path, title: result.title, id: result.id, updated: true }));
|
|
1987
|
+
} catch {
|
|
1988
|
+
const result = await api('POST', '/api/wiki/pages', body);
|
|
1989
|
+
return ok(withOrg({ path: result.path, title: result.title, id: result.id, created: true }));
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
// ── edit ────────────────────────────────────────────────────
|
|
1994
|
+
case 'edit': {
|
|
1995
|
+
const { path: editPath, operations: editOps } = args;
|
|
1996
|
+
if (!editPath) throw new Error('path required for action=edit');
|
|
1997
|
+
if (!Array.isArray(editOps) || editOps.length === 0) throw new Error('operations (array) required for action=edit');
|
|
1998
|
+
const normalized = normalizeWikiPath(editPath);
|
|
1999
|
+
const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`);
|
|
2000
|
+
const newContent = applyHashlineOps(page.content || '', editOps);
|
|
2001
|
+
const result = await api('PATCH', `/api/wiki/pages/${page.id}`, { content: newContent });
|
|
2002
|
+
return ok(withOrg({ path: result.path, id: result.id, updated: true }));
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
// ── mv ──────────────────────────────────────────────────────
|
|
2006
|
+
case 'mv': {
|
|
2007
|
+
const { from: mvFrom, to: mvTo, dryRun: mvDryRun = false } = args;
|
|
2008
|
+
if (!mvFrom) throw new Error('from (source path) required for action=mv');
|
|
2009
|
+
if (!mvTo) throw new Error('to (destination path) required for action=mv');
|
|
2010
|
+
const fromPath = normalizeWikiPath(mvFrom);
|
|
2011
|
+
const toPath = normalizeWikiPath(mvTo);
|
|
2012
|
+
if (fromPath === toPath) throw new Error('source and destination are the same');
|
|
2013
|
+
|
|
2014
|
+
const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(fromPath)}`);
|
|
2015
|
+
|
|
2016
|
+
if (mvDryRun) {
|
|
2017
|
+
const { referrers } = await api('GET', `/api/wiki/pages/${page.id}/referrers`);
|
|
2018
|
+
return ok(withOrg({ impacted: referrers.map(r => ({ path: r.path, title: r.title })) }));
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
// Server-side cascade: /move rewrites referrers in one transaction
|
|
2022
|
+
const moved = await api('PATCH', `/api/wiki/pages/${page.id}/move`, { path: toPath });
|
|
2023
|
+
return ok(withOrg({ path: moved.path, title: moved.title, id: moved.id, referrersUpdated: moved.referrersUpdated ?? 0 }));
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
// ── rm ──────────────────────────────────────────────────────
|
|
2027
|
+
case 'rm': {
|
|
2028
|
+
const { path: rmPath, dryRun: rmDryRun = false } = args;
|
|
2029
|
+
if (!rmPath) throw new Error('path required for action=rm');
|
|
2030
|
+
const normalized = normalizeWikiPath(rmPath);
|
|
2031
|
+
const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`);
|
|
2032
|
+
const { referrers } = await api('GET', `/api/wiki/pages/${page.id}/referrers`);
|
|
2033
|
+
const broken = referrers.map(r => ({ path: r.path, title: r.title }));
|
|
2034
|
+
|
|
2035
|
+
if (rmDryRun) {
|
|
2036
|
+
return ok(withOrg({ brokenAfterDelete: broken }));
|
|
2037
|
+
}
|
|
2038
|
+
await api('DELETE', `/api/wiki/pages/${page.id}`);
|
|
2039
|
+
return ok(withOrg({ deleted: true, path: normalized, id: page.id, brokenReferences: broken }));
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
default:
|
|
2043
|
+
throw new Error(`Unknown wiki action: ${action}`);
|
|
2044
|
+
}
|
|
2045
|
+
} catch (error) { return err(error); }
|
|
2046
|
+
});
|
|
2047
|
+
|
|
1660
2048
|
// ── Resource: canvas info ─────────────────────────────────────────
|
|
1661
2049
|
|
|
1662
2050
|
server.resource('info', 'drafted://info', {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drafted",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.3",
|
|
4
4
|
"description": "Drafted CLI - Design preview server for Claude agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"dotenv": "^17.3.1",
|
|
41
41
|
"drizzle-orm": "^0.45.1",
|
|
42
42
|
"express": "^4.18.2",
|
|
43
|
+
"mdast-util-from-markdown": "^2.0.3",
|
|
43
44
|
"multer": "^2.1.1",
|
|
44
45
|
"nodemailer": "^8.0.2",
|
|
45
46
|
"pg": "^8.20.0",
|