drafted 1.24.1 → 1.24.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.
Files changed (2) hide show
  1. package/mcp/server.mjs +131 -17
  2. package/package.json +1 -1
package/mcp/server.mjs CHANGED
@@ -485,6 +485,7 @@ const TOOL_ANNOTATIONS = {
485
485
 
486
486
  // Canvas / view
487
487
  mail: { title: 'Connected email threads', readOnlyHint: false, destructiveHint: false, openWorldHint: false, description: 'Connect a Gmail thread to a project so a reply to it is noticed. Drafted never reads mail: the user\'s own computer checks connected threads with their read-only gws sign-in and tells Drafted only that a new message arrived. Dispatch by `action`: connect (project, gmailThreadId, title, onReply), list (project), set (id, onReply/title), handled (id: clear the New reply mark after you dealt with it), disconnect (id). onReply: notify = mark it and tell the user; work = also start the user\'s agent on it. Read the thread itself with gws, and send any reply with action(propose, type="send_email").' },
488
+ frame_db: { title: 'Frame documents', readOnlyHint: false, destructiveHint: false, openWorldHint: false, description: 'Read and write an app frame\'s documents — the same store the frame\'s own JavaScript uses through `drafted.db`. Dispatch by `action`: list, get, set, merge, delete. Documents are addressed by (frame, collection, id); a write can carry `expectedUpdatedAt` so a stale write is refused instead of overwriting. Authorization is the frame\'s own: access to the frame, edit access for writes, and the `db` capability its author granted.' },
488
489
  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.' },
489
490
  tour: { title: 'Guided tours', readOnlyHint: false, destructiveHint: false, openWorldHint: false, description: 'Play or stop an agent-authored guided tour (driver.js) on the project surface for all connected clients. Tours live on a frame\'s metadata.tour — author them with fs(write, metadata={tour: {title, steps}}). Dispatch by `action`: play (frameId) starts the walkthrough, stop (projectId) ends it. Use for presentations and collaborative walkthroughs.' },
490
491
  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.' },
@@ -1616,21 +1617,47 @@ async function pathOrgMatches(orgSegment, meta) {
1616
1617
  return !!hit && meta?.orgId === hit.id;
1617
1618
  }
1618
1619
 
1619
- async function resolveProjectRef(ref) {
1620
+ /**
1621
+ * Fetch /api/projects with NO org scope.
1622
+ *
1623
+ * api() otherwise stamps the session's working org on every request, so a
1624
+ * listing meant to answer "which projects exist in the org this PATH names"
1625
+ * came back scoped to the org the session happened to be working in, and the
1626
+ * org filter then removed all of it. An empty header is how the server is told
1627
+ * "unaddressed" (req.get('x-drafted-org') is falsy) without a server change.
1628
+ */
1629
+ const NO_ORG_SCOPE = { 'X-Drafted-Org': '' };
1630
+
1631
+ async function resolveProjectRef(ref, orgHint = null) {
1620
1632
  if (!ref) return null;
1621
- const hit = projectRefCache.get(ref);
1633
+ const cacheKey = orgHint ? `${orgHint}|${ref}` : ref;
1634
+ const hit = projectRefCache.get(cacheKey) || (!orgHint && projectRefCache.get(ref));
1622
1635
  if (hit) return hit;
1623
- const data = await api('GET', '/api/projects');
1624
- const list = Array.isArray(data?.projects) ? data.projects : [];
1636
+ // Scope the lookup to the org the path named. Without a hint the session's
1637
+ // org applies — which is right for `fs ls /projects` but WRONG for a path
1638
+ // that addressed another org, and that mismatch is what made a project
1639
+ // created in org B unresolvable from a session working in org A.
1640
+ const scoped = orgHint ? await api('GET', '/api/projects', undefined, { 'X-Drafted-Org': orgHint }) : await api('GET', '/api/projects');
1641
+ let list = Array.isArray(scoped?.projects) ? scoped.projects : [];
1625
1642
  const want = String(ref).toLowerCase();
1626
- const p =
1627
- list.find(x => x.id === ref) ||
1628
- list.find(x => String(x.slug || '').toLowerCase() === want) ||
1629
- list.find(x => String(x.name || '').toLowerCase() === want);
1643
+ const pick = (rows) =>
1644
+ rows.find(x => x.id === ref) ||
1645
+ rows.find(x => String(x.slug || '').toLowerCase() === want) ||
1646
+ rows.find(x => String(x.name || '').toLowerCase() === want);
1647
+ let p = pick(list);
1648
+ // A miss is worth one unscoped look before "not found": the hint may be stale
1649
+ // (a project that moved org), and an id passed as projectId= carries no org at
1650
+ // all — only a UUID can name a project in an org the session is not inside.
1651
+ if (!p && (orgHint || /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(ref)))) {
1652
+ const all = await api('GET', '/api/projects', undefined, NO_ORG_SCOPE);
1653
+ list = Array.isArray(all?.projects) ? all.projects : [];
1654
+ p = pick(list);
1655
+ }
1630
1656
  if (!p) return null;
1631
1657
  // `folder` rides along so a caller can rebuild the project's printed address
1632
1658
  // (/o/<org>/projects/<folder>/<slug>) without a second lookup.
1633
1659
  const meta = { id: p.id, slug: p.slug || null, name: p.name || null, orgId: p.orgId || null, orgSlug: p.orgSlug || null, folder: p.folder || '' };
1660
+ projectRefCache.set(cacheKey, meta);
1634
1661
  projectRefCache.set(ref, meta);
1635
1662
  projectRefCache.set(p.id, meta);
1636
1663
  return meta;
@@ -1645,13 +1672,15 @@ async function resolveProjectRef(ref) {
1645
1672
  *
1646
1673
  * One /api/projects fetch for the whole scan, not one per candidate.
1647
1674
  */
1648
- async function resolveFolderQualifiedProject(parts) {
1675
+ async function resolveFolderQualifiedProject(parts, orgHint = null) {
1649
1676
  // NOT `.catch(() => null)`. Swallowing the fetch turns "the session is gated"
1650
1677
  // or "the server is down" into "project not found", which is a wrong DIAGNOSIS,
1651
1678
  // not just a lost detail — it sends you hunting for a project instead of
1652
1679
  // reading the gate. Observed once for real: an unnamed session after an MCP
1653
1680
  // restart reported `project not found: Marketing/beoflow-marketing`.
1654
- const data = await api('GET', '/api/projects');
1681
+ // Same org rule as resolveProjectRef: a folder-qualified address names an org,
1682
+ // so the lookup has to be made in it rather than in the working org.
1683
+ const data = await api('GET', '/api/projects', undefined, orgHint ? { 'X-Drafted-Org': orgHint } : NO_ORG_SCOPE);
1655
1684
  const list = Array.isArray(data?.projects) ? data.projects : [];
1656
1685
  const norm = (s) => String(s || '').toLowerCase();
1657
1686
  for (let i = 1; i < parts.length; i++) {
@@ -3055,6 +3084,80 @@ tool('mail', {
3055
3084
  } catch (error) { return err(error); }
3056
3085
  });
3057
3086
 
3087
+ tool('frame_db', `Read and write an app frame's documents — the same store the frame's own JavaScript uses through \`drafted.db\`.
3088
+
3089
+ An app frame has a document store now (collection → document, one document per row of whatever the app shows). This tool is the agent's side of it: seeding a list, reading what the team has recorded, correcting a row. Before it existed the store was reachable only from the frame's own code or a raw authenticated HTTP call.
3090
+
3091
+ Authorization is the frame's own: the caller must have access to the frame, writes need edit access, and the frame's author must have granted the \`db\` capability. A read-only share can READ (the data is what they were invited to look at) and is refused on writes.
3092
+
3093
+ Actions:
3094
+ list — documents in one collection (limit/offset, ordered by updatedAt or id)
3095
+ get — one document
3096
+ set — replace one document's data (creates it)
3097
+ merge — shallow-merge fields into one document
3098
+ delete — remove one document
3099
+
3100
+ \`expectedUpdatedAt\` is optimistic concurrency: pass the document's updatedAt as you last read it and a stale write is refused with code \`conflict\` plus the current document, instead of silently overwriting somebody's change. Omit it for a blind write.`, {
3101
+ action: z.enum(['list', 'get', 'set', 'merge', 'delete']).describe('the operation'),
3102
+ target: z.string().describe('the frame: its URL (…/o/<org>/projects/<project>/<layer>/<lane>/<file>), a /projects path, or a frame UUID'),
3103
+ collection: z.string().describe('the collection name, e.g. "tracking" — letters, digits and _ . : - only'),
3104
+ id: z.string().optional().describe('document id, required for get/set/merge/delete'),
3105
+ data: z.any().optional().describe('[set/merge] the document body: plain JSON, at most 64KB. merge is a shallow merge, so nested objects are replaced, not merged'),
3106
+ expectedUpdatedAt: z.string().optional().describe("the document updatedAt as you last read it; a stale write is refused with code \"conflict\" rather than overwriting"),
3107
+ limit: z.number().int().min(1).max(500).optional().describe('[list] page size (default 100, max 500)'),
3108
+ offset: z.number().int().min(0).optional().describe('[list] page offset — the cursor for walking a large collection'),
3109
+ orderBy: z.enum(['updatedAt', 'id']).optional().describe('[list] ordering key (default updatedAt)'),
3110
+ dir: z.enum(['asc', 'desc']).optional().describe('[list] direction (default desc)'),
3111
+ }, async (args) => {
3112
+ const { action, target, collection, id, data, expectedUpdatedAt, limit, offset, orderBy, dir } = args;
3113
+ if (!target) return err(new Error('frame_db needs a target: the frame URL, /projects path, or frame id'));
3114
+ if (!collection) return err(new Error('frame_db needs a collection'));
3115
+
3116
+ // The org is in the target when the target is a path; a bare UUID has none and
3117
+ // resolves in the session's org. Same rule as fs: the address carries the scope.
3118
+ const orgInTarget = (String(target).match(/\/o\/([^/]+)\//) || [])[1] || null;
3119
+ const orgHeader = orgInTarget ? { 'X-Drafted-Org': decodeURIComponent(orgInTarget) } : {};
3120
+
3121
+ let frameId;
3122
+ try {
3123
+ ({ frameId } = await resolveFsFramePath(target));
3124
+ } catch (e) {
3125
+ return err(new Error(`${e.message}${orgInTarget ? '' : ' — include the /o/<org>/… URL when the frame is not in the workspace you are working in'}`));
3126
+ }
3127
+
3128
+ const base = `/api/frame-runtime/${frameId}/db/${encodeURIComponent(collection)}`;
3129
+ const q = [];
3130
+ if (limit != null) q.push('limit=' + limit);
3131
+ if (offset != null) q.push('offset=' + offset);
3132
+ if (orderBy) q.push('orderBy=' + encodeURIComponent(orderBy));
3133
+ if (dir) q.push('dir=' + encodeURIComponent(dir));
3134
+ const qs = q.length ? '?' + q.join('&') : '';
3135
+
3136
+ try {
3137
+ if (action === 'list') {
3138
+ const res = await api('GET', base + qs, undefined, orgHeader);
3139
+ return ok(res);
3140
+ }
3141
+ if (!id) return err(new Error(`frame_db ${action} needs a document id`));
3142
+ const docPath = `${base}/${encodeURIComponent(id)}`;
3143
+ if (action === 'get') return ok(await api('GET', docPath, undefined, orgHeader));
3144
+ if (action === 'delete') return ok(await api('DELETE', docPath, undefined, orgHeader));
3145
+ if (data === undefined) return err(new Error(`frame_db ${action} needs data`));
3146
+ const res = await api(action === 'merge' ? 'PATCH' : 'PUT', docPath, { data, expectedUpdatedAt }, orgHeader);
3147
+ return ok(res);
3148
+ } catch (e) {
3149
+ // A conflict carries the current document; a locked frame names the holder.
3150
+ // Flattening either into "HTTP 409" would hide the thing the caller acts on.
3151
+ if (e && (e.code === 'conflict' || e.code === 'locked' || e.code === 'quota_exceeded' || e.code === 'not_granted')) {
3152
+ // api() attaches the parsed body: the conflict carries the CURRENT document
3153
+ // and the lock carries its holder. Both are what the caller acts on.
3154
+ const detail = e.body ? JSON.stringify(e.body).slice(0, 800) : '';
3155
+ return err(new Error(`${e.code}: ${e.message}${detail ? ' — ' + detail : ''}`));
3156
+ }
3157
+ throw e;
3158
+ }
3159
+ });
3160
+
3058
3161
  tool('focus', {
3059
3162
  target: z.string().describe('What to pan the canvas to: a frame path (/projects/<project>/<layer>/<lane>/<file>), a frame URL (any URL containing /f/{uuid} or /o/<org>/projects/...), or a frame ID (UUID). When a user shares a Drafted link, pass it directly here.'),
3060
3163
  }, async ({ target }) => {
@@ -3342,6 +3445,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3342
3445
  title: z.string().optional().describe('[write + googleType] title for a new native file'),
3343
3446
  ops: z.array(z.any()).optional().describe('[edit] hashline ops for text frames. Apply ONLY against a fresh fs(read) of the frame: each op = {type, lineHash, newContent} where type is one of replace | delete | insertAfter | insertBefore, and lineHash is the FULL anchor from the read output (line number + hash, e.g. the token left of the |, like 42srt) — NOT a bare hash. delete/replace consume the target line; at most one replace/delete per line per edit (combine into one replace). If an op targets a line that changed since read it is rejected — re-read and retry. Element ops for excalidraw ({id,x,y,...}), or structured ops for office'),
3344
3447
  state: z.any().optional().describe('[write] app-frame state (JSON) to persist for a deployed windowType:"app" frame; the canvas hydrates the app from it on load. Max 64KB.'),
3448
+ expectedUpdatedAt: z.string().optional().describe('[write] optimistic concurrency: the frame\'s updatedAt as you last read it. If somebody else wrote since, the write is refused with code "conflict" and the current version, instead of silently overwriting their change. Omit for a blind write.'),
3345
3449
  metadata: z.any().optional().describe('[write] JSON metadata to attach to the written frame (e.g. {"tour": {title, auto?, steps}} to define a guided tour on this frame). Max 64KB.'),
3346
3450
  readme: z.string().optional().describe('[write] README.md markdown for a skill at /o/<org>/skills/<slug> — optional on both create and update. Stored as a supporting skill file so the skill directory renders a landing page on GitHub when exported; a skill created without one is returned with a warning, never rejected. Equivalent to a later fs(write, path="/o/<org>/skills/<slug>/README.md").'),
3347
3451
  position: z.number().int().min(0).optional().describe('[mv] on a LAYER path, no `to`: move the layer to this 0-based index in the project\'s layer order (0 = first; `ls` of the project shows the order). A layer born from a first write is appended last, so use this to pin one first.'),
@@ -3353,7 +3457,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3353
3457
  org: z.string().optional().describe('Org (id or name) for bare /wiki and /skills paths (org-scoped /o/<org>/... paths don\'t need it). Per-request only; project paths self-derive org from the project row.'),
3354
3458
  projectId: PROJECT_OVERRIDE_PARAM,
3355
3459
  }, async (args) => {
3356
- const { action, path: rawPath, to: rawTo, query, content, file_path, base64, googleType, title, ops, state, metadata, readme, recursive, pattern, lines, org } = args;
3460
+ const { action, path: rawPath, to: rawTo, query, content, file_path, base64, googleType, title, ops, state, metadata, expectedUpdatedAt, readme, recursive, pattern, lines, org } = args;
3357
3461
  // Org-scoped path (Shape A): /o/<org>/<root>/... — strip the org segment here,
3358
3462
  // carry it as the per-request org scope (orgHeader), validate per-root below.
3359
3463
  // The PATH is the address; the org= param is legacy for bare roots only, and a
@@ -3631,7 +3735,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3631
3735
  // (/o/<org>/projects/eng/x). The chain wins when both are given.
3632
3736
  const projectFolder = folder || (parts.length === 2 ? parts[0] : null);
3633
3737
  const projectRef = parts[parts.length - 1];
3634
- const existing = await resolveProjectRef(projectRef).catch(() => null);
3738
+ const existing = await resolveProjectRef(projectRef, orgFromPath || org || null).catch(() => null);
3635
3739
  if (existing) return err(new Error(`project already exists: ${projectRef}`));
3636
3740
  const projectName = projectRef.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
3637
3741
  const created = await api('POST', '/api/projects', { name: projectName, ...(projectFolder ? { folder: projectFolder } : {}) }, orgHeader);
@@ -3925,7 +4029,14 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3925
4029
  // Archived projects live in the Archive bin and are hidden from the agent's
3926
4030
  // active list — mirroring the web UI. An org-scoped root lists ONLY that
3927
4031
  // org's projects (Shape A).
3928
- const listing = await api('GET', '/api/projects');
4032
+ //
4033
+ // Fetch in the org the PATH names, not the one this session happens to be
4034
+ // working in. api() stamps the working org on every request, so the
4035
+ // unfiltered call came back as the working org's projects and the org
4036
+ // filter below then removed every one of them: `ls /o/<other-org>/projects`
4037
+ // answered "no projects" for an org with 265. Unscoped when the path names
4038
+ // no org, which is what makes `/projects` the whole inventory again.
4039
+ const listing = await api('GET', '/api/projects', undefined, orgFromPath ? { 'X-Drafted-Org': orgFromPath } : NO_ORG_SCOPE);
3929
4040
  let rows = (Array.isArray(listing?.projects) ? listing.projects : []).filter(x => !isTrashedRow(x));
3930
4041
  if (orgFromPath) {
3931
4042
  const kept = [];
@@ -4014,9 +4125,11 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
4014
4125
  // every failure as "project not found", so a gated session or an unreachable
4015
4126
  // server reads as a missing project.
4016
4127
  let lookupError = null;
4017
- let pathProject = await resolveProjectRef(projectRef).catch((e) => { lookupError = e; return null; });
4128
+ // The org from the path rides the lookup: without it a project in another
4129
+ // org was invisible and every write to it answered "project not found".
4130
+ let pathProject = await resolveProjectRef(projectRef, orgFromPath || org || null).catch((e) => { lookupError = e; return null; });
4018
4131
  if (!pathProject?.id && parts.length > 1) {
4019
- const found = await resolveFolderQualifiedProject(parts);
4132
+ const found = await resolveFolderQualifiedProject(parts, orgFromPath || org || null);
4020
4133
  if (found) { projectRef = parts[found.index]; tail = parts.slice(found.index + 1); pathProject = found.meta; }
4021
4134
  }
4022
4135
  if (!pathProject?.id && lookupError) return err(lookupError);
@@ -4057,9 +4170,9 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
4057
4170
  const segs = clean.replace(/^projects\/?/, '').split('/').filter(Boolean);
4058
4171
  if (!segs.length) throw new Error('destination needs a project: /o/<org>/projects/<project>/...');
4059
4172
  let idx = 0;
4060
- let meta = await resolveProjectRef(segs[0]).catch(() => null);
4173
+ let meta = await resolveProjectRef(segs[0], orgFromPath || org || null).catch(() => null);
4061
4174
  if (!meta?.id && segs.length > 1) {
4062
- const found = await resolveFolderQualifiedProject(segs);
4175
+ const found = await resolveFolderQualifiedProject(segs, orgFromPath || org || null);
4063
4176
  if (found) { idx = found.index; meta = found.meta; }
4064
4177
  }
4065
4178
  if (!meta?.id) throw new Error(`destination project not found: ${segs.join('/')} — list projects with fs(ls, path="/o/<org>/projects")`);
@@ -4215,6 +4328,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
4215
4328
  else if (base64) body.base64 = base64;
4216
4329
  else return err(new Error('write requires one of content / file_path / base64 / googleType / state'));
4217
4330
  if (metadata !== undefined) body.metadata = metadata;
4331
+ if (expectedUpdatedAt) body.expectedUpdatedAt = expectedUpdatedAt;
4218
4332
  const result = await api('PUT', `/api/fs/${filePath}`, body, orgHeader);
4219
4333
  if (state !== undefined && result?.id) {
4220
4334
  try { await api('POST', `/api/file/${result.id}/state`, { state }); } catch { /* state best-effort */ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.24.1",
3
+ "version": "1.24.3",
4
4
  "description": "Drafted — visual thinking surface for humans and AI agents. Renders HTML, markdown, images, and code as frames on a zoomable canvas, with MCP tools for AI agents and real-time sync for humans.",
5
5
  "type": "module",
6
6
  "bin": {