@theronap/cortex-mcp 0.9.66 → 0.9.68
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 +77 -48
- 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;
|
|
@@ -556,7 +556,7 @@ export async function runServer(version) {
|
|
|
556
556
|
// retype silently deleted a sentence, a [[link]] (a graph edge), a command list and the
|
|
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
|
-
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.`
|
|
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. If what went false is the SUMMARY at the top rather than a section, use \`set_summary\` — \`edit_page\` cannot reach it, and worse, it carries the old summary FORWARD into every revision it writes, so fixing a section silently re-certifies a summary that already contradicts 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
560
|
// ADDRESSING (2026-08-04). The server accepts `ref` on every page/node route and its ambiguity
|
|
561
561
|
// 409s hand refs back — but read_page never PRINTED one, so the only way to obtain a ref was to
|
|
562
562
|
// trigger the error first. That made ID addressing reachable in principle and unusable in
|
|
@@ -770,6 +770,49 @@ export async function runServer(version) {
|
|
|
770
770
|
},
|
|
771
771
|
)
|
|
772
772
|
|
|
773
|
+
server.registerTool(
|
|
774
|
+
'set_summary',
|
|
775
|
+
{
|
|
776
|
+
title: 'Rewrite a wiki page summary without touching its sections',
|
|
777
|
+
description: 'Replace a page\'s SUMMARY — the one-sentence line at the top — in place, leaving every section untouched. Use this the moment you notice a summary that no longer matches the page: `edit_page` structurally CANNOT reach it (it edits section bodies; the summary is not one), and `author` reaches it only by retyping every section on the way through, which is how a 2026-07-30 write silently deleted a sentence and a [[link]] from a section nobody meant to touch. Correcting a body with `edit_page` actively carries the OLD summary forward into the new revision, so a stale summary does not decay quietly — each unrelated fix re-certifies it. Fixing it matters more than it looks: page retrieval FTS-matches on title + summary ALONE (section bodies are not in that index) and session context prints the summary as "Why this was chosen", so a stale summary decides whether the page is found at all and what a future session believes before it reads a word. Requires base_version (the `version` read_page prints) — that is also how it finds the right brain, so it can never write to the wrong one. Recorded in page_history as a correction, and reversible with rollback_page.',
|
|
778
|
+
inputSchema: {
|
|
779
|
+
name: z.string().describe('the exact page name, as read_page shows it'),
|
|
780
|
+
summary: z.string().describe('the new summary: one sentence saying what this is and where it stands. May contain [[links]]. Max 2,000 chars — detail belongs in a section.'),
|
|
781
|
+
base_version: z.string().describe('the `version` read_page prints for this page (64-hex). REQUIRED — it is the concurrency check AND how the right brain is resolved. A per-SECTION hash is not valid here.'),
|
|
782
|
+
reason: z.string().optional().describe('WHY the summary was wrong, in one short phrase — recorded in page_history. Say what changed ("blocker resolved 08-04; was still claiming BLOCKED"), not what you did.'),
|
|
783
|
+
tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('only when the page exists at MORE THAN ONE tier — which one to rewrite. This never moves content between tiers.'),
|
|
784
|
+
},
|
|
785
|
+
},
|
|
786
|
+
async ({ name, summary, base_version, reason, tier }) => {
|
|
787
|
+
let res
|
|
788
|
+
try {
|
|
789
|
+
res = await fetchCortex(`${BASE}/api/brain/set-summary`, {
|
|
790
|
+
method: 'POST',
|
|
791
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
792
|
+
body: JSON.stringify({
|
|
793
|
+
name, summary, base_version,
|
|
794
|
+
...(tier ? { tier } : {}), ...(reason ? { reason } : {}),
|
|
795
|
+
}),
|
|
796
|
+
})
|
|
797
|
+
} catch (e) {
|
|
798
|
+
return toolError(`Could not set the summary: ${e.message}`)
|
|
799
|
+
}
|
|
800
|
+
const out = await res.json().catch(() => null)
|
|
801
|
+
if (!res.ok) {
|
|
802
|
+
// Surface the server's hint AND the disambiguators it named, so a 409 is directly actionable
|
|
803
|
+
// rather than something to retry blindly.
|
|
804
|
+
const extra = [
|
|
805
|
+
out?.detail ? `detail: ${out.detail}` : '',
|
|
806
|
+
Array.isArray(out?.tiers) ? `tiers: ${out.tiers.join(', ')}` : '',
|
|
807
|
+
out?.currentVersion ? `current version: ${out.currentVersion}` : '',
|
|
808
|
+
].filter(Boolean).join(' · ')
|
|
809
|
+
const hint = out?.hint ? `\n${out.hint}` : ''
|
|
810
|
+
return toolError(`Could not set the summary on "${name}": ${out?.error ?? res.status}${extra ? `\n${extra}` : ''}${hint}`)
|
|
811
|
+
}
|
|
812
|
+
return { content: [{ type: 'text', text: `Summary rewritten on "${name}" (${out.brain} · ${out.tier} tier). Every section kept its body, position and as-of date.\nwas: ${out.previousSummary}\nnow: ${out.summary}\nNew version: ${out.version}` }] }
|
|
813
|
+
},
|
|
814
|
+
)
|
|
815
|
+
|
|
773
816
|
server.registerTool(
|
|
774
817
|
'edit_page',
|
|
775
818
|
{
|
|
@@ -1126,50 +1169,11 @@ export async function runServer(version) {
|
|
|
1126
1169
|
},
|
|
1127
1170
|
)
|
|
1128
1171
|
|
|
1129
|
-
server.registerTool(
|
|
1130
|
-
'set_active_brain',
|
|
1131
|
-
{
|
|
1132
|
-
title: 'Set the default brain for NEW pages',
|
|
1133
|
-
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.",
|
|
1134
|
-
inputSchema: {
|
|
1135
|
-
org_id: z.string().nullable().describe('the org id of the brain to write to (from my_brains), or null to clear the pointer'),
|
|
1136
|
-
scope: z.enum(['session', 'account']).optional()
|
|
1137
|
-
.describe("'session' (default) = this window only; 'account' = person-wide, affects your other open sessions too"),
|
|
1138
|
-
},
|
|
1139
|
-
},
|
|
1140
|
-
async ({ org_id, scope }) => {
|
|
1141
|
-
let res
|
|
1142
|
-
try {
|
|
1143
|
-
res = await fetchCortex(`${BASE}/api/brains`, {
|
|
1144
|
-
method: 'PUT',
|
|
1145
|
-
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1146
|
-
body: JSON.stringify({ orgId: org_id ?? null, scope: scope ?? 'session' }),
|
|
1147
|
-
})
|
|
1148
|
-
} catch (e) {
|
|
1149
|
-
return toolError(`Could not set active brain: ${e.message}`)
|
|
1150
|
-
}
|
|
1151
|
-
if (!res.ok) {
|
|
1152
|
-
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
1153
|
-
return toolError(`Could not set active brain: ${d.message}`)
|
|
1154
|
-
}
|
|
1155
|
-
const r = await res.json()
|
|
1156
|
-
// Always say WHICH scope changed. The default is session-only, so a caller expecting the old
|
|
1157
|
-
// person-wide stickiness must be able to see that it did NOT change their other windows.
|
|
1158
|
-
const where = r.scope === 'session' ? 'this session only' : 'ALL your sessions (person-wide)'
|
|
1159
|
-
if (!r.activeOrgId) {
|
|
1160
|
-
return { content: [{ type: 'text', text: r.scope === 'session'
|
|
1161
|
-
? 'Cleared this session\'s brain override — writes fall back to your account pointer.'
|
|
1162
|
-
: 'Cleared your account-wide active-brain pointer (writes fall back to your default brain).' }] }
|
|
1163
|
-
}
|
|
1164
|
-
return { content: [{ type: 'text', text: `Writes now land in brain ${r.activeOrgId} — ${where}.` }] }
|
|
1165
|
-
},
|
|
1166
|
-
)
|
|
1167
|
-
|
|
1168
1172
|
server.registerTool(
|
|
1169
1173
|
'create_brain',
|
|
1170
1174
|
{
|
|
1171
1175
|
title: 'Create a new brain under your existing account',
|
|
1172
|
-
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
|
|
1176
|
+
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',
|
|
1173
1177
|
inputSchema: { name: z.string().describe('display name for the new brain, e.g. "Cortex Codebase"') },
|
|
1174
1178
|
},
|
|
1175
1179
|
async ({ name }) => {
|
|
@@ -1188,7 +1192,7 @@ export async function runServer(version) {
|
|
|
1188
1192
|
return toolError(`Could not create brain: ${d.message}`)
|
|
1189
1193
|
}
|
|
1190
1194
|
const r = await res.json()
|
|
1191
|
-
return { content: [{ type: 'text', text: `Created brain "${name}" [${r.orgId}]. Reads already span it. Edits to pages in it route themselves;
|
|
1195
|
+
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.` }] }
|
|
1192
1196
|
},
|
|
1193
1197
|
)
|
|
1194
1198
|
|
|
@@ -1398,7 +1402,10 @@ export async function runServer(version) {
|
|
|
1398
1402
|
const out = await res.json().catch(() => null)
|
|
1399
1403
|
if (!res.ok) return toolError(`Could not snooze "${name}": ${out?.error ?? res.status}`)
|
|
1400
1404
|
if (!out) return { content: [{ type: 'text', text: `Snoozed "${name}", but the server returned no body.` }] }
|
|
1401
|
-
|
|
1405
|
+
// Name the brains when there is more than one: a snooze that quieted the same wanted name in two
|
|
1406
|
+
// brains you steward is a multi-row write, and reporting it as a single one hides that.
|
|
1407
|
+
const where = out.brains?.length > 1 ? ` in ${out.brains.map((b) => b.brain).join(' and ')}` : ''
|
|
1408
|
+
return { content: [{ type: 'text', text: `Snoozed "${out.name}"${where} for ${out.days} day${out.days === 1 ? '' : 's'} — it won't surface until then.` }] }
|
|
1402
1409
|
},
|
|
1403
1410
|
)
|
|
1404
1411
|
|
|
@@ -1719,13 +1726,14 @@ export async function runServer(version) {
|
|
|
1719
1726
|
'Fetch the scaffolding to author a Cortex wiki node: the canonical NAMESPACE (current node names — link to these with the EXACT name inside [[ ]]), any deliberately RETIRED page names and their successors, and the node-type CONNECTION RULES. ALWAYS call this BEFORE `author` so the page links to current knowledge rather than minting synonyms or reviving a retired page. Reference a node in the namespace as [[Name]]; if you reference something real that is NOT in the namespace, still write [[Name]] — that is a red-link marking a node worth creating. If the page IS about a specific code repo or chat channel, also stamp it once — [[repo:owner/name]] or [[channel:name]] (lowercase, no #) — identifier join keys, not page links.',
|
|
1720
1727
|
inputSchema: {
|
|
1721
1728
|
kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('the node type you are about to author (default project)'),
|
|
1729
|
+
brain: z.string().optional().describe('which brain\'s namespace to describe — pass the SAME brain you will pass to `author`, so the namespace you plan against is the one your write lands in. Unnecessary when you only have one brain.'),
|
|
1722
1730
|
},
|
|
1723
1731
|
},
|
|
1724
|
-
async ({ kind }) => {
|
|
1732
|
+
async ({ kind, brain }) => {
|
|
1725
1733
|
const k = kind ?? 'project'
|
|
1726
1734
|
let res
|
|
1727
1735
|
try {
|
|
1728
|
-
res = await fetchCortex(`${BASE}/api/brain/authoring-context?kind=${k}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
1736
|
+
res = await fetchCortex(`${BASE}/api/brain/authoring-context?kind=${k}${brain ? `&brain=${encodeURIComponent(brain)}` : ''}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
1729
1737
|
} catch (e) {
|
|
1730
1738
|
return toolError(`Could not fetch authoring context: ${e.message}`)
|
|
1731
1739
|
}
|
|
@@ -1755,7 +1763,7 @@ export async function runServer(version) {
|
|
|
1755
1763
|
{
|
|
1756
1764
|
title: 'Author a wiki node (live, while it is hot)',
|
|
1757
1765
|
description:
|
|
1758
|
-
'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`
|
|
1766
|
+
'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.',
|
|
1759
1767
|
inputSchema: {
|
|
1760
1768
|
kind: z.enum(['project', 'person', 'org', 'user']).describe('the node type'),
|
|
1761
1769
|
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"'),
|
|
@@ -1789,7 +1797,28 @@ export async function runServer(version) {
|
|
|
1789
1797
|
return toolError(`Could not author "${name}": ${e.message}`)
|
|
1790
1798
|
}
|
|
1791
1799
|
if (!res.ok) {
|
|
1792
|
-
|
|
1800
|
+
// Read the body ONCE — classify() consumes it as text, so parsing after it is a spent stream.
|
|
1801
|
+
const raw = await res.text()
|
|
1802
|
+
let err = null
|
|
1803
|
+
try { err = JSON.parse(raw) } catch { /* not JSON; classify handles it below */ }
|
|
1804
|
+
// BRAIN-RESOLUTION REFUSALS carry the only thing that makes them actionable: which brains, and
|
|
1805
|
+
// what is in each. classify() flattens a response to a generic status message, so routing these
|
|
1806
|
+
// through it would drop the payload and leave the agent to guess a brain — the exact guess the
|
|
1807
|
+
// 409 exists to prevent. Page counts and sample titles matter more than the names: per
|
|
1808
|
+
// brain_identity.ts a brain's NAME is actively misleading about its contents.
|
|
1809
|
+
if (err?.error === 'create_needs_brain' || err?.error === 'ambiguous_brain' || err?.error === 'unknown_brain') {
|
|
1810
|
+
const list = (err.brains ?? []).map((b) => {
|
|
1811
|
+
const count = b.pageCount != null ? ` — ${b.pageCount} page${b.pageCount === 1 ? '' : 's'}` : ''
|
|
1812
|
+
const sample = b.sampleTitles?.length ? `: ${b.sampleTitles.slice(0, 4).join(', ')}` : ''
|
|
1813
|
+
return ` • ${b.name ?? b.brain} (${b.orgId})${count}${sample}`
|
|
1814
|
+
}).join('\n')
|
|
1815
|
+
return toolError(
|
|
1816
|
+
`Could not author "${name}": ${err.message ?? err.error}` +
|
|
1817
|
+
(list ? `\n\nYour brains:\n${list}` : '') +
|
|
1818
|
+
`\n\nRe-run author with brain:"<name>" — choose by what each brain HOLDS, not by its name.`,
|
|
1819
|
+
)
|
|
1820
|
+
}
|
|
1821
|
+
const d = classify(res.status, res.headers.get('content-type'), raw, res.headers.get('x-vercel-id'))
|
|
1793
1822
|
return toolError(`Could not author "${name}": ${d.message}`)
|
|
1794
1823
|
}
|
|
1795
1824
|
const out = await res.json()
|