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.
- package/mcp/server.mjs +131 -17
- 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
|
-
|
|
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
|
|
1633
|
+
const cacheKey = orgHint ? `${orgHint}|${ref}` : ref;
|
|
1634
|
+
const hit = projectRefCache.get(cacheKey) || (!orgHint && projectRefCache.get(ref));
|
|
1622
1635
|
if (hit) return hit;
|
|
1623
|
-
|
|
1624
|
-
|
|
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
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|