@theronap/cortex-mcp 0.9.65 → 0.9.67
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/lib/server.mjs +50 -55
- package/package.json +1 -1
package/lib/server.mjs
CHANGED
|
@@ -59,7 +59,7 @@ export async function runServer(version) {
|
|
|
59
59
|
// PGL-21 fix: "one MCP process = one AI session" is an assumption, not a guarantee — the HOST
|
|
60
60
|
// (Claude Code) can restart this stdio process mid-conversation (reconnects, tool-loading events),
|
|
61
61
|
// and a fresh randomUUID() on every restart silently orphaned the PRIOR process's session-scoped
|
|
62
|
-
//
|
|
62
|
+
// ADR-0022 deleted the write pointer, so a session no longer carries a brain of its own.
|
|
63
63
|
// stop taking effect one restart later, falling back to the account pointer with no visible cause
|
|
64
64
|
// — reproduced live 2026-07-27. Claude Code sets CLAUDE_CODE_SESSION_ID for the lifetime of one
|
|
65
65
|
// logical conversation across any number of subprocess restarts, so prefer it as the session key;
|
|
@@ -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
|
-
|
|
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) }] }
|
|
@@ -1117,50 +1126,11 @@ export async function runServer(version) {
|
|
|
1117
1126
|
},
|
|
1118
1127
|
)
|
|
1119
1128
|
|
|
1120
|
-
server.registerTool(
|
|
1121
|
-
'set_active_brain',
|
|
1122
|
-
{
|
|
1123
|
-
title: 'Set the default brain for NEW pages',
|
|
1124
|
-
description: "RARELY NEEDED — do not reach for this reflexively. Editing an EXISTING page routes itself: author/set_page_validity/rollback resolve the brain from the page (via base_version, ref, or name), so switching first is unnecessary and switching WRONG is now impossible to cause. This only sets the default for creating a page that exists in NO brain yet, and for log_session/capture. If you find yourself about to call this so an edit lands correctly, don't — just author; it will find the page. Pass org_id = null to clear. SCOPE: 'session' (default) changes ONLY this session; 'account' changes the person-wide pointer and redirects every other open session that has not set its own.",
|
|
1125
|
-
inputSchema: {
|
|
1126
|
-
org_id: z.string().nullable().describe('the org id of the brain to write to (from my_brains), or null to clear the pointer'),
|
|
1127
|
-
scope: z.enum(['session', 'account']).optional()
|
|
1128
|
-
.describe("'session' (default) = this window only; 'account' = person-wide, affects your other open sessions too"),
|
|
1129
|
-
},
|
|
1130
|
-
},
|
|
1131
|
-
async ({ org_id, scope }) => {
|
|
1132
|
-
let res
|
|
1133
|
-
try {
|
|
1134
|
-
res = await fetchCortex(`${BASE}/api/brains`, {
|
|
1135
|
-
method: 'PUT',
|
|
1136
|
-
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1137
|
-
body: JSON.stringify({ orgId: org_id ?? null, scope: scope ?? 'session' }),
|
|
1138
|
-
})
|
|
1139
|
-
} catch (e) {
|
|
1140
|
-
return toolError(`Could not set active brain: ${e.message}`)
|
|
1141
|
-
}
|
|
1142
|
-
if (!res.ok) {
|
|
1143
|
-
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
1144
|
-
return toolError(`Could not set active brain: ${d.message}`)
|
|
1145
|
-
}
|
|
1146
|
-
const r = await res.json()
|
|
1147
|
-
// Always say WHICH scope changed. The default is session-only, so a caller expecting the old
|
|
1148
|
-
// person-wide stickiness must be able to see that it did NOT change their other windows.
|
|
1149
|
-
const where = r.scope === 'session' ? 'this session only' : 'ALL your sessions (person-wide)'
|
|
1150
|
-
if (!r.activeOrgId) {
|
|
1151
|
-
return { content: [{ type: 'text', text: r.scope === 'session'
|
|
1152
|
-
? 'Cleared this session\'s brain override — writes fall back to your account pointer.'
|
|
1153
|
-
: 'Cleared your account-wide active-brain pointer (writes fall back to your default brain).' }] }
|
|
1154
|
-
}
|
|
1155
|
-
return { content: [{ type: 'text', text: `Writes now land in brain ${r.activeOrgId} — ${where}.` }] }
|
|
1156
|
-
},
|
|
1157
|
-
)
|
|
1158
|
-
|
|
1159
1129
|
server.registerTool(
|
|
1160
1130
|
'create_brain',
|
|
1161
1131
|
{
|
|
1162
1132
|
title: 'Create a new brain under your existing account',
|
|
1163
|
-
description: 'Create a brand-new brain (org/workspace) — a fully independent knowledge graph — under your EXISTING account. No new login, no new email/password: this adds a second membership to the account you are already using. Reads never cross brains; new pages default to your active brain, so use
|
|
1133
|
+
description: 'Create a brand-new brain (org/workspace) — a fully independent knowledge graph — under your EXISTING account. No new login, no new email/password: this adds a second membership to the account you are already using. Reads never cross brains; new pages default to your active brain, so use pass `brain` when an operation needs one named',
|
|
1164
1134
|
inputSchema: { name: z.string().describe('display name for the new brain, e.g. "Cortex Codebase"') },
|
|
1165
1135
|
},
|
|
1166
1136
|
async ({ name }) => {
|
|
@@ -1179,7 +1149,7 @@ export async function runServer(version) {
|
|
|
1179
1149
|
return toolError(`Could not create brain: ${d.message}`)
|
|
1180
1150
|
}
|
|
1181
1151
|
const r = await res.json()
|
|
1182
|
-
return { content: [{ type: 'text', text: `Created brain "${name}" [${r.orgId}]. Reads already span it. Edits to pages in it route themselves;
|
|
1152
|
+
return { content: [{ type: 'text', text: `Created brain "${name}" [${r.orgId}]. Reads already span it. Edits to pages in it route themselves from the page, so nothing needs pointing at it; pass brain="${name}" on an operation that creates something new here.` }] }
|
|
1183
1153
|
},
|
|
1184
1154
|
)
|
|
1185
1155
|
|
|
@@ -1400,20 +1370,21 @@ export async function runServer(version) {
|
|
|
1400
1370
|
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.',
|
|
1401
1371
|
inputSchema: {
|
|
1402
1372
|
kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
|
|
1403
|
-
name: z.string().describe('the exact page name'),
|
|
1373
|
+
name: z.string().optional().describe('the exact page name (or pass `ref` instead — one of the two is required)'),
|
|
1374
|
+
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.'),
|
|
1404
1375
|
tier: z.enum(['accessible', 'scoped', 'confidential']).describe('the new visibility tier'),
|
|
1405
1376
|
source_tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('when the node has multiple tier variants: which one to move'),
|
|
1406
1377
|
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.'),
|
|
1407
1378
|
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.'),
|
|
1408
1379
|
},
|
|
1409
1380
|
},
|
|
1410
|
-
async ({ kind, name, tier, source_tier, absorb, target_version }) => {
|
|
1381
|
+
async ({ kind, name, ref, tier, source_tier, absorb, target_version }) => {
|
|
1411
1382
|
let res
|
|
1412
1383
|
try {
|
|
1413
1384
|
res = await fetchCortex(`${BASE}/api/brain/page-privacy`, {
|
|
1414
1385
|
method: 'POST',
|
|
1415
1386
|
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1416
|
-
body: JSON.stringify({ kind, name, tier, ...(source_tier ? { source_tier } : {}), ...(absorb ? { absorb: true } : {}), ...(target_version ? { target_version } : {}) }),
|
|
1387
|
+
body: JSON.stringify({ kind, ...(name ? { name } : {}), ...(ref ? { ref } : {}), tier, ...(source_tier ? { source_tier } : {}), ...(absorb ? { absorb: true } : {}), ...(target_version ? { target_version } : {}) }),
|
|
1417
1388
|
})
|
|
1418
1389
|
} catch (e) {
|
|
1419
1390
|
return toolError(`Could not set page privacy: ${e.message}`)
|
|
@@ -1445,19 +1416,20 @@ export async function runServer(version) {
|
|
|
1445
1416
|
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.',
|
|
1446
1417
|
inputSchema: {
|
|
1447
1418
|
kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
|
|
1448
|
-
name: z.string().describe('the exact page name'),
|
|
1449
|
-
|
|
1419
|
+
name: z.string().optional().describe('the exact page name (or pass `ref` instead — one of the two is required)'),
|
|
1420
|
+
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.'),
|
|
1421
|
+
grantee: z.string().describe('display name or email of a member OF THE PAGE\'S BRAIN (must resolve uniquely — use email if ambiguous)'),
|
|
1450
1422
|
action: z.enum(['grant', 'revoke']).describe('grant or revoke'),
|
|
1451
1423
|
tier: z.enum(['scoped', 'confidential']).optional().describe('which variant (default: the most restrictive one)'),
|
|
1452
1424
|
},
|
|
1453
1425
|
},
|
|
1454
|
-
async ({ kind, name, grantee, action, tier }) => {
|
|
1426
|
+
async ({ kind, name, ref, grantee, action, tier }) => {
|
|
1455
1427
|
let res
|
|
1456
1428
|
try {
|
|
1457
1429
|
res = await fetchCortex(`${BASE}/api/brain/page-grants`, {
|
|
1458
1430
|
method: 'POST',
|
|
1459
1431
|
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1460
|
-
body: JSON.stringify({ kind, name, grantee, action, ...(tier ? { tier } : {}) }),
|
|
1432
|
+
body: JSON.stringify({ kind, ...(name ? { name } : {}), ...(ref ? { ref } : {}), grantee, action, ...(tier ? { tier } : {}) }),
|
|
1461
1433
|
})
|
|
1462
1434
|
} catch (e) {
|
|
1463
1435
|
return toolError(`Could not ${action}: ${e.message}`)
|
|
@@ -1476,13 +1448,15 @@ export async function runServer(version) {
|
|
|
1476
1448
|
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.',
|
|
1477
1449
|
inputSchema: {
|
|
1478
1450
|
kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
|
|
1479
|
-
name: z.string().describe('the exact page name'),
|
|
1451
|
+
name: z.string().optional().describe('the exact page name (or pass `ref` instead — one of the two is required)'),
|
|
1452
|
+
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.'),
|
|
1480
1453
|
},
|
|
1481
1454
|
},
|
|
1482
|
-
async ({ kind, name }) => {
|
|
1455
|
+
async ({ kind, name, ref }) => {
|
|
1483
1456
|
let res
|
|
1457
|
+
const addr = ref ? `ref=${encodeURIComponent(ref)}` : `name=${encodeURIComponent(name ?? '')}`
|
|
1484
1458
|
try {
|
|
1485
|
-
res = await fetchCortex(`${BASE}/api/brain/page-grants?kind=${encodeURIComponent(kind)}
|
|
1459
|
+
res = await fetchCortex(`${BASE}/api/brain/page-grants?kind=${encodeURIComponent(kind)}&${addr}`, {
|
|
1486
1460
|
headers: { Authorization: `Bearer ${TOKEN}` },
|
|
1487
1461
|
})
|
|
1488
1462
|
} catch (e) {
|
|
@@ -1742,7 +1716,7 @@ export async function runServer(version) {
|
|
|
1742
1716
|
{
|
|
1743
1717
|
title: 'Author a wiki node (live, while it is hot)',
|
|
1744
1718
|
description:
|
|
1745
|
-
'Write your CURRENT understanding of a project/person/org/you into the org wiki as a maintained page. Call `authoring_context` FIRST. Author from your own synthesis of the session — the compiled mental model, not a transcript dump: what it IS, where it stands, dated decisions, open threads, key people. Weave inline [[links]] to other nodes (canonical names from the namespace; red-links for wanted-but-absent nodes). The server re-authorizes the tier and resolves links. CREATES the node if it does not exist yet (project/person/org) — the conversation IS the evidence, so a brand-new entity that surfaced only in this session is authorable on the spot; you do NOT need prior records. Because such a node has nothing external to corroborate it, author it DELIBERATELY: only when you genuinely understand it is a real, distinct entity, and use its exact canonical name so it does not duplicate one already in the namespace (`user` nodes are never created). Use this continuously whenever your understanding of a node meaningfully advanced, and at session end (/log). Authoring is PRE-AUTHORIZED — never ask the user "should I update the page?" before calling this (every edit is versioned + reversible via page_history/rollback_page); update, then briefly report what you updated. ⚠ WHICH BRAIN A NEW PAGE GOES IN IS A CONTENT DECISION, SO MAKE IT FROM THE CONTENT. Only a page that exists in NO brain needs this — an update resolves its brain from the page itself. If you hold more than one brain, call `my_brains` (it returns each brain\'s name, page count and sample titles, which is enough to tell what each one is FOR) and pass `brain`
|
|
1719
|
+
'Write your CURRENT understanding of a project/person/org/you into the org wiki as a maintained page. Call `authoring_context` FIRST. Author from your own synthesis of the session — the compiled mental model, not a transcript dump: what it IS, where it stands, dated decisions, open threads, key people. Weave inline [[links]] to other nodes (canonical names from the namespace; red-links for wanted-but-absent nodes). The server re-authorizes the tier and resolves links. CREATES the node if it does not exist yet (project/person/org) — the conversation IS the evidence, so a brand-new entity that surfaced only in this session is authorable on the spot; you do NOT need prior records. Because such a node has nothing external to corroborate it, author it DELIBERATELY: only when you genuinely understand it is a real, distinct entity, and use its exact canonical name so it does not duplicate one already in the namespace (`user` nodes are never created). Use this continuously whenever your understanding of a node meaningfully advanced, and at session end (/log). Authoring is PRE-AUTHORIZED — never ask the user "should I update the page?" before calling this (every edit is versioned + reversible via page_history/rollback_page); update, then briefly report what you updated. ⚠ WHICH BRAIN A NEW PAGE GOES IN IS A CONTENT DECISION, SO MAKE IT FROM THE CONTENT. Only a page that exists in NO brain needs this — an update resolves its brain from the page itself. If you hold more than one brain, the server REFUSES a create it cannot attribute (409 `create_needs_brain`) rather than letting the write pointer decide — the pointer is stale out-of-band state that knows nothing about what you are writing. So call `my_brains` (it returns each brain\'s name, page count and sample titles, which is enough to tell what each one is FOR) and pass `brain` up front; that turns a refused round trip into a single call. State which brain you picked and why in one short line, then proceed — do NOT ask when the answer is obvious from the content. DO ask when it is genuinely ambiguous: brains are a confidentiality boundary, so a page born in the wrong one can expose private work to a teammate, and that is not a filing error you can quietly fix later.',
|
|
1746
1720
|
inputSchema: {
|
|
1747
1721
|
kind: z.enum(['project', 'person', 'org', 'user']).describe('the node type'),
|
|
1748
1722
|
name: z.string().describe('the canonical node name — an EXACT existing name from the namespace to update it, or a new name to create the node (project/person/org). e.g. "Cortex" or "Theron Peterson"'),
|
|
@@ -1776,7 +1750,28 @@ export async function runServer(version) {
|
|
|
1776
1750
|
return toolError(`Could not author "${name}": ${e.message}`)
|
|
1777
1751
|
}
|
|
1778
1752
|
if (!res.ok) {
|
|
1779
|
-
|
|
1753
|
+
// Read the body ONCE — classify() consumes it as text, so parsing after it is a spent stream.
|
|
1754
|
+
const raw = await res.text()
|
|
1755
|
+
let err = null
|
|
1756
|
+
try { err = JSON.parse(raw) } catch { /* not JSON; classify handles it below */ }
|
|
1757
|
+
// BRAIN-RESOLUTION REFUSALS carry the only thing that makes them actionable: which brains, and
|
|
1758
|
+
// what is in each. classify() flattens a response to a generic status message, so routing these
|
|
1759
|
+
// through it would drop the payload and leave the agent to guess a brain — the exact guess the
|
|
1760
|
+
// 409 exists to prevent. Page counts and sample titles matter more than the names: per
|
|
1761
|
+
// brain_identity.ts a brain's NAME is actively misleading about its contents.
|
|
1762
|
+
if (err?.error === 'create_needs_brain' || err?.error === 'ambiguous_brain' || err?.error === 'unknown_brain') {
|
|
1763
|
+
const list = (err.brains ?? []).map((b) => {
|
|
1764
|
+
const count = b.pageCount != null ? ` — ${b.pageCount} page${b.pageCount === 1 ? '' : 's'}` : ''
|
|
1765
|
+
const sample = b.sampleTitles?.length ? `: ${b.sampleTitles.slice(0, 4).join(', ')}` : ''
|
|
1766
|
+
return ` • ${b.name ?? b.brain} (${b.orgId})${count}${sample}`
|
|
1767
|
+
}).join('\n')
|
|
1768
|
+
return toolError(
|
|
1769
|
+
`Could not author "${name}": ${err.message ?? err.error}` +
|
|
1770
|
+
(list ? `\n\nYour brains:\n${list}` : '') +
|
|
1771
|
+
`\n\nRe-run author with brain:"<name>" — choose by what each brain HOLDS, not by its name.`,
|
|
1772
|
+
)
|
|
1773
|
+
}
|
|
1774
|
+
const d = classify(res.status, res.headers.get('content-type'), raw, res.headers.get('x-vercel-id'))
|
|
1780
1775
|
return toolError(`Could not author "${name}": ${d.message}`)
|
|
1781
1776
|
}
|
|
1782
1777
|
const out = await res.json()
|