@theronap/cortex-mcp 0.9.46 → 0.9.48

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.
@@ -15,11 +15,34 @@ export function mergeClaudeMcp(existing, spec, token) {
15
15
  return cfg
16
16
  }
17
17
 
18
+ /** The Cortex tools every seat may run WITHOUT a permission prompt: the read surface, the live
19
+ * authoring core, and trivially-reversible maintenance. The contract this enforces: authoring is
20
+ * EXPECTED agent behavior — a page update must never stall on a yes/no dialog the user won't read
21
+ * (the ask-permission failure mode is how pages go stale). Safe because every page edit is
22
+ * CAS-protected + snapshotted (page_revisions → page_history/rollback_page).
23
+ * Deliberately EXCLUDED — these keep prompting: send_imessage (external side effect),
24
+ * set_page_privacy / set_record_privacy / grant_page_access (visibility widening — the
25
+ * unowned-project accessible-default sharp edge, 2026-07-02), rollback_page, decide_page_merge /
26
+ * decide_file_request / request_file / get_file, create_brain / set_active_brain, alias_page,
27
+ * set_writing_style. */
28
+ export const CORTEX_ALLOWED_TOOLS = [
29
+ // read surface
30
+ 'grep', 'read_page', 'my_context', 'project_status', 'session_context', 'search_org',
31
+ 'list_records', 'page_history', 'timeline_pull', 'my_brains', 'my_sessions', 'writing_style',
32
+ 'code_graph_query', 'my_retier_notices', 'list_page_grants', 'file_requests', 'page_merge_requests',
33
+ // live authoring core
34
+ 'authoring_context', 'author', 'log_session',
35
+ // routine, reversible maintenance
36
+ 'set_page_validity', 'snooze_red_link', 'attribute_thread',
37
+ ].map((t) => `mcp__cortex__${t}`)
38
+
18
39
  /** Merge Cortex's Claude Code hooks into a ~/.claude/settings.json object. Pure + idempotent: drops
19
40
  * any prior cortex entry (old token/path/version) from each hook array before appending the current
20
41
  * one — capture (Stop), status + skills-repair + snapshot-context (SessionStart), precompact
21
42
  * (PreCompact). Commands carry NO inline token (each subcommand self-resolves it). Mirrors setup.mjs
22
- * step 2 exactly; foreign hooks are never touched. */
43
+ * step 2 exactly; foreign hooks are never touched. Also merges the CORTEX_ALLOWED_TOOLS permission
44
+ * allowlist — additive-only: a user's own allow entries (even extra mcp__cortex__* ones) are never
45
+ * removed, and `deny` is never touched (a user deny always beats our allow). */
23
46
  export function mergeClaudeSettings(existing, spec) {
24
47
  const s = existing && typeof existing === 'object' ? existing : {}
25
48
  s.hooks = s.hooks ?? {}
@@ -69,6 +92,15 @@ export function mergeClaudeSettings(existing, spec) {
69
92
  pgrp.hooks = pgrp.hooks ?? []
70
93
  pgrp.hooks.push({ type: 'command', command: precompactCmd })
71
94
 
95
+ // Permissions — pre-authorize the read + authoring core so a page update never stalls on a
96
+ // permission prompt. Append-missing only (no filter-and-rebuild like the hooks above): removals
97
+ // ship via uninstall, and rebuilding would delete allows the user added by hand.
98
+ s.permissions = s.permissions && typeof s.permissions === 'object' ? s.permissions : {}
99
+ s.permissions.allow = Array.isArray(s.permissions.allow) ? s.permissions.allow : []
100
+ for (const rule of CORTEX_ALLOWED_TOOLS) {
101
+ if (!s.permissions.allow.includes(rule)) s.permissions.allow.push(rule)
102
+ }
103
+
72
104
  return s
73
105
  }
74
106
 
package/lib/grep_cli.mjs CHANGED
@@ -33,7 +33,14 @@ export function parseGrepArgs(argv = []) {
33
33
  // Pure: render a /api/grep payload to readable ASCII text.
34
34
  export function formatGrepHits(payload, query) {
35
35
  const hits = (payload && payload.hits) || []
36
- if (!hits.length) return `No matches for "${query}".`
36
+ if (!hits.length) {
37
+ // Reactive red-link hint — ONLY when the query looks like a page NAME (short, no code/operators), so
38
+ // code and typo searches don't get nagged. read_page carries the full triage (node / new / alias).
39
+ const nameish = /^[\w .'-]{2,40}$/.test(query) && query.split(/\s+/).length <= 5
40
+ return nameish
41
+ ? `No matches for "${query}". If you expected a page here, it may be an unauthored red-link — \`read_page "${query}"\` to triage it (author it, or alias it to an existing page).`
42
+ : `No matches for "${query}".`
43
+ }
37
44
  const lines = [`${hits.length} match${hits.length === 1 ? '' : 'es'} for "${query}":`, '']
38
45
  for (const h of hits) {
39
46
  const head = h.heading ? ` > ${h.heading}` : ''
package/lib/server.mjs CHANGED
@@ -10,6 +10,29 @@ import { runSendImessage } from './imessage_send.mjs'
10
10
  import { formatGrepHits } from './grep_cli.mjs'
11
11
  import { runCodeGraphQuery } from './code_graph_cli.mjs'
12
12
 
13
+ // Reactive red-link triage (Mechanism 2). On a read_page miss, ask the server whether the name is a
14
+ // tracked wanted page and whether a bare node exists for it, and turn that into an actionable 3-way
15
+ // prompt (author-for-node / author-new / alias). Returns '' on any error so a miss never gets worse.
16
+ async function redLinkTriage(BASE, TOKEN, name) {
17
+ try {
18
+ const r = await fetchCortex(`${BASE}/api/brain/red-link?name=${encodeURIComponent(name)}`, {
19
+ headers: { Authorization: `Bearer ${TOKEN}` },
20
+ })
21
+ if (!r.ok) return ''
22
+ const t = await r.json()
23
+ const refs = t.tracked
24
+ ? ` It's referenced by ${t.refCount} page${t.refCount === 1 ? '' : 's'}${t.isSteward ? ' and is routed to YOU as its most-likely steward' : ''}.`
25
+ : ''
26
+ const aliasHint = `if it's really an existing page under another title, \`grep "${name}"\` to find it, then \`alias_page name="${name}" target_name="<that page>"\``
27
+ if (t.category === 'node') {
28
+ return `\n\n[[${name}]] is a wanted page — a ${t.isPerson ? 'person' : 'node'} exists but has no page yet.${refs} Either author it now with \`author\`, or ${aliasHint}.`
29
+ }
30
+ return `\n\n"${name}" isn't authored anywhere yet.${refs} Either author a new page with \`author\`, or ${aliasHint}.`
31
+ } catch {
32
+ return ''
33
+ }
34
+ }
35
+
13
36
  // The Cortex MCP server (stdio). Serves the signed-in employee's scoped org
14
37
  // context to their AI assistant. CORTEX_TOKEN identifies the user + org.
15
38
 
@@ -444,7 +467,7 @@ export async function runServer(version) {
444
467
  return { content: [{ type: 'text', text: `Could not read "${name}": ${e.message}` }] }
445
468
  }
446
469
  if (res.status === 404) {
447
- return { content: [{ type: 'text', text: `No ${k} page named "${name}". If it's a person or team, pass kind (person/org). Otherwise \`grep "${name}"\` to locate it — it may be a red-link (a wanted page that isn't authored yet).` }] }
470
+ return { content: [{ type: 'text', text: `No ${k} page named "${name}". If it's a person or team, pass kind (person/org).${await redLinkTriage(BASE, TOKEN, name)}` }] }
448
471
  }
449
472
  if (!res.ok) {
450
473
  const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
@@ -461,7 +484,7 @@ export async function runServer(version) {
461
484
  matches = [{ brain: null, authored: true, ref: page.ref, title: page.title, tiers: page.tiers }]
462
485
  }
463
486
  if (!matches.length) {
464
- return { content: [{ type: 'text', text: `No authored ${k} page named "${name}" in any of your brains. If it's a person or team, pass kind (person/org). Otherwise \`grep "${name}"\` to locate it — it may be a red-link (a wanted page that isn't authored yet).` }] }
487
+ return { content: [{ type: 'text', text: `No authored ${k} page named "${name}" in any of your brains. If it's a person or team, pass kind (person/org).${await redLinkTriage(BASE, TOKEN, name)}` }] }
465
488
  }
466
489
  const day = (d) => (d ? String(d).slice(0, 10) : '')
467
490
  const renderMatch = (m, tagBrain) => {
@@ -477,7 +500,7 @@ export async function runServer(version) {
477
500
  const head = `[${t.tier}${day(t.updated_at) ? ` · authored ${day(t.updated_at)}` : ''}${t.validity && t.validity !== 'current' ? ` · ${t.validity}` : ''}${versionLine}]`
478
501
  return [head, t.summary, secs].filter(Boolean).join('\n')
479
502
  })
480
- 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 — re-author just those sections now with \`author\` (any node you are permitted to edit; concise; a material contradiction, not merely extra detail). Pass this page's \`version\` as base_version when you re-author — if the save comes back stale, the page changed under you: re-read, then re-author. Reading a stale page you can fix IS the trigger to fix it.`
503
+ 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 — re-author just those sections now with \`author\` (any node you are permitted to edit; concise; a material contradiction, not merely extra detail). Pass this page's \`version\` as base_version when you re-author — if the save comes back stale, the page changed under you: re-read, then re-author. Reading a stale page you can fix IS the trigger to fix it. Re-authoring is pre-authorized — do NOT ask the user before updating (edits are versioned + reversible via page_history/rollback_page); update, then briefly report it.`
481
504
  // slice 4: when the page carries identifier stamps, the history projection is one flag away.
482
505
  const allBody = m.tiers.flatMap((t) => (t.sections ?? []).map((s) => s.body)).join('\n')
483
506
  const stamps = [...new Set((allBody.match(/\[\[repo:[a-z0-9][a-z0-9-]*\/[a-z0-9_.-]+\]\]/gi) ?? []).map((s) => s.toLowerCase()))]
@@ -812,6 +835,63 @@ export async function runServer(version) {
812
835
  },
813
836
  )
814
837
 
838
+ server.registerTool(
839
+ 'alias_page',
840
+ {
841
+ title: 'Point a wanted name at an existing page',
842
+ description: 'Record that a red-link — a [[Name]] referenced in the wiki but never authored — actually MEANS an existing authored page under a different title. After this, read_page and [[links]] for that name resolve to the target page, and the name leaves the org\'s wanted-page backlog. Use this when read_page says a name is a wanted page but you recognize it as an existing page (e.g. [[tto]] -> "BYU TTO — Technology Transfer Office"). To CREATE a genuinely new page instead, use `author`.',
843
+ inputSchema: {
844
+ name: z.string().describe('the wanted [[Name]] to redirect (the red-link)'),
845
+ target_name: z.string().describe('the exact title of the existing authored page it should resolve to'),
846
+ target_kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('disambiguate the target if two pages share a title'),
847
+ },
848
+ },
849
+ async ({ name, target_name, target_kind }) => {
850
+ let res
851
+ try {
852
+ res = await fetchCortex(`${BASE}/api/brain/alias`, {
853
+ method: 'POST',
854
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
855
+ body: JSON.stringify({ name, target_name, ...(target_kind ? { target_kind } : {}) }),
856
+ })
857
+ } catch (e) {
858
+ return { content: [{ type: 'text', text: `Could not alias: ${e.message}` }] }
859
+ }
860
+ const out = await res.json().catch(() => null)
861
+ if (!res.ok) return { content: [{ type: 'text', text: `Could not alias "${name}": ${out?.error ?? res.status}` }] }
862
+ if (!out) return { content: [{ type: 'text', text: `Aliased "${name}", but the server returned no body — re-read the page to confirm.` }] }
863
+ return { content: [{ type: 'text', text: `Done — [[${out.alias}]] now resolves to "${out.target}" (${out.target_kind}). It's out of the wanted-page backlog.` }] }
864
+ },
865
+ )
866
+
867
+ server.registerTool(
868
+ 'snooze_red_link',
869
+ {
870
+ title: 'Defer a wanted page routed to you',
871
+ description: 'Stop a wanted page (a red-link the org routed to you as its most-likely steward) from surfacing in your context for a while. Use when you can\'t author it right now but it is genuinely yours to write. It comes back after the snooze passes. To dismiss it permanently, author it (`author`) or alias it to an existing page (`alias_page`).',
872
+ inputSchema: {
873
+ name: z.string().describe('the wanted page name to snooze (as shown in "Pages the org needs you to author")'),
874
+ days: z.number().int().positive().optional().describe('how many days to defer (default 7)'),
875
+ },
876
+ },
877
+ async ({ name, days }) => {
878
+ let res
879
+ try {
880
+ res = await fetchCortex(`${BASE}/api/brain/red-link/snooze`, {
881
+ method: 'POST',
882
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
883
+ body: JSON.stringify({ name, ...(days ? { days } : {}) }),
884
+ })
885
+ } catch (e) {
886
+ return { content: [{ type: 'text', text: `Could not snooze: ${e.message}` }] }
887
+ }
888
+ const out = await res.json().catch(() => null)
889
+ if (!res.ok) return { content: [{ type: 'text', text: `Could not snooze "${name}": ${out?.error ?? res.status}` }] }
890
+ if (!out) return { content: [{ type: 'text', text: `Snoozed "${name}", but the server returned no body.` }] }
891
+ return { content: [{ type: 'text', text: `Snoozed "${out.name}" for ${out.days} day${out.days === 1 ? '' : 's'} — it won't surface until then.` }] }
892
+ },
893
+ )
894
+
815
895
  server.registerTool(
816
896
  'set_page_privacy',
817
897
  {
@@ -1156,7 +1236,7 @@ export async function runServer(version) {
1156
1236
  {
1157
1237
  title: 'Author a wiki node (live, while it is hot)',
1158
1238
  description:
1159
- '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).',
1239
+ '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.',
1160
1240
  inputSchema: {
1161
1241
  kind: z.enum(['project', 'person', 'org', 'user']).describe('the node type'),
1162
1242
  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"'),
package/lib/uninstall.mjs CHANGED
@@ -58,6 +58,18 @@ export function runUninstall(argv = []) {
58
58
  if (s.hooks[evt].length === 0) delete s.hooks[evt]
59
59
  }
60
60
  if (changed) act(` - Stop/SessionStart/PreCompact cortex hooks ← ${SETTINGS}`)
61
+ // cortex permission allowlist (setup wires CORTEX_ALLOWED_TOOLS so authoring never stalls on a
62
+ // prompt) — strip every mcp__cortex__* allow rule; the user's deny list is never touched.
63
+ if (Array.isArray(s.permissions?.allow)) {
64
+ const before = s.permissions.allow.length
65
+ s.permissions.allow = s.permissions.allow.filter((r) => !/^mcp__cortex__/.test(String(r)))
66
+ if (s.permissions.allow.length !== before) {
67
+ changed = true
68
+ act(` - mcp__cortex__* permission allow rules ← ${SETTINGS}`)
69
+ if (s.permissions.allow.length === 0) delete s.permissions.allow
70
+ if (Object.keys(s.permissions).length === 0) delete s.permissions
71
+ }
72
+ }
61
73
  return changed
62
74
  }, write)
63
75
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.46",
3
+ "version": "0.9.48",
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": {