drafted 1.4.0 → 1.6.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 +106 -31
- 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));
|
|
@@ -1479,8 +1545,8 @@ tool('batch', 'Batch operations on the ACTIVE PROJECT. Response includes "projec
|
|
|
1479
1545
|
|
|
1480
1546
|
// ── Asset tools ──────────────────────────────────────────────────
|
|
1481
1547
|
|
|
1482
|
-
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.', {
|
|
1483
|
-
action: z.enum(['upload', 'list']).describe('Operation to perform.'),
|
|
1548
|
+
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, `action=rm` to delete.', {
|
|
1549
|
+
action: z.enum(['upload', 'list', 'rm']).describe('Operation to perform.'),
|
|
1484
1550
|
asset_path: z.string().optional().describe('[upload] relative asset path (e.g. "css/styles.css"). Must match the path used in HTML references.'),
|
|
1485
1551
|
file_path: z.string().optional().describe('[upload] absolute path to a local file. Mutually exclusive with content/base64.'),
|
|
1486
1552
|
content: z.string().optional().describe('[upload] text content (for CSS/JS). Mutually exclusive with file_path/base64.'),
|
|
@@ -1523,6 +1589,14 @@ tool('asset', 'Manage supporting files (CSS, JS, images, fonts) in the ACTIVE PR
|
|
|
1523
1589
|
const query = frame_id ? `?frameId=${frame_id}` : '';
|
|
1524
1590
|
return ok(await api('GET', `/api/projects/${projectId}/assets${query}`));
|
|
1525
1591
|
}
|
|
1592
|
+
if (action === 'rm') {
|
|
1593
|
+
const skillErr = await checkProjectSkills(projectId);
|
|
1594
|
+
if (skillErr) return err(new Error(skillErr));
|
|
1595
|
+
const { asset_path } = args;
|
|
1596
|
+
if (!asset_path) throw new Error('asset_path is required for action=rm');
|
|
1597
|
+
if (asset_path.includes('..')) throw new Error('asset_path must not contain ".."');
|
|
1598
|
+
return ok(await api('DELETE', `/api/projects/${projectId}/assets/${asset_path}`));
|
|
1599
|
+
}
|
|
1526
1600
|
throw new Error(`Unknown asset action: ${action}`);
|
|
1527
1601
|
} catch (error) { return err(error); }
|
|
1528
1602
|
});
|
|
@@ -1693,7 +1767,7 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
|
|
|
1693
1767
|
const isUuid = /^[a-f0-9-]{36}$/.test(skill);
|
|
1694
1768
|
const endpoint = isUuid ? `/api/skills/${skill}` : `/api/skills/slug/${skill}`;
|
|
1695
1769
|
const result = await api('GET', endpoint);
|
|
1696
|
-
if (result?.id) loadedSkillIds.add(result.id);
|
|
1770
|
+
if (result?.id) getSessionState().loadedSkillIds.add(result.id);
|
|
1697
1771
|
return ok(result);
|
|
1698
1772
|
}
|
|
1699
1773
|
case 'list': {
|
|
@@ -1894,20 +1968,18 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
1894
1968
|
}
|
|
1895
1969
|
|
|
1896
1970
|
// ── read ────────────────────────────────────────────────────
|
|
1971
|
+
// Returns content in hashline format (`lineNum:hash|content`) so the
|
|
1972
|
+
// agent can produce hashline edit operations. Mirrors frame.read.
|
|
1897
1973
|
case 'read': {
|
|
1898
1974
|
const { path: readPath, lines: readLines } = args;
|
|
1899
1975
|
if (!readPath) throw new Error('path required for action=read');
|
|
1900
1976
|
const normalized = normalizeWikiPath(readPath);
|
|
1901
|
-
const
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
const m = readLines.match(/^(\d+)-(\d+)$/);
|
|
1906
|
-
if (!m) throw new Error(`lines must be "N-M" (e.g. "10-50"), got: ${readLines}`);
|
|
1907
|
-
const [start, end] = [parseInt(m[1]), parseInt(m[2])];
|
|
1908
|
-
const allLines = outContent.split('\n');
|
|
1909
|
-
outContent = allLines.slice(Math.max(0, start - 1), end).join('\n');
|
|
1977
|
+
const params = new URLSearchParams({ path: normalized, format: 'hashline' });
|
|
1978
|
+
if (readLines) {
|
|
1979
|
+
if (!/^\d+-\d+$/.test(readLines)) throw new Error(`lines must be "N-M" (e.g. "10-50"), got: ${readLines}`);
|
|
1980
|
+
params.set('lines', readLines);
|
|
1910
1981
|
}
|
|
1982
|
+
const page = await api('GET', `/api/wiki/page?${params.toString()}`);
|
|
1911
1983
|
// Get backlink count via search (approximate)
|
|
1912
1984
|
let backlinkCount = 0;
|
|
1913
1985
|
try {
|
|
@@ -1919,7 +1991,8 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
1919
1991
|
title: page.title,
|
|
1920
1992
|
type: page.type,
|
|
1921
1993
|
frontmatter: page.frontmatter,
|
|
1922
|
-
content:
|
|
1994
|
+
content: page.content,
|
|
1995
|
+
totalLines: page.totalLines,
|
|
1923
1996
|
lastEditedBy: page.updatedBy,
|
|
1924
1997
|
lastEditedAt: page.updatedAt,
|
|
1925
1998
|
backlinkCount,
|
|
@@ -2003,15 +2076,17 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
2003
2076
|
}
|
|
2004
2077
|
|
|
2005
2078
|
// ── edit ────────────────────────────────────────────────────
|
|
2079
|
+
// Hashlines come from `read` (which now formats content as
|
|
2080
|
+
// `lineNum:hash|content`). The server applies the ops via the same
|
|
2081
|
+
// hashline algorithm — no client-side re-hashing, no algorithm drift.
|
|
2006
2082
|
case 'edit': {
|
|
2007
2083
|
const { path: editPath, operations: editOps } = args;
|
|
2008
2084
|
if (!editPath) throw new Error('path required for action=edit');
|
|
2009
2085
|
if (!Array.isArray(editOps) || editOps.length === 0) throw new Error('operations (array) required for action=edit');
|
|
2010
2086
|
const normalized = normalizeWikiPath(editPath);
|
|
2011
2087
|
const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`);
|
|
2012
|
-
const
|
|
2013
|
-
|
|
2014
|
-
return ok(withOrg({ path: result.path, id: result.id, updated: true }));
|
|
2088
|
+
const result = await api('POST', `/api/wiki/pages/${page.id}/edit`, { operations: editOps });
|
|
2089
|
+
return ok(withOrg({ path: result.path, id: result.id, updated: true, applied: result.applied }));
|
|
2015
2090
|
}
|
|
2016
2091
|
|
|
2017
2092
|
// ── mv ──────────────────────────────────────────────────────
|