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.
Files changed (2) hide show
  1. package/mcp/server.mjs +106 -31
  2. 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
- return requestState.run({ ...standaloneState, ...initial }, fn);
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
- const loadedSkillIds = new Set();
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 unloaded = skills.filter(s => !loadedSkillIds.has(s.id));
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
- if (cachedOrgId && Date.now() - cachedOrgIdTime < 30000) return cachedOrgId;
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 unloaded = skills.filter(s => !loadedSkillIds.has(s.id));
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 unloaded = projectSkillsList.filter(s => !loadedSkillIds.has(s.id));
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 page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`);
1902
- // Client-side line slice — server returns full content; we trim if `lines` was passed.
1903
- let outContent = page.content;
1904
- if (readLines && typeof outContent === 'string') {
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: outContent,
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 newContent = applyHashlineOps(page.content || '', editOps);
2013
- const result = await api('PATCH', `/api/wiki/pages/${page.id}`, { content: newContent });
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 ──────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Drafted CLI - Design preview server for Claude agents",
5
5
  "type": "module",
6
6
  "files": [