drafted 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/mcp/server.mjs +125 -18
- package/package.json +1 -1
package/mcp/server.mjs
CHANGED
|
@@ -32,12 +32,54 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
32
32
|
const requestState = new AsyncLocalStorage();
|
|
33
33
|
const standaloneState = { sessionId: null, projectId: null, projectMeta: null };
|
|
34
34
|
|
|
35
|
+
// Per-MCP-session sticky state. Keyed by Clerk sessionId (HTTP /mcp route)
|
|
36
|
+
// or '__stdio__' for the long-lived stdio process. Holds active-project,
|
|
37
|
+
// loaded-skill, and cached-org state that must persist across HTTP request
|
|
38
|
+
// boundaries so a `project open` followed by `frame.read` in a separate
|
|
39
|
+
// request still has an active project.
|
|
40
|
+
//
|
|
41
|
+
// Using a per-session bucket also fixes a multi-tenant correctness bug: with
|
|
42
|
+
// module-global state, user A's loaded skills and cached orgId would leak
|
|
43
|
+
// into user B's request, and the gate would check the wrong org's skills.
|
|
44
|
+
const sessionStates = new Map();
|
|
45
|
+
|
|
46
|
+
function getOrCreateSessionState(sid) {
|
|
47
|
+
const key = sid || '__stdio__';
|
|
48
|
+
let s = sessionStates.get(key);
|
|
49
|
+
if (!s) {
|
|
50
|
+
s = {
|
|
51
|
+
activeProjectId: null,
|
|
52
|
+
activeProjectMeta: null,
|
|
53
|
+
loadedSkillIds: new Set(),
|
|
54
|
+
cachedOrgId: null,
|
|
55
|
+
cachedOrgIdTime: 0,
|
|
56
|
+
wsSessionId: null,
|
|
57
|
+
};
|
|
58
|
+
sessionStates.set(key, s);
|
|
59
|
+
}
|
|
60
|
+
return s;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getSessionState() {
|
|
64
|
+
const rs = requestState.getStore();
|
|
65
|
+
if (rs && rs._session) return rs._session;
|
|
66
|
+
return getOrCreateSessionState(null); // stdio fallback
|
|
67
|
+
}
|
|
68
|
+
|
|
35
69
|
function getState() {
|
|
36
70
|
return requestState.getStore() || standaloneState;
|
|
37
71
|
}
|
|
38
72
|
|
|
39
73
|
export function runWithRequestState(initial, fn) {
|
|
40
|
-
|
|
74
|
+
const session = getOrCreateSessionState(initial.sessionId);
|
|
75
|
+
// Hydrate request state from session: if the caller didn't pass an explicit
|
|
76
|
+
// projectId, fall back to the active project remembered for this session.
|
|
77
|
+
const merged = { ...standaloneState, ...initial, _session: session };
|
|
78
|
+
if (merged.projectId == null && session.activeProjectId) {
|
|
79
|
+
merged.projectId = session.activeProjectId;
|
|
80
|
+
merged.projectMeta = session.activeProjectMeta;
|
|
81
|
+
}
|
|
82
|
+
return requestState.run(merged, fn);
|
|
41
83
|
}
|
|
42
84
|
|
|
43
85
|
// ── MCP server factory ────────────────────────────────────────────
|
|
@@ -377,6 +419,12 @@ function setMcpActiveProject(projectId, meta = null) {
|
|
|
377
419
|
const s = getState();
|
|
378
420
|
s.projectId = projectId;
|
|
379
421
|
s.projectMeta = meta; // { id, slug, name, orgId } — for echo on mutation responses
|
|
422
|
+
// Persist to session state so subsequent HTTP requests in the same MCP
|
|
423
|
+
// session inherit the active project (without this, every call after
|
|
424
|
+
// project.open in HTTP mode would start with projectId=null again).
|
|
425
|
+
const sess = getSessionState();
|
|
426
|
+
sess.activeProjectId = projectId;
|
|
427
|
+
sess.activeProjectMeta = meta;
|
|
380
428
|
}
|
|
381
429
|
|
|
382
430
|
// Returns { id, slug, name, orgId } for the project this MCP session most
|
|
@@ -494,7 +542,10 @@ async function checkAnchors(layer) {
|
|
|
494
542
|
// ── Project skill enforcement ─────────────────────────────────────
|
|
495
543
|
// Skills attached to a project must be loaded by the agent before any
|
|
496
544
|
// mutating operation in that project. Same shape as anchor enforcement.
|
|
497
|
-
|
|
545
|
+
//
|
|
546
|
+
// Loaded-skill state is per-MCP-session (see getSessionState) so that
|
|
547
|
+
// (a) skill.load + wiki.write across separate HTTP requests stay coherent
|
|
548
|
+
// and (b) one user's loads never satisfy another user's gate.
|
|
498
549
|
const projectSkillsCache = new Map(); // projectId -> { data, time }
|
|
499
550
|
|
|
500
551
|
async function getProjectSkills(projectId) {
|
|
@@ -519,7 +570,8 @@ async function checkProjectSkills(projectId, operation) {
|
|
|
519
570
|
if (!projectId) return null;
|
|
520
571
|
const skills = await getProjectSkills(projectId);
|
|
521
572
|
if (skills.length === 0) return null;
|
|
522
|
-
const
|
|
573
|
+
const loaded = getSessionState().loadedSkillIds;
|
|
574
|
+
const unloaded = skills.filter(s => !loaded.has(s.id));
|
|
523
575
|
if (unloaded.length === 0) return null;
|
|
524
576
|
|
|
525
577
|
// Fire-and-forget: tell the server so any open browser shows a toast.
|
|
@@ -539,24 +591,23 @@ async function checkProjectSkills(projectId, operation) {
|
|
|
539
591
|
// Same shape as project skill enforcement but for org-attached skills.
|
|
540
592
|
// Wiki mutations require org skills to be loaded first.
|
|
541
593
|
|
|
542
|
-
let cachedOrgId = null;
|
|
543
|
-
let cachedOrgIdTime = 0;
|
|
544
|
-
|
|
545
594
|
async function getCurrentOrgId() {
|
|
546
595
|
const ctx = await getCurrentOrgContext();
|
|
547
596
|
return ctx?.id || null;
|
|
548
597
|
}
|
|
549
598
|
|
|
550
599
|
// 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.
|
|
600
|
+
// independent on purpose — parallel agents can run on different orgs. Cache
|
|
601
|
+
// is per-session, not module-global, so concurrent OAuth users can't collide.
|
|
552
602
|
async function getCurrentOrgContext() {
|
|
553
|
-
|
|
603
|
+
const sess = getSessionState();
|
|
604
|
+
if (sess.cachedOrgId && Date.now() - sess.cachedOrgIdTime < 30000) return sess.cachedOrgId;
|
|
554
605
|
try {
|
|
555
606
|
const data = await api('GET', '/auth/me');
|
|
556
607
|
if (!data?.orgId) return null;
|
|
557
|
-
cachedOrgId = { id: data.orgId, name: data.currentOrg?.name || null };
|
|
558
|
-
cachedOrgIdTime = Date.now();
|
|
559
|
-
return cachedOrgId;
|
|
608
|
+
sess.cachedOrgId = { id: data.orgId, name: data.currentOrg?.name || null };
|
|
609
|
+
sess.cachedOrgIdTime = Date.now();
|
|
610
|
+
return sess.cachedOrgId;
|
|
560
611
|
} catch { return null; }
|
|
561
612
|
}
|
|
562
613
|
|
|
@@ -574,7 +625,8 @@ async function checkOrgSkills(orgId, operation) {
|
|
|
574
625
|
if (!orgId) return null;
|
|
575
626
|
const skills = await getOrgSkills(orgId);
|
|
576
627
|
if (skills.length === 0) return null;
|
|
577
|
-
const
|
|
628
|
+
const loaded = getSessionState().loadedSkillIds;
|
|
629
|
+
const unloaded = skills.filter(s => !loaded.has(s.id));
|
|
578
630
|
if (unloaded.length === 0) return null;
|
|
579
631
|
const lines = unloaded.map(s => ` skill(action="load", skill="${s.slug || s.id}") -- ${s.name}`);
|
|
580
632
|
return `This org has ${skills.length} attached skill(s) that must be loaded before making changes. ` +
|
|
@@ -828,7 +880,7 @@ tool('project', 'START HERE for project management. Dispatch by `action`: list (
|
|
|
828
880
|
s.files = full.files || [];
|
|
829
881
|
// Skill content was inlined in this response — count it as loaded
|
|
830
882
|
// so the project-skill gate doesn't immediately demand a re-load.
|
|
831
|
-
loadedSkillIds.add(s.id);
|
|
883
|
+
getSessionState().loadedSkillIds.add(s.id);
|
|
832
884
|
} catch { /* skip */ }
|
|
833
885
|
}
|
|
834
886
|
}
|
|
@@ -838,7 +890,8 @@ tool('project', 'START HERE for project management. Dispatch by `action`: list (
|
|
|
838
890
|
|
|
839
891
|
const responseExtras = { url, opened: true, navigated, skills: projectSkillsList };
|
|
840
892
|
if (projectSkillsList.length > 3) {
|
|
841
|
-
const
|
|
893
|
+
const sessionLoaded = getSessionState().loadedSkillIds;
|
|
894
|
+
const unloaded = projectSkillsList.filter(s => !sessionLoaded.has(s.id));
|
|
842
895
|
if (unloaded.length > 0) {
|
|
843
896
|
responseExtras.skillGateNotice =
|
|
844
897
|
`This project has ${projectSkillsList.length} attached skills (too many to auto-inline). ` +
|
|
@@ -1151,6 +1204,7 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
1151
1204
|
})).optional().describe('[edit] hashline edit operations'),
|
|
1152
1205
|
from: z.string().optional().describe('[mv] source path /{layer}/{lane}/{filename}'),
|
|
1153
1206
|
to: z.string().optional().describe('[mv] destination path /{layer}/{lane}/{filename}'),
|
|
1207
|
+
dryRun: z.boolean().optional().describe('[mv] preview the move without applying it; returns the resolved frame and current path so you can confirm before retrying with dryRun=false.'),
|
|
1154
1208
|
anchored: z.boolean().optional().describe('[anchor] true to anchor, false to unanchor. Anchored frames MUST be read before writing/editing in the same layer.'),
|
|
1155
1209
|
query: z.string().optional().describe('[search] term to match against frame names'),
|
|
1156
1210
|
projectId: z.string().optional().describe('[search] limit to a specific project (optional)'),
|
|
@@ -1254,8 +1308,20 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
1254
1308
|
});
|
|
1255
1309
|
}
|
|
1256
1310
|
case 'mv': {
|
|
1257
|
-
const { from, to } = args;
|
|
1311
|
+
const { from, to, dryRun = false } = args;
|
|
1258
1312
|
if (!from || !to) throw new Error('from and to required for action=mv');
|
|
1313
|
+
if (dryRun) {
|
|
1314
|
+
// Resolve current frame at `from`. Don't apply the rename, just
|
|
1315
|
+
// confirm the source exists and report what `to` will become.
|
|
1316
|
+
const current = await api('GET', `/api/fs?path=${encodeURIComponent(from)}`);
|
|
1317
|
+
return ok(withProject({
|
|
1318
|
+
dryRun: true,
|
|
1319
|
+
from,
|
|
1320
|
+
to,
|
|
1321
|
+
currentFrame: current?.frame || current,
|
|
1322
|
+
note: 'No changes applied. Re-call with dryRun=false (or omit) to perform the rename.',
|
|
1323
|
+
}));
|
|
1324
|
+
}
|
|
1259
1325
|
const skillErr = await checkProjectSkills(getState().projectId);
|
|
1260
1326
|
if (skillErr) return err(new Error(skillErr));
|
|
1261
1327
|
const fromErr = await checkAnchors(parseLayer(from));
|
|
@@ -1693,7 +1759,7 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
|
|
|
1693
1759
|
const isUuid = /^[a-f0-9-]{36}$/.test(skill);
|
|
1694
1760
|
const endpoint = isUuid ? `/api/skills/${skill}` : `/api/skills/slug/${skill}`;
|
|
1695
1761
|
const result = await api('GET', endpoint);
|
|
1696
|
-
if (result?.id) loadedSkillIds.add(result.id);
|
|
1762
|
+
if (result?.id) getSessionState().loadedSkillIds.add(result.id);
|
|
1697
1763
|
return ok(result);
|
|
1698
1764
|
}
|
|
1699
1765
|
case 'list': {
|
|
@@ -1772,7 +1838,7 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
|
|
|
1772
1838
|
// skill gate; mutations require org-level wiki-maintainer skills loaded.
|
|
1773
1839
|
|
|
1774
1840
|
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', 'source-register', 'source-list', 'source-get']).describe('Operation to perform.'),
|
|
1841
|
+
action: z.enum(['ls', 'recent', 'read', 'search', 'links', 'log', 'health', 'write', 'edit', 'mv', 'rm', 'source-register', 'source-list', 'source-get', 'bulk-write']).describe('Operation to perform.'),
|
|
1776
1842
|
path: z.string().optional().describe('[ls|read|links] wiki path. For ls: default / (root). For read: required. For links: required.'),
|
|
1777
1843
|
recursive: z.boolean().optional().describe('[ls] list recursively with depth indicators'),
|
|
1778
1844
|
limit: z.number().optional().describe('[recent|search] max results (recent default 10, search default 25)'),
|
|
@@ -1797,6 +1863,8 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
1797
1863
|
contentType: z.string().optional().describe('[source-register] MIME type (informational)'),
|
|
1798
1864
|
size: z.number().optional().describe('[source-register] byte size (informational)'),
|
|
1799
1865
|
sourceId: z.string().optional().describe('[source-get] source ID returned from source-register'),
|
|
1866
|
+
manifest_path: z.string().optional().describe('[bulk-write] absolute path to a JSON file containing {pages: [{path, title, content, type?, frontmatter?}, ...]}. The MCP wrapper reads the file locally and posts the array to the server in a single request — avoids round-tripping every page through tool args.'),
|
|
1867
|
+
pages: z.array(z.any()).optional().describe('[bulk-write] alternative to manifest_path: pass the pages array inline.'),
|
|
1800
1868
|
}, async (args) => {
|
|
1801
1869
|
try {
|
|
1802
1870
|
const { action } = args;
|
|
@@ -1813,7 +1881,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
1813
1881
|
// Ensure the wiki-maintainer skill is attached to this org BEFORE the
|
|
1814
1882
|
// gate check, so the gate fires reliably on the very first wiki call —
|
|
1815
1883
|
// not just after the org has visited /wiki in a browser. Idempotent.
|
|
1816
|
-
const MUTATING = new Set(['write', 'edit', 'mv', 'rm', 'log', 'source-register']);
|
|
1884
|
+
const MUTATING = new Set(['write', 'edit', 'mv', 'rm', 'log', 'source-register', 'bulk-write']);
|
|
1817
1885
|
if (MUTATING.has(action)) {
|
|
1818
1886
|
try { await api('POST', '/api/wiki/_ensure-skill'); } catch { /* non-fatal */ }
|
|
1819
1887
|
const skillErr = await checkOrgSkills(orgId, action);
|
|
@@ -2049,6 +2117,45 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
2049
2117
|
return ok(withOrg({ deleted: true, path: normalized, id: page.id, brokenReferences: broken }));
|
|
2050
2118
|
}
|
|
2051
2119
|
|
|
2120
|
+
// ── bulk-write ──────────────────────────────────────────────
|
|
2121
|
+
// Commit many pages in a single transaction. The MCP wrapper reads
|
|
2122
|
+
// the manifest file locally and posts the pages array inline to
|
|
2123
|
+
// /api/wiki/pages/bulk — avoids paying a tool-call round-trip per
|
|
2124
|
+
// page and keeps the orchestrator's context clean.
|
|
2125
|
+
case 'bulk-write': {
|
|
2126
|
+
const { manifest_path, pages: inlinePages } = args;
|
|
2127
|
+
let pages;
|
|
2128
|
+
if (manifest_path) {
|
|
2129
|
+
if (!existsSync(manifest_path)) throw new Error(`manifest not found: ${manifest_path}`);
|
|
2130
|
+
const text = readFileSync(manifest_path, 'utf8');
|
|
2131
|
+
let parsed;
|
|
2132
|
+
try { parsed = JSON.parse(text); } catch (e) { throw new Error(`invalid JSON in ${manifest_path}: ${e.message}`); }
|
|
2133
|
+
// Accept either {pages: [...]} or a bare array
|
|
2134
|
+
pages = Array.isArray(parsed) ? parsed : parsed.pages;
|
|
2135
|
+
} else if (Array.isArray(inlinePages)) {
|
|
2136
|
+
pages = inlinePages;
|
|
2137
|
+
} else {
|
|
2138
|
+
throw new Error('bulk-write requires either manifest_path or pages array');
|
|
2139
|
+
}
|
|
2140
|
+
if (!Array.isArray(pages) || pages.length === 0) throw new Error('pages array is empty');
|
|
2141
|
+
// Strip surplus fields the server ignores; keep payload tight.
|
|
2142
|
+
const payload = pages.map((p) => ({
|
|
2143
|
+
path: p.path,
|
|
2144
|
+
title: p.title,
|
|
2145
|
+
content: p.content,
|
|
2146
|
+
type: p.type,
|
|
2147
|
+
frontmatter: p.frontmatter,
|
|
2148
|
+
}));
|
|
2149
|
+
const result = await api('POST', '/api/wiki/pages/bulk', { pages: payload });
|
|
2150
|
+
return ok(withOrg({
|
|
2151
|
+
created: result.created?.length ?? 0,
|
|
2152
|
+
updated: result.updated?.length ?? 0,
|
|
2153
|
+
createdPaths: (result.created ?? []).map((r) => r.path),
|
|
2154
|
+
updatedPaths: (result.updated ?? []).map((r) => r.path),
|
|
2155
|
+
errors: result.errors ?? [],
|
|
2156
|
+
}));
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2052
2159
|
// ── source-register ────────────────────────────────────────
|
|
2053
2160
|
// Register an external source (paper, article, transcript) by content
|
|
2054
2161
|
// hash. Idempotent: same hash + same org returns the existing source
|