@theronap/cortex-mcp 0.9.63 → 0.9.65
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 +168 -3
- package/package.json +1 -1
package/lib/server.mjs
CHANGED
|
@@ -921,6 +921,167 @@ export async function runServer(version) {
|
|
|
921
921
|
},
|
|
922
922
|
)
|
|
923
923
|
|
|
924
|
+
// Stage 1 of agent-assisted member-add. It PREPARES the invite and stops — it does not perform it.
|
|
925
|
+
//
|
|
926
|
+
// WHY IT STOPS. Member-add lives on /api/invite and /api/members, which authenticate with
|
|
927
|
+
// `verifyAuthToken` (the Supabase login JWT) and reject the personal token this client holds. That
|
|
928
|
+
// split is not an oversight: `create_brain` accepts a personal token and writes a `users` row, so
|
|
929
|
+
// the line is not "membership writes need a browser" — it is "enrolling YOURSELF is self-service,
|
|
930
|
+
// granting a THIRD PARTY access to your brain is not." Stage 2 revisits that deliberately.
|
|
931
|
+
//
|
|
932
|
+
// What is worth automating is everything up to the grant. `managerId` is a `users.id` scoped to
|
|
933
|
+
// ONE brain — a multi-brain caller has a different one per membership and no way to see any of
|
|
934
|
+
// them from a chat window. That lookup is the part that actually blocks people, so this resolves
|
|
935
|
+
// it and hands back a payload the console can accept verbatim.
|
|
936
|
+
server.registerTool(
|
|
937
|
+
'add_to_brain',
|
|
938
|
+
{
|
|
939
|
+
title: 'Add someone to one of your brains',
|
|
940
|
+
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.",
|
|
941
|
+
inputSchema: {
|
|
942
|
+
email: z.string().describe("the person's email address — the login their Agnoclast account is (or will be) on"),
|
|
943
|
+
name: z.string().optional().describe('their full name; falls back to the email local-part'),
|
|
944
|
+
title: z.string().optional().describe('job title (optional)'),
|
|
945
|
+
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"),
|
|
946
|
+
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'),
|
|
947
|
+
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'),
|
|
948
|
+
},
|
|
949
|
+
},
|
|
950
|
+
async ({ email, name, title, role, brain, execute }) => {
|
|
951
|
+
const addr = (email ?? '').trim().toLowerCase()
|
|
952
|
+
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(addr)) return toolError(`"${email}" does not look like an email address.`)
|
|
953
|
+
|
|
954
|
+
// Executing without naming a brain would let the ACCOUNT-WIDE write pointer decide who gets
|
|
955
|
+
// access to what. That pointer is shared by every session that has not set its own, so it
|
|
956
|
+
// reflects whatever unrelated work last touched it — it is not information about this grant.
|
|
957
|
+
// Planning may fall back to it (nothing happens); performing may not.
|
|
958
|
+
if (execute && !brain?.trim()) {
|
|
959
|
+
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).')
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
let res
|
|
963
|
+
try {
|
|
964
|
+
res = await fetchCortex(`${BASE}/api/brains`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
965
|
+
} catch (e) {
|
|
966
|
+
return toolError(`Could not read your brains: ${e.message}`)
|
|
967
|
+
}
|
|
968
|
+
if (!res.ok) {
|
|
969
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
970
|
+
return toolError(`Could not read your brains: ${d.message}`)
|
|
971
|
+
}
|
|
972
|
+
const { brains } = await res.json()
|
|
973
|
+
if (!brains?.length) return toolError('You have no brains, so there is nothing to add anyone to.')
|
|
974
|
+
|
|
975
|
+
// Name or org id, either way — a person says "my mom's brain", not a uuid.
|
|
976
|
+
//
|
|
977
|
+
// ⚠ BRAIN NAMES ARE NOT UNIQUE ACROSS ACCOUNTS, and the collision is the LIKELY case, not the
|
|
978
|
+
// exotic one: the default personal brain is called "Personal", so the moment you are added to
|
|
979
|
+
// someone else's you hold two. Observed 2026-08-04 within a minute of exactly that happening.
|
|
980
|
+
// A `.find()` here would silently return whichever sorted first and add the person to an
|
|
981
|
+
// arbitrary one — a wrong-brain member-add with no symptom, which is the same silent-misroute
|
|
982
|
+
// family as the write pointer. An org id always wins; an ambiguous NAME is a 409-shaped error,
|
|
983
|
+
// never a guess.
|
|
984
|
+
const wanted = brain?.trim().toLowerCase()
|
|
985
|
+
let target
|
|
986
|
+
if (wanted) {
|
|
987
|
+
const byId = brains.find((b) => b.orgId.toLowerCase() === wanted)
|
|
988
|
+
const byName = brains.filter((b) => b.name.toLowerCase() === wanted)
|
|
989
|
+
if (!byId && byName.length > 1) {
|
|
990
|
+
const rows = byName.map((b) => ` ${b.name} (${b.role}, ${b.pageCount} pages) [${b.orgId}]`)
|
|
991
|
+
return toolError(
|
|
992
|
+
`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')}`,
|
|
993
|
+
)
|
|
994
|
+
}
|
|
995
|
+
target = byId ?? byName[0]
|
|
996
|
+
} else {
|
|
997
|
+
target = brains.find((b) => b.isActive)
|
|
998
|
+
}
|
|
999
|
+
if (!target) {
|
|
1000
|
+
const names = brains.map((b) => `${b.name} [${b.orgId}]`).join(', ')
|
|
1001
|
+
return toolError(
|
|
1002
|
+
wanted
|
|
1003
|
+
? `No brain called "${brain}". You belong to: ${names}.`
|
|
1004
|
+
: `Could not tell which brain you mean — you belong to ${brains.length} and none is marked active. Pass one of: ${names}.`,
|
|
1005
|
+
)
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// Role is PER MEMBERSHIP. Being an owner elsewhere grants nothing here, and the server will
|
|
1009
|
+
// enforce this again — checking now turns a later 403 into an answer.
|
|
1010
|
+
if (!['owner', 'manager', 'admin'].includes(target.role)) {
|
|
1011
|
+
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.`)
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// Absent on any console deployed before userId was added to /api/brains. Say so precisely —
|
|
1015
|
+
// a published MCP version is not a deployed API, and the two drift.
|
|
1016
|
+
if (!target.userId) {
|
|
1017
|
+
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".`)
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
const payload = {
|
|
1021
|
+
name: name?.trim() || addr.split('@')[0],
|
|
1022
|
+
email: addr,
|
|
1023
|
+
...(title?.trim() ? { title: title.trim() } : {}),
|
|
1024
|
+
role: role ?? 'member',
|
|
1025
|
+
managerId: target.userId,
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
if (execute) {
|
|
1029
|
+
// `brain` is sent as the ORG ID, never the label the caller typed: the server resolves
|
|
1030
|
+
// labels too, and a name that was unambiguous here could match differently there. The id is
|
|
1031
|
+
// the same value on both sides.
|
|
1032
|
+
let done
|
|
1033
|
+
try {
|
|
1034
|
+
done = await fetchCortex(`${BASE}/api/invite`, {
|
|
1035
|
+
method: 'POST',
|
|
1036
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1037
|
+
body: JSON.stringify({ ...payload, brain: target.orgId }),
|
|
1038
|
+
})
|
|
1039
|
+
} catch (e) {
|
|
1040
|
+
return toolError(`Could not add ${payload.email} to ${target.name}: ${e.message}`)
|
|
1041
|
+
}
|
|
1042
|
+
if (!done.ok) {
|
|
1043
|
+
const d = classify(done.status, done.headers.get('content-type'), await done.text(), done.headers.get('x-vercel-id'))
|
|
1044
|
+
return toolError(`Could not add ${payload.email} to ${target.name}: ${d.message}`)
|
|
1045
|
+
}
|
|
1046
|
+
const r = await done.json().catch(() => ({}))
|
|
1047
|
+
if (r.alreadyMember) {
|
|
1048
|
+
return { content: [{ type: 'text', text: `${payload.email} was already a member of ${target.name} — nothing changed.` }] }
|
|
1049
|
+
}
|
|
1050
|
+
// Two different outcomes for the human: a brand-new account has a credential that somebody
|
|
1051
|
+
// must physically pass on, an existing one has none.
|
|
1052
|
+
const how = r.existingAccount
|
|
1053
|
+
? 'They already had an Agnoclast account, so they keep their current login and simply gain this brain.'
|
|
1054
|
+
: `A new account was created. Temporary password: ${r.password} — they must change it on first sign-in.`
|
|
1055
|
+
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.` }] }
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
const text = [
|
|
1059
|
+
`Ready to add ${payload.name} <${payload.email}> to ${target.name} as ${payload.role}.`,
|
|
1060
|
+
'',
|
|
1061
|
+
` brain ${target.name} [${target.orgId}]`,
|
|
1062
|
+
` your role ${target.role} — you may add people here`,
|
|
1063
|
+
` manager you [${target.userId}] (they are placed under you)`,
|
|
1064
|
+
'',
|
|
1065
|
+
'Nothing has happened yet. To go ahead, re-run with execute:true and the same brain —',
|
|
1066
|
+
`or do it yourself at ${BASE}/?invite=1 with these values:`,
|
|
1067
|
+
'',
|
|
1068
|
+
` Full name ${payload.name}`,
|
|
1069
|
+
` Email ${payload.email}`,
|
|
1070
|
+
...(payload.title ? [` Job title ${payload.title}`] : []),
|
|
1071
|
+
` Role ${payload.role}`,
|
|
1072
|
+
` Manager you`,
|
|
1073
|
+
'',
|
|
1074
|
+
'If they already have an Agnoclast account they keep their existing password and simply gain',
|
|
1075
|
+
'this brain; if not, the console shows a temporary password to pass on. Either way the button',
|
|
1076
|
+
'handles it — you do not need to know which in advance.',
|
|
1077
|
+
'',
|
|
1078
|
+
`payload: ${JSON.stringify(payload)}`,
|
|
1079
|
+
].join('\n')
|
|
1080
|
+
|
|
1081
|
+
return { content: [{ type: 'text', text }] }
|
|
1082
|
+
},
|
|
1083
|
+
)
|
|
1084
|
+
|
|
924
1085
|
server.registerTool(
|
|
925
1086
|
'list_brain_pages',
|
|
926
1087
|
{
|
|
@@ -1581,7 +1742,7 @@ export async function runServer(version) {
|
|
|
1581
1742
|
{
|
|
1582
1743
|
title: 'Author a wiki node (live, while it is hot)',
|
|
1583
1744
|
description:
|
|
1584
|
-
'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.',
|
|
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` explicitly rather than letting the write pointer decide; the pointer is stale out-of-band state that knows nothing about what you are writing. 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.',
|
|
1585
1746
|
inputSchema: {
|
|
1586
1747
|
kind: z.enum(['project', 'person', 'org', 'user']).describe('the node type'),
|
|
1587
1748
|
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"'),
|
|
@@ -1594,9 +1755,10 @@ export async function runServer(version) {
|
|
|
1594
1755
|
base_version: z.string().optional().describe('the `version` hash shown when you read this page (read_page) — REQUIRED when updating an existing page, so a concurrent edit is caught instead of clobbered. Omit only for a brand-new node. If the save returns "stale" or "read first", read_page again and retry with the fresh version.'),
|
|
1595
1756
|
reason: z.string().describe('WHY you are making this edit, in one short phrase — recorded permanently in page_history so a later reader can tell a routine addition from a correction. Say what CHANGED and what prompted it ("Ben pilot abandoned per Theron 07-17", "corrected: 0069 already widened the CHECK"), not what you did ("updated page"). This is the field that makes staleness auditable.'),
|
|
1596
1757
|
change_kind: z.enum(['add', 'correct', 'supersede', 'expand', 'retire']).optional().describe('what KIND of edit: "add" (new information), "correct" (the page said something FALSE — the currency-critical one), "supersede" (was true, now outdated by events), "expand" (elaborates, no claim changed), "retire" (putting the page or a section to rest). Be honest with "correct" — a page whose history shows repeated corrections is a page whose claims need checking, and that signal is the point.'),
|
|
1758
|
+
brain: z.string().optional().describe('which brain a genuinely NEW page is created in — a brain name or its org id. Choose by RELEVANCE to what you are writing (`my_brains` shows what each brain holds), not by the active pointer. Has top precedence, so it also disambiguates a page name you hold in several brains. Unnecessary when the brain is resolvable from the write itself (base_version, or an existing page of this name) and unnecessary when you only have one brain.'),
|
|
1597
1759
|
},
|
|
1598
1760
|
},
|
|
1599
|
-
async ({ kind, name, summary, sections, tier, base_version, reason, change_kind }) => {
|
|
1761
|
+
async ({ kind, name, summary, sections, tier, base_version, reason, change_kind, brain }) => {
|
|
1600
1762
|
// No client-side tier default — the server computes the per-kind safe default (page-privacy
|
|
1601
1763
|
// T4/D10) so version-pinned installs can't bake a stale policy.
|
|
1602
1764
|
const pages = [{ ...(tier ? { tier } : {}), summary, base_version, sections: Array.isArray(sections) ? sections : [] }]
|
|
@@ -1605,7 +1767,10 @@ export async function runServer(version) {
|
|
|
1605
1767
|
res = await fetchCortex(`${BASE}/api/brain/author`, {
|
|
1606
1768
|
method: 'POST',
|
|
1607
1769
|
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1608
|
-
|
|
1770
|
+
// `brain` is forwarded only when the caller named one. The server's resolveAuthorBrain gives
|
|
1771
|
+
// an explicit brain top precedence and 409s on an unknown one rather than falling back to
|
|
1772
|
+
// the pointer, so sending an empty value would turn "I did not choose" into "I chose wrong".
|
|
1773
|
+
body: JSON.stringify({ kind, name, pages, reason, change_kind, ...(brain ? { brain } : {}) }),
|
|
1609
1774
|
})
|
|
1610
1775
|
} catch (e) {
|
|
1611
1776
|
return toolError(`Could not author "${name}": ${e.message}`)
|