drafted 1.11.24 → 1.11.26

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 +92 -46
  2. package/package.json +1 -1
package/mcp/server.mjs CHANGED
@@ -1138,6 +1138,18 @@ async function requireBoundOrgForProjectlessMutation(explicitOrg) {
1138
1138
  const sess = getSessionState();
1139
1139
  if (sess.boundOrgId) return; // bound via project open / get_org switch
1140
1140
  if (getState().projectId) return; // an active project implies its org
1141
+ // Remote/web sessions are isolated per connection: each one gets its OWN
1142
+ // server-side session row with its own org_id set on connect, so the session
1143
+ // org IS this session's binding — there's no shared, long-lived session to
1144
+ // confuse here the way stdio has. Adopt that org automatically instead of
1145
+ // forcing the agent to call get_org switch / project open before a project-less
1146
+ // wiki or skill write. The DRAFT-36 Phase 4 "refuse to guess" guard below
1147
+ // therefore applies to stdio only.
1148
+ if (isRemote) {
1149
+ const ctx = await getCurrentOrgContext();
1150
+ if (ctx?.id) sess.boundOrgId = ctx.id;
1151
+ return;
1152
+ }
1141
1153
  let count = sess.cachedOrgCount;
1142
1154
  if (count == null) {
1143
1155
  try {
@@ -1406,6 +1418,7 @@ tool('project', 'START HERE for project management. Dispatch by `action`: list (
1406
1418
  name: z.string().optional().describe('[create|update] project name'),
1407
1419
  description: z.string().nullable().optional().describe('[create|update] project description'),
1408
1420
  templateSlug: z.string().optional().describe('[create] template slug (e.g. "web-design", "mobile-app", "landing-page")'),
1421
+ org: z.string().optional().describe('[create] org slug or id to create the project in, without switching the session. Defaults to the bound/active org.'),
1409
1422
  folder: z.string().nullable().optional().describe('[update] folder name (null to remove from folder)'),
1410
1423
  layers: z.array(z.object({}).passthrough()).optional().describe('[update] full layers array replacement. Use ls / to read current layers first.'),
1411
1424
  targetOrgId: z.string().optional().describe('[move] destination organization ID. Get org IDs from action=list (each project has an orgId field) or get_org. Both source and target org must include the current user.'),
@@ -1529,12 +1542,14 @@ tool('project', 'START HERE for project management. Dispatch by `action`: list (
1529
1542
  case 'create': {
1530
1543
  const g3 = g3Block(getSessionState().gates);
1531
1544
  if (g3) return err(new Error(g3));
1532
- const { name, description, templateSlug } = args;
1545
+ const { name, description, templateSlug, org } = args;
1533
1546
  if (!name) throw new Error('name required for action=create');
1534
1547
  const body = { name };
1535
1548
  if (description) body.description = description;
1536
1549
  if (templateSlug) body.templateSlug = templateSlug;
1537
- return ok(withProjectBreadcrumb(await api('POST', '/api/projects', body)));
1550
+ // `org` targets a specific org without switching the session cursor.
1551
+ const createExtra = org ? { 'X-Drafted-Org': org } : {};
1552
+ return ok(withProjectBreadcrumb(await api('POST', '/api/projects', body, createExtra)));
1538
1553
  }
1539
1554
  case 'update': {
1540
1555
  const { projectId, name, folder, description, layers } = args;
@@ -2947,8 +2962,11 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
2947
2962
  const { action } = args;
2948
2963
  // Org-creating/editing skill actions on the project-less surface must have a
2949
2964
  // bound org (or explicit org=) when the user is multi-org. (DRAFT-36 Phase 4.)
2965
+ // UUID-first exception: update/remove addressed by a skillId derive their org
2966
+ // from the skill row server-side, so the bound-org gate is unnecessary.
2950
2967
  if (['add', 'update', 'remove', 'push', 'fork'].includes(action)) {
2951
- await requireBoundOrgForProjectlessMutation(args.org);
2968
+ const byId = (action === 'update' || action === 'remove') && /^[a-f0-9-]{36}$/.test(args.skillId || '');
2969
+ if (!byId) await requireBoundOrgForProjectlessMutation(args.org);
2952
2970
  }
2953
2971
  switch (action) {
2954
2972
  case 'search': {
@@ -3155,6 +3173,8 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
3155
3173
  tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and other agents/humans share maintenance — every edit broadcasts live, and edits from others appear in `recent` and on `read`.\n\n**Wiki always operates on the ACTIVE org.** If the content you need lives in a different org, switch first via `get_org(action="switch", orgId=...)` — don\'t assume "no hits" means the content doesn\'t exist. Verify the active org with `get_org` before searching.\n\nBefore mutating: check `recent` and `search` for relevant existing pages. Before mv/rm: check `links` (or pass `dryRun=true`). After completing a logical session of work, append a `log` entry.\n\nThe tool handles bookkeeping you\'d otherwise forget: `mv` rewrites inbound references via the link index, `read` shows who edited last and when. Use `health` to find unlinked pages and broken links.\n\n**Skill gate:** the org may attach a `wiki-maintainer` skill that you MUST load before mutations. If you get a skill-gate error, run skill(action="load", skill="wiki-maintainer") then retry.', {
3156
3174
  action: z.enum(['ls', 'recent', 'read', 'search', 'links', 'log', 'health', 'write', 'edit', 'mv', 'rm', 'source-register', 'source-list', 'source-get', 'bulk-write']).describe('Operation to perform.'),
3157
3175
  path: z.string().optional().describe('[ls|read|links] wiki path. For ls: default / (root). For read: required. For links: required.'),
3176
+ pageId: z.string().optional().describe('[read|edit|mv|rm|links] page UUID (from read/search). UUID-first: addresses the page directly, org auto-derives — no org needed and no path lookup. Preferred over path for an existing page.'),
3177
+ org: z.string().optional().describe('[write] org slug or id to create/target the page in, without switching the session. Required to create a page when you belong to more than one org and none is bound.'),
3158
3178
  recursive: z.boolean().optional().describe('[ls] list recursively with depth indicators'),
3159
3179
  limit: z.number().optional().describe('[recent|search] max results (recent default 10, search default 25)'),
3160
3180
  query: z.string().optional().describe('[search] term to search in title, path, and content'),
@@ -3184,10 +3204,13 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
3184
3204
  try {
3185
3205
  const { action } = args;
3186
3206
 
3187
- // Project-less wiki mutations must have a bound org when the user is
3188
- // multi-org, so a write never silently lands in an inherited org. (Ph4.)
3207
+ // UUID-first: a `pageId` (edit/mv/rm) or an explicit `org` (write) makes the
3208
+ // target org unambiguous, so the bound-org gate is unnecessary skip it.
3209
+ // Otherwise (path-addressed, multi-org, nothing bound) the gate still
3210
+ // refuses to guess the org so a write never silently lands in the wrong one.
3211
+ const orgHeader = args.org ? { 'X-Drafted-Org': args.org } : {};
3189
3212
  if (['write', 'edit', 'mv', 'rm', 'bulk-write'].includes(action)) {
3190
- await requireBoundOrgForProjectlessMutation(undefined);
3213
+ if (!args.pageId) await requireBoundOrgForProjectlessMutation(args.org);
3191
3214
  }
3192
3215
 
3193
3216
  // Resolve org context once for the wiki tool. Each MCP session is bound
@@ -3288,22 +3311,29 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
3288
3311
  // Returns content in hashline format (`LINE+ID|content`) so the
3289
3312
  // agent can produce hashline edit operations. Mirrors frame.read.
3290
3313
  case 'read': {
3291
- const { path: readPath, lines: readLines } = args;
3292
- if (!readPath) throw new Error('path required for action=read');
3293
- const normalized = normalizeWikiPath(readPath);
3294
- const params = new URLSearchParams({ path: normalized, format: 'hashline' });
3295
- if (readLines) {
3296
- if (!/^\d+-\d+$/.test(readLines)) throw new Error(`lines must be "N-M" (e.g. "10-50"), got: ${readLines}`);
3297
- params.set('lines', readLines);
3314
+ const { path: readPath, pageId: readPageId, lines: readLines } = args;
3315
+ if (readLines && !/^\d+-\d+$/.test(readLines)) throw new Error(`lines must be "N-M" (e.g. "10-50"), got: ${readLines}`);
3316
+ let page;
3317
+ if (readPageId) {
3318
+ // UUID-first: address the page directly, org auto-derives server-side.
3319
+ const params = new URLSearchParams({ format: 'hashline' });
3320
+ if (readLines) params.set('lines', readLines);
3321
+ page = await api('GET', `/api/wiki/pages/${readPageId}?${params.toString()}`);
3322
+ } else {
3323
+ if (!readPath) throw new Error('path or pageId required for action=read');
3324
+ const normalized = normalizeWikiPath(readPath);
3325
+ const params = new URLSearchParams({ path: normalized, format: 'hashline' });
3326
+ if (readLines) params.set('lines', readLines);
3327
+ page = await api('GET', `/api/wiki/page?${params.toString()}`, undefined, orgHeader);
3298
3328
  }
3299
- const page = await api('GET', `/api/wiki/page?${params.toString()}`);
3300
3329
  // Get backlink count via search (approximate)
3301
3330
  let backlinkCount = 0;
3302
3331
  try {
3303
- const searchRes = await api('GET', `/api/wiki/search?q=${encodeURIComponent(normalized)}`);
3332
+ const searchRes = await api('GET', `/api/wiki/search?q=${encodeURIComponent(page.path)}`, undefined, orgHeader);
3304
3333
  backlinkCount = (searchRes.hits || []).length;
3305
3334
  } catch { /* best-effort */ }
3306
3335
  return ok({
3336
+ id: page.id,
3307
3337
  path: page.path,
3308
3338
  title: page.title,
3309
3339
  type: page.type,
@@ -3321,19 +3351,23 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
3321
3351
  case 'search': {
3322
3352
  const { query: searchQuery, limit: searchLimit = 25 } = args;
3323
3353
  if (!searchQuery) throw new Error('query required for action=search');
3324
- const result = await api('GET', `/api/wiki/search?q=${encodeURIComponent(searchQuery)}&limit=${searchLimit}`);
3325
- const hits = (result.hits || []).map(h => ({ path: h.path, title: h.title, url: wikiBrowserUrl(h.path) }));
3354
+ const result = await api('GET', `/api/wiki/search?q=${encodeURIComponent(searchQuery)}&limit=${searchLimit}`, undefined, orgHeader);
3355
+ // Surface pageId so the agent can re-address the page by UUID (org-free).
3356
+ const hits = (result.hits || []).map(h => ({ id: h.id, path: h.path, title: h.title, url: wikiBrowserUrl(h.path) }));
3326
3357
  markSearched(getSessionState().gates, 'wiki');
3327
3358
  return ok({ hits });
3328
3359
  }
3329
3360
 
3330
3361
  // ── links ───────────────────────────────────────────────────
3331
3362
  case 'links': {
3332
- const { path: linksPath } = args;
3333
- if (!linksPath) throw new Error('path required for action=links');
3334
- const normalized = normalizeWikiPath(linksPath);
3335
- const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`);
3336
- return ok(await api('GET', `/api/wiki/pages/${page.id}/links`));
3363
+ const { path: linksPath, pageId: linksPageId } = args;
3364
+ let id = linksPageId;
3365
+ if (!id) {
3366
+ if (!linksPath) throw new Error('path or pageId required for action=links');
3367
+ const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalizeWikiPath(linksPath))}`, undefined, orgHeader);
3368
+ id = page.id;
3369
+ }
3370
+ return ok(await api('GET', `/api/wiki/pages/${id}/links`));
3337
3371
  }
3338
3372
 
3339
3373
  // ── log ─────────────────────────────────────────────────────
@@ -3383,13 +3417,14 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
3383
3417
  if (writeType) body.type = writeType;
3384
3418
  if (frontmatter !== undefined) body.frontmatter = frontmatter;
3385
3419
 
3386
- // Check if page exists — if so, update; otherwise create
3420
+ // Check if page exists — if so, update; otherwise create. `orgHeader`
3421
+ // (the `org` arg) targets a specific org without switching the session.
3387
3422
  try {
3388
- const existing = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`);
3389
- const result = await api('PATCH', `/api/wiki/pages/${existing.id}`, body);
3423
+ const existing = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`, undefined, orgHeader);
3424
+ const result = await api('PATCH', `/api/wiki/pages/${existing.id}`, body, orgHeader);
3390
3425
  return ok(withOrg({ path: result.path, title: result.title, id: result.id, updated: true, url: wikiBrowserUrl(result.path) }));
3391
3426
  } catch {
3392
- const result = await api('POST', '/api/wiki/pages', body);
3427
+ const result = await api('POST', '/api/wiki/pages', body, orgHeader);
3393
3428
  return ok(withOrg({ path: result.path, title: result.title, id: result.id, created: true, url: wikiBrowserUrl(result.path) }));
3394
3429
  }
3395
3430
  }
@@ -3417,50 +3452,61 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
3417
3452
  });
3418
3453
  }
3419
3454
  case 'edit': {
3420
- const { path: editPath, operations: editOps } = args;
3421
- if (!editPath) throw new Error('path required for action=edit');
3455
+ const { path: editPath, pageId: editPageId, operations: editOps } = args;
3422
3456
  if (!Array.isArray(editOps) || editOps.length === 0) throw new Error('operations (array) required for action=edit');
3423
- const normalized = normalizeWikiPath(editPath);
3424
- const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`);
3425
- const result = await api('POST', `/api/wiki/pages/${page.id}/edit`, { operations: editOps });
3457
+ let id = editPageId;
3458
+ if (!id) {
3459
+ if (!editPath) throw new Error('path or pageId required for action=edit');
3460
+ const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalizeWikiPath(editPath))}`, undefined, orgHeader);
3461
+ id = page.id;
3462
+ }
3463
+ const result = await api('POST', `/api/wiki/pages/${id}/edit`, { operations: editOps });
3426
3464
  return ok(withOrg({ path: result.path, id: result.id, updated: true, applied: result.applied, url: wikiBrowserUrl(result.path) }));
3427
3465
  }
3428
3466
 
3429
3467
  // ── mv ──────────────────────────────────────────────────────
3430
3468
  case 'mv': {
3431
- const { from: mvFrom, to: mvTo, dryRun: mvDryRun = false } = args;
3432
- if (!mvFrom) throw new Error('from (source path) required for action=mv');
3469
+ const { from: mvFrom, to: mvTo, pageId: mvPageId, dryRun: mvDryRun = false } = args;
3433
3470
  if (!mvTo) throw new Error('to (destination path) required for action=mv');
3434
- const fromPath = normalizeWikiPath(mvFrom);
3435
3471
  const toPath = normalizeWikiPath(mvTo);
3436
- if (fromPath === toPath) throw new Error('source and destination are the same');
3437
-
3438
- const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(fromPath)}`);
3472
+ let id = mvPageId;
3473
+ if (!id) {
3474
+ if (!mvFrom) throw new Error('from (source path) or pageId required for action=mv');
3475
+ const fromPath = normalizeWikiPath(mvFrom);
3476
+ if (fromPath === toPath) throw new Error('source and destination are the same');
3477
+ const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(fromPath)}`, undefined, orgHeader);
3478
+ id = page.id;
3479
+ }
3439
3480
 
3440
3481
  if (mvDryRun) {
3441
- const { referrers } = await api('GET', `/api/wiki/pages/${page.id}/referrers`);
3482
+ const { referrers } = await api('GET', `/api/wiki/pages/${id}/referrers`);
3442
3483
  return ok(withOrg({ impacted: referrers.map(r => ({ path: r.path, title: r.title })) }));
3443
3484
  }
3444
3485
 
3445
3486
  // Server-side cascade: /move rewrites referrers in one transaction
3446
- const moved = await api('PATCH', `/api/wiki/pages/${page.id}/move`, { path: toPath });
3487
+ const moved = await api('PATCH', `/api/wiki/pages/${id}/move`, { path: toPath });
3447
3488
  return ok(withOrg({ path: moved.path, title: moved.title, id: moved.id, referrersUpdated: moved.referrersUpdated ?? 0, url: wikiBrowserUrl(moved.path) }));
3448
3489
  }
3449
3490
 
3450
3491
  // ── rm ──────────────────────────────────────────────────────
3451
3492
  case 'rm': {
3452
- const { path: rmPath, dryRun: rmDryRun = false } = args;
3453
- if (!rmPath) throw new Error('path required for action=rm');
3454
- const normalized = normalizeWikiPath(rmPath);
3455
- const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`);
3456
- const { referrers } = await api('GET', `/api/wiki/pages/${page.id}/referrers`);
3493
+ const { path: rmPath, pageId: rmPageId, dryRun: rmDryRun = false } = args;
3494
+ let id = rmPageId;
3495
+ let normalized = null;
3496
+ if (!id) {
3497
+ if (!rmPath) throw new Error('path or pageId required for action=rm');
3498
+ normalized = normalizeWikiPath(rmPath);
3499
+ const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`, undefined, orgHeader);
3500
+ id = page.id;
3501
+ }
3502
+ const { referrers } = await api('GET', `/api/wiki/pages/${id}/referrers`);
3457
3503
  const broken = referrers.map(r => ({ path: r.path, title: r.title }));
3458
3504
 
3459
3505
  if (rmDryRun) {
3460
3506
  return ok(withOrg({ brokenAfterDelete: broken }));
3461
3507
  }
3462
- await api('DELETE', `/api/wiki/pages/${page.id}`);
3463
- return ok(withOrg({ deleted: true, path: normalized, id: page.id, brokenReferences: broken }));
3508
+ await api('DELETE', `/api/wiki/pages/${id}`);
3509
+ return ok(withOrg({ deleted: true, path: normalized, id, brokenReferences: broken }));
3464
3510
  }
3465
3511
 
3466
3512
  // ── bulk-write ──────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.11.24",
3
+ "version": "1.11.26",
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": [