drafted 1.14.10 → 1.14.12
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 +147 -35
- package/mcp/test-org-guards.mjs +9 -4
- package/package.json +1 -1
package/mcp/server.mjs
CHANGED
|
@@ -180,8 +180,11 @@ function scrubLocalPathMentions(description) {
|
|
|
180
180
|
// project is the one legitimately-kept addressing root, NOT the deleted session
|
|
181
181
|
// cursor. Awareness of a surprising destination (e.g. a fork) comes from the
|
|
182
182
|
// response receipt naming the org (orgEcho), not from hard-blocking the flow.
|
|
183
|
-
|
|
184
|
-
|
|
183
|
+
// A remote session is NOT an exemption: its session org is inherited (the user's default),
|
|
184
|
+
// not chosen, so for a multi-org user it is exactly the guess this guard exists to refuse.
|
|
185
|
+
// Single-org callers (remote or stdio) are unambiguous and proceed.
|
|
186
|
+
export function projectlessMutationNeedsOrg({ explicitOrg, boundOrgId, activeProjectId, orgCount }) {
|
|
187
|
+
if (explicitOrg || boundOrgId || activeProjectId) return false;
|
|
185
188
|
return (orgCount || 0) > 1;
|
|
186
189
|
}
|
|
187
190
|
|
|
@@ -404,6 +407,14 @@ function tool(name, descOrSchema, schemaOrHandler, handler) {
|
|
|
404
407
|
}
|
|
405
408
|
}
|
|
406
409
|
}
|
|
410
|
+
// Per-call project override (see withProjectOverride). Scoping happens here so
|
|
411
|
+
// every project-scoped tool gets it from one seam instead of each handler.
|
|
412
|
+
const ref = PROJECT_SCOPED_TOOLS.has(name) ? args?.[0]?.projectId : null;
|
|
413
|
+
if (ref) {
|
|
414
|
+
const meta = await resolveProjectRef(ref);
|
|
415
|
+
if (!meta) return err(new Error(`Project "${ref}" not found — pass a project UUID, slug, or name from project(action="list").`));
|
|
416
|
+
return await withProjectOverride(meta, () => cb(...args));
|
|
417
|
+
}
|
|
407
418
|
return await cb(...args);
|
|
408
419
|
} finally {
|
|
409
420
|
state.currentTool = previousTool;
|
|
@@ -919,6 +930,17 @@ async function serverFetch(url, opts) {
|
|
|
919
930
|
catch (e) { throw enrichFetchError(e, url); }
|
|
920
931
|
}
|
|
921
932
|
|
|
933
|
+
// The org this session is actually working in: an explicit switch, else the org of the
|
|
934
|
+
// bound project (a project belongs to exactly one org, so it IS an address). Never the
|
|
935
|
+
// server session's org — that one is INHERITED (a fresh session lands on the user's
|
|
936
|
+
// default), and trusting it is what wrote a Beoflow-bound agent's wiki page into
|
|
937
|
+
// Personal. Used both to address requests (X-Drafted-Org) and to echo where a write
|
|
938
|
+
// landed, so the two can never disagree.
|
|
939
|
+
function workingOrgId() {
|
|
940
|
+
const session = getSessionState();
|
|
941
|
+
return session.boundOrgId || session.activeProjectMeta?.orgId || getState().projectMeta?.orgId || null;
|
|
942
|
+
}
|
|
943
|
+
|
|
922
944
|
async function api(method, path, body, extraHeaders = {}, _retried = false) {
|
|
923
945
|
await ensureSession();
|
|
924
946
|
const pid = getState().projectId;
|
|
@@ -926,6 +948,11 @@ async function api(method, path, body, extraHeaders = {}, _retried = false) {
|
|
|
926
948
|
const scopedPath = pid ? `${path}${sep}projectId=${pid}` : path;
|
|
927
949
|
const url = `${getServerUrl()}${scopedPath}`;
|
|
928
950
|
const headers = { ...getAuthHeaders(), ...extraHeaders };
|
|
951
|
+
// Declare an unbound session explicitly. Without this the server falls back to the
|
|
952
|
+
// user's SHARED active-project row (any browser tab or parallel agent can rewrite it)
|
|
953
|
+
// and a frame write silently succeeds in the WRONG project. Project-scoped routes
|
|
954
|
+
// fail closed on this header; org-scoped ones (wiki, skill, get_org) ignore it.
|
|
955
|
+
if (!pid) headers['X-Drafted-Project'] = 'none';
|
|
929
956
|
// Bind every data request to the org this MCP session is actually working in
|
|
930
957
|
// (the active project's org, or the org chosen via get_org switch). Without
|
|
931
958
|
// this, project-scoped routes that resolve org from the SHARED server-side
|
|
@@ -938,7 +965,26 @@ async function api(method, path, body, extraHeaders = {}, _retried = false) {
|
|
|
938
965
|
// per-request — exactly like the org-less /project/:slug link that already
|
|
939
966
|
// works. An explicit override in extraHeaders (e.g. the wiki/skill `org` arg)
|
|
940
967
|
// always wins, and /auth/* is left untouched so it reports true session state.
|
|
941
|
-
|
|
968
|
+
// The bound project's org is as good an address as an explicit switch — and it's the
|
|
969
|
+
// one the projectless-mutation guard already credits. Send it when boundOrgId wasn't
|
|
970
|
+
// set (an open whose meta lacked orgId, or state rehydrated from an older on-disk
|
|
971
|
+
// entry), so an org-scoped write can never fall back to the session's inherited org.
|
|
972
|
+
// A bound project whose org we don't know yet (an open whose meta lacked orgId, or an
|
|
973
|
+
// older on-disk entry rehydrated at boot) leaves workingOrgId() null — and then an
|
|
974
|
+
// org-scoped write would resolve against the session's inherited org. Learn the org
|
|
975
|
+
// from the project itself, once. The /api/projects guard stops resolveProjectRef, which
|
|
976
|
+
// calls that endpoint, from recursing back into here.
|
|
977
|
+
if (pid && !workingOrgId() && !path.startsWith('/api/projects')) {
|
|
978
|
+
try {
|
|
979
|
+
const meta = await resolveProjectRef(pid);
|
|
980
|
+
if (meta?.orgId) {
|
|
981
|
+
const s = getSessionState();
|
|
982
|
+
s.boundOrgId = meta.orgId;
|
|
983
|
+
s.activeProjectMeta = s.activeProjectMeta || meta;
|
|
984
|
+
}
|
|
985
|
+
} catch { /* best-effort; the server derives org from ?projectId anyway */ }
|
|
986
|
+
}
|
|
987
|
+
const boundOrg = workingOrgId();
|
|
942
988
|
const hasExplicitOrg = Object.keys(headers).some((k) => k.toLowerCase() === 'x-drafted-org');
|
|
943
989
|
if (boundOrg && !hasExplicitOrg && !path.startsWith('/auth/')) {
|
|
944
990
|
headers['X-Drafted-Org'] = boundOrg;
|
|
@@ -988,7 +1034,10 @@ async function api(method, path, body, extraHeaders = {}, _retried = false) {
|
|
|
988
1034
|
// a get_org switch), and now the project is invisible. Clear our
|
|
989
1035
|
// sticky reference so the next tool call doesn't append a stale
|
|
990
1036
|
// projectId to its URL — and tell the agent what happened.
|
|
991
|
-
|
|
1037
|
+
// Only a PROJECT-not-found clears the binding. A bare 404 also means "frame not
|
|
1038
|
+
// found" / "page not found" — clearing on those unbound the session mid-turn, and
|
|
1039
|
+
// the next write fell through to the shared active-project row in another project.
|
|
1040
|
+
if (/project not found/i.test(msg) && pid && getState().projectId === pid) {
|
|
992
1041
|
const meta = getState().projectMeta;
|
|
993
1042
|
getState().projectId = null;
|
|
994
1043
|
getState().projectMeta = null;
|
|
@@ -1161,6 +1210,55 @@ function setMcpActiveProject(projectId, meta = null) {
|
|
|
1161
1210
|
}
|
|
1162
1211
|
}
|
|
1163
1212
|
|
|
1213
|
+
// Resolve a project reference — UUID, slug, or name (case-insensitive) — to its meta.
|
|
1214
|
+
// Agents carry slugs in context ("ios-app-shell-re-ui"), not UUIDs; demanding a UUID
|
|
1215
|
+
// forced a frame-search dance just to name a project. Backs both project(action="open")
|
|
1216
|
+
// and the per-call `projectId` override below.
|
|
1217
|
+
// ponytail: process-lifetime cache, no TTL — a project rename goes stale until restart.
|
|
1218
|
+
// Add invalidation if renames ever become common.
|
|
1219
|
+
const projectRefCache = new Map();
|
|
1220
|
+
async function resolveProjectRef(ref) {
|
|
1221
|
+
if (!ref) return null;
|
|
1222
|
+
const hit = projectRefCache.get(ref);
|
|
1223
|
+
if (hit) return hit;
|
|
1224
|
+
const data = await api('GET', '/api/projects');
|
|
1225
|
+
const list = Array.isArray(data?.projects) ? data.projects : [];
|
|
1226
|
+
const want = String(ref).toLowerCase();
|
|
1227
|
+
const p =
|
|
1228
|
+
list.find(x => x.id === ref) ||
|
|
1229
|
+
list.find(x => String(x.slug || '').toLowerCase() === want) ||
|
|
1230
|
+
list.find(x => String(x.name || '').toLowerCase() === want);
|
|
1231
|
+
if (!p) return null;
|
|
1232
|
+
const meta = { id: p.id, slug: p.slug || null, name: p.name || null, orgId: p.orgId || null, orgSlug: p.orgSlug || null };
|
|
1233
|
+
projectRefCache.set(ref, meta);
|
|
1234
|
+
projectRefCache.set(p.id, meta);
|
|
1235
|
+
return meta;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// Run one tool call against a caller-named project WITHOUT rebinding the session.
|
|
1239
|
+
// The override lives in this call's AsyncLocalStorage frame only — request-local, so
|
|
1240
|
+
// it satisfies the DRAFT-36 addressing invariant (no shared, another-session-writable
|
|
1241
|
+
// resolution state) while giving a reconnected agent a way to address the project it
|
|
1242
|
+
// already knows, and to reach into another project for a one-off (e.g. deleting a
|
|
1243
|
+
// stray frame) without the open → act → re-open dance.
|
|
1244
|
+
function withProjectOverride(meta, fn) {
|
|
1245
|
+
const base = getState();
|
|
1246
|
+
return requestState.run(
|
|
1247
|
+
{ ...base, projectId: meta.id, projectMeta: meta, _session: base._session || getSessionState() },
|
|
1248
|
+
fn,
|
|
1249
|
+
);
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// Tools that address frames/assets/layout inside ONE project. These accept the
|
|
1253
|
+
// per-call `projectId` override; everything else resolves org-side.
|
|
1254
|
+
const PROJECT_SCOPED_TOOLS = new Set(['frame', 'ls', 'rm', 'asset', 'connector', 'layout']);
|
|
1255
|
+
|
|
1256
|
+
const PROJECT_OVERRIDE_PARAM = z.string().optional().describe(
|
|
1257
|
+
'Target project (UUID, slug, or name) for THIS call only — does NOT rebind the session. ' +
|
|
1258
|
+
'Use it when the session lost its binding (a reconnect), or to touch another project once ' +
|
|
1259
|
+
'without re-opening it. Omit to use the project bound by project(action="open").',
|
|
1260
|
+
);
|
|
1261
|
+
|
|
1164
1262
|
// Returns { id, slug, name, orgId } for the project this MCP session most
|
|
1165
1263
|
// recently opened — what frame mutations will actually target. Echoed on
|
|
1166
1264
|
// every mutation so silent cross-project drift is visible.
|
|
@@ -1337,21 +1435,20 @@ async function requireBoundOrgForProjectlessMutation(explicitOrg) {
|
|
|
1337
1435
|
if (explicitOrg) return;
|
|
1338
1436
|
const sess = getSessionState();
|
|
1339
1437
|
if (sess.boundOrgId) return; // bound via project open (org = project's org)
|
|
1340
|
-
if (getState().projectId) return; // an active project implies its org
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
//
|
|
1346
|
-
//
|
|
1347
|
-
//
|
|
1348
|
-
|
|
1438
|
+
if (getState().projectId) return; // an active project implies its org — api() now
|
|
1439
|
+
// sends that project's org as X-Drafted-Org, so
|
|
1440
|
+
// the org the guard credits is the org used
|
|
1441
|
+
const orgs = await getOrgList();
|
|
1442
|
+
if (!orgs.length) return; // can't determine membership — don't block a legit write
|
|
1443
|
+
// A remote/web session does get its own server-side session row, but for a MULTI-ORG
|
|
1444
|
+
// user the org on it is the one the connection INHERITED (the user's default), not one
|
|
1445
|
+
// anybody chose — adopting it as a binding is how a Beoflow write landed in Personal.
|
|
1446
|
+
// A single-org user is unambiguous, so keep the zero-friction path for them.
|
|
1447
|
+
if (isRemote && orgs.length === 1) {
|
|
1349
1448
|
const ctx = await getCurrentOrgContext();
|
|
1350
1449
|
if (ctx?.id) sess.boundOrgId = ctx.id;
|
|
1351
1450
|
return;
|
|
1352
1451
|
}
|
|
1353
|
-
const orgs = await getOrgList();
|
|
1354
|
-
if (!orgs.length) return; // can't determine membership — don't block a legit write
|
|
1355
1452
|
if (projectlessMutationNeedsOrg({ orgCount: orgs.length })) {
|
|
1356
1453
|
throw new Error(
|
|
1357
1454
|
`Refusing to guess the org: no project is open and no explicit org was ` +
|
|
@@ -1885,23 +1982,18 @@ tool('project', 'START HERE for project management. Dispatch by `action`: list (
|
|
|
1885
1982
|
return ok(data, { structuredContent });
|
|
1886
1983
|
}
|
|
1887
1984
|
case 'open': {
|
|
1888
|
-
const
|
|
1889
|
-
if (!
|
|
1985
|
+
const ref = args.projectId;
|
|
1986
|
+
if (!ref) throw new Error('projectId required for action=open (UUID, slug, or name)');
|
|
1987
|
+
// Accept a slug or name, not just a UUID — the slug is what an agent actually has
|
|
1988
|
+
// in context after a reconnect.
|
|
1989
|
+
const resolved = await resolveProjectRef(ref).catch(() => null);
|
|
1990
|
+
const projectId = resolved?.id || ref;
|
|
1890
1991
|
const result = await api('POST', '/api/project/switch', { projectId });
|
|
1891
1992
|
joinAgentWsRoom(projectId);
|
|
1892
1993
|
const base = getServerUrl();
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
try {
|
|
1897
|
-
const data = await api('GET', '/api/projects');
|
|
1898
|
-
const proj = (data.projects || []).find(p => p.id === projectId);
|
|
1899
|
-
if (proj) {
|
|
1900
|
-
projectSlug = proj.slug || projectId;
|
|
1901
|
-
orgSlug = proj.orgSlug || null;
|
|
1902
|
-
projectMeta = { id: proj.id, slug: proj.slug || null, name: proj.name || null, orgId: proj.orgId || null, orgSlug };
|
|
1903
|
-
}
|
|
1904
|
-
} catch { /* fall back to projectId */ }
|
|
1994
|
+
const projectMeta = resolved || { id: projectId, slug: null, name: null, orgId: null, orgSlug: null };
|
|
1995
|
+
const projectSlug = projectMeta.slug || projectId;
|
|
1996
|
+
const orgSlug = projectMeta.orgSlug || null;
|
|
1905
1997
|
setMcpActiveProject(projectId, projectMeta);
|
|
1906
1998
|
// Semantic /o/<org-slug>/<project-slug> when the org slug is known; otherwise
|
|
1907
1999
|
// the /project/<slug> resolver redirects to it.
|
|
@@ -2564,6 +2656,7 @@ tool('get_org', {
|
|
|
2564
2656
|
// ── Filesystem tools (direct HTTP to /api/fs) ─────────────────────
|
|
2565
2657
|
|
|
2566
2658
|
tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by path, frame URL, or UUID), write (new frame or overwrite), set_state / get_state (persist or read a deployed windowType:"app" frame\'s hydration state — push e.g. {specText} to drive a generic app frame with data after deploy; the canvas hydrates the app from it on load), Google Sheet actions (`get_sheet`, `read_sheet_values`, `write_sheet_values`, `append_sheet_rows`, `clear_sheet_range`, `update_sheet`), Google Doc actions (`get_doc`, `read_doc_content`, `write_doc_content`, `append_doc_content`, `clear_doc_content`, `update_doc`), Google Slide actions (`get_slide`, `read_slide_content`, `write_slide_content`, `append_slides`, `clear_slides`, `update_slide`), write_excalidraw (native editable Excalidraw diagram), edit (hashline ops), mv (rename/move), anchor (mark as required-read for the layer), search (match frame names). Use project(action="open") first. For listing use `ls`, for deletion use `rm`.\n\n**Google Workspace native content:** Create or attach Google Docs/Sheets/Slides with `frame(action="write", googleType=...)`. After creating, immediately populate the native file using the matching write action in the same tool — do NOT leave it empty and do NOT tell the user you cannot write to it. For Sheets: `write_sheet_values` or `append_sheet_rows` (pass `path` or `googleId` from the create response). For Docs: `write_doc_content`/`append_doc_content`. For Slides: `write_slide_content`/`append_slides`. Read with `read_sheet_values`/`read_doc_content`/`read_slide_content`. Do NOT use inline `frame.write(content)` or hashline `frame.edit` to populate Google Workspace frames.\n\n**Write — content, binary, or Google Workspace frame:** ' + (isRemote ? 'Provide exactly one of `content` (HTML/markdown/text), `base64` (base64-encoded binary with optional `content_type`), or `googleType` (`google-doc`, `google-sheet`, `google-slide`).' : 'Provide exactly one of `content` (HTML/markdown/text), `file_path` (absolute local file), `base64` (base64-encoded binary with optional `content_type`), or `googleType` (`google-doc`, `google-sheet`, `google-slide`).') + ' Call get_org first; when `googleDrive.connected` is true, strongly prefer Google Workspace frames for docs, sheets, and slides in that org. For inline content, filename extension matters: use `.html` for complete HTML documents and `.md` for Markdown. Never place a full HTML document in a `.md` or extensionless frame. For a new Google file, pass `googleType` and optional `title`; for an existing Google file, pass `googleType` plus `url` or `googleId`. ' + (isRemote ? 'For binary frames (images, PDFs, videos), pass `base64` with the binary bytes.' : 'For binary frames (images, PDFs, videos), use `file_path` when the file is local to the MCP host, or `base64` when the caller already has binary bytes.') + '\n\n**Write — dimensions:** By default, frames use the layer\'s default size (e.g. 1440×900 for designs, 1440×3000 for wireframes). Often too large for small content. Use `autoSize: true` to measure HTML content and size to fit, or pass explicit `width`/`height`.', {
|
|
2659
|
+
projectId: PROJECT_OVERRIDE_PARAM,
|
|
2567
2660
|
action: z.enum(['read', 'write', 'set_state', 'get_state', 'write_sheet_values', 'read_sheet_values', 'append_sheet_rows', 'clear_sheet_range', 'get_sheet', 'update_sheet', 'get_doc', 'read_doc_content', 'write_doc_content', 'append_doc_content', 'clear_doc_content', 'update_doc', 'get_slide', 'read_slide_content', 'write_slide_content', 'append_slides', 'clear_slides', 'update_slide', 'create_office', 'read_office', 'edit_office', 'write_excalidraw', 'edit', 'mv', 'anchor', 'search', 'versions', 'read_version', 'restore_version']).describe('Operation to perform. Use native Doc/Slide actions for Google Docs/Slides; do not use inline write/edit for native Workspace content.'),
|
|
2568
2661
|
path: z.string().optional().describe('[read] /{layer}/{lane}/{filename}, frame URL, or UUID. [write|edit|anchor] /{layer}/{lane}/{filename}.'),
|
|
2569
2662
|
lines: z.string().optional().describe('[read] line range (e.g. "1-50"). Omit to read all.'),
|
|
@@ -2650,7 +2743,6 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
2650
2743
|
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.'),
|
|
2651
2744
|
anchored: z.boolean().optional().describe('[anchor] true to anchor, false to unanchor. Anchored frames MUST be read before writing/editing in the same layer.'),
|
|
2652
2745
|
query: z.string().optional().describe('[search] term to match against frame names'),
|
|
2653
|
-
projectId: z.string().optional().describe('[search] limit to a specific project (optional)'),
|
|
2654
2746
|
limit: z.number().optional().describe('[search] max results (default 50, max 200)'),
|
|
2655
2747
|
versionId: z.string().optional().describe('[read_version|restore_version] version id'),
|
|
2656
2748
|
reason: z.string().optional().describe('[restore_version] reason recorded on the snapshot of current content'),
|
|
@@ -3040,7 +3132,9 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
3040
3132
|
const { query, projectId, limit = 50 } = args;
|
|
3041
3133
|
if (!query) throw new Error('query required for action=search');
|
|
3042
3134
|
const params = new URLSearchParams({ q: query });
|
|
3043
|
-
|
|
3135
|
+
// projectId may be a slug/name (the shared override param) — the tool() seam has
|
|
3136
|
+
// already resolved it into state, so take the resolved UUID from there.
|
|
3137
|
+
if (projectId) params.set('projectId', getState().projectId || projectId);
|
|
3044
3138
|
await ensureSession();
|
|
3045
3139
|
const url = `${getServerUrl()}/api/search?${params.toString()}`;
|
|
3046
3140
|
const res = await fetch(url, { headers: getAuthHeaders() });
|
|
@@ -3069,6 +3163,7 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
3069
3163
|
});
|
|
3070
3164
|
|
|
3071
3165
|
tool('ls', 'List contents of the ACTIVE PROJECT. Use ls / after project(action="open") to see layers, workflow, and confirm you\'re in the right project.', {
|
|
3166
|
+
projectId: PROJECT_OVERRIDE_PARAM,
|
|
3072
3167
|
path: z.string().optional().default('/').describe('Directory path: / (layers), /{layer} (lanes), /{layer}/{lane} (frames). Frame entries include frameUrl (canvas deep link) and id (frame UUID).'),
|
|
3073
3168
|
recursive: z.boolean().optional().describe('List contents of subdirectories. When true, forces summary mode (metadata only, no full content) to keep results under the 25k token cap.'),
|
|
3074
3169
|
summary: z.boolean().optional().describe('Include size, updatedAt, title for frames'),
|
|
@@ -3140,10 +3235,17 @@ tool('ls', 'List contents of the ACTIVE PROJECT. Use ls / after project(action="
|
|
|
3140
3235
|
} catch (error) { return err(error); }
|
|
3141
3236
|
});
|
|
3142
3237
|
|
|
3143
|
-
tool('rm', 'Delete a frame or lane
|
|
3144
|
-
|
|
3145
|
-
}
|
|
3238
|
+
tool('rm', 'Delete a frame or lane. Defaults to the ACTIVE PROJECT; pass projectId to delete in another project, or frameId to delete a frame by UUID from anywhere (no project binding needed — the frame\'s own org/project is authoritative). Response includes "project" so you see where the deletion landed.', {
|
|
3239
|
+
projectId: PROJECT_OVERRIDE_PARAM,
|
|
3240
|
+
path: z.string().optional().describe('Path to delete: /{layer}/{lane}/{filename} or /{layer}/{lane} (deletes entire lane). Mutually exclusive with frameId.'),
|
|
3241
|
+
frameId: z.string().optional().describe('Frame UUID to delete, resolved independently of the active project — the one-call cleanup for a frame that landed in the wrong place. Mutually exclusive with path.'),
|
|
3242
|
+
}, async ({ path, frameId }) => {
|
|
3146
3243
|
try {
|
|
3244
|
+
if (frameId && path) throw new Error('Pass either path or frameId, not both.');
|
|
3245
|
+
if (!frameId && !path) throw new Error('rm needs a path or a frameId.');
|
|
3246
|
+
// A frame UUID self-derives its org and project server-side, so this works from an
|
|
3247
|
+
// unbound session and across projects — no open/re-open dance to remove a stray frame.
|
|
3248
|
+
if (frameId) return ok(await api('DELETE', `/api/designs/${encodeURIComponent(frameId)}`));
|
|
3147
3249
|
const clean = path.replace(/^\/+|\/+$/g, '');
|
|
3148
3250
|
const result = await api('DELETE', `/api/fs/${clean}`);
|
|
3149
3251
|
return ok({ ...result, project: getCurrentProjectContext() });
|
|
@@ -3257,6 +3359,7 @@ tool('batch', 'Batch operations on the ACTIVE PROJECT. Response includes "projec
|
|
|
3257
3359
|
// ── Asset tools ──────────────────────────────────────────────────
|
|
3258
3360
|
|
|
3259
3361
|
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.', {
|
|
3362
|
+
projectId: PROJECT_OVERRIDE_PARAM,
|
|
3260
3363
|
action: z.enum(['upload', 'list', 'rm']).describe('Operation to perform.'),
|
|
3261
3364
|
asset_path: z.string().optional().describe('[upload] relative asset path (e.g. "css/styles.css"). Must match the path used in HTML references.'),
|
|
3262
3365
|
file_path: z.string().optional().describe('[upload] absolute path to a local file. Mutually exclusive with content/base64.'),
|
|
@@ -3311,6 +3414,7 @@ tool('asset', 'Manage supporting files (CSS, JS, images, fonts) in the ACTIVE PR
|
|
|
3311
3414
|
// ── Connector tools ───────────────────────────────────────────────
|
|
3312
3415
|
|
|
3313
3416
|
tool('connector', 'Create or remove connectors (arrows) between frames on the surface. `action=connect` adds an arrow from source to target. `action=disconnect` removes one — pass either connectorId directly or source+target to find and delete.', {
|
|
3417
|
+
projectId: PROJECT_OVERRIDE_PARAM,
|
|
3314
3418
|
action: z.enum(['connect', 'disconnect']).describe('Operation to perform.'),
|
|
3315
3419
|
source: z.string().optional().describe('[connect|disconnect] source frame path or ID'),
|
|
3316
3420
|
target: z.string().optional().describe('[connect|disconnect] target frame path or ID'),
|
|
@@ -3357,6 +3461,7 @@ tool('connector', 'Create or remove connectors (arrows) between frames on the su
|
|
|
3357
3461
|
// ── Layout tools ──────────────────────────────────────────────────
|
|
3358
3462
|
|
|
3359
3463
|
tool('layout', 'Auto-arrange frames using graph layout algorithm. Positions connected frames as a directed graph.', {
|
|
3464
|
+
projectId: PROJECT_OVERRIDE_PARAM,
|
|
3360
3465
|
direction: z.enum(['TB', 'LR', 'BT', 'RL']).optional().default('TB').describe('Layout direction: TB (top-bottom), LR (left-right), BT (bottom-top), RL (right-left)'),
|
|
3361
3466
|
}, async ({ direction }) => {
|
|
3362
3467
|
try {
|
|
@@ -3796,7 +3901,14 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
3796
3901
|
// request, nothing is switched); otherwise the session's binding (the open
|
|
3797
3902
|
// project's org). Resolving the override here keeps the echoed `org` field
|
|
3798
3903
|
// and every emitted browser URL truthful about where the call landed.
|
|
3799
|
-
|
|
3904
|
+
// Where the write will ACTUALLY land: the working org (explicit switch, else the
|
|
3905
|
+
// bound project's org — the same address api() puts on the wire). getCurrentOrgContext
|
|
3906
|
+
// reports the session's INHERITED org, so echoing it made a correctly-placed write
|
|
3907
|
+
// look misfiled — and an agent trusting that echo would "fix" a page that was fine.
|
|
3908
|
+
const working = workingOrgId();
|
|
3909
|
+
let orgCtx = working
|
|
3910
|
+
? ((await getOrgList()).find(o => o.id === working) || { id: working, name: null })
|
|
3911
|
+
: await getCurrentOrgContext();
|
|
3800
3912
|
if (args.org) {
|
|
3801
3913
|
const d = await api('GET', '/api/orgs');
|
|
3802
3914
|
const list = (d.orgs || d || []).map(o => ({ id: o.orgId || o.id, name: o.orgName || o.name }));
|
package/mcp/test-org-guards.mjs
CHANGED
|
@@ -5,14 +5,19 @@ import assert from 'node:assert/strict';
|
|
|
5
5
|
import { projectlessMutationNeedsOrg } from './server.mjs';
|
|
6
6
|
|
|
7
7
|
// One rule governs create AND fork (a fork is a create). A write proceeds when its
|
|
8
|
-
// org is a real root — explicit org=, a bound/active project, a
|
|
9
|
-
//
|
|
10
|
-
// multi-org with nothing bound.
|
|
8
|
+
// org is a real root — explicit org=, a bound/active project, or a single-org user's
|
|
9
|
+
// only org. It refuses to GUESS only when the user is multi-org with nothing bound.
|
|
11
10
|
assert.equal(projectlessMutationNeedsOrg({ explicitOrg: 'ee', orgCount: 5 }), false, 'explicit org → allow');
|
|
12
11
|
assert.equal(projectlessMutationNeedsOrg({ boundOrgId: 'causeway', orgCount: 5 }), false, 'bound project → allow (a real root, not the cursor)');
|
|
13
12
|
assert.equal(projectlessMutationNeedsOrg({ activeProjectId: 'p1', orgCount: 5 }), false, 'active project → allow');
|
|
14
|
-
assert.equal(projectlessMutationNeedsOrg({ isRemote: true, orgCount: 5 }), false, 'remote session → allow (adopts its own connection org)');
|
|
15
13
|
assert.equal(projectlessMutationNeedsOrg({ orgCount: 1 }), false, 'single org → allow');
|
|
14
|
+
|
|
15
|
+
// A remote session is NOT a root. Its session org is the org the connection INHERITED
|
|
16
|
+
// (the user's default), not one anybody chose — that inheritance wrote a Beoflow-bound
|
|
17
|
+
// agent's wiki page into Personal. Multi-org remote must name its org like anyone else;
|
|
18
|
+
// single-org remote stays frictionless (covered by the orgCount:1 case above).
|
|
19
|
+
assert.equal(projectlessMutationNeedsOrg({ isRemote: true, orgCount: 5 }), true, 'remote + multi-org → BLOCK (its session org is inherited, not chosen)');
|
|
20
|
+
assert.equal(projectlessMutationNeedsOrg({ isRemote: true, orgCount: 1 }), false, 'remote + single org → allow');
|
|
16
21
|
assert.equal(projectlessMutationNeedsOrg({ orgCount: 0 }), false, 'unknown membership → allow (never block a legit write)');
|
|
17
22
|
assert.equal(projectlessMutationNeedsOrg({ orgCount: 3 }), true, 'multi-org, nothing bound → BLOCK (refuse to guess)');
|
|
18
23
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drafted",
|
|
3
|
-
"version": "1.14.
|
|
3
|
+
"version": "1.14.12",
|
|
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": [
|