drafted 1.19.27 → 1.19.28
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 +48 -5
- package/mcp/test-org-guards.mjs +27 -1
- package/package.json +1 -1
package/mcp/server.mjs
CHANGED
|
@@ -230,6 +230,27 @@ export function projectlessMutationNeedsOrg({ explicitOrg, boundOrgId, activePro
|
|
|
230
230
|
return (orgCount || 0) > 1;
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
+
// SEARCH IS STRICTER THAN A MUTATION, and deliberately so.
|
|
234
|
+
//
|
|
235
|
+
// A mutation credits a bound/active project as an addressing root, because the
|
|
236
|
+
// destination is echoed back in the receipt: a surprising org is VISIBLE after
|
|
237
|
+
// the fact. A search has no such tell. Scoping it from a binding the agent did
|
|
238
|
+
// not state produces an empty result set indistinguishable from "this does not
|
|
239
|
+
// exist anywhere" — the agent stops looking, and the thing it wanted was one org
|
|
240
|
+
// away the whole time. A silent false negative is worse than an error, because
|
|
241
|
+
// nothing about it announces itself.
|
|
242
|
+
//
|
|
243
|
+
// So a search must NAME where it is looking. Only two things satisfy that: an
|
|
244
|
+
// org in the path (/o/<org>/...) or an explicit org= on the call. A binding does
|
|
245
|
+
// not, because the point is that the CALLER declared the scope.
|
|
246
|
+
//
|
|
247
|
+
// Single-org users are exempt: with one org there is nothing to be fenced off
|
|
248
|
+
// FROM, so the answer cannot be misleading and the friction buys nothing.
|
|
249
|
+
export function searchNeedsOrg({ explicitOrg, orgFromPath, orgCount }) {
|
|
250
|
+
if (explicitOrg || orgFromPath) return false;
|
|
251
|
+
return (orgCount || 0) > 1;
|
|
252
|
+
}
|
|
253
|
+
|
|
233
254
|
// Did the server reject the working org THIS session injected as X-Drafted-Org?
|
|
234
255
|
// A working org the caller isn't a member of can only be stale or foreign — state
|
|
235
256
|
// rehydrated from another server's database, or the user removed from the org —
|
|
@@ -3160,12 +3181,12 @@ server.resource('info', 'drafted://info', {
|
|
|
3160
3181
|
};
|
|
3161
3182
|
});
|
|
3162
3183
|
|
|
3163
|
-
tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single container and the org is the folder at depth 0: `fs(ls, path="/")` lists the orgs you can address, `/o/<org>` is that org, and every folder level — the org included — carries the SAME four roots:\n\n- `<folder>/wiki/<path>` — knowledge pages (markdown, OKF; free nesting; `index.md` at any level is synthesized and read-only)\n- `<folder>/skills/<slug>` — reusable procedures (flat: one dir per skill slug, `SKILL.md` + supporting files inside; slugs stay unique per ORG, so a skill resolves by slug from anywhere)\n- `<folder>/tasks/<lane?>/<file>` — work items. A task IS a frame: `read` renders its `drafted:status:`/`drafted:assignee:` as front matter and `write`/`edit` parse them back into columns, so they are never stored in the body. The keys are namespaced so an ordinary `status:` in your own front matter is left alone. Valid statuses: open, in_progress, scheduled, needs_review, needs_decision, done, failed (an empty `drafted:status:` clears it). Status is a column, not a location — a task moved out of /tasks stays a task.\n- `<folder>/projects/<project>/<layer>/<lane>/<file>` — producible frames (then exactly layer → lane → file)\n\nFolders nest arbitrarily: `/o/<org>/engineering/backend/wiki/deploy`. The ROOT KEYWORD IS THE SEPARATOR — everything before `wiki`/`skills`/`tasks`/`projects` is the folder chain, everything after is the path inside that root, so `/o/<org>/wiki/engineering/foo` (the org wiki, nested page) and `/o/<org>/engineering/wiki/foo` (the engineering folder\'s wiki) are different pages. The four names are therefore RESERVED: a folder cannot be called one. `fs(ls, path="/o/<org>/<folder>")` shows a folder\'s four roots plus the folders inside it; `fs(mkdir, path="/o/<org>/<folder>")` creates one.\n\n(Bare `/wiki`, `/skills`, `/tasks`, `/projects` roots still resolve via the session\'s working org, at that org\'s root.)\n\nVerbs: `ls` (list a directory), `read` (file content — hashline-annotated for text so `edit` stays surgical), `write` (create/overwrite; extension + layer classify the type: .html design, .md document, .excalidraw diagram, .xlsx/.docx office, images/videos media, .pdf asset, .google-doc/.google-sheet/.google-slide create native Google Workspace files), `edit` (hashline ops for text, element ops for excalidraw, structured ops for office), `mv` (rename/move, cross-project), `rm` (delete), `search` (frames are searched by label AND content, with the matching line returned as a snippet; `fs(search, path="/
|
|
3184
|
+
tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single container and the org is the folder at depth 0: `fs(ls, path="/")` lists the orgs you can address, `/o/<org>` is that org, and every folder level — the org included — carries the SAME four roots:\n\n- `<folder>/wiki/<path>` — knowledge pages (markdown, OKF; free nesting; `index.md` at any level is synthesized and read-only)\n- `<folder>/skills/<slug>` — reusable procedures (flat: one dir per skill slug, `SKILL.md` + supporting files inside; slugs stay unique per ORG, so a skill resolves by slug from anywhere)\n- `<folder>/tasks/<lane?>/<file>` — work items. A task IS a frame: `read` renders its `drafted:status:`/`drafted:assignee:` as front matter and `write`/`edit` parse them back into columns, so they are never stored in the body. The keys are namespaced so an ordinary `status:` in your own front matter is left alone. Valid statuses: open, in_progress, scheduled, needs_review, needs_decision, done, failed (an empty `drafted:status:` clears it). Status is a column, not a location — a task moved out of /tasks stays a task.\n- `<folder>/projects/<project>/<layer>/<lane>/<file>` — producible frames (then exactly layer → lane → file)\n\nFolders nest arbitrarily: `/o/<org>/engineering/backend/wiki/deploy`. The ROOT KEYWORD IS THE SEPARATOR — everything before `wiki`/`skills`/`tasks`/`projects` is the folder chain, everything after is the path inside that root, so `/o/<org>/wiki/engineering/foo` (the org wiki, nested page) and `/o/<org>/engineering/wiki/foo` (the engineering folder\'s wiki) are different pages. The four names are therefore RESERVED: a folder cannot be called one. `fs(ls, path="/o/<org>/<folder>")` shows a folder\'s four roots plus the folders inside it; `fs(mkdir, path="/o/<org>/<folder>")` creates one.\n\n(Bare `/wiki`, `/skills`, `/tasks`, `/projects` roots still resolve via the session\'s working org, at that org\'s root.)\n\nVerbs: `ls` (list a directory), `read` (file content — hashline-annotated for text so `edit` stays surgical), `write` (create/overwrite; extension + layer classify the type: .html design, .md document, .excalidraw diagram, .xlsx/.docx office, images/videos media, .pdf asset, .google-doc/.google-sheet/.google-slide create native Google Workspace files), `edit` (hashline ops for text, element ops for excalidraw, structured ops for office), `mv` (rename/move, cross-project), `rm` (delete), `search` (frames are searched by label AND content, with the matching line returned as a snippet; `fs(search, path="/o/<org>")` fans out across wiki + skills + projects in one call. SEARCH MUST NAME ITS ORG — put it in the path, or pass org=. If you belong to more than one org an unaddressed search is REFUSED rather than silently scoped, because "no matches" from one org is indistinguishable from "nowhere" and you would stop looking. Search several orgs with one call each; every result says which org it came from), `link` / `unlink` / `links` (relate one frame to another frame, to a project, or to an external url — `links` lists a frame\'s edges plus its backlinks, and on a project path lists the tasks linked to that project; a link is stored by ID, so `mv` never breaks it). `mkdir` creates a project only: use `/projects/<project>` or `/projects/<folder>/<project>`, never a layer path. To create a layer, write its first frame at `/projects/<project>/<new-layer>/<lane>/<file>`.\n\nThe project is resolved from the path itself — no separate "open" step. Guardrails are server-side and unchanged: the org in the path must be the project\'s own org (project paths under /o/<org>/ validate it), the G1 wiki-search gate fires before project mutations, attached-skill gates fire on mutations, anchored frames must be read before editing a layer, `.skillinstall/` is stripped on skill push.', {
|
|
3164
3185
|
action: z.enum(['ls', 'read', 'write', 'edit', 'mv', 'rm', 'mkdir', 'search', 'link', 'unlink', 'links']).describe('Filesystem verb.'),
|
|
3165
3186
|
path: z.string().describe('Drafted path: /o/<org>[/<folder…>]/wiki/... | .../skills/... | .../tasks/... | .../projects/... — a folder chain may precede any root (bare /wiki, /skills, /tasks, /projects also work; for mv: source)'),
|
|
3166
3187
|
to: z.string().optional().describe('[mv] destination path; [link/unlink] target path — a frame path, or a project path (/o/<org>/projects/<project>) to link a task to a project'),
|
|
3167
3188
|
url: z.string().optional().describe('[link/unlink] external target URL, instead of `to` (a link is internal-by-id OR external-by-url, never both)'),
|
|
3168
|
-
query: z.string().optional().describe('[search] term to match against names/content'),
|
|
3189
|
+
query: z.string().optional().describe('[search] term to match against names/content. The path must name an org (/o/<org>) unless you belong to exactly one.'),
|
|
3169
3190
|
content: z.string().optional().describe('[write] inline HTML/markdown/text'),
|
|
3170
3191
|
file_path: z.string().optional().describe('[write] absolute path to a local file to upload (stdio only). Under /projects it uploads bytes (images, PDFs, office files); under /wiki and /skills it reads the file as UTF-8 text, so a markdown file on disk can be written straight to a page or a SKILL.md without pasting it.'),
|
|
3171
3192
|
base64: z.string().optional().describe('[write] base64-encoded binary content'),
|
|
@@ -3234,14 +3255,33 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
|
|
|
3234
3255
|
// three inventories. A leg that fails says so instead of reading as zero hits.
|
|
3235
3256
|
if (action === 'search') {
|
|
3236
3257
|
const q = String(query || '').trim();
|
|
3237
|
-
if (!q) return err(new Error('search requires a query — e.g. fs(search, path="/", query="<terms>")'));
|
|
3258
|
+
if (!q) return err(new Error('search requires a query — e.g. fs(search, path="/o/<org>", query="<terms>")'));
|
|
3259
|
+
// Name the org, or don't search. See searchNeedsOrg: an unaddressed search
|
|
3260
|
+
// returns "no matches" from ONE org and reads as "nowhere", which is the
|
|
3261
|
+
// one wrong answer a search can give that nothing announces.
|
|
3262
|
+
{
|
|
3263
|
+
const orgs = await getOrgList();
|
|
3264
|
+
if (searchNeedsOrg({ explicitOrg: org, orgFromPath, orgCount: orgs.length })) {
|
|
3265
|
+
const names = orgs.map(o => o.slug || o.name || o.id).filter(Boolean);
|
|
3266
|
+
return err(new Error(
|
|
3267
|
+
`Which org? Search needs one, and you belong to ${orgs.length}: ${names.join(', ')}.\n` +
|
|
3268
|
+
`An unaddressed search would scope to whichever org this session inherited and report ` +
|
|
3269
|
+
`"no matches" for the others — a false negative you cannot see, which is worse than this error.\n` +
|
|
3270
|
+
`Address the org in the PATH: fs(search, path="/o/<org>", query="${q}").\n` +
|
|
3271
|
+
`(Or org="<name>" on this call. Searching several orgs = one call each; the results say which org they came from.)`
|
|
3272
|
+
));
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
3238
3275
|
const scope = orgFromPath ? `/o/${orgFromPath}` : '';
|
|
3239
3276
|
const leg = async (fn) => { try { return { v: await fn() }; } catch (e) { return { e: e?.message || String(e) }; } };
|
|
3240
3277
|
const [wiki, skills, projects, frames] = await Promise.all([
|
|
3241
3278
|
leg(() => api('GET', `/api/wiki/search?q=${encodeURIComponent(q)}&limit=10`, undefined, orgHeader)),
|
|
3242
3279
|
leg(() => api('GET', `/api/skills/search?q=${encodeURIComponent(q)}`, undefined, orgHeader)),
|
|
3243
3280
|
leg(() => api('GET', '/api/projects', undefined, orgHeader)),
|
|
3244
|
-
|
|
3281
|
+
// orgHeader, so the server scopes frames instead of us filtering after the
|
|
3282
|
+
// fact — the row cap is applied server-side, so a client-side filter can be
|
|
3283
|
+
// handed a page already filled by other orgs' hits.
|
|
3284
|
+
leg(() => withoutProjectScope(() => api('GET', `/api/search?q=${encodeURIComponent(q)}&limit=15`, undefined, orgHeader))),
|
|
3245
3285
|
]);
|
|
3246
3286
|
// A root search IS the prior-art search both gates ask for — it read the wiki
|
|
3247
3287
|
// and the skill library. Not crediting it would send the agent back to run
|
|
@@ -3285,7 +3325,10 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
|
|
|
3285
3325
|
return hits.length ? formatFrameHits(hits, { limit: 15 }) + note : (note ? note.trimStart() : '');
|
|
3286
3326
|
});
|
|
3287
3327
|
|
|
3288
|
-
|
|
3328
|
+
const searched = orgFromPath || org || null;
|
|
3329
|
+
// ALWAYS say where this looked. "No matches" is only safe to act on when
|
|
3330
|
+
// the reader can see the scope it was computed over.
|
|
3331
|
+
return ok(`Search "${q}" in ${searched ? `/o/${searched}` : 'your only org'}\n\n${out.join('\n\n')}`);
|
|
3289
3332
|
}
|
|
3290
3333
|
if (action !== 'ls') return err(new Error('read/write/edit/mv/rm require a path under /o/<org>/wiki, /o/<org>/skills, /o/<org>/tasks, or /o/<org>/projects'));
|
|
3291
3334
|
if (orgFromPath) {
|
package/mcp/test-org-guards.mjs
CHANGED
|
@@ -181,4 +181,30 @@ assert.equal(rmScope(null, null, null), 'project', 'only the bare project path a
|
|
|
181
181
|
console.log('org-guard policy OK');
|
|
182
182
|
// Importing server.mjs builds the stdio MCP singleton, which opens a WS reconnect
|
|
183
183
|
// loop that keeps the event loop alive. Assertions are done — exit deterministically.
|
|
184
|
-
|
|
184
|
+
// ── searchNeedsOrg: stricter than a mutation, on purpose ───────────
|
|
185
|
+
// A mutation credits a bound project because its receipt names the destination.
|
|
186
|
+
// A search has no such tell: scoping it from an unstated binding yields an empty
|
|
187
|
+
// result the agent reads as "does not exist anywhere". Only a NAMED org counts.
|
|
188
|
+
{
|
|
189
|
+
const { searchNeedsOrg } = await import('./server.mjs');
|
|
190
|
+
const t = (label, args, expected) => {
|
|
191
|
+
const got = searchNeedsOrg(args);
|
|
192
|
+
if (got !== expected) {
|
|
193
|
+
console.error(`FAIL searchNeedsOrg: ${label} -> ${got}, expected ${expected}`);
|
|
194
|
+
process.exitCode = 1;
|
|
195
|
+
} else console.log(`ok searchNeedsOrg: ${label}`);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
t('multi-org, nothing named -> REFUSE', { orgCount: 3 }, true);
|
|
199
|
+
t('org in the path -> proceed', { orgFromPath: 'beoflow', orgCount: 3 }, false);
|
|
200
|
+
t('explicit org= -> proceed', { explicitOrg: 'beoflow', orgCount: 3 }, false);
|
|
201
|
+
t('single org -> proceed (nothing to be fenced from)', { orgCount: 1 }, false);
|
|
202
|
+
t('zero/unknown orgs -> proceed, never block on unknown membership', { orgCount: 0 }, false);
|
|
203
|
+
// The divergence from the mutation rule is the whole point: a binding does NOT
|
|
204
|
+
// satisfy a search, because the caller never stated the scope.
|
|
205
|
+
t('bound project does NOT satisfy a search', { activeProjectId: 'p1', boundOrgId: 'o1', orgCount: 2 }, true);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Must exit: importing server.mjs opens the MCP WebSocket at module scope and
|
|
209
|
+
// pins the event loop (AGENTS.md).
|
|
210
|
+
process.exit(process.exitCode || 0);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drafted",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.28",
|
|
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
|
"files": [
|