@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 +27 -10
- package/lib/edge_extract.mjs +92 -0
- package/lib/imessage_send.mjs +88 -0
- package/lib/imessage_send.test.mjs +35 -0
- package/lib/server.mjs +193 -1
- package/package.json +8 -2
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.
|
|
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": [
|
|
21
|
+
"keywords": [
|
|
22
|
+
"mcp",
|
|
23
|
+
"cortex",
|
|
24
|
+
"claude",
|
|
25
|
+
"ai",
|
|
26
|
+
"org-intelligence"
|
|
27
|
+
],
|
|
22
28
|
"license": "MIT"
|
|
23
29
|
}
|