drafted 1.24.1 → 1.24.2
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 +56 -17
- package/package.json +1 -1
package/mcp/server.mjs
CHANGED
|
@@ -1616,21 +1616,47 @@ async function pathOrgMatches(orgSegment, meta) {
|
|
|
1616
1616
|
return !!hit && meta?.orgId === hit.id;
|
|
1617
1617
|
}
|
|
1618
1618
|
|
|
1619
|
-
|
|
1619
|
+
/**
|
|
1620
|
+
* Fetch /api/projects with NO org scope.
|
|
1621
|
+
*
|
|
1622
|
+
* api() otherwise stamps the session's working org on every request, so a
|
|
1623
|
+
* listing meant to answer "which projects exist in the org this PATH names"
|
|
1624
|
+
* came back scoped to the org the session happened to be working in, and the
|
|
1625
|
+
* org filter then removed all of it. An empty header is how the server is told
|
|
1626
|
+
* "unaddressed" (req.get('x-drafted-org') is falsy) without a server change.
|
|
1627
|
+
*/
|
|
1628
|
+
const NO_ORG_SCOPE = { 'X-Drafted-Org': '' };
|
|
1629
|
+
|
|
1630
|
+
async function resolveProjectRef(ref, orgHint = null) {
|
|
1620
1631
|
if (!ref) return null;
|
|
1621
|
-
const
|
|
1632
|
+
const cacheKey = orgHint ? `${orgHint}|${ref}` : ref;
|
|
1633
|
+
const hit = projectRefCache.get(cacheKey) || (!orgHint && projectRefCache.get(ref));
|
|
1622
1634
|
if (hit) return hit;
|
|
1623
|
-
|
|
1624
|
-
|
|
1635
|
+
// Scope the lookup to the org the path named. Without a hint the session's
|
|
1636
|
+
// org applies — which is right for `fs ls /projects` but WRONG for a path
|
|
1637
|
+
// that addressed another org, and that mismatch is what made a project
|
|
1638
|
+
// created in org B unresolvable from a session working in org A.
|
|
1639
|
+
const scoped = orgHint ? await api('GET', '/api/projects', undefined, { 'X-Drafted-Org': orgHint }) : await api('GET', '/api/projects');
|
|
1640
|
+
let list = Array.isArray(scoped?.projects) ? scoped.projects : [];
|
|
1625
1641
|
const want = String(ref).toLowerCase();
|
|
1626
|
-
const
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1642
|
+
const pick = (rows) =>
|
|
1643
|
+
rows.find(x => x.id === ref) ||
|
|
1644
|
+
rows.find(x => String(x.slug || '').toLowerCase() === want) ||
|
|
1645
|
+
rows.find(x => String(x.name || '').toLowerCase() === want);
|
|
1646
|
+
let p = pick(list);
|
|
1647
|
+
// A miss is worth one unscoped look before "not found": the hint may be stale
|
|
1648
|
+
// (a project that moved org), and an id passed as projectId= carries no org at
|
|
1649
|
+
// all — only a UUID can name a project in an org the session is not inside.
|
|
1650
|
+
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)))) {
|
|
1651
|
+
const all = await api('GET', '/api/projects', undefined, NO_ORG_SCOPE);
|
|
1652
|
+
list = Array.isArray(all?.projects) ? all.projects : [];
|
|
1653
|
+
p = pick(list);
|
|
1654
|
+
}
|
|
1630
1655
|
if (!p) return null;
|
|
1631
1656
|
// `folder` rides along so a caller can rebuild the project's printed address
|
|
1632
1657
|
// (/o/<org>/projects/<folder>/<slug>) without a second lookup.
|
|
1633
1658
|
const meta = { id: p.id, slug: p.slug || null, name: p.name || null, orgId: p.orgId || null, orgSlug: p.orgSlug || null, folder: p.folder || '' };
|
|
1659
|
+
projectRefCache.set(cacheKey, meta);
|
|
1634
1660
|
projectRefCache.set(ref, meta);
|
|
1635
1661
|
projectRefCache.set(p.id, meta);
|
|
1636
1662
|
return meta;
|
|
@@ -1645,13 +1671,15 @@ async function resolveProjectRef(ref) {
|
|
|
1645
1671
|
*
|
|
1646
1672
|
* One /api/projects fetch for the whole scan, not one per candidate.
|
|
1647
1673
|
*/
|
|
1648
|
-
async function resolveFolderQualifiedProject(parts) {
|
|
1674
|
+
async function resolveFolderQualifiedProject(parts, orgHint = null) {
|
|
1649
1675
|
// NOT `.catch(() => null)`. Swallowing the fetch turns "the session is gated"
|
|
1650
1676
|
// or "the server is down" into "project not found", which is a wrong DIAGNOSIS,
|
|
1651
1677
|
// not just a lost detail — it sends you hunting for a project instead of
|
|
1652
1678
|
// reading the gate. Observed once for real: an unnamed session after an MCP
|
|
1653
1679
|
// restart reported `project not found: Marketing/beoflow-marketing`.
|
|
1654
|
-
|
|
1680
|
+
// Same org rule as resolveProjectRef: a folder-qualified address names an org,
|
|
1681
|
+
// so the lookup has to be made in it rather than in the working org.
|
|
1682
|
+
const data = await api('GET', '/api/projects', undefined, orgHint ? { 'X-Drafted-Org': orgHint } : NO_ORG_SCOPE);
|
|
1655
1683
|
const list = Array.isArray(data?.projects) ? data.projects : [];
|
|
1656
1684
|
const norm = (s) => String(s || '').toLowerCase();
|
|
1657
1685
|
for (let i = 1; i < parts.length; i++) {
|
|
@@ -3342,6 +3370,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
|
|
|
3342
3370
|
title: z.string().optional().describe('[write + googleType] title for a new native file'),
|
|
3343
3371
|
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
3372
|
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.'),
|
|
3373
|
+
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
3374
|
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
3375
|
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
3376
|
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 +3382,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
|
|
|
3353
3382
|
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
3383
|
projectId: PROJECT_OVERRIDE_PARAM,
|
|
3355
3384
|
}, 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;
|
|
3385
|
+
const { action, path: rawPath, to: rawTo, query, content, file_path, base64, googleType, title, ops, state, metadata, expectedUpdatedAt, readme, recursive, pattern, lines, org } = args;
|
|
3357
3386
|
// Org-scoped path (Shape A): /o/<org>/<root>/... — strip the org segment here,
|
|
3358
3387
|
// carry it as the per-request org scope (orgHeader), validate per-root below.
|
|
3359
3388
|
// The PATH is the address; the org= param is legacy for bare roots only, and a
|
|
@@ -3631,7 +3660,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
|
|
|
3631
3660
|
// (/o/<org>/projects/eng/x). The chain wins when both are given.
|
|
3632
3661
|
const projectFolder = folder || (parts.length === 2 ? parts[0] : null);
|
|
3633
3662
|
const projectRef = parts[parts.length - 1];
|
|
3634
|
-
const existing = await resolveProjectRef(projectRef).catch(() => null);
|
|
3663
|
+
const existing = await resolveProjectRef(projectRef, orgFromPath || org || null).catch(() => null);
|
|
3635
3664
|
if (existing) return err(new Error(`project already exists: ${projectRef}`));
|
|
3636
3665
|
const projectName = projectRef.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
3637
3666
|
const created = await api('POST', '/api/projects', { name: projectName, ...(projectFolder ? { folder: projectFolder } : {}) }, orgHeader);
|
|
@@ -3925,7 +3954,14 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
|
|
|
3925
3954
|
// Archived projects live in the Archive bin and are hidden from the agent's
|
|
3926
3955
|
// active list — mirroring the web UI. An org-scoped root lists ONLY that
|
|
3927
3956
|
// org's projects (Shape A).
|
|
3928
|
-
|
|
3957
|
+
//
|
|
3958
|
+
// Fetch in the org the PATH names, not the one this session happens to be
|
|
3959
|
+
// working in. api() stamps the working org on every request, so the
|
|
3960
|
+
// unfiltered call came back as the working org's projects and the org
|
|
3961
|
+
// filter below then removed every one of them: `ls /o/<other-org>/projects`
|
|
3962
|
+
// answered "no projects" for an org with 265. Unscoped when the path names
|
|
3963
|
+
// no org, which is what makes `/projects` the whole inventory again.
|
|
3964
|
+
const listing = await api('GET', '/api/projects', undefined, orgFromPath ? { 'X-Drafted-Org': orgFromPath } : NO_ORG_SCOPE);
|
|
3929
3965
|
let rows = (Array.isArray(listing?.projects) ? listing.projects : []).filter(x => !isTrashedRow(x));
|
|
3930
3966
|
if (orgFromPath) {
|
|
3931
3967
|
const kept = [];
|
|
@@ -4014,9 +4050,11 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
|
|
|
4014
4050
|
// every failure as "project not found", so a gated session or an unreachable
|
|
4015
4051
|
// server reads as a missing project.
|
|
4016
4052
|
let lookupError = null;
|
|
4017
|
-
|
|
4053
|
+
// The org from the path rides the lookup: without it a project in another
|
|
4054
|
+
// org was invisible and every write to it answered "project not found".
|
|
4055
|
+
let pathProject = await resolveProjectRef(projectRef, orgFromPath || org || null).catch((e) => { lookupError = e; return null; });
|
|
4018
4056
|
if (!pathProject?.id && parts.length > 1) {
|
|
4019
|
-
const found = await resolveFolderQualifiedProject(parts);
|
|
4057
|
+
const found = await resolveFolderQualifiedProject(parts, orgFromPath || org || null);
|
|
4020
4058
|
if (found) { projectRef = parts[found.index]; tail = parts.slice(found.index + 1); pathProject = found.meta; }
|
|
4021
4059
|
}
|
|
4022
4060
|
if (!pathProject?.id && lookupError) return err(lookupError);
|
|
@@ -4057,9 +4095,9 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
|
|
|
4057
4095
|
const segs = clean.replace(/^projects\/?/, '').split('/').filter(Boolean);
|
|
4058
4096
|
if (!segs.length) throw new Error('destination needs a project: /o/<org>/projects/<project>/...');
|
|
4059
4097
|
let idx = 0;
|
|
4060
|
-
let meta = await resolveProjectRef(segs[0]).catch(() => null);
|
|
4098
|
+
let meta = await resolveProjectRef(segs[0], orgFromPath || org || null).catch(() => null);
|
|
4061
4099
|
if (!meta?.id && segs.length > 1) {
|
|
4062
|
-
const found = await resolveFolderQualifiedProject(segs);
|
|
4100
|
+
const found = await resolveFolderQualifiedProject(segs, orgFromPath || org || null);
|
|
4063
4101
|
if (found) { idx = found.index; meta = found.meta; }
|
|
4064
4102
|
}
|
|
4065
4103
|
if (!meta?.id) throw new Error(`destination project not found: ${segs.join('/')} — list projects with fs(ls, path="/o/<org>/projects")`);
|
|
@@ -4215,6 +4253,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
|
|
|
4215
4253
|
else if (base64) body.base64 = base64;
|
|
4216
4254
|
else return err(new Error('write requires one of content / file_path / base64 / googleType / state'));
|
|
4217
4255
|
if (metadata !== undefined) body.metadata = metadata;
|
|
4256
|
+
if (expectedUpdatedAt) body.expectedUpdatedAt = expectedUpdatedAt;
|
|
4218
4257
|
const result = await api('PUT', `/api/fs/${filePath}`, body, orgHeader);
|
|
4219
4258
|
if (state !== undefined && result?.id) {
|
|
4220
4259
|
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.2",
|
|
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": {
|