@theronap/cortex-mcp 0.9.60 → 0.9.62

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.
@@ -36,11 +36,13 @@ if (cmd === '--version' || cmd === '-v') {
36
36
  if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
37
37
  process.stdout.write(
38
38
  `cortex-mcp ${VERSION} — connect your AI assistant to Cortex\n\n` +
39
- `Onboard (one command):\n` +
40
- ` npx -y @theronap/cortex-mcp setup <YOUR_TOKEN>\n\n` +
41
- `This wires your Claude config so your AI sees your Cortex context and your\n` +
42
- `sessions flow into the org automatically. Restart Claude Code after.\n\n` +
39
+ `Onboard (one command, no token to copy):\n` +
40
+ ` npx -y @theronap/cortex-mcp login\n\n` +
41
+ `This opens your browser, you click Approve, and it wires everything up. Restart\n` +
42
+ `Claude Code after, and your AI sees your Cortex context while your sessions flow\n` +
43
+ `into the org automatically.\n\n` +
43
44
  `Subcommands:\n` +
45
+ ` login [--label <name>] browser-approved sign-in — gets a token for you, then runs setup\n` +
44
46
  ` setup <token> wire MCP server + capture hook into ~/.claude config (single editor)\n` +
45
47
  ` install [<token>] [--editor auto|all|<id,...>] wire Cortex into EVERY detected editor + write the capability manifest\n` +
46
48
  ` repair re-run setup at the latest version using your existing token (no token needed)\n` +
@@ -68,7 +70,14 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
68
70
  // A graceful drain lets libuv finish those handles first, so the assertion never fires. The MCP
69
71
  // server (default branch) runs forever and never reaches an exit path. if/else so the CLI
70
72
  // commands don't fall through into the server.
71
- if (cmd === 'setup') {
73
+ if (cmd === 'login') {
74
+ // Browser-approved sign-in: no token to copy. Ends by calling runSetup with the token it
75
+ // collected, so there is still exactly ONE code path that writes a credential to disk.
76
+ const { runLogin } = await import('../lib/login.mjs')
77
+ await runLogin(rest, VERSION)
78
+ const { closeFetch } = await import('../lib/diagnose.mjs')
79
+ await closeFetch()
80
+ } else if (cmd === 'setup') {
72
81
  const { runSetup } = await import('../lib/setup.mjs')
73
82
  await runSetup(rest, VERSION)
74
83
  const { closeFetch } = await import('../lib/diagnose.mjs')
package/lib/login.mjs ADDED
@@ -0,0 +1,148 @@
1
+ import os from 'node:os'
2
+ import { spawn } from 'node:child_process'
3
+ import { resolveBase, CANONICAL_BASE } from './diagnose.mjs'
4
+
5
+ // `cortex-mcp login` — get a token without the human copy-pasting one out of the web console.
6
+ //
7
+ // WHY THIS EXISTS: the old path was log into the console, find /connect, copy a uuid, paste it into
8
+ // a terminal. That copy-paste is the step that failed on the first non-technical onboarding
9
+ // (2026-08-03), and it is the reason there could never be a real one-line install. Here the person
10
+ // clicks Approve in a browser and types nothing.
11
+ //
12
+ // THE FLOW (server side: /api/device/{start,approve,exchange}):
13
+ // 1. start — we generate nothing; the server issues a device_code (our secret) + a short
14
+ // user_code, and we open the pre-filled approval URL.
15
+ // 2. approve — happens in their browser, against their login. We never see their password and
16
+ // never handle a JWT.
17
+ // 3. exchange — we poll with the device_code; once a human has approved, the token is minted and
18
+ // returned exactly once. Then we hand straight off to the normal `setup` wiring.
19
+ //
20
+ // Deliberately a DEVICE flow rather than a loopback redirect. Loopback needs a local port and an
21
+ // open browser on the same machine, so it dies over SSH and on locked-down laptops. With the URL
22
+ // pre-filled, the device flow costs the user the same single click and works everywhere.
23
+
24
+ /** Open a URL in the user's default browser. Best-effort: never throws, never blocks the flow. */
25
+ function openBrowser(url) {
26
+ const cmd =
27
+ process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'
28
+ const args = process.platform === 'win32' ? ['/c', 'start', '""', url] : [url]
29
+ try {
30
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true })
31
+ // If the browser cannot be launched we still printed the URL, so this is genuinely non-fatal.
32
+ child.on('error', () => {})
33
+ child.unref()
34
+ return true
35
+ } catch {
36
+ return false
37
+ }
38
+ }
39
+
40
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
41
+
42
+ /** A label the person will recognise on the approval screen. Machine name, not a user name. */
43
+ function deviceLabel() {
44
+ const host = os.hostname().replace(/\.local$/i, '')
45
+ return host || `${os.platform()} device`
46
+ }
47
+
48
+ export async function runLogin(argv = [], version = 'dev') {
49
+ const base = resolveBase(process.env.CORTEX_URL) || CANONICAL_BASE
50
+
51
+ // --label lets a person running several machines tell them apart later; account_tokens.label
52
+ // carries it, so it survives long after the grant row is swept.
53
+ const labelFlag = argv.indexOf('--label')
54
+ const label = labelFlag !== -1 && argv[labelFlag + 1] ? argv[labelFlag + 1] : deviceLabel()
55
+
56
+ let start
57
+ try {
58
+ const res = await fetch(`${base}/api/device/start`, {
59
+ method: 'POST',
60
+ headers: { 'Content-Type': 'application/json' },
61
+ body: JSON.stringify({ label }),
62
+ })
63
+ if (!res.ok) throw new Error(`server said ${res.status}`)
64
+ start = await res.json()
65
+ } catch (err) {
66
+ process.stderr.write(
67
+ `cortex: could not reach ${base} to start login (${err.message}).\n` +
68
+ `Check your connection, then try again.\n`,
69
+ )
70
+ process.exitCode = 1
71
+ return
72
+ }
73
+
74
+ const opened = openBrowser(start.verificationUriComplete)
75
+
76
+ process.stdout.write(
77
+ `\n Confirm this code in your browser: ${start.userCode}\n\n` +
78
+ (opened
79
+ ? ` A browser should have opened. If not, go to:\n ${start.verificationUriComplete}\n\n`
80
+ : ` Open this in your browser:\n ${start.verificationUriComplete}\n\n`) +
81
+ ` Waiting for you to approve it...\n`,
82
+ )
83
+
84
+ // Poll until approved or the grant expires. The server sets the pace (and says slow_down if we
85
+ // are early), so the cadence stays server-controlled rather than hardcoded here.
86
+ let interval = (start.interval ?? 2) * 1000
87
+ const deadline = Date.now() + (start.expiresIn ?? 600) * 1000
88
+ let token = null
89
+ let activeOrgId = null
90
+
91
+ while (Date.now() < deadline) {
92
+ await sleep(interval)
93
+ let res
94
+ try {
95
+ res = await fetch(`${base}/api/device/exchange`, {
96
+ method: 'POST',
97
+ headers: { 'Content-Type': 'application/json' },
98
+ body: JSON.stringify({ deviceCode: start.deviceCode }),
99
+ })
100
+ } catch {
101
+ continue // transient network blip: keep waiting rather than failing a login mid-approval
102
+ }
103
+
104
+ if (res.status === 429) {
105
+ interval = Math.min(interval * 2, 10_000) // back off, do not give up
106
+ continue
107
+ }
108
+ if (res.status === 410) {
109
+ process.stderr.write(`\ncortex: that took too long and the code expired. Run login again.\n`)
110
+ process.exitCode = 1
111
+ return
112
+ }
113
+ if (res.status === 409 || res.status === 404) {
114
+ const body = await res.json().catch(() => ({}))
115
+ process.stderr.write(`\ncortex: ${body.error ?? 'this login is no longer valid'}. Run login again.\n`)
116
+ process.exitCode = 1
117
+ return
118
+ }
119
+ if (!res.ok) continue
120
+
121
+ const body = await res.json().catch(() => ({}))
122
+ if (body.status === 'approved' && body.personalToken) {
123
+ token = body.personalToken
124
+ activeOrgId = body.activeOrgId ?? null
125
+ break
126
+ }
127
+ // 'pending' — the human has not clicked yet. Keep waiting quietly; a spinner that reprints
128
+ // every two seconds is noise on the one screen where the person is reading instructions.
129
+ }
130
+
131
+ if (!token) {
132
+ process.stderr.write(`\ncortex: nobody approved that login before it expired. Run login again.\n`)
133
+ process.exitCode = 1
134
+ return
135
+ }
136
+
137
+ process.stdout.write(`\n Approved. Setting up...\n\n`)
138
+
139
+ // Hand the raw token straight to the existing installer. We never write it anywhere ourselves —
140
+ // runSetup owns every file that touches a credential, so there is exactly one code path that
141
+ // stores a token and one place to audit.
142
+ const { runSetup } = await import('./setup.mjs')
143
+ await runSetup([token], version)
144
+
145
+ if (activeOrgId) {
146
+ process.stdout.write(` New pages will be saved in the space you picked.\n`)
147
+ }
148
+ }
package/lib/server.mjs CHANGED
@@ -235,8 +235,8 @@ export async function runServer(version) {
235
235
  server.registerTool(
236
236
  'timeline_pull',
237
237
  {
238
- title: 'Pull unattributed messaging threads to triage',
239
- description: "Surface messaging threads captured in the org (email, etc.) that AREN'T yet attributed to a project — the backlog awaiting your judgment. Call it as you work; when you recognize which KNOWN project a surfaced thread is about, attribute it with attribute_thread. Threads you don't recognize: leave them (they self-heal as the graph fills). Pass a `project` you're working on to RANK the backlog by relevance (threads sharing participants with that project come first, marked ★). Returns participants + subject + recency per thread enough to recognize, not the message bodies.",
238
+ title: 'Pull the unclaimed backlog to triage',
239
+ description: "Surface everything captured in the org that NO ONE has judged yet — the backlog awaiting your judgment. Two sections: unattributed EMAIL threads (grouped, since a thread is what you actually triage), and unclaimed RECORDS from every other source (commits, sessions, docs, calendar) one by one. Call it as you work; when you recognize which KNOWN project a surfaced thread is about, attribute it with attribute_thread. Things you don't recognize: leave them they stay in the backlog and self-heal as the graph fills. Pass a `project` you're working on to RANK the thread half by relevance (threads sharing participants with that project come first, marked ★); records are always newest-first. Returns enough to RECOGNIZE an itemparticipants, subject, title, recency — never the bodies.",
240
240
  inputSchema: {
241
241
  limit: z.number().optional().describe('max threads to return (default 20)'),
242
242
  project: z.string().optional().describe('optional KNOWN project slug to rank the backlog by relevance to what you are working on; omit for the whole backlog newest-first'),
@@ -252,18 +252,37 @@ export async function runServer(version) {
252
252
  const body = await res.text()
253
253
  throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
254
254
  }
255
- const { threads } = await res.json()
256
- if (!threads?.length) return { content: [{ type: 'text', text: 'No unattributed threads in the backlog.' }] }
257
- const lines = threads.map((t) => {
258
- const who = (t.participants ?? []).map((p) => String(p).replace(/^email:/, '')).join(', ')
259
- const when = t.lastAt ? String(t.lastAt).slice(0, 10) : '—'
260
- const rel = t.relevance && t.relevance.score > 0 ? ` · ★${t.relevance.score} ${t.relevance.reason}` : ''
261
- return `- ${t.threadKey} — ${t.subject ?? '(no subject)'} · ${who || 'unknown participants'} · ${t.eventCount} msg · ${when}${rel}`
262
- })
263
- const header = project
264
- ? `Unattributed threads (${threads.length}), ranked for project:${project} (★ = shares participants)`
265
- : `Unattributed threads (${threads.length})`
266
- return { content: [{ type: 'text', text: `${header} — attribute recognized ones with attribute_thread(threadKey, project):\n${lines.join('\n')}` }] }
255
+ const { threads, records } = await res.json()
256
+ if (!threads?.length && !records?.length) return { content: [{ type: 'text', text: 'Nothing in the backlog — everything captured so far has been claimed.' }] }
257
+
258
+ const sections = []
259
+
260
+ if (threads?.length) {
261
+ const lines = threads.map((t) => {
262
+ const who = (t.participants ?? []).map((p) => String(p).replace(/^email:/, '')).join(', ')
263
+ const when = t.lastAt ? String(t.lastAt).slice(0, 10) : '—'
264
+ const rel = t.relevance && t.relevance.score > 0 ? ` · ★${t.relevance.score} ${t.relevance.reason}` : ''
265
+ return `- ${t.threadKey} — ${t.subject ?? '(no subject)'} · ${who || 'unknown participants'} · ${t.eventCount} msg · ${when}${rel}`
266
+ })
267
+ const header = project
268
+ ? `Unattributed threads (${threads.length}), ranked for project:${project} (★ = shares participants)`
269
+ : `Unattributed threads (${threads.length})`
270
+ sections.push(`${header} — attribute recognized ones with attribute_thread(threadKey, project):\n${lines.join('\n')}`)
271
+ }
272
+
273
+ // The other 12 sources. No thread concept exists for a commit or a session log, so each is one
274
+ // row. `node` is shown when the record already carries a project — often the whole answer, and it
275
+ // is why these are NOT ranked by relevance: a record that already has a node needs judgment least.
276
+ if (records?.length) {
277
+ const lines = records.map((r) => {
278
+ const when = r.occurredAt ? String(r.occurredAt).slice(0, 10) : '—'
279
+ const node = r.node ? ` · → ${r.node}` : ''
280
+ return `- ${r.source}/${r.recordType ?? 'record'} — ${r.title ?? '(untitled)'} · ${when}${node} [${r.recordId}]`
281
+ })
282
+ sections.push(`Unclaimed records (${records.length}) from other sources — newest first:\n${lines.join('\n')}`)
283
+ }
284
+
285
+ return { content: [{ type: 'text', text: sections.join('\n\n') }] }
267
286
  },
268
287
  )
269
288
 
@@ -542,6 +561,12 @@ export async function runServer(version) {
542
561
  const allBody = m.tiers.flatMap((t) => (t.sections ?? []).map((s) => s.body)).join('\n')
543
562
  const stamps = [...new Set((allBody.match(/\[\[repo:[a-z0-9][a-z0-9-]*\/[a-z0-9_.-]+\]\]/gi) ?? []).map((s) => s.toLowerCase()))]
544
563
  if (stamps.length) footer += `\n— This page carries ${stamps.join(', ')} — \`read_page "${name}"\` with history: true for its event timeline (page = present, timeline = history).`
564
+ // Gate 4's per-node backlog nudge (also gate 3's KWA-36). The SERVER decides whether this
565
+ // fires — it sends `nudge` only when 25+ records are unclaimed AND the page has gone 7+ days
566
+ // unedited — so there is no threshold logic here to drift out of sync. It rides in the footer
567
+ // because that is the surface with evidence behind it: a session read a page through a [[link]],
568
+ // saw the footer, and repaired the page. A dashboard nobody opens would not have.
569
+ if (m.backlog?.nudge) footer += `\n— ${m.backlog.nudge}`
545
570
  const brainTag = tagBrain ? ` · brain: ${m.brain}` : ''
546
571
  return `# ${m.title ?? name} (full authored page${brainTag})\n\n${blocks.join('\n\n---\n\n')}\n\n${footer}`
547
572
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.60",
3
+ "version": "0.9.62",
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": {