@theronap/cortex-mcp 0.2.0 → 0.4.0

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.
@@ -5,6 +5,7 @@
5
5
  * Subcommands:
6
6
  * (none) run the MCP server (stdio) — used by your Claude config
7
7
  * setup <TOKEN> wire BOTH the MCP server + capture hook into your Claude config
8
+ * doctor live health check — is the token actually working? (no restart needed)
8
9
  * capture the Stop-hook capturer (invoked by Claude Code, not by hand)
9
10
  * --version | -v
10
11
  * --help | -h
@@ -15,7 +16,7 @@
15
16
  * Get your token from the Cortex console → Connect your AI.
16
17
  */
17
18
 
18
- const VERSION = '0.2.0'
19
+ const VERSION = '0.4.0'
19
20
  const cmd = process.argv[2]
20
21
  const rest = process.argv.slice(3)
21
22
 
@@ -33,6 +34,7 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
33
34
  `sessions flow into the org automatically. Restart Claude Code after.\n\n` +
34
35
  `Subcommands:\n` +
35
36
  ` setup <token> wire MCP server + capture hook into ~/.claude config\n` +
37
+ ` doctor live health check — confirm your token works (no restart needed)\n` +
36
38
  ` capture Stop-hook capturer (invoked by Claude Code)\n` +
37
39
  ` (no args) run the MCP server (used by your Claude config)\n\n` +
38
40
  `Get your token from the Cortex console → Connect your AI.\n`,
@@ -42,10 +44,15 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
42
44
 
43
45
  if (cmd === 'setup') {
44
46
  const { runSetup } = await import('../lib/setup.mjs')
45
- runSetup(rest)
47
+ await runSetup(rest)
46
48
  process.exit(0)
47
49
  }
48
50
 
51
+ if (cmd === 'doctor') {
52
+ const { runDoctor } = await import('../lib/doctor.mjs')
53
+ process.exit(await runDoctor())
54
+ }
55
+
49
56
  if (cmd === 'capture') {
50
57
  const { runCapture } = await import('../lib/capture.mjs')
51
58
  await runCapture()
package/lib/capture.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { readFileSync } from 'fs'
2
+ import { fetchCortex, classify } from './diagnose.mjs'
2
3
 
3
4
  // Claude Code Stop hook → POSTs a session digest to Cortex cloud, which
4
5
  // summarizes server-side and upserts ONE record per session. Node-native
@@ -40,23 +41,31 @@ export async function runCapture() {
40
41
 
41
42
  if (!transcript && !hook.session_id) { process.stderr.write('cortex: empty session, skipping\n'); return }
42
43
 
43
- const res = await fetch(`${base}/api/ingest`, {
44
- method: 'POST',
45
- headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
46
- body: JSON.stringify({
47
- source: 'claude-code',
48
- project: repo,
49
- sessionId: hook.session_id,
50
- transcript,
51
- title: `Worked in ${repo}`,
52
- payload: { session_id: hook.session_id, cwd: hook.cwd },
53
- }),
54
- }).catch((e) => { process.stderr.write(`cortex: ingest failed: ${e}\n`); return null })
55
-
56
- if (res && res.ok) {
44
+ let res
45
+ try {
46
+ res = await fetchCortex(`${base}/api/ingest`, {
47
+ method: 'POST',
48
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
49
+ body: JSON.stringify({
50
+ source: 'claude-code',
51
+ project: repo,
52
+ sessionId: hook.session_id,
53
+ transcript,
54
+ title: `Worked in ${repo}`,
55
+ payload: { session_id: hook.session_id, cwd: hook.cwd },
56
+ }),
57
+ })
58
+ } catch (e) {
59
+ // Never break a session — just report and move on.
60
+ process.stderr.write(`cortex: ${e.message}\n`)
61
+ return
62
+ }
63
+
64
+ if (res.ok) {
57
65
  const j = await res.json().catch(() => ({}))
58
66
  process.stderr.write(`cortex: ${j.inserted ? 'captured' : 'updated'} "${j.title ?? repo}" → ${repo}\n`)
59
- } else if (res) {
60
- process.stderr.write(`cortex: ingest ${res.status}\n`)
67
+ } else {
68
+ const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
69
+ process.stderr.write(`cortex: ingest failed — ${d.message}\n`)
61
70
  }
62
71
  }
@@ -0,0 +1,110 @@
1
+ // Shared health / diagnosis helpers for the cortex MCP client.
2
+ //
3
+ // The whole reason this file exists: a transient infrastructure block (Vercel firewall / bot
4
+ // protection / deployment protection) once surfaced as a bare "Cortex API 403: unknown", which
5
+ // read like an auth failure and sent everyone chasing token regeneration for hours. The fix is
6
+ // to tell the truth about WHAT failed.
7
+ //
8
+ // KEY SIGNAL: the Cortex app ALWAYS returns JSON ({ error: ... }). So a NON-JSON body on a
9
+ // 4xx/5xx means infrastructure handled the request, not Cortex auth — re-running setup or
10
+ // regenerating the token will not help; it is usually transient and worth a retry.
11
+
12
+ export const isUuid = (s) =>
13
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s ?? '')
14
+
15
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
16
+
17
+ // A non-OK response → an actionable diagnosis: { kind, retriable, message }.
18
+ // kind: 'auth' → token bad/revoked/wrong-deployment (NOT retriable)
19
+ // 'infra' → blocked by infrastructure, non-JSON body (retriable, usually transient)
20
+ // 'app' → a real Cortex API error with a JSON message (retriable only if 5xx)
21
+ export function classify(status, contentType, bodyText, requestId) {
22
+ const isJson = (contentType ?? '').includes('application/json')
23
+ let appError = null
24
+ if (isJson) { try { appError = JSON.parse(bodyText)?.error ?? null } catch { /* not json after all */ } }
25
+ const rid = requestId ? ` [request id: ${requestId}]` : ''
26
+
27
+ if (status === 401 || (isJson && appError === 'invalid token')) {
28
+ return {
29
+ kind: 'auth', retriable: false,
30
+ message: `Token rejected (HTTP ${status}: ${appError ?? 'unauthorized'}). The token is invalid, ` +
31
+ `revoked, or for a different deployment — not an infrastructure problem. Get a fresh token from ` +
32
+ `the Cortex console → Connect your AI, then re-run setup.${rid}`,
33
+ }
34
+ }
35
+ if (!isJson) {
36
+ return {
37
+ kind: 'infra', retriable: true,
38
+ message: `Blocked by infrastructure (HTTP ${status}, non-JSON response) — NOT by Cortex auth. This is ` +
39
+ `usually a transient firewall / bot-protection hiccup; retrying often clears it. If it persists, check ` +
40
+ `Vercel firewall / deployment protection on the API route. Re-running setup will not help.${rid}`,
41
+ }
42
+ }
43
+ return {
44
+ kind: 'app', retriable: status >= 500,
45
+ message: `Cortex API ${status}: ${appError ?? 'unknown error'}.${rid}`,
46
+ }
47
+ }
48
+
49
+ // fetch with retry on TRANSIENT responses only (429, 5xx, and infra-style non-JSON 403).
50
+ // App-level 401/403-with-JSON are returned immediately (retrying won't change the verdict).
51
+ // Throws a clear network error if the host is unreachable after retries.
52
+ export async function fetchCortex(url, opts = {}, { retries = 2, baseDelayMs = 400 } = {}) {
53
+ let lastErr
54
+ for (let attempt = 0; attempt <= retries; attempt++) {
55
+ try {
56
+ const res = await fetch(url, opts)
57
+ const ct = res.headers.get('content-type') ?? ''
58
+ const transient =
59
+ res.status === 429 ||
60
+ res.status >= 500 ||
61
+ (res.status === 403 && !ct.includes('application/json')) // infra block, not app authz
62
+ if (transient && attempt < retries) {
63
+ await sleep(baseDelayMs * 2 ** attempt)
64
+ continue
65
+ }
66
+ return res
67
+ } catch (e) {
68
+ lastErr = e
69
+ if (attempt < retries) { await sleep(baseDelayMs * 2 ** attempt); continue }
70
+ }
71
+ }
72
+ throw new Error(
73
+ `Could not reach Cortex at ${url} — ${lastErr?.message ?? 'network error'}. ` +
74
+ `Check your connection (and CORTEX_URL if you set it).`,
75
+ )
76
+ }
77
+
78
+ // Live health probe: hit /api/mcp-context with the token and classify the result.
79
+ // Returns { ok, status, projectCount?, requestId?, diagnosis? }.
80
+ export async function checkToken(token, base) {
81
+ if (!token) {
82
+ return { ok: false, status: 0, diagnosis: { kind: 'config', retriable: false,
83
+ message: 'No CORTEX_TOKEN found. Run: npx -y @theronap/cortex-mcp setup <your-token>' } }
84
+ }
85
+ if (!isUuid(token)) {
86
+ return { ok: false, status: 0, diagnosis: { kind: 'config', retriable: false,
87
+ message: `Token "${String(token).slice(0, 8)}…" is not a valid Cortex token (expected a UUID). ` +
88
+ `Re-run setup with the token from the console.` } }
89
+ }
90
+ const url = `${(base ?? 'https://cortex-console.vercel.app').replace(/\/$/, '')}/api/mcp-context`
91
+ let res
92
+ try {
93
+ res = await fetchCortex(url, { headers: { Authorization: `Bearer ${token}` } })
94
+ } catch (e) {
95
+ return { ok: false, status: 0, diagnosis: { kind: 'network', retriable: true, message: e.message } }
96
+ }
97
+ const requestId = res.headers.get('x-vercel-id') ?? null
98
+ const contentType = res.headers.get('content-type')
99
+ const body = await res.text()
100
+ if (!res.ok) {
101
+ return { ok: false, status: res.status, requestId, diagnosis: classify(res.status, contentType, body, requestId) }
102
+ }
103
+ let projectCount
104
+ try {
105
+ const ctx = JSON.parse(body).context ?? ''
106
+ const m = ctx.match(/## Projects \((\d+)\)/)
107
+ if (m) projectCount = Number(m[1])
108
+ } catch { /* context shape changed — non-fatal for a health check */ }
109
+ return { ok: true, status: 200, projectCount, requestId }
110
+ }
package/lib/doctor.mjs ADDED
@@ -0,0 +1,56 @@
1
+ import { readFileSync, existsSync } from 'fs'
2
+ import { homedir } from 'os'
3
+ import { join } from 'path'
4
+ import { checkToken } from './diagnose.mjs'
5
+
6
+ // `npx @theronap/cortex-mcp doctor` — a live, one-command health check.
7
+ //
8
+ // This is the "is it ACTUALLY working?" tool that was missing: it reads your token, calls the
9
+ // real Cortex API, and prints a clear PASS/FAIL with the actual cause. No Claude Code restart
10
+ // needed — so onboarding can confirm the connection independently of "did the server load."
11
+
12
+ // Token resolution: env first, then the Claude config the setup command wrote (so `doctor`
13
+ // works the moment after `setup`, before any restart). Returns { token, source }.
14
+ function resolveToken() {
15
+ if (process.env.CORTEX_TOKEN) return { token: process.env.CORTEX_TOKEN, source: 'CORTEX_TOKEN env' }
16
+ const claudeJson = join(homedir(), '.claude.json')
17
+ if (existsSync(claudeJson)) {
18
+ try {
19
+ const cfg = JSON.parse(readFileSync(claudeJson, 'utf8'))
20
+ const t = cfg?.mcpServers?.cortex?.env?.CORTEX_TOKEN
21
+ if (t) return { token: t, source: `${claudeJson} (mcpServers.cortex)` }
22
+ } catch { /* malformed config — fall through to "not found" */ }
23
+ }
24
+ return { token: null, source: null }
25
+ }
26
+
27
+ export async function runDoctor() {
28
+ const base = (process.env.CORTEX_URL ?? 'https://cortex-console.vercel.app').replace(/\/$/, '')
29
+ const out = (m) => process.stdout.write(m + '\n')
30
+
31
+ out('')
32
+ out('Cortex doctor — checking your connection…')
33
+ const { token, source } = resolveToken()
34
+ out(` token source: ${source ?? 'NONE FOUND'}`)
35
+ out(` endpoint: ${base}`)
36
+ out('')
37
+
38
+ const r = await checkToken(token, base)
39
+
40
+ if (r.ok) {
41
+ out(' ✓ PASS — your token authenticates and Cortex returned your context.')
42
+ if (typeof r.projectCount === 'number') out(` You can currently see ${r.projectCount} project(s).`)
43
+ if (r.requestId) out(` (request id: ${r.requestId})`)
44
+ out('')
45
+ out(' If your AI still does not see Cortex, the server just is not loaded yet —')
46
+ out(' fully quit and reopen Claude Code (the MCP server starts on launch).')
47
+ out('')
48
+ return 0
49
+ }
50
+
51
+ out(` ✗ FAIL — ${r.diagnosis?.kind ?? 'error'}${r.status ? ` (HTTP ${r.status})` : ''}`)
52
+ out(` ${r.diagnosis?.message ?? 'unknown error'}`)
53
+ if (r.diagnosis?.retriable) out(' This looks transient — running `doctor` again shortly may succeed.')
54
+ out('')
55
+ return 1
56
+ }
package/lib/server.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
3
  import { z } from 'zod'
4
+ import { fetchCortex, classify } from './diagnose.mjs'
4
5
 
5
6
  // The Cortex MCP server (stdio). Serves the signed-in employee's scoped org
6
7
  // context to their AI assistant. CORTEX_TOKEN identifies the user + org.
@@ -19,11 +20,12 @@ export async function runServer(version) {
19
20
  async function fetchContext() {
20
21
  const now = Date.now()
21
22
  if (cache && now - cache.ts < 5 * 60 * 1000) return cache.text
22
- const res = await fetch(`${BASE}/api/mcp-context`, { headers: { Authorization: `Bearer ${TOKEN}` } })
23
+ // fetchCortex retries transient infra/5xx; classify turns a failure into an honest message
24
+ // (token vs infra-block vs network) instead of a bare "Cortex API 403: unknown".
25
+ const res = await fetchCortex(`${BASE}/api/mcp-context`, { headers: { Authorization: `Bearer ${TOKEN}` } })
23
26
  if (!res.ok) {
24
- let detail = 'unknown'
25
- try { detail = (await res.json()).error ?? detail } catch { /* ignore */ }
26
- throw new Error(`Cortex API ${res.status}: ${detail}`)
27
+ const body = await res.text()
28
+ throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
27
29
  }
28
30
  const { context } = await res.json()
29
31
  cache = { text: context, ts: now }
@@ -71,5 +73,64 @@ export async function runServer(version) {
71
73
  },
72
74
  )
73
75
 
76
+ server.registerTool(
77
+ 'story',
78
+ {
79
+ title: 'Story of a topic',
80
+ description: 'Ask "what\'s the story of X?" — assembles a chronological narrative of what happened with a project, topic, or piece of work, from across all your activity (commits, sessions, discussions). Scoped to what you can see.',
81
+ inputSchema: { question: z.string().describe('the topic/project/thing to get the story of, e.g. "the auth work" or "checkout-v2"') },
82
+ },
83
+ async ({ question }) => {
84
+ let res
85
+ try {
86
+ res = await fetchCortex(`${BASE}/api/story`, {
87
+ method: 'POST',
88
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
89
+ body: JSON.stringify({ question }),
90
+ })
91
+ } catch (e) {
92
+ return { content: [{ type: 'text', text: `Could not assemble story: ${e.message}` }] }
93
+ }
94
+ if (!res.ok) {
95
+ const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
96
+ return { content: [{ type: 'text', text: `Could not assemble story: ${d.message}` }] }
97
+ }
98
+ const { answer, sourceCount, sources } = await res.json()
99
+ const srcList = (sources ?? []).map((s) => ` - [${s.id}] ${s.title} (${s.source})`).join('\n')
100
+ const tail = srcList ? `\n\nSources (pass an id to set_record_privacy to reclassify a record you own):\n${srcList}` : ''
101
+ return { content: [{ type: 'text', text: `${answer}\n\n(assembled from ${sourceCount} record${sourceCount === 1 ? '' : 's'})${tail}` }] }
102
+ },
103
+ )
104
+
105
+ server.registerTool(
106
+ 'set_record_privacy',
107
+ {
108
+ title: 'Set a record\'s privacy tier',
109
+ description: 'Reclassify the access tier of one of YOUR OWN records: "accessible" (anyone in the org), "scoped" (you + your management chain), or "confidential" (you + people you explicitly grant access). Get record_id from a `story` result\'s Sources list. Only affects records you own — others return an error.',
110
+ inputSchema: {
111
+ record_id: z.string().describe('the record id (uuid), taken from a story result source'),
112
+ privacy: z.enum(['accessible', 'scoped', 'confidential']).describe('the new access tier'),
113
+ },
114
+ },
115
+ async ({ record_id, privacy }) => {
116
+ let res
117
+ try {
118
+ res = await fetchCortex(`${BASE}/api/records/${record_id}`, {
119
+ method: 'PATCH',
120
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
121
+ body: JSON.stringify({ privacy }),
122
+ })
123
+ } catch (e) {
124
+ return { content: [{ type: 'text', text: `Could not set privacy: ${e.message}` }] }
125
+ }
126
+ if (!res.ok) {
127
+ const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
128
+ return { content: [{ type: 'text', text: `Could not set privacy: ${d.message}` }] }
129
+ }
130
+ const out = await res.json()
131
+ return { content: [{ type: 'text', text: `Done — record ${out.id} is now "${out.privacy}".` }] }
132
+ },
133
+ )
134
+
74
135
  await server.connect(new StdioServerTransport())
75
136
  }
package/lib/setup.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'fs'
2
2
  import { homedir } from 'os'
3
3
  import { join, dirname } from 'path'
4
+ import { checkToken } from './diagnose.mjs'
4
5
 
5
6
  // One-command employee onboarding. Wires both:
6
7
  // 1. ~/.claude.json → the cortex MCP server (context-serving)
@@ -31,7 +32,7 @@ function ensureDir(path) {
31
32
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
32
33
  }
33
34
 
34
- export function runSetup(argv) {
35
+ export async function runSetup(argv) {
35
36
  const token = argv[0]
36
37
  if (!token || token.startsWith('-')) {
37
38
  process.stderr.write(
@@ -104,9 +105,24 @@ export function runSetup(argv) {
104
105
  process.exit(1)
105
106
  }
106
107
 
108
+ // ── 3. Self-verify — writing config proves "files written", NOT "connection works".
109
+ // Actually call the API so a bad/expired token is caught HERE, not 40 minutes into debugging.
107
110
  log('')
108
- log('Done. Restart Claude Code, then your AI will see your Cortex context')
109
- log('and your sessions will flow into the org automatically.')
110
- log(`Console: ${base}`)
111
+ log('Verifying your token against Cortex')
112
+ const health = await checkToken(token, base)
113
+ if (health.ok) {
114
+ const n = health.projectCount
115
+ log(` ✓ Verified — your token works${typeof n === 'number' ? ` (you can see ${n} project${n === 1 ? '' : 's'})` : ''}.`)
116
+ } else {
117
+ log(' ⚠ Config written, but the live check did NOT pass:')
118
+ log(` ${health.diagnosis?.message ?? 'unknown error'}`)
119
+ log(' The files are in place; fix the above, then re-check with `doctor`.')
120
+ }
121
+
122
+ log('')
123
+ log('⟳ IMPORTANT: fully quit and reopen Claude Code to load the Cortex server.')
124
+ log(' Then your AI sees your Cortex context and your sessions flow into the org.')
125
+ log(' Re-check anytime: npx -y @theronap/cortex-mcp doctor')
126
+ log(` Console: ${base}`)
111
127
  log('')
112
128
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
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": {