@theronap/cortex-mcp 0.9.64 → 0.9.66

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/lib/server.mjs +185 -11
  2. package/package.json +1 -1
package/lib/server.mjs CHANGED
@@ -557,6 +557,12 @@ export async function runServer(version) {
557
557
  // word "today" from sections it was never meant to touch. This footer renders on EVERY
558
558
  // page read in the system, so it was the single widest surface pointing the wrong way.
559
559
  let footer = `— Follow any [[links]] above with read_page to go deeper.\n— If you hold fresher FIRST-HAND truth than this page — something you established THIS session (ran the command, made the change, hold the artifact) that a section here gets concretely wrong — fix just that passage with \`edit_page\`: quote the wrong text as old_string and pass this page's \`version\` as base_version (any node you are permitted to edit; concise; a material contradiction, not merely extra detail). If the save comes back stale the page changed under you — the conflict hands back what changed, so re-anchor from that instead of re-reading. Reading a stale page you can fix IS the trigger to fix it. Editing is pre-authorized — do NOT ask the user before updating (edits are versioned + reversible via page_history/rollback_page); update, then briefly report it. Use \`author\` only to CREATE a page or rewrite one wholesale: it re-emits every section, so untouched sections get retyped on the way through and drift.\n— Citing code? Use a SYMBOL and file (\`formConnections\` in \`web/app/api/ingest/route.ts\`), never a line number — line numbers drift with every commit above them. And cite only what you opened THIS session; re-emitting a reference you read on another page is how a stale claim gains a second source and starts looking corroborated.`
560
+ // ADDRESSING (2026-08-04). The server accepts `ref` on every page/node route and its ambiguity
561
+ // 409s hand refs back — but read_page never PRINTED one, so the only way to obtain a ref was to
562
+ // trigger the error first. That made ID addressing reachable in principle and unusable in
563
+ // practice. The ref is per-NODE (engram_ref) while `version` is per-TIER, which is why it rides
564
+ // on the page header rather than inside a tier block.
565
+ if (m.ref) footer += `\n— \`ref:\` above is this page's stable id. Pass it as \`ref\` to any page tool (set_page_privacy, page grants, node policy, author, rollback, node timeline) to address THIS page: refs are unique across brains, so two pages sharing a name in different brains cannot collide and no active-brain guess is involved. Prefer it over \`name\` whenever you already hold one.`
560
566
  // slice 4: when the page carries identifier stamps, the history projection is one flag away.
561
567
  const allBody = m.tiers.flatMap((t) => (t.sections ?? []).map((s) => s.body)).join('\n')
562
568
  const stamps = [...new Set((allBody.match(/\[\[repo:[a-z0-9][a-z0-9-]*\/[a-z0-9_.-]+\]\]/gi) ?? []).map((s) => s.toLowerCase()))]
@@ -568,7 +574,10 @@ export async function runServer(version) {
568
574
  // saw the footer, and repaired the page. A dashboard nobody opens would not have.
569
575
  if (m.backlog?.nudge) footer += `\n— ${m.backlog.nudge}`
570
576
  const brainTag = tagBrain ? ` · brain: ${m.brain}` : ''
571
- return `# ${m.title ?? name} (full authored page${brainTag})\n\n${blocks.join('\n\n---\n\n')}\n\n${footer}`
577
+ // Always emitted, not only in the multi-brain case: the ref is what makes the brain question
578
+ // moot, so withholding it until brains collide is exactly backwards.
579
+ const refLine = m.ref ? `\nref: ${m.ref}` : ''
580
+ return `# ${m.title ?? name} (full authored page${brainTag})${refLine}\n\n${blocks.join('\n\n---\n\n')}\n\n${footer}`
572
581
  }
573
582
  if (matches.length === 1) {
574
583
  return { content: [{ type: 'text', text: renderMatch(matches[0], false) }] }
@@ -921,6 +930,167 @@ export async function runServer(version) {
921
930
  },
922
931
  )
923
932
 
933
+ // Stage 1 of agent-assisted member-add. It PREPARES the invite and stops — it does not perform it.
934
+ //
935
+ // WHY IT STOPS. Member-add lives on /api/invite and /api/members, which authenticate with
936
+ // `verifyAuthToken` (the Supabase login JWT) and reject the personal token this client holds. That
937
+ // split is not an oversight: `create_brain` accepts a personal token and writes a `users` row, so
938
+ // the line is not "membership writes need a browser" — it is "enrolling YOURSELF is self-service,
939
+ // granting a THIRD PARTY access to your brain is not." Stage 2 revisits that deliberately.
940
+ //
941
+ // What is worth automating is everything up to the grant. `managerId` is a `users.id` scoped to
942
+ // ONE brain — a multi-brain caller has a different one per membership and no way to see any of
943
+ // them from a chat window. That lookup is the part that actually blocks people, so this resolves
944
+ // it and hands back a payload the console can accept verbatim.
945
+ server.registerTool(
946
+ 'add_to_brain',
947
+ {
948
+ title: 'Add someone to one of your brains',
949
+ description: "Add a person to one of your brains, or work out what it would take. Resolves which brain, whether you may add to it, and the manager id they are placed under. WITHOUT execute:true it only reports the plan and hands back a console link — call it that way first and show the user what you are about to do. WITH execute:true it performs the add, and then `brain` is REQUIRED: an access grant must never be aimed by a shared write pointer. Adding cannot be undone through the API. Use when asked to invite/add someone to a brain.",
950
+ inputSchema: {
951
+ email: z.string().describe("the person's email address — the login their Agnoclast account is (or will be) on"),
952
+ name: z.string().optional().describe('their full name; falls back to the email local-part'),
953
+ title: z.string().optional().describe('job title (optional)'),
954
+ role: z.enum(['member', 'manager', 'owner']).optional().describe("their role in this brain (default 'member'). NOTE: only owner/manager can see records scoped to people below them"),
955
+ brain: z.string().optional().describe('which brain, by name or org id. Optional when planning; REQUIRED with execute:true. Pass the org id when a name matches more than one of your brains'),
956
+ execute: z.boolean().optional().describe('default false. false = report the plan only. true = actually add them — not undoable through the API, and requires an explicit brain'),
957
+ },
958
+ },
959
+ async ({ email, name, title, role, brain, execute }) => {
960
+ const addr = (email ?? '').trim().toLowerCase()
961
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(addr)) return toolError(`"${email}" does not look like an email address.`)
962
+
963
+ // Executing without naming a brain would let the ACCOUNT-WIDE write pointer decide who gets
964
+ // access to what. That pointer is shared by every session that has not set its own, so it
965
+ // reflects whatever unrelated work last touched it — it is not information about this grant.
966
+ // Planning may fall back to it (nothing happens); performing may not.
967
+ if (execute && !brain?.trim()) {
968
+ return toolError('To actually add someone you must name the brain — an access grant must not be aimed by the shared write pointer. Re-run with brain set (org id if the name is not unique).')
969
+ }
970
+
971
+ let res
972
+ try {
973
+ res = await fetchCortex(`${BASE}/api/brains`, { headers: { Authorization: `Bearer ${TOKEN}` } })
974
+ } catch (e) {
975
+ return toolError(`Could not read your brains: ${e.message}`)
976
+ }
977
+ if (!res.ok) {
978
+ const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
979
+ return toolError(`Could not read your brains: ${d.message}`)
980
+ }
981
+ const { brains } = await res.json()
982
+ if (!brains?.length) return toolError('You have no brains, so there is nothing to add anyone to.')
983
+
984
+ // Name or org id, either way — a person says "my mom's brain", not a uuid.
985
+ //
986
+ // ⚠ BRAIN NAMES ARE NOT UNIQUE ACROSS ACCOUNTS, and the collision is the LIKELY case, not the
987
+ // exotic one: the default personal brain is called "Personal", so the moment you are added to
988
+ // someone else's you hold two. Observed 2026-08-04 within a minute of exactly that happening.
989
+ // A `.find()` here would silently return whichever sorted first and add the person to an
990
+ // arbitrary one — a wrong-brain member-add with no symptom, which is the same silent-misroute
991
+ // family as the write pointer. An org id always wins; an ambiguous NAME is a 409-shaped error,
992
+ // never a guess.
993
+ const wanted = brain?.trim().toLowerCase()
994
+ let target
995
+ if (wanted) {
996
+ const byId = brains.find((b) => b.orgId.toLowerCase() === wanted)
997
+ const byName = brains.filter((b) => b.name.toLowerCase() === wanted)
998
+ if (!byId && byName.length > 1) {
999
+ const rows = byName.map((b) => ` ${b.name} (${b.role}, ${b.pageCount} pages) [${b.orgId}]`)
1000
+ return toolError(
1001
+ `You belong to ${byName.length} brains called "${brain}". I will not guess which one to add someone to — pass the org id:\n${rows.join('\n')}`,
1002
+ )
1003
+ }
1004
+ target = byId ?? byName[0]
1005
+ } else {
1006
+ target = brains.find((b) => b.isActive)
1007
+ }
1008
+ if (!target) {
1009
+ const names = brains.map((b) => `${b.name} [${b.orgId}]`).join(', ')
1010
+ return toolError(
1011
+ wanted
1012
+ ? `No brain called "${brain}". You belong to: ${names}.`
1013
+ : `Could not tell which brain you mean — you belong to ${brains.length} and none is marked active. Pass one of: ${names}.`,
1014
+ )
1015
+ }
1016
+
1017
+ // Role is PER MEMBERSHIP. Being an owner elsewhere grants nothing here, and the server will
1018
+ // enforce this again — checking now turns a later 403 into an answer.
1019
+ if (!['owner', 'manager', 'admin'].includes(target.role)) {
1020
+ return toolError(`You are a "${target.role}" in ${target.name}, and only owners and managers can add people. Ask an owner of ${target.name} to do it.`)
1021
+ }
1022
+
1023
+ // Absent on any console deployed before userId was added to /api/brains. Say so precisely —
1024
+ // a published MCP version is not a deployed API, and the two drift.
1025
+ if (!target.userId) {
1026
+ return toolError(`This Agnoclast deployment does not report your member id for ${target.name} yet, so the manager cannot be resolved. The API needs the /api/brains update that adds "userId".`)
1027
+ }
1028
+
1029
+ const payload = {
1030
+ name: name?.trim() || addr.split('@')[0],
1031
+ email: addr,
1032
+ ...(title?.trim() ? { title: title.trim() } : {}),
1033
+ role: role ?? 'member',
1034
+ managerId: target.userId,
1035
+ }
1036
+
1037
+ if (execute) {
1038
+ // `brain` is sent as the ORG ID, never the label the caller typed: the server resolves
1039
+ // labels too, and a name that was unambiguous here could match differently there. The id is
1040
+ // the same value on both sides.
1041
+ let done
1042
+ try {
1043
+ done = await fetchCortex(`${BASE}/api/invite`, {
1044
+ method: 'POST',
1045
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
1046
+ body: JSON.stringify({ ...payload, brain: target.orgId }),
1047
+ })
1048
+ } catch (e) {
1049
+ return toolError(`Could not add ${payload.email} to ${target.name}: ${e.message}`)
1050
+ }
1051
+ if (!done.ok) {
1052
+ const d = classify(done.status, done.headers.get('content-type'), await done.text(), done.headers.get('x-vercel-id'))
1053
+ return toolError(`Could not add ${payload.email} to ${target.name}: ${d.message}`)
1054
+ }
1055
+ const r = await done.json().catch(() => ({}))
1056
+ if (r.alreadyMember) {
1057
+ return { content: [{ type: 'text', text: `${payload.email} was already a member of ${target.name} — nothing changed.` }] }
1058
+ }
1059
+ // Two different outcomes for the human: a brand-new account has a credential that somebody
1060
+ // must physically pass on, an existing one has none.
1061
+ const how = r.existingAccount
1062
+ ? 'They already had an Agnoclast account, so they keep their current login and simply gain this brain.'
1063
+ : `A new account was created. Temporary password: ${r.password} — they must change it on first sign-in.`
1064
+ return { content: [{ type: 'text', text: `Added ${payload.name} <${payload.email}> to ${target.name} as ${payload.role}, placed under you.\n${how}\n\nThis cannot be undone through the API — removing a membership currently needs direct database access.` }] }
1065
+ }
1066
+
1067
+ const text = [
1068
+ `Ready to add ${payload.name} <${payload.email}> to ${target.name} as ${payload.role}.`,
1069
+ '',
1070
+ ` brain ${target.name} [${target.orgId}]`,
1071
+ ` your role ${target.role} — you may add people here`,
1072
+ ` manager you [${target.userId}] (they are placed under you)`,
1073
+ '',
1074
+ 'Nothing has happened yet. To go ahead, re-run with execute:true and the same brain —',
1075
+ `or do it yourself at ${BASE}/?invite=1 with these values:`,
1076
+ '',
1077
+ ` Full name ${payload.name}`,
1078
+ ` Email ${payload.email}`,
1079
+ ...(payload.title ? [` Job title ${payload.title}`] : []),
1080
+ ` Role ${payload.role}`,
1081
+ ` Manager you`,
1082
+ '',
1083
+ 'If they already have an Agnoclast account they keep their existing password and simply gain',
1084
+ 'this brain; if not, the console shows a temporary password to pass on. Either way the button',
1085
+ 'handles it — you do not need to know which in advance.',
1086
+ '',
1087
+ `payload: ${JSON.stringify(payload)}`,
1088
+ ].join('\n')
1089
+
1090
+ return { content: [{ type: 'text', text }] }
1091
+ },
1092
+ )
1093
+
924
1094
  server.registerTool(
925
1095
  'list_brain_pages',
926
1096
  {
@@ -1239,20 +1409,21 @@ export async function runServer(version) {
1239
1409
  description: 'Re-tier a wiki page you own or may edit: "accessible" (anyone in the org), "scoped" (owner + their management chain), or "confidential" (owner only, plus explicit grants). Demoting a PROJECT page also demotes its evidence records (demote-only; each record\'s owner is notified and can revert). Promotions never touch records. If the target tier already has a page, merge your content into it via `author` FIRST, then read_page the target again to get its fresh version, then re-run this with absorb=true and target_version=<that version> — absorb will REJECT (not silently drop content) if target_version doesn\'t match what\'s actually there, so a merge that didn\'t really land can\'t destroy your source page. Org admins may demote any page, never promote.',
1240
1410
  inputSchema: {
1241
1411
  kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
1242
- name: z.string().describe('the exact page name'),
1412
+ name: z.string().optional().describe('the exact page name (or pass `ref` instead — one of the two is required)'),
1413
+ ref: z.string().optional().describe('the page\'s stable id, printed as `ref:` by read_page. PREFER THIS over name when you have it: a ref is unique across brains, so it addresses exactly one page and never needs a brain to disambiguate it.'),
1243
1414
  tier: z.enum(['accessible', 'scoped', 'confidential']).describe('the new visibility tier'),
1244
1415
  source_tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('when the node has multiple tier variants: which one to move'),
1245
1416
  absorb: z.boolean().optional().describe('after merging your content into an existing target-tier page via author: true removes your now-absorbed source variant. Requires target_version.'),
1246
1417
  target_version: z.string().optional().describe('REQUIRED with absorb=true — the target page\'s version, read via read_page AFTER your author() merge landed. Proves the merge actually happened before your source page is deleted; a stale or guessed value is rejected, not silently accepted.'),
1247
1418
  },
1248
1419
  },
1249
- async ({ kind, name, tier, source_tier, absorb, target_version }) => {
1420
+ async ({ kind, name, ref, tier, source_tier, absorb, target_version }) => {
1250
1421
  let res
1251
1422
  try {
1252
1423
  res = await fetchCortex(`${BASE}/api/brain/page-privacy`, {
1253
1424
  method: 'POST',
1254
1425
  headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
1255
- body: JSON.stringify({ kind, name, tier, ...(source_tier ? { source_tier } : {}), ...(absorb ? { absorb: true } : {}), ...(target_version ? { target_version } : {}) }),
1426
+ body: JSON.stringify({ kind, ...(name ? { name } : {}), ...(ref ? { ref } : {}), tier, ...(source_tier ? { source_tier } : {}), ...(absorb ? { absorb: true } : {}), ...(target_version ? { target_version } : {}) }),
1256
1427
  })
1257
1428
  } catch (e) {
1258
1429
  return toolError(`Could not set page privacy: ${e.message}`)
@@ -1284,19 +1455,20 @@ export async function runServer(version) {
1284
1455
  description: 'Share one of YOUR non-accessible wiki pages with a specific org member (or take that access back). A grant lets exactly that person read the page even though its tier would hide it — the escape hatch for "confidential, but Dana needs it". Owner-only. Grants survive re-tiering: revoke them when they should end.',
1285
1456
  inputSchema: {
1286
1457
  kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
1287
- name: z.string().describe('the exact page name'),
1288
- grantee: z.string().describe('the org member\'s display name or email (must resolve uniquely use email if ambiguous)'),
1458
+ name: z.string().optional().describe('the exact page name (or pass `ref` instead — one of the two is required)'),
1459
+ ref: z.string().optional().describe('the page\'s stable id, printed as `ref:` by read_page. Prefer this over name: unique across brains, so it addresses exactly one page.'),
1460
+ grantee: z.string().describe('display name or email of a member OF THE PAGE\'S BRAIN (must resolve uniquely — use email if ambiguous)'),
1289
1461
  action: z.enum(['grant', 'revoke']).describe('grant or revoke'),
1290
1462
  tier: z.enum(['scoped', 'confidential']).optional().describe('which variant (default: the most restrictive one)'),
1291
1463
  },
1292
1464
  },
1293
- async ({ kind, name, grantee, action, tier }) => {
1465
+ async ({ kind, name, ref, grantee, action, tier }) => {
1294
1466
  let res
1295
1467
  try {
1296
1468
  res = await fetchCortex(`${BASE}/api/brain/page-grants`, {
1297
1469
  method: 'POST',
1298
1470
  headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
1299
- body: JSON.stringify({ kind, name, grantee, action, ...(tier ? { tier } : {}) }),
1471
+ body: JSON.stringify({ kind, ...(name ? { name } : {}), ...(ref ? { ref } : {}), grantee, action, ...(tier ? { tier } : {}) }),
1300
1472
  })
1301
1473
  } catch (e) {
1302
1474
  return toolError(`Could not ${action}: ${e.message}`)
@@ -1315,13 +1487,15 @@ export async function runServer(version) {
1315
1487
  description: 'Show every explicit access grant on YOUR page\'s tier variants (owner-only). Use after re-tiering a page — grants survive tier changes and keep granting until revoked.',
1316
1488
  inputSchema: {
1317
1489
  kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
1318
- name: z.string().describe('the exact page name'),
1490
+ name: z.string().optional().describe('the exact page name (or pass `ref` instead — one of the two is required)'),
1491
+ ref: z.string().optional().describe('the page\'s stable id, printed as `ref:` by read_page. Prefer this over name: unique across brains, so it addresses exactly one page.'),
1319
1492
  },
1320
1493
  },
1321
- async ({ kind, name }) => {
1494
+ async ({ kind, name, ref }) => {
1322
1495
  let res
1496
+ const addr = ref ? `ref=${encodeURIComponent(ref)}` : `name=${encodeURIComponent(name ?? '')}`
1323
1497
  try {
1324
- res = await fetchCortex(`${BASE}/api/brain/page-grants?kind=${encodeURIComponent(kind)}&name=${encodeURIComponent(name)}`, {
1498
+ res = await fetchCortex(`${BASE}/api/brain/page-grants?kind=${encodeURIComponent(kind)}&${addr}`, {
1325
1499
  headers: { Authorization: `Bearer ${TOKEN}` },
1326
1500
  })
1327
1501
  } catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.64",
3
+ "version": "0.9.66",
4
4
  "description": "Connect your AI assistant to Cortex — your org's projects, activity, gaps, and directives, scoped to you.",
5
5
  "type": "module",
6
6
  "bin": {