@theronap/cortex-mcp 0.9.9 → 0.9.10

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/lib/capture.mjs CHANGED
@@ -3,6 +3,7 @@ import { homedir } from 'os'
3
3
  import { resolve } from 'path'
4
4
  import { createHash } from 'crypto'
5
5
  import { fetchCortex, classify, resolveBase } from './diagnose.mjs'
6
+ import { extractSession } from './edge_extract.mjs'
6
7
 
7
8
  // Project name from the hook cwd — cross-platform. Windows hooks send backslash paths,
8
9
  // and splitting on '/' alone turned the WHOLE path into one garbage project slug
@@ -65,6 +66,11 @@ function transcriptTail(path) {
65
66
  }
66
67
 
67
68
  export async function runCapture() {
69
+ // Recursion guard: edge extraction below shells out to `claude --print` with CORTEX_SUMMARIZING=1.
70
+ // That headless session fires its own Stop hook → this same capture command. Without this guard it
71
+ // would recurse (and re-ingest the summarizer's prompt as a phantom session). Bail immediately.
72
+ if (process.env.CORTEX_SUMMARIZING) { process.stderr.write('cortex: summarizer subprocess, skipping\n'); return }
73
+
68
74
  const token = process.env.CORTEX_TOKEN
69
75
  if (!token) { process.stderr.write('cortex: CORTEX_TOKEN not set, skipping\n'); return }
70
76
  const base = resolveBase(process.env.CORTEX_URL)
@@ -86,21 +92,32 @@ export async function runCapture() {
86
92
  if (Array.isArray(parsed.refs) && Date.now() - (parsed.ts ?? 0) < 12 * 3600 * 1000) hydratedFrom = parsed.refs
87
93
  } catch { /* none — guard stays a no-op */ }
88
94
 
95
+ // Extract LOCALLY on the subscription (claude -p): summary + people + non-person entities. The
96
+ // cloud then receives only the derived digest, never the raw transcript or the metered API
97
+ // summarizer (which has been the silent point of failure). Fall back to shipping the transcript
98
+ // tail only if local extraction is unavailable (e.g. `claude` not on PATH) so we never drop a
99
+ // session. The server re-validates people/entities — the edge is not trusted.
100
+ const common = {
101
+ source: 'claude-code',
102
+ project: repo,
103
+ sessionId: hook.session_id,
104
+ title: `Worked in ${repo}`,
105
+ payload: { session_id: hook.session_id, cwd: hook.cwd },
106
+ captureSource: 'hook', // T8: fallback writer — never clobbers a cortex-log ('skill') record
107
+ ...(hydratedFrom.length ? { hydratedFrom } : {}),
108
+ }
109
+ const extracted = transcript ? extractSession(transcript) : null
110
+ if (extracted && /^NOOP\b/i.test(extracted.summary)) { process.stderr.write('cortex: no-op session, skipping\n'); return }
111
+ const ingestBody = extracted
112
+ ? { ...common, summary: extracted.summary, people: extracted.people, entities: extracted.namedEntities }
113
+ : { ...common, transcript }
114
+
89
115
  let res
90
116
  try {
91
117
  res = await fetchCortex(`${base}/api/ingest`, {
92
118
  method: 'POST',
93
119
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
94
- body: JSON.stringify({
95
- source: 'claude-code',
96
- project: repo,
97
- sessionId: hook.session_id,
98
- transcript,
99
- title: `Worked in ${repo}`,
100
- payload: { session_id: hook.session_id, cwd: hook.cwd },
101
- captureSource: 'hook', // T8: fallback writer — never clobbers a cortex-log ('skill') record
102
- ...(hydratedFrom.length ? { hydratedFrom } : {}),
103
- }),
120
+ body: JSON.stringify(ingestBody),
104
121
  })
105
122
  } catch (e) {
106
123
  // Never break a session — just report and move on.
@@ -0,0 +1,92 @@
1
+ import { spawnSync } from 'child_process'
2
+
3
+ // Edge session extractor (slice 2c) — ONE `claude --print` call on the user's SUBSCRIPTION that
4
+ // produces summary + people + non-person entities LOCALLY, so the cloud receives only the derived
5
+ // digest. The server-side summarizer (Anthropic API) silently failed in prod ~2026-06-16 and took
6
+ // both session people AND the entity catalogue down; extracting here keeps the engine on `claude -p`
7
+ // (the standing preference) and removes that dependency. Node-native (no bun) to match capture.mjs.
8
+ //
9
+ // edgeSafeEnv() strips EVERY ANTHROPIC_* var + CLAUDE_CODE_OAUTH_TOKEN before spawning, so the call
10
+ // runs on the file-based subscription login and a stray ANTHROPIC_BASE_URL (e.g. a keytunnel proxy)
11
+ // can't reroute "free, local" inference through a billed proxy or ship raw text off-machine. The
12
+ // spawn also sets CORTEX_SUMMARIZING=1 so the headless session's own Stop hook no-ops (runCapture
13
+ // guards on it) instead of recursing.
14
+ //
15
+ // CONTRACT: people[] / namedEntities[] shapes MUST stay compatible with the server validators
16
+ // web/lib/engine/extract_people.ts (parsePeople) + extract_entities.ts (parseEntities). The prompt
17
+ // fragments are copied from there; the SERVER validator is the enforced source of truth — the edge
18
+ // is never trusted. Returns null on any failure → caller falls back to shipping the transcript tail.
19
+
20
+ export function edgeSafeEnv(base = process.env, extra = {}) {
21
+ const env = {}
22
+ for (const [k, v] of Object.entries(base)) {
23
+ if (v === undefined) continue
24
+ if (k.startsWith('ANTHROPIC_') || k === 'CLAUDE_CODE_OAUTH_TOKEN') continue
25
+ env[k] = v
26
+ }
27
+ return { ...env, ...extra }
28
+ }
29
+
30
+ const PEOPLE_FRAGMENT =
31
+ '"people" (array of important, NAMED individuals mentioned — each an object with ' +
32
+ '"name" (the person\'s real full name as written — NEVER an email address, handle, or group alias), ' +
33
+ '"email" (only if explicitly stated, else null), ' +
34
+ '"company" (their org/employer if stated, else null), ' +
35
+ '"title" (their role if stated, else null), ' +
36
+ '"relationship" (one short phrase: how they relate to this work), ' +
37
+ '"importance" ("high" for people central to the work, "low" for incidental). ' +
38
+ 'Only real named humans; omit anonymous or incidental mentions. Empty array is fine.)'
39
+
40
+ const ENTITY_FRAGMENT =
41
+ '"namedEntities" (array of important NON-PERSON things this content is about — concrete, named ' +
42
+ 'projects, processes, systems, products, documents, teams, tools, events, places, or topics. ' +
43
+ 'Each an object with "name" (the specific name as written, NOT a generic word), ' +
44
+ '"kind" (one of: project|process|system|product|document|team|topic|tool|event|place), ' +
45
+ '"description" (one short phrase: what it is / how it relates to this work, else null), ' +
46
+ '"importance" ("high" if central to the work, "low" if incidental). ' +
47
+ 'Do NOT include people or companies (those go in "people"). Omit vague/generic mentions. Empty array is fine.)'
48
+
49
+ export function extractSession(transcript) {
50
+ const text = (transcript ?? '').trim()
51
+ if (!text || process.env.CORTEX_SUMMARIZE_DISABLED) return null
52
+ const prompt =
53
+ 'You are processing a Claude Code work session for a knowledge base. Return ONLY minified JSON ' +
54
+ '(no prose, no markdown fences) with EXACTLY these three keys:\n' +
55
+ '"summary" (ONE concrete sentence under 20 words: what was worked on or decided. If the session ' +
56
+ 'had no real work — greetings, no tasks — set summary to exactly "NOOP"),\n' +
57
+ PEOPLE_FRAGMENT + ',\n' +
58
+ ENTITY_FRAGMENT +
59
+ '\n\n--- SESSION ---\n' + text.slice(0, 12000) + '\n--- END ---'
60
+ try {
61
+ const r = spawnSync(
62
+ 'claude',
63
+ ['--print', '--model', process.env.CORTEX_SUMMARY_MODEL ?? 'claude-haiku-4-5', prompt],
64
+ { env: edgeSafeEnv(process.env, { CORTEX_SUMMARIZING: '1' }), encoding: 'utf8', timeout: 90_000, maxBuffer: 4 * 1024 * 1024 },
65
+ )
66
+ if (r.status !== 0 || !r.stdout) return null
67
+ return parseEdgeJson(r.stdout.trim())
68
+ } catch {
69
+ return null
70
+ }
71
+ }
72
+
73
+ // Tolerant JSON extraction: the model may wrap output in prose / ```json fences, so grab the first
74
+ // balanced-looking {...} span. Arrays pass through untouched — the SERVER validates them.
75
+ export function parseEdgeJson(out) {
76
+ if (!out) return null
77
+ const start = out.indexOf('{')
78
+ const end = out.lastIndexOf('}')
79
+ if (start < 0 || end <= start) return null
80
+ try {
81
+ const o = JSON.parse(out.slice(start, end + 1))
82
+ const summary = typeof o.summary === 'string' ? o.summary.trim() : ''
83
+ if (!summary) return null
84
+ return {
85
+ summary: summary.slice(0, 200),
86
+ people: Array.isArray(o.people) ? o.people : [],
87
+ namedEntities: Array.isArray(o.namedEntities) ? o.namedEntities : [],
88
+ }
89
+ } catch {
90
+ return null
91
+ }
92
+ }
@@ -0,0 +1,88 @@
1
+ // send_imessage — outbound iMessage via Messages.app. This is a personal automation, NOT org
2
+ // intelligence: it writes nothing to Cortex. Three layers of safety (eng-review D3/D6/D10):
3
+ // D6 argv-safe: recipient + body are passed as osascript `on run argv` arguments, NEVER
4
+ // interpolated into the script source → no AppleScript injection, no quote/newline breakage.
5
+ // D3 draft-by-default: nothing sends unless the caller explicitly passes send:true.
6
+ // D10 recipient gating: an MCP send tool is reachable by any agent context (prompt-injection
7
+ // surface), so a boolean alone is not enough. Allowlisted recipients send on send:true;
8
+ // OFF-list recipients additionally require an out-of-band confirm secret the agent context
9
+ // doesn't have (CORTEX_IMESSAGE_SEND_CONFIRM), else the send is BLOCKED.
10
+ import { spawn } from 'child_process'
11
+
12
+ // AppleScript reads its inputs from argv — the message text is DATA, never code.
13
+ const SEND_SCRIPT = `on run argv
14
+ set targetId to item 1 of argv
15
+ set targetMessage to item 2 of argv
16
+ tell application "Messages"
17
+ set targetService to 1st account whose service type = iMessage
18
+ set targetBuddy to participant targetId of targetService
19
+ send targetMessage to targetBuddy
20
+ end tell
21
+ end run`
22
+
23
+ export function phoneKey(raw) {
24
+ const d = String(raw).replace(/[^0-9]/g, '')
25
+ return d.length < 7 ? null : d.slice(-10)
26
+ }
27
+ function handleKey(h) {
28
+ return h.includes('@') ? h.trim().toLowerCase() : phoneKey(h)
29
+ }
30
+ export function loadSendAllowlist(raw = process.env.CORTEX_IMESSAGE_SEND_ALLOWLIST ?? '') {
31
+ const set = new Set()
32
+ for (const item of raw.split(',').map((s) => s.trim()).filter(Boolean)) {
33
+ set.add(item.includes('@') ? item.toLowerCase() : (phoneKey(item) ?? item))
34
+ }
35
+ return set
36
+ }
37
+
38
+ // Pure decision: what should happen, given inputs + config. Testable without Messages.app.
39
+ // → { action: 'draft' | 'send' | 'blocked', reason }
40
+ export function decideSend({ recipient, message, send, confirm, allowlist, confirmSecret }) {
41
+ if (!recipient || !String(recipient).trim()) return { action: 'blocked', reason: 'no recipient' }
42
+ if (!message || !String(message).trim()) return { action: 'blocked', reason: 'empty message' }
43
+ if (!send) return { action: 'draft', reason: 'draft-by-default — re-call with send:true to actually send' }
44
+ const key = handleKey(String(recipient))
45
+ if (key && allowlist.has(key)) return { action: 'send', reason: 'allowlisted recipient' }
46
+ // off-list: require the out-of-band confirm secret
47
+ if (confirmSecret && confirm && confirm === confirmSecret) return { action: 'send', reason: 'off-list send authorized by confirm secret' }
48
+ return {
49
+ action: 'blocked',
50
+ reason: confirmSecret
51
+ ? 'recipient not allowlisted — pass the out-of-band confirm secret to send, or add them to CORTEX_IMESSAGE_SEND_ALLOWLIST'
52
+ : 'recipient not allowlisted — add them to CORTEX_IMESSAGE_SEND_ALLOWLIST (or set CORTEX_IMESSAGE_SEND_CONFIRM for an out-of-band override)',
53
+ }
54
+ }
55
+
56
+ export function sendViaOsascript(recipient, message) {
57
+ return new Promise((resolve) => {
58
+ let err = ''
59
+ let proc
60
+ try {
61
+ proc = spawn('osascript', ['-', String(recipient), String(message)], { stdio: ['pipe', 'ignore', 'pipe'] })
62
+ } catch (e) {
63
+ resolve({ ok: false, error: String(e) }); return
64
+ }
65
+ proc.stderr.on('data', (d) => { err += d })
66
+ proc.on('error', (e) => resolve({ ok: false, error: String(e) }))
67
+ proc.on('close', (code) => resolve({ ok: code === 0, error: err.trim() }))
68
+ proc.stdin.write(SEND_SCRIPT)
69
+ proc.stdin.end()
70
+ })
71
+ }
72
+
73
+ // Tool entry: decide, then act. Returns MCP content text.
74
+ export async function runSendImessage({ recipient, message, send, confirm }) {
75
+ const decision = decideSend({
76
+ recipient, message, send, confirm,
77
+ allowlist: loadSendAllowlist(),
78
+ confirmSecret: process.env.CORTEX_IMESSAGE_SEND_CONFIRM ?? null,
79
+ })
80
+ if (decision.action === 'draft') {
81
+ return `📝 DRAFT (not sent) — to ${recipient}:\n${message}\n\n(${decision.reason})`
82
+ }
83
+ if (decision.action === 'blocked') {
84
+ return `🚫 Not sent: ${decision.reason}`
85
+ }
86
+ const r = await sendViaOsascript(recipient, message)
87
+ return r.ok ? `✅ Sent to ${recipient}.` : `❌ Send failed: ${r.error || 'Messages.app returned an error (is it signed in to iMessage? is Automation permission granted?)'}`
88
+ }
@@ -0,0 +1,35 @@
1
+ import { test, expect } from 'bun:test'
2
+ import { decideSend, loadSendAllowlist, phoneKey } from './imessage_send.mjs'
3
+
4
+ const allow = loadSendAllowlist('+18015551234, mom@example.com')
5
+
6
+ test('draft-by-default: no send flag → draft, never sends (D3)', () => {
7
+ const d = decideSend({ recipient: '+18015551234', message: 'hi', send: false, allowlist: allow, confirmSecret: null })
8
+ expect(d.action).toBe('draft')
9
+ })
10
+
11
+ test('allowlisted recipient + send:true → send (D10)', () => {
12
+ expect(decideSend({ recipient: '+1 (801) 555-1234', message: 'hi', send: true, allowlist: allow, confirmSecret: null }).action).toBe('send')
13
+ expect(decideSend({ recipient: 'MOM@example.com', message: 'hi', send: true, allowlist: allow, confirmSecret: null }).action).toBe('send')
14
+ })
15
+
16
+ test('off-list recipient + send:true, no secret → BLOCKED (D10 prompt-injection guard)', () => {
17
+ const d = decideSend({ recipient: '+19998887777', message: 'hi', send: true, allowlist: allow, confirmSecret: null })
18
+ expect(d.action).toBe('blocked')
19
+ })
20
+
21
+ test('off-list recipient sends only with the correct out-of-band confirm secret', () => {
22
+ expect(decideSend({ recipient: '+19998887777', message: 'hi', send: true, confirm: 'wrong', allowlist: allow, confirmSecret: 'sesame' }).action).toBe('blocked')
23
+ expect(decideSend({ recipient: '+19998887777', message: 'hi', send: true, confirm: 'sesame', allowlist: allow, confirmSecret: 'sesame' }).action).toBe('send')
24
+ })
25
+
26
+ test('empty recipient/message is blocked', () => {
27
+ expect(decideSend({ recipient: '', message: 'hi', send: true, allowlist: allow, confirmSecret: null }).action).toBe('blocked')
28
+ expect(decideSend({ recipient: '+18015551234', message: ' ', send: true, allowlist: allow, confirmSecret: null }).action).toBe('blocked')
29
+ })
30
+
31
+ test('phoneKey + allowlist normalization', () => {
32
+ expect(phoneKey('+1 (801) 555-1234')).toBe('8015551234')
33
+ expect(allow.has('8015551234')).toBe(true)
34
+ expect(allow.has('mom@example.com')).toBe(true)
35
+ })
package/lib/server.mjs CHANGED
@@ -4,8 +4,9 @@ import { z } from 'zod'
4
4
  import { writeFileSync, mkdirSync } from 'fs'
5
5
  import { homedir } from 'os'
6
6
  import { join } from 'path'
7
- import { createHash } from 'crypto'
7
+ import { createHash, randomUUID } from 'crypto'
8
8
  import { fetchCortex, classify, resolveBase } from './diagnose.mjs'
9
+ import { runSendImessage } from './imessage_send.mjs'
9
10
 
10
11
  // The Cortex MCP server (stdio). Serves the signed-in employee's scoped org
11
12
  // context to their AI assistant. CORTEX_TOKEN identifies the user + org.
@@ -19,6 +20,23 @@ export async function runServer(version) {
19
20
  process.exit(1)
20
21
  }
21
22
 
23
+ // Self active-sessions (ADR-0017): one MCP process = one AI session. A stable per-process key + the
24
+ // working dir identify this session; we heartbeat /api/session-ping while alive so the user can see
25
+ // all THEIR sessions via my_sessions. Self-only on the server; best-effort (never breaks serving).
26
+ const SESSION_KEY = randomUUID()
27
+ async function pingSession() {
28
+ try {
29
+ await fetchCortex(`${BASE}/api/session-ping`, {
30
+ method: 'POST',
31
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
32
+ body: JSON.stringify({ sessionKey: SESSION_KEY, cwd: process.cwd() }),
33
+ })
34
+ } catch { /* best-effort heartbeat — never disrupt the session */ }
35
+ }
36
+ void pingSession()
37
+ const _hb = setInterval(() => void pingSession(), 60_000)
38
+ if (typeof _hb.unref === 'function') _hb.unref() // don't keep the process alive just for the heartbeat
39
+
22
40
  // Cache context for 5 minutes so repeated tool calls don't re-fetch.
23
41
  let cache = null
24
42
  async function fetchContext() {
@@ -238,6 +256,160 @@ export async function runServer(version) {
238
256
  },
239
257
  )
240
258
 
259
+ server.registerTool(
260
+ 'daily_brief',
261
+ {
262
+ title: 'Your daily brief',
263
+ description: 'A short, action-forward brief of what needs YOU today: pending decisions/directives, your stalled or unowned projects, today\'s meetings, who else moved on your work, and where you left off. The inverse of my_context — call it at the start of a session or whenever you want "what should I focus on?". RLS-scoped to you.',
264
+ inputSchema: {},
265
+ },
266
+ async () => {
267
+ let res
268
+ try {
269
+ res = await fetchCortex(`${BASE}/api/brief`, { headers: { Authorization: `Bearer ${TOKEN}` } })
270
+ } catch (e) {
271
+ return { content: [{ type: 'text', text: `Could not build your brief: ${e.message}` }] }
272
+ }
273
+ if (!res.ok) {
274
+ const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
275
+ return { content: [{ type: 'text', text: `Could not build your brief: ${d.message}` }] }
276
+ }
277
+ const { brief } = await res.json()
278
+ return { content: [{ type: 'text', text: brief || 'Nothing needs you right now.' }] }
279
+ },
280
+ )
281
+
282
+ server.registerTool(
283
+ 'writing_style',
284
+ {
285
+ title: 'How the user writes (for drafting in their voice)',
286
+ description: 'Returns the user\'s saved writing-style profile so you can DRAFT in their voice (email, message, doc). Call this right before composing anything on their behalf. Self-only — it is always the calling user\'s own profile. If none is saved, it tells you to derive one and save it with set_writing_style.',
287
+ inputSchema: {},
288
+ },
289
+ async () => {
290
+ let res
291
+ try {
292
+ res = await fetchCortex(`${BASE}/api/style`, { headers: { Authorization: `Bearer ${TOKEN}` } })
293
+ } catch (e) {
294
+ return { content: [{ type: 'text', text: `Could not load writing style: ${e.message}` }] }
295
+ }
296
+ if (!res.ok) {
297
+ const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
298
+ return { content: [{ type: 'text', text: `Could not load writing style: ${d.message}` }] }
299
+ }
300
+ const { style } = await res.json()
301
+ return { content: [{ type: 'text', text: style }] }
302
+ },
303
+ )
304
+
305
+ server.registerTool(
306
+ 'set_writing_style',
307
+ {
308
+ title: 'Save the user\'s writing-style profile',
309
+ description: 'Save (or update) a description of HOW the user writes — tone, sentence rhythm, structure, formatting habits, signature quirks — derived from prose you have seen them write this session. Store the STYLE, never their private content. Self-only: it always updates the calling user\'s own profile. Pass an empty string to clear it.',
310
+ inputSchema: { style_md: z.string().describe('a concise markdown description of the user\'s writing voice (tone/structure/quirks), ~1-2 paragraphs') },
311
+ },
312
+ async ({ style_md }) => {
313
+ let res
314
+ try {
315
+ res = await fetchCortex(`${BASE}/api/style`, {
316
+ method: 'PUT',
317
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
318
+ body: JSON.stringify({ style_md }),
319
+ })
320
+ } catch (e) {
321
+ return { content: [{ type: 'text', text: `Could not save writing style: ${e.message}` }] }
322
+ }
323
+ if (!res.ok) {
324
+ const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
325
+ return { content: [{ type: 'text', text: `Could not save writing style: ${d.message}` }] }
326
+ }
327
+ const r = await res.json()
328
+ return { content: [{ type: 'text', text: r.saved ? `Saved your writing-style profile (${r.chars} chars).` : 'Cleared your writing-style profile.' }] }
329
+ },
330
+ )
331
+
332
+ server.registerTool(
333
+ 'org_report',
334
+ {
335
+ title: 'Cross-project status report',
336
+ description: 'A status digest across all projects you can see: how many are healthy vs slowing/stalled/unowned, what needs attention, and (for managers) cross-source gaps. RLS-scoped to you. Use for "where do things stand?" / a standup or weekly review.',
337
+ inputSchema: {},
338
+ },
339
+ async () => {
340
+ let res
341
+ try {
342
+ res = await fetchCortex(`${BASE}/api/report`, { headers: { Authorization: `Bearer ${TOKEN}` } })
343
+ } catch (e) {
344
+ return { content: [{ type: 'text', text: `Could not build the report: ${e.message}` }] }
345
+ }
346
+ if (!res.ok) {
347
+ const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
348
+ return { content: [{ type: 'text', text: `Could not build the report: ${d.message}` }] }
349
+ }
350
+ const { report } = await res.json()
351
+ return { content: [{ type: 'text', text: report }] }
352
+ },
353
+ )
354
+
355
+ server.registerTool(
356
+ 'list_records',
357
+ {
358
+ title: 'List activity records (filtered)',
359
+ description: 'List recent activity records you can see, filtered by type, project, and recency. RLS-scoped. Use to enumerate "what happened on project X this week" or "recent meetings". Returns titles + ids (use an id with story/request_file).',
360
+ inputSchema: {
361
+ type: z.string().optional().describe("record type, e.g. 'meeting', 'comm', 'activity', 'ai_session', 'doc', 'note'"),
362
+ project: z.string().optional().describe('project key to filter to (e.g. "cortex")'),
363
+ since_days: z.number().optional().describe('only records from the last N days'),
364
+ limit: z.number().optional().describe('max rows (1-50, default 20)'),
365
+ },
366
+ },
367
+ async ({ type, project, since_days, limit }) => {
368
+ const qs = new URLSearchParams()
369
+ if (type) qs.set('type', type)
370
+ if (project) qs.set('project', project)
371
+ if (since_days != null) qs.set('since_days', String(since_days))
372
+ if (limit != null) qs.set('limit', String(limit))
373
+ let res
374
+ try {
375
+ res = await fetchCortex(`${BASE}/api/records?${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
376
+ } catch (e) {
377
+ return { content: [{ type: 'text', text: `Could not list records: ${e.message}` }] }
378
+ }
379
+ if (!res.ok) {
380
+ const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
381
+ return { content: [{ type: 'text', text: `Could not list records: ${d.message}` }] }
382
+ }
383
+ const { text } = await res.json()
384
+ return { content: [{ type: 'text', text }] }
385
+ },
386
+ )
387
+
388
+ server.registerTool(
389
+ 'my_sessions',
390
+ {
391
+ title: 'My active AI sessions',
392
+ description: "See what all of YOUR OWN active Cortex/AI sessions are doing right now (working directory + how recently each was active), so you can coordinate across windows/devices. Self-only — only your own sessions, never anyone else's.",
393
+ inputSchema: {},
394
+ },
395
+ async () => {
396
+ let res
397
+ try {
398
+ res = await fetchCortex(`${BASE}/api/sessions?current=${encodeURIComponent(SESSION_KEY)}`, {
399
+ headers: { Authorization: `Bearer ${TOKEN}` },
400
+ })
401
+ } catch (e) {
402
+ return { content: [{ type: 'text', text: `Could not list your sessions: ${e.message}` }] }
403
+ }
404
+ if (!res.ok) {
405
+ const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
406
+ return { content: [{ type: 'text', text: `Could not list your sessions: ${d.message}` }] }
407
+ }
408
+ const { text } = await res.json()
409
+ return { content: [{ type: 'text', text }] }
410
+ },
411
+ )
412
+
241
413
  server.registerTool(
242
414
  'set_record_privacy',
243
415
  {
@@ -364,5 +536,25 @@ export async function runServer(version) {
364
536
  },
365
537
  )
366
538
 
539
+ // send_imessage — local outbound texting (NOT org intelligence; writes nothing to Cortex). Runs on
540
+ // this machine via Messages.app. Draft-by-default + recipient allowlist + OOB confirm (D3/D6/D10).
541
+ server.registerTool(
542
+ 'send_imessage',
543
+ {
544
+ title: 'Send an iMessage (draft-by-default)',
545
+ description: 'Compose/send an iMessage via the local Messages app. SAFE BY DEFAULT: without send:true it only returns a draft preview. Sending requires the recipient to be on CORTEX_IMESSAGE_SEND_ALLOWLIST (or an out-of-band confirm). Use this to text someone on the user\'s behalf — always show the draft and get the user\'s OK before sending.',
546
+ inputSchema: {
547
+ recipient: z.string().describe('phone number (e.g. +18015551234) or iMessage email'),
548
+ message: z.string().describe('the message body to send'),
549
+ send: z.boolean().optional().describe('must be true to actually send; omit/false returns a draft preview only'),
550
+ confirm: z.string().optional().describe('out-of-band confirm secret, required to send to a recipient not on the allowlist'),
551
+ },
552
+ },
553
+ async ({ recipient, message, send, confirm }) => {
554
+ const text = await runSendImessage({ recipient, message, send, confirm })
555
+ return { content: [{ type: 'text', text }] }
556
+ },
557
+ )
558
+
367
559
  await server.connect(new StdioServerTransport())
368
560
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.9",
3
+ "version": "0.9.10",
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": {
@@ -18,6 +18,12 @@
18
18
  "@modelcontextprotocol/sdk": "^1.29.0",
19
19
  "zod": "^3.23.8"
20
20
  },
21
- "keywords": ["mcp", "cortex", "claude", "ai", "org-intelligence"],
21
+ "keywords": [
22
+ "mcp",
23
+ "cortex",
24
+ "claude",
25
+ "ai",
26
+ "org-intelligence"
27
+ ],
22
28
  "license": "MIT"
23
29
  }