@theronap/cortex-mcp 0.4.2 → 0.4.4
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 +2 -2
- package/lib/capture.mjs +3 -3
- package/lib/diagnose.mjs +45 -8
- package/lib/doctor.mjs +2 -2
- package/lib/server.mjs +5 -5
- package/lib/setup.mjs +8 -5
- package/package.json +1 -1
package/bin/cortex-mcp.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* Get your token from the Cortex console → Connect your AI.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
const VERSION = '0.4.
|
|
19
|
+
const VERSION = '0.4.3'
|
|
20
20
|
const cmd = process.argv[2]
|
|
21
21
|
const rest = process.argv.slice(3)
|
|
22
22
|
|
|
@@ -51,7 +51,7 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
|
51
51
|
// commands don't fall through into the server.
|
|
52
52
|
if (cmd === 'setup') {
|
|
53
53
|
const { runSetup } = await import('../lib/setup.mjs')
|
|
54
|
-
await runSetup(rest)
|
|
54
|
+
await runSetup(rest, VERSION)
|
|
55
55
|
const { closeFetch } = await import('../lib/diagnose.mjs')
|
|
56
56
|
await closeFetch()
|
|
57
57
|
} else if (cmd === 'doctor') {
|
package/lib/capture.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from 'fs'
|
|
2
|
-
import { fetchCortex, classify } from './diagnose.mjs'
|
|
2
|
+
import { fetchCortex, classify, resolveBase } from './diagnose.mjs'
|
|
3
3
|
|
|
4
4
|
// Claude Code Stop hook → POSTs a session digest to Cortex cloud, which
|
|
5
5
|
// summarizes server-side and upserts ONE record per session. Node-native
|
|
@@ -32,7 +32,7 @@ function transcriptTail(path) {
|
|
|
32
32
|
export async function runCapture() {
|
|
33
33
|
const token = process.env.CORTEX_TOKEN
|
|
34
34
|
if (!token) { process.stderr.write('cortex: CORTEX_TOKEN not set, skipping\n'); return }
|
|
35
|
-
const base = (process.env.CORTEX_URL
|
|
35
|
+
const base = resolveBase(process.env.CORTEX_URL)
|
|
36
36
|
|
|
37
37
|
let hook = {}
|
|
38
38
|
try { hook = JSON.parse(readStdin()) } catch { /* no/invalid stdin */ }
|
|
@@ -65,7 +65,7 @@ export async function runCapture() {
|
|
|
65
65
|
const j = await res.json().catch(() => ({}))
|
|
66
66
|
process.stderr.write(`cortex: ${j.inserted ? 'captured' : 'updated'} "${j.title ?? repo}" → ${repo}\n`)
|
|
67
67
|
} else {
|
|
68
|
-
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
68
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'), res.headers.get('x-deny-reason'))
|
|
69
69
|
process.stderr.write(`cortex: ingest failed — ${d.message}\n`)
|
|
70
70
|
}
|
|
71
71
|
}
|
package/lib/diagnose.mjs
CHANGED
|
@@ -6,19 +6,46 @@
|
|
|
6
6
|
// to tell the truth about WHAT failed.
|
|
7
7
|
//
|
|
8
8
|
// KEY SIGNAL: the Cortex app ALWAYS returns JSON ({ error: ... }). So a NON-JSON body on a
|
|
9
|
-
// 4xx/5xx means
|
|
10
|
-
// regenerating the token will not help
|
|
9
|
+
// 4xx/5xx means something else handled the request, not Cortex auth — re-running setup or
|
|
10
|
+
// regenerating the token will not help. Two cases: (1) an egress allowlist block
|
|
11
|
+
// (x-deny-reason: host_not_allowed) — NOT retriable, the host must be added to the allowlist;
|
|
12
|
+
// (2) a transient infrastructure hiccup — retriable.
|
|
11
13
|
|
|
12
14
|
export const isUuid = (s) =>
|
|
13
15
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s ?? '')
|
|
14
16
|
|
|
17
|
+
// The production alias — exempt from Vercel Deployment Protection.
|
|
18
|
+
export const CANONICAL_BASE = 'https://cortex-console.vercel.app'
|
|
19
|
+
|
|
20
|
+
// Resolve the API base from CORTEX_URL. A Vercel *deployment* URL
|
|
21
|
+
// (cortex-console-<hash>-<team>.vercel.app, or any non-canonical *.vercel.app) is guarded by
|
|
22
|
+
// Deployment Protection and returns an HTML auth page to programmatic clients — which looks
|
|
23
|
+
// exactly like an "infra block" (403/401, non-JSON). The production alias is exempt. So if
|
|
24
|
+
// CORTEX_URL points at a deployment URL, we fall back to the alias (and warn on stderr). A real
|
|
25
|
+
// custom domain (not *.vercel.app) is respected as-is. This makes the client robust to a stale
|
|
26
|
+
// CORTEX_URL pointing at a protected preview/deployment — the root cause of the Windows 403.
|
|
27
|
+
export function resolveBase(rawUrl) {
|
|
28
|
+
const raw = (rawUrl ?? '').trim()
|
|
29
|
+
if (!raw) return CANONICAL_BASE
|
|
30
|
+
let host
|
|
31
|
+
try { host = new URL(raw).host } catch { return CANONICAL_BASE }
|
|
32
|
+
if (host.endsWith('.vercel.app') && host !== 'cortex-console.vercel.app') {
|
|
33
|
+
process.stderr.write(
|
|
34
|
+
`cortex: CORTEX_URL (${raw}) is a protected Vercel deployment URL; using ${CANONICAL_BASE} instead.\n`,
|
|
35
|
+
)
|
|
36
|
+
return CANONICAL_BASE
|
|
37
|
+
}
|
|
38
|
+
return raw.replace(/\/$/, '')
|
|
39
|
+
}
|
|
40
|
+
|
|
15
41
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
16
42
|
|
|
17
43
|
// A non-OK response → an actionable diagnosis: { kind, retriable, message }.
|
|
18
|
-
// kind: 'auth'
|
|
19
|
-
// '
|
|
20
|
-
// '
|
|
21
|
-
|
|
44
|
+
// kind: 'auth' → token bad/revoked/wrong-deployment (NOT retriable)
|
|
45
|
+
// 'egress' → blocked by network egress allowlist (NOT retriable, not a Cortex/Vercel issue)
|
|
46
|
+
// 'infra' → blocked by infrastructure, non-JSON body (retriable, usually transient)
|
|
47
|
+
// 'app' → a real Cortex API error with a JSON message (retriable only if 5xx)
|
|
48
|
+
export function classify(status, contentType, bodyText, requestId, denyReason) {
|
|
22
49
|
const isJson = (contentType ?? '').includes('application/json')
|
|
23
50
|
let appError = null
|
|
24
51
|
if (isJson) { try { appError = JSON.parse(bodyText)?.error ?? null } catch { /* not json after all */ } }
|
|
@@ -32,6 +59,15 @@ export function classify(status, contentType, bodyText, requestId) {
|
|
|
32
59
|
`the Cortex console → Connect your AI, then re-run setup.${rid}`,
|
|
33
60
|
}
|
|
34
61
|
}
|
|
62
|
+
if (denyReason === 'host_not_allowed' || /host (not in allowlist|not allowed)/i.test(bodyText ?? '')) {
|
|
63
|
+
return {
|
|
64
|
+
kind: 'egress', retriable: false,
|
|
65
|
+
message: `Blocked by your network's egress allowlist (HTTP ${status}: ${denyReason ?? 'host_not_allowed'}). ` +
|
|
66
|
+
`The request never left this environment — this is NOT a Cortex, token, or Vercel issue. ` +
|
|
67
|
+
`Add ${CANONICAL_BASE} to your environment's outbound allowlist, or run from a machine with open egress. ` +
|
|
68
|
+
`Re-running setup or regenerating the token will not help.${rid}`,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
35
71
|
if (!isJson) {
|
|
36
72
|
return {
|
|
37
73
|
kind: 'infra', retriable: true,
|
|
@@ -55,10 +91,11 @@ export async function fetchCortex(url, opts = {}, { retries = 2, baseDelayMs = 4
|
|
|
55
91
|
try {
|
|
56
92
|
const res = await fetch(url, opts)
|
|
57
93
|
const ct = res.headers.get('content-type') ?? ''
|
|
94
|
+
const denyReason = res.headers.get('x-deny-reason') ?? ''
|
|
58
95
|
const transient =
|
|
59
96
|
res.status === 429 ||
|
|
60
97
|
res.status >= 500 ||
|
|
61
|
-
(res.status === 403 && !ct.includes('application/json')) //
|
|
98
|
+
(res.status === 403 && !ct.includes('application/json') && !denyReason) // egress block is not transient
|
|
62
99
|
if (transient && attempt < retries) {
|
|
63
100
|
await sleep(baseDelayMs * 2 ** attempt)
|
|
64
101
|
continue
|
|
@@ -98,7 +135,7 @@ export async function checkToken(token, base) {
|
|
|
98
135
|
const contentType = res.headers.get('content-type')
|
|
99
136
|
const body = await res.text()
|
|
100
137
|
if (!res.ok) {
|
|
101
|
-
return { ok: false, status: res.status, requestId, diagnosis: classify(res.status, contentType, body, requestId) }
|
|
138
|
+
return { ok: false, status: res.status, requestId, diagnosis: classify(res.status, contentType, body, requestId, res.headers.get('x-deny-reason')) }
|
|
102
139
|
}
|
|
103
140
|
let projectCount
|
|
104
141
|
try {
|
package/lib/doctor.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from 'fs'
|
|
2
2
|
import { homedir } from 'os'
|
|
3
3
|
import { join } from 'path'
|
|
4
|
-
import { checkToken } from './diagnose.mjs'
|
|
4
|
+
import { checkToken, resolveBase } from './diagnose.mjs'
|
|
5
5
|
|
|
6
6
|
// `npx @theronap/cortex-mcp doctor` — a live, one-command health check.
|
|
7
7
|
//
|
|
@@ -25,7 +25,7 @@ function resolveToken() {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export async function runDoctor() {
|
|
28
|
-
const base = (process.env.CORTEX_URL
|
|
28
|
+
const base = resolveBase(process.env.CORTEX_URL)
|
|
29
29
|
const out = (m) => process.stdout.write(m + '\n')
|
|
30
30
|
|
|
31
31
|
out('')
|
package/lib/server.mjs
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
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
|
+
import { fetchCortex, classify, resolveBase } from './diagnose.mjs'
|
|
5
5
|
|
|
6
6
|
// The Cortex MCP server (stdio). Serves the signed-in employee's scoped org
|
|
7
7
|
// context to their AI assistant. CORTEX_TOKEN identifies the user + org.
|
|
8
8
|
|
|
9
9
|
export async function runServer(version) {
|
|
10
10
|
const TOKEN = process.env.CORTEX_TOKEN
|
|
11
|
-
const BASE = (process.env.CORTEX_URL
|
|
11
|
+
const BASE = resolveBase(process.env.CORTEX_URL)
|
|
12
12
|
|
|
13
13
|
if (!TOKEN) {
|
|
14
14
|
process.stderr.write('cortex-mcp: CORTEX_TOKEN is required. Get yours from the Cortex console → Connect your AI.\n')
|
|
@@ -25,7 +25,7 @@ export async function runServer(version) {
|
|
|
25
25
|
const res = await fetchCortex(`${BASE}/api/mcp-context`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
26
26
|
if (!res.ok) {
|
|
27
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)
|
|
28
|
+
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id'), res.headers.get('x-deny-reason')).message)
|
|
29
29
|
}
|
|
30
30
|
const { context } = await res.json()
|
|
31
31
|
cache = { text: context, ts: now }
|
|
@@ -92,7 +92,7 @@ export async function runServer(version) {
|
|
|
92
92
|
return { content: [{ type: 'text', text: `Could not assemble story: ${e.message}` }] }
|
|
93
93
|
}
|
|
94
94
|
if (!res.ok) {
|
|
95
|
-
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
95
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'), res.headers.get('x-deny-reason'))
|
|
96
96
|
return { content: [{ type: 'text', text: `Could not assemble story: ${d.message}` }] }
|
|
97
97
|
}
|
|
98
98
|
const { answer, sourceCount, sources } = await res.json()
|
|
@@ -124,7 +124,7 @@ export async function runServer(version) {
|
|
|
124
124
|
return { content: [{ type: 'text', text: `Could not set privacy: ${e.message}` }] }
|
|
125
125
|
}
|
|
126
126
|
if (!res.ok) {
|
|
127
|
-
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
127
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'), res.headers.get('x-deny-reason'))
|
|
128
128
|
return { content: [{ type: 'text', text: `Could not set privacy: ${d.message}` }] }
|
|
129
129
|
}
|
|
130
130
|
const out = await res.json()
|
package/lib/setup.mjs
CHANGED
|
@@ -1,7 +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
|
+
import { checkToken, resolveBase } from './diagnose.mjs'
|
|
5
5
|
|
|
6
6
|
// One-command employee onboarding. Wires both:
|
|
7
7
|
// 1. ~/.claude.json → the cortex MCP server (context-serving)
|
|
@@ -32,7 +32,7 @@ function ensureDir(path) {
|
|
|
32
32
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export async function runSetup(argv) {
|
|
35
|
+
export async function runSetup(argv, version) {
|
|
36
36
|
const token = argv[0]
|
|
37
37
|
if (!token || token.startsWith('-')) {
|
|
38
38
|
process.stderr.write(
|
|
@@ -41,7 +41,10 @@ export async function runSetup(argv) {
|
|
|
41
41
|
)
|
|
42
42
|
process.exit(1)
|
|
43
43
|
}
|
|
44
|
-
|
|
44
|
+
// Pin the wired commands to the installed version so the config can't later run a stale
|
|
45
|
+
// cached build (npx may reuse a cached older version for an unpinned spec).
|
|
46
|
+
const spec = version ? `${PKG}@${version}` : PKG
|
|
47
|
+
const base = resolveBase(process.env.CORTEX_URL)
|
|
45
48
|
const home = homedir()
|
|
46
49
|
const claudeJson = join(home, '.claude.json')
|
|
47
50
|
const settingsJson = join(home, '.claude', 'settings.json')
|
|
@@ -62,7 +65,7 @@ export async function runSetup(argv) {
|
|
|
62
65
|
cfg.mcpServers.cortex = {
|
|
63
66
|
type: 'stdio',
|
|
64
67
|
command: 'npx',
|
|
65
|
-
args: ['-y',
|
|
68
|
+
args: ['-y', spec],
|
|
66
69
|
env: { CORTEX_TOKEN: token },
|
|
67
70
|
}
|
|
68
71
|
ensureDir(claudeJson)
|
|
@@ -84,7 +87,7 @@ export async function runSetup(argv) {
|
|
|
84
87
|
s.hooks = s.hooks ?? {}
|
|
85
88
|
s.hooks.Stop = Array.isArray(s.hooks.Stop) ? s.hooks.Stop : []
|
|
86
89
|
|
|
87
|
-
const captureCmd = `CORTEX_TOKEN=${token} npx -y ${
|
|
90
|
+
const captureCmd = `CORTEX_TOKEN=${token} npx -y ${spec} capture`
|
|
88
91
|
// Remove any prior cortex capture hook (idempotent: drop old token / old path forms).
|
|
89
92
|
for (const grp of s.hooks.Stop) {
|
|
90
93
|
if (Array.isArray(grp.hooks)) {
|