@theronap/cortex-mcp 0.1.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.
- package/bin/cortex-mcp.mjs +44 -84
- package/lib/capture.mjs +71 -0
- package/lib/diagnose.mjs +110 -0
- package/lib/doctor.mjs +56 -0
- package/lib/server.mjs +136 -0
- package/lib/setup.mjs +128 -0
- package/package.json +3 -2
package/bin/cortex-mcp.mjs
CHANGED
|
@@ -2,103 +2,63 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* cortex-mcp — connect your AI assistant to Cortex.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Subcommands:
|
|
6
|
+
* (none) run the MCP server (stdio) — used by your Claude config
|
|
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)
|
|
9
|
+
* capture the Stop-hook capturer (invoked by Claude Code, not by hand)
|
|
10
|
+
* --version | -v
|
|
11
|
+
* --help | -h
|
|
6
12
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* "args": ["-y", "cortex-mcp"],
|
|
10
|
-
* "env": { "CORTEX_TOKEN": "<your-personal-token>" }
|
|
11
|
-
* }
|
|
13
|
+
* Zero-install onboarding:
|
|
14
|
+
* npx -y @theronap/cortex-mcp setup <your-token>
|
|
12
15
|
*
|
|
13
|
-
* Get your
|
|
14
|
-
*
|
|
15
|
-
* Env:
|
|
16
|
-
* CORTEX_TOKEN (required) your personal token — identifies you + your org
|
|
17
|
-
* CORTEX_URL (optional) defaults to https://cortex-console.vercel.app
|
|
16
|
+
* Get your token from the Cortex console → Connect your AI.
|
|
18
17
|
*/
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
const VERSION = '0.4.0'
|
|
20
|
+
const cmd = process.argv[2]
|
|
21
|
+
const rest = process.argv.slice(3)
|
|
22
|
+
|
|
23
|
+
if (cmd === '--version' || cmd === '-v') {
|
|
24
|
+
process.stdout.write(`cortex-mcp ${VERSION}\n`)
|
|
25
|
+
process.exit(0)
|
|
26
|
+
}
|
|
23
27
|
|
|
24
|
-
|
|
25
|
-
const arg = process.argv[2]
|
|
26
|
-
if (arg === '--version' || arg === '-v') { process.stdout.write(`cortex-mcp ${VERSION}\n`); process.exit(0) }
|
|
27
|
-
if (arg === '--help' || arg === '-h') {
|
|
28
|
+
if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
28
29
|
process.stdout.write(
|
|
29
30
|
`cortex-mcp ${VERSION} — connect your AI assistant to Cortex\n\n` +
|
|
30
|
-
`
|
|
31
|
-
`
|
|
32
|
-
`Claude
|
|
33
|
-
`
|
|
34
|
-
`
|
|
31
|
+
`Onboard (one command):\n` +
|
|
32
|
+
` npx -y @theronap/cortex-mcp setup <YOUR_TOKEN>\n\n` +
|
|
33
|
+
`This wires your Claude config so your AI sees your Cortex context and your\n` +
|
|
34
|
+
`sessions flow into the org automatically. Restart Claude Code after.\n\n` +
|
|
35
|
+
`Subcommands:\n` +
|
|
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` +
|
|
38
|
+
` capture Stop-hook capturer (invoked by Claude Code)\n` +
|
|
39
|
+
` (no args) run the MCP server (used by your Claude config)\n\n` +
|
|
40
|
+
`Get your token from the Cortex console → Connect your AI.\n`,
|
|
35
41
|
)
|
|
36
42
|
process.exit(0)
|
|
37
43
|
}
|
|
38
44
|
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
process.stderr.write('cortex-mcp: CORTEX_TOKEN is required. Get yours from the Cortex console → Connect your AI.\n')
|
|
44
|
-
process.exit(1)
|
|
45
|
+
if (cmd === 'setup') {
|
|
46
|
+
const { runSetup } = await import('../lib/setup.mjs')
|
|
47
|
+
await runSetup(rest)
|
|
48
|
+
process.exit(0)
|
|
45
49
|
}
|
|
46
50
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const now = Date.now()
|
|
51
|
-
if (cache && now - cache.ts < 5 * 60 * 1000) return cache.text
|
|
52
|
-
const res = await fetch(`${BASE}/api/mcp-context`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
53
|
-
if (!res.ok) {
|
|
54
|
-
let detail = 'unknown'
|
|
55
|
-
try { detail = (await res.json()).error ?? detail } catch { /* ignore */ }
|
|
56
|
-
throw new Error(`Cortex API ${res.status}: ${detail}`)
|
|
57
|
-
}
|
|
58
|
-
const { context } = await res.json()
|
|
59
|
-
cache = { text: context, ts: now }
|
|
60
|
-
return context
|
|
51
|
+
if (cmd === 'doctor') {
|
|
52
|
+
const { runDoctor } = await import('../lib/doctor.mjs')
|
|
53
|
+
process.exit(await runDoctor())
|
|
61
54
|
}
|
|
62
55
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
title: 'My Cortex context',
|
|
69
|
-
description: 'Your current work context from the org — your projects, recent activity, gaps, and any directives from leadership. Scoped to what you are permitted to see.',
|
|
70
|
-
inputSchema: {},
|
|
71
|
-
},
|
|
72
|
-
async () => ({ content: [{ type: 'text', text: await fetchContext() }] }),
|
|
73
|
-
)
|
|
74
|
-
|
|
75
|
-
server.registerTool(
|
|
76
|
-
'search_org',
|
|
77
|
-
{
|
|
78
|
-
title: 'Search the org',
|
|
79
|
-
description: 'Search your visible work activity and projects by keyword.',
|
|
80
|
-
inputSchema: { query: z.string().describe('keyword to search for') },
|
|
81
|
-
},
|
|
82
|
-
async ({ query }) => {
|
|
83
|
-
const text = await fetchContext()
|
|
84
|
-
const q = query.toLowerCase()
|
|
85
|
-
const lines = text.split('\n').filter((l) => l.toLowerCase().includes(q))
|
|
86
|
-
return { content: [{ type: 'text', text: lines.length ? `Matches for "${query}":\n${lines.join('\n')}` : `No visible results for "${query}".` }] }
|
|
87
|
-
},
|
|
88
|
-
)
|
|
89
|
-
|
|
90
|
-
server.registerTool(
|
|
91
|
-
'project_status',
|
|
92
|
-
{
|
|
93
|
-
title: 'Project status',
|
|
94
|
-
description: 'Status of a specific project by key (e.g. checkout-v2). Returns only what you can see.',
|
|
95
|
-
inputSchema: { key: z.string().describe('project key, e.g. checkout-v2') },
|
|
96
|
-
},
|
|
97
|
-
async ({ key }) => {
|
|
98
|
-
const text = await fetchContext()
|
|
99
|
-
const line = text.split('\n').find((l) => l.includes(`**${key}**`) || l.includes(key))
|
|
100
|
-
return { content: [{ type: 'text', text: line ? `Project ${key}:\n${line.trim()}` : `No visible project "${key}".` }] }
|
|
101
|
-
},
|
|
102
|
-
)
|
|
56
|
+
if (cmd === 'capture') {
|
|
57
|
+
const { runCapture } = await import('../lib/capture.mjs')
|
|
58
|
+
await runCapture()
|
|
59
|
+
process.exit(0)
|
|
60
|
+
}
|
|
103
61
|
|
|
104
|
-
|
|
62
|
+
// Default: run the MCP server.
|
|
63
|
+
const { runServer } = await import('../lib/server.mjs')
|
|
64
|
+
await runServer(VERSION)
|
package/lib/capture.mjs
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readFileSync } from 'fs'
|
|
2
|
+
import { fetchCortex, classify } from './diagnose.mjs'
|
|
3
|
+
|
|
4
|
+
// Claude Code Stop hook → POSTs a session digest to Cortex cloud, which
|
|
5
|
+
// summarizes server-side and upserts ONE record per session. Node-native
|
|
6
|
+
// (no bun, no repo clone). Always exits 0 — capture must never break a session.
|
|
7
|
+
|
|
8
|
+
function readStdin() {
|
|
9
|
+
try { return readFileSync(0, 'utf8') } catch { return '' }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Claude transcripts are JSONL; pull human-readable text from the tail so the
|
|
13
|
+
// server has real content to summarize (not raw tool JSON).
|
|
14
|
+
function transcriptTail(path) {
|
|
15
|
+
let raw = ''
|
|
16
|
+
try { raw = readFileSync(path, 'utf8') } catch { return '' }
|
|
17
|
+
const lines = raw.split('\n').filter(Boolean).slice(-60)
|
|
18
|
+
const texts = []
|
|
19
|
+
for (const line of lines) {
|
|
20
|
+
try {
|
|
21
|
+
const obj = JSON.parse(line)
|
|
22
|
+
const content = obj?.message?.content ?? obj?.content
|
|
23
|
+
if (typeof content === 'string') texts.push(content)
|
|
24
|
+
else if (Array.isArray(content)) {
|
|
25
|
+
for (const c of content) if (c?.type === 'text' && c.text) texts.push(c.text)
|
|
26
|
+
}
|
|
27
|
+
} catch { /* skip non-JSON */ }
|
|
28
|
+
}
|
|
29
|
+
return texts.join('\n').slice(-6000)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function runCapture() {
|
|
33
|
+
const token = process.env.CORTEX_TOKEN
|
|
34
|
+
if (!token) { process.stderr.write('cortex: CORTEX_TOKEN not set, skipping\n'); return }
|
|
35
|
+
const base = (process.env.CORTEX_URL ?? 'https://cortex-console.vercel.app').replace(/\/$/, '')
|
|
36
|
+
|
|
37
|
+
let hook = {}
|
|
38
|
+
try { hook = JSON.parse(readStdin()) } catch { /* no/invalid stdin */ }
|
|
39
|
+
const repo = hook.cwd?.split('/').filter(Boolean).pop() ?? 'general'
|
|
40
|
+
const transcript = hook.transcript_path ? transcriptTail(hook.transcript_path) : ''
|
|
41
|
+
|
|
42
|
+
if (!transcript && !hook.session_id) { process.stderr.write('cortex: empty session, skipping\n'); return }
|
|
43
|
+
|
|
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) {
|
|
65
|
+
const j = await res.json().catch(() => ({}))
|
|
66
|
+
process.stderr.write(`cortex: ${j.inserted ? 'captured' : 'updated'} "${j.title ?? repo}" → ${repo}\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`)
|
|
70
|
+
}
|
|
71
|
+
}
|
package/lib/diagnose.mjs
ADDED
|
@@ -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
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import { fetchCortex, classify } from './diagnose.mjs'
|
|
5
|
+
|
|
6
|
+
// The Cortex MCP server (stdio). Serves the signed-in employee's scoped org
|
|
7
|
+
// context to their AI assistant. CORTEX_TOKEN identifies the user + org.
|
|
8
|
+
|
|
9
|
+
export async function runServer(version) {
|
|
10
|
+
const TOKEN = process.env.CORTEX_TOKEN
|
|
11
|
+
const BASE = (process.env.CORTEX_URL ?? 'https://cortex-console.vercel.app').replace(/\/$/, '')
|
|
12
|
+
|
|
13
|
+
if (!TOKEN) {
|
|
14
|
+
process.stderr.write('cortex-mcp: CORTEX_TOKEN is required. Get yours from the Cortex console → Connect your AI.\n')
|
|
15
|
+
process.exit(1)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Cache context for 5 minutes so repeated tool calls don't re-fetch.
|
|
19
|
+
let cache = null
|
|
20
|
+
async function fetchContext() {
|
|
21
|
+
const now = Date.now()
|
|
22
|
+
if (cache && now - cache.ts < 5 * 60 * 1000) return cache.text
|
|
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}` } })
|
|
26
|
+
if (!res.ok) {
|
|
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)
|
|
29
|
+
}
|
|
30
|
+
const { context } = await res.json()
|
|
31
|
+
cache = { text: context, ts: now }
|
|
32
|
+
return context
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const server = new McpServer({ name: 'cortex', version })
|
|
36
|
+
|
|
37
|
+
server.registerTool(
|
|
38
|
+
'my_context',
|
|
39
|
+
{
|
|
40
|
+
title: 'My Cortex context',
|
|
41
|
+
description: 'Your current work context from the org — your projects, recent activity, gaps, and any directives from leadership. Scoped to what you are permitted to see.',
|
|
42
|
+
inputSchema: {},
|
|
43
|
+
},
|
|
44
|
+
async () => ({ content: [{ type: 'text', text: await fetchContext() }] }),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
server.registerTool(
|
|
48
|
+
'search_org',
|
|
49
|
+
{
|
|
50
|
+
title: 'Search the org',
|
|
51
|
+
description: 'Search your visible work activity and projects by keyword.',
|
|
52
|
+
inputSchema: { query: z.string().describe('keyword to search for') },
|
|
53
|
+
},
|
|
54
|
+
async ({ query }) => {
|
|
55
|
+
const text = await fetchContext()
|
|
56
|
+
const q = query.toLowerCase()
|
|
57
|
+
const lines = text.split('\n').filter((l) => l.toLowerCase().includes(q))
|
|
58
|
+
return { content: [{ type: 'text', text: lines.length ? `Matches for "${query}":\n${lines.join('\n')}` : `No visible results for "${query}".` }] }
|
|
59
|
+
},
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
server.registerTool(
|
|
63
|
+
'project_status',
|
|
64
|
+
{
|
|
65
|
+
title: 'Project status',
|
|
66
|
+
description: 'Status of a specific project by key (e.g. checkout-v2). Returns only what you can see.',
|
|
67
|
+
inputSchema: { key: z.string().describe('project key, e.g. checkout-v2') },
|
|
68
|
+
},
|
|
69
|
+
async ({ key }) => {
|
|
70
|
+
const text = await fetchContext()
|
|
71
|
+
const line = text.split('\n').find((l) => l.includes(`**${key}**`) || l.includes(key))
|
|
72
|
+
return { content: [{ type: 'text', text: line ? `Project ${key}:\n${line.trim()}` : `No visible project "${key}".` }] }
|
|
73
|
+
},
|
|
74
|
+
)
|
|
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
|
+
|
|
135
|
+
await server.connect(new StdioServerTransport())
|
|
136
|
+
}
|
package/lib/setup.mjs
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'fs'
|
|
2
|
+
import { homedir } from 'os'
|
|
3
|
+
import { join, dirname } from 'path'
|
|
4
|
+
import { checkToken } from './diagnose.mjs'
|
|
5
|
+
|
|
6
|
+
// One-command employee onboarding. Wires both:
|
|
7
|
+
// 1. ~/.claude.json → the cortex MCP server (context-serving)
|
|
8
|
+
// 2. ~/.claude/settings.json → the capture Stop hook (activity ingest)
|
|
9
|
+
//
|
|
10
|
+
// Safe by construction: backs up each file before touching it, validates JSON,
|
|
11
|
+
// merges into existing structures (never clobbers other MCP servers / hooks),
|
|
12
|
+
// and is idempotent (re-running just updates the cortex entries in place).
|
|
13
|
+
|
|
14
|
+
const PKG = '@theronap/cortex-mcp'
|
|
15
|
+
|
|
16
|
+
function readJson(path) {
|
|
17
|
+
if (!existsSync(path)) return {}
|
|
18
|
+
const raw = readFileSync(path, 'utf8').trim()
|
|
19
|
+
if (!raw) return {}
|
|
20
|
+
return JSON.parse(raw) // throws on malformed — caller handles
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function backup(path) {
|
|
24
|
+
if (!existsSync(path)) return null
|
|
25
|
+
const bak = `${path}.cortex-bak`
|
|
26
|
+
copyFileSync(path, bak)
|
|
27
|
+
return bak
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function ensureDir(path) {
|
|
31
|
+
const dir = dirname(path)
|
|
32
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function runSetup(argv) {
|
|
36
|
+
const token = argv[0]
|
|
37
|
+
if (!token || token.startsWith('-')) {
|
|
38
|
+
process.stderr.write(
|
|
39
|
+
'Usage: npx @theronap/cortex-mcp setup <CORTEX_TOKEN>\n\n' +
|
|
40
|
+
'Get your token from the Cortex console → Connect your AI.\n'
|
|
41
|
+
)
|
|
42
|
+
process.exit(1)
|
|
43
|
+
}
|
|
44
|
+
const base = process.env.CORTEX_URL ?? 'https://cortex-console.vercel.app'
|
|
45
|
+
const home = homedir()
|
|
46
|
+
const claudeJson = join(home, '.claude.json')
|
|
47
|
+
const settingsJson = join(home, '.claude', 'settings.json')
|
|
48
|
+
|
|
49
|
+
const log = (m) => process.stdout.write(m + '\n')
|
|
50
|
+
log('')
|
|
51
|
+
log('Cortex setup — wiring your AI assistant…')
|
|
52
|
+
|
|
53
|
+
// ── 1. MCP server in ~/.claude.json ──────────────────────────────────────
|
|
54
|
+
try {
|
|
55
|
+
let cfg
|
|
56
|
+
try { cfg = readJson(claudeJson) } catch (e) {
|
|
57
|
+
process.stderr.write(`\n✗ ${claudeJson} is not valid JSON — fix or remove it, then re-run.\n`)
|
|
58
|
+
process.exit(1)
|
|
59
|
+
}
|
|
60
|
+
const bak = backup(claudeJson)
|
|
61
|
+
cfg.mcpServers = cfg.mcpServers ?? {}
|
|
62
|
+
cfg.mcpServers.cortex = {
|
|
63
|
+
type: 'stdio',
|
|
64
|
+
command: 'npx',
|
|
65
|
+
args: ['-y', PKG],
|
|
66
|
+
env: { CORTEX_TOKEN: token },
|
|
67
|
+
}
|
|
68
|
+
ensureDir(claudeJson)
|
|
69
|
+
writeFileSync(claudeJson, JSON.stringify(cfg, null, 2))
|
|
70
|
+
log(` ✓ MCP server → ${claudeJson}${bak ? ' (backup saved)' : ''}`)
|
|
71
|
+
} catch (e) {
|
|
72
|
+
process.stderr.write(` ✗ failed to update ${claudeJson}: ${e.message}\n`)
|
|
73
|
+
process.exit(1)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── 2. Capture Stop hook in ~/.claude/settings.json ──────────────────────
|
|
77
|
+
try {
|
|
78
|
+
let s
|
|
79
|
+
try { s = readJson(settingsJson) } catch (e) {
|
|
80
|
+
process.stderr.write(`\n✗ ${settingsJson} is not valid JSON — fix or remove it, then re-run.\n`)
|
|
81
|
+
process.exit(1)
|
|
82
|
+
}
|
|
83
|
+
const bak = backup(settingsJson)
|
|
84
|
+
s.hooks = s.hooks ?? {}
|
|
85
|
+
s.hooks.Stop = Array.isArray(s.hooks.Stop) ? s.hooks.Stop : []
|
|
86
|
+
|
|
87
|
+
const captureCmd = `CORTEX_TOKEN=${token} npx -y ${PKG} capture`
|
|
88
|
+
// Remove any prior cortex capture hook (idempotent: drop old token / old path forms).
|
|
89
|
+
for (const grp of s.hooks.Stop) {
|
|
90
|
+
if (Array.isArray(grp.hooks)) {
|
|
91
|
+
grp.hooks = grp.hooks.filter((h) => !/cortex-mcp.*capture|capture-session-cloud/.test(h.command ?? ''))
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Find or create a matcher:"" group and append.
|
|
95
|
+
let grp = s.hooks.Stop.find((g) => (g.matcher ?? '') === '')
|
|
96
|
+
if (!grp) { grp = { matcher: '', hooks: [] }; s.hooks.Stop.push(grp) }
|
|
97
|
+
grp.hooks = grp.hooks ?? []
|
|
98
|
+
grp.hooks.push({ type: 'command', command: captureCmd })
|
|
99
|
+
|
|
100
|
+
ensureDir(settingsJson)
|
|
101
|
+
writeFileSync(settingsJson, JSON.stringify(s, null, 2))
|
|
102
|
+
log(` ✓ Capture hook → ${settingsJson}${bak ? ' (backup saved)' : ''}`)
|
|
103
|
+
} catch (e) {
|
|
104
|
+
process.stderr.write(` ✗ failed to update ${settingsJson}: ${e.message}\n`)
|
|
105
|
+
process.exit(1)
|
|
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.
|
|
110
|
+
log('')
|
|
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}`)
|
|
127
|
+
log('')
|
|
128
|
+
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@theronap/cortex-mcp",
|
|
3
|
-
"version": "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": {
|
|
7
7
|
"cortex-mcp": "bin/cortex-mcp.mjs"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
|
-
"bin"
|
|
10
|
+
"bin",
|
|
11
|
+
"lib"
|
|
11
12
|
],
|
|
12
13
|
"engines": {
|
|
13
14
|
"node": ">=18"
|