Skip to main content

cortex_mcp/tools/
context.rs

1//! `cortex_context` — context-pack builder MCP tool.
2//!
3//! Mirrors the `cortex context build` command (ADR 0045 §3 gate-equivalence).
4//! Uses [`cortex_context::ContextPackBuilder`] with the same proof-closure and
5//! open-contradiction gates as the CLI path. Rows tagged `pending_mcp_commit`
6//! are excluded per ADR 0047 §2.
7//!
8//! A `Reject`/`Quarantine`/`BreakGlass` composed pack policy returns
9//! [`ToolError::PolicyRejected`] (ADR 0045 §3: BreakGlass is treated as Reject
10//! at the MCP boundary).
11
12use std::sync::{Arc, Mutex};
13
14use cortex_context::{ContextPackBuilder, ContextRefCandidate, ContextRefId, Sensitivity};
15use cortex_core::{AuthorityClass, ClaimCeiling, PolicyOutcome, RuntimeMode};
16use cortex_retrieval::{
17    resolve_conflicts, AuthorityLevel, AuthorityProofHint, ConflictingMemoryInput, ProofClosureHint,
18};
19use cortex_store::proof::verify_memory_proof_closure;
20use cortex_store::repo::{ContradictionRepo, MemoryRepo};
21use cortex_store::Pool;
22use serde_json::json;
23
24use crate::tool_handler::{GateId, ToolError, ToolHandler};
25
26/// Default task description used when the caller does not supply one.
27///
28/// `cortex_context` does not expose a `task` parameter in its MCP schema
29/// (ADR 0045 §4). The builder requires a non-empty task, so we use a
30/// sentinel that identifies the MCP origin.
31const MCP_TASK_SENTINEL: &str = "mcp_context_request";
32
33/// Default token budget when the caller does not supply one.
34const DEFAULT_MAX_TOKENS: usize = 4096;
35
36/// MCP handler for `cortex_context`.
37///
38/// Schema (ADR 0045 §4):
39/// ```jsonc
40/// cortex_context(
41///   domains: [string],        // default []
42///   include_doctrine: bool,   // default false — accepted, ignored for now
43///   session_id?: string       // optional, accepted and ignored
44/// ) → { pack_id, entries, token_count, redacted_count }
45/// ```
46///
47/// `rusqlite::Connection` is not `Sync`; the pool is wrapped in a `Mutex`
48/// to satisfy the `Send + Sync` bound on [`ToolHandler`].
49#[derive(Debug)]
50pub struct CortexContextTool {
51    /// SQLite connection pool, mutex-wrapped because `rusqlite::Connection`
52    /// is not `Sync`.
53    pub pool: Arc<Mutex<Pool>>,
54}
55
56impl ToolHandler for CortexContextTool {
57    fn name(&self) -> &'static str {
58        "cortex_context"
59    }
60
61    fn gate_set(&self) -> &'static [GateId] {
62        &[GateId::ContextRead]
63    }
64
65    fn call(&self, params: serde_json::Value) -> Result<serde_json::Value, ToolError> {
66        // --- param extraction ---
67        let domains = extract_domains(&params)?;
68        let include_doctrine = params
69            .get("include_doctrine")
70            .and_then(|v| v.as_bool())
71            .unwrap_or(false);
72
73        if include_doctrine {
74            // Doctrine injection is not yet wired in the MCP path. Accept the
75            // flag, emit a diagnostic, and proceed without doctrine entries.
76            tracing::warn!(
77                "cortex_context: include_doctrine=true requested but doctrine wiring is \
78                 deferred in the MCP path; proceeding without doctrine entries"
79            );
80        }
81
82        // session_id is accepted per schema but not forwarded.
83        let _ = params.get("session_id");
84
85        // --- retrieval ---
86        let pool = self
87            .pool
88            .lock()
89            .map_err(|e| ToolError::Internal(format!("pool lock poisoned: {e}")))?;
90        let repo = MemoryRepo::new(&pool);
91        let active = repo.list_by_status("active").map_err(|e| {
92            tracing::error!(error = %e, "cortex_context: failed to read active memories");
93            ToolError::Internal(format!("failed to read active memories: {e}"))
94        })?;
95
96        // Filter out any pending_mcp_commit rows (ADR 0047 §2). The current
97        // schema does not have this status, so this filter is a no-op until
98        // the ADR 0047 migration lands.
99        let active: Vec<_> = active
100            .into_iter()
101            .filter(|m| m.status != "pending_mcp_commit")
102            .collect();
103
104        // Apply domain filter when the caller supplies a non-empty list.
105        let filtered: Vec<_> = if domains.is_empty() {
106            active.iter().collect()
107        } else {
108            active
109                .iter()
110                .filter(|m| {
111                    let mem_domains: Vec<String> = m
112                        .domains_json
113                        .as_array()
114                        .map(|arr| {
115                            arr.iter()
116                                .filter_map(|v| v.as_str().map(str::to_owned))
117                                .collect()
118                        })
119                        .unwrap_or_default();
120                    domains.iter().any(|d| mem_domains.contains(d))
121                })
122                .collect()
123        };
124
125        // Gate open contradictions — mirrors `gate_open_contradictions_for_default_context`.
126        gate_contradictions(&pool, &active).map_err(|e| {
127            tracing::warn!(error = %e, "cortex_context: contradiction gate blocked");
128            ToolError::PolicyRejected(e)
129        })?;
130
131        // Build the context pack.
132        let mut builder = ContextPackBuilder::new(MCP_TASK_SENTINEL, DEFAULT_MAX_TOKENS);
133
134        for memory in &filtered {
135            let proof = verify_memory_proof_closure(&pool, &memory.id).map_err(|e| {
136                tracing::error!(
137                    memory_id = %memory.id,
138                    error = %e,
139                    "cortex_context: proof closure check failed"
140                );
141                ToolError::Internal(format!("proof closure check failed for {}: {e}", memory.id))
142            })?;
143
144            if let Err(e) = proof.require_current_use_allowed() {
145                tracing::warn!(
146                    memory_id = %memory.id,
147                    error = %e,
148                    "cortex_context: memory excluded from default context use"
149                );
150                return Err(ToolError::PolicyRejected(format!(
151                    "memory {} excluded from default context use: {e}",
152                    memory.id
153                )));
154            }
155
156            builder = builder.select_ref(
157                ContextRefCandidate::new(
158                    ContextRefId::Memory {
159                        memory_id: memory.id,
160                    },
161                    memory.claim.clone(),
162                )
163                .with_claim_metadata(
164                    RuntimeMode::LocalUnsigned,
165                    AuthorityClass::Derived,
166                    proof.state().into(),
167                    ClaimCeiling::LocalUnsigned,
168                )
169                .with_sensitivity(Sensitivity::Internal),
170            );
171        }
172
173        let pack = builder.build().map_err(|e| {
174            tracing::error!(error = %e, "cortex_context: context pack build failed");
175            ToolError::Internal(format!("context pack build failed: {e}"))
176        })?;
177
178        // Enforce policy — Reject/Quarantine/BreakGlass all map to PolicyRejected.
179        let policy = pack.policy_decision();
180        match policy.final_outcome {
181            PolicyOutcome::Reject | PolicyOutcome::Quarantine | PolicyOutcome::BreakGlass => {
182                tracing::warn!(
183                    outcome = ?policy.final_outcome,
184                    "cortex_context: pack policy rejected"
185                );
186                return Err(ToolError::PolicyRejected(format!(
187                    "context pack policy outcome: {:?}",
188                    policy.final_outcome
189                )));
190            }
191            PolicyOutcome::Allow | PolicyOutcome::Warn => {}
192        }
193
194        // Map to the ADR 0045 §4 wire shape.
195        let entries: Vec<serde_json::Value> = pack
196            .selected_refs
197            .iter()
198            .map(|r| {
199                json!({
200                    "id": ref_id_string(&r.ref_id),
201                    "summary": r.summary,
202                    "claim_ceiling": format!("{:?}", r.claim_ceiling),
203                    "scope": r.scope,
204                })
205            })
206            .collect();
207
208        let token_count = pack.selection_audit.estimated_tokens;
209        let redacted_count = pack.exclusions.len();
210
211        Ok(json!({
212            "pack_id": pack.context_pack_id.to_string(),
213            "entries": entries,
214            "token_count": token_count,
215            "redacted_count": redacted_count,
216        }))
217    }
218}
219
220fn extract_domains(params: &serde_json::Value) -> Result<Vec<String>, ToolError> {
221    match params.get("domains") {
222        None => Ok(Vec::new()),
223        Some(serde_json::Value::Array(arr)) => {
224            let domains: Option<Vec<String>> =
225                arr.iter().map(|v| v.as_str().map(str::to_owned)).collect();
226            domains.ok_or_else(|| {
227                ToolError::InvalidParams("domains must be an array of strings".to_string())
228            })
229        }
230        Some(_) => Err(ToolError::InvalidParams(
231            "domains must be an array of strings".to_string(),
232        )),
233    }
234}
235
236fn ref_id_string(ref_id: &ContextRefId) -> String {
237    match ref_id {
238        ContextRefId::Memory { memory_id } => memory_id.to_string(),
239        ContextRefId::Principle { principle_id } => principle_id.to_string(),
240        ContextRefId::Event { event_id } => event_id.to_string(),
241    }
242}
243
244/// Checks that no open contradictions block default context-pack use.
245///
246/// Mirrors `gate_open_contradictions_for_default_context` from
247/// `cortex-cli/src/cmd/context.rs`.
248fn gate_contradictions(
249    pool: &Pool,
250    memories: &[cortex_store::repo::MemoryRecord],
251) -> Result<(), String> {
252    use std::collections::{BTreeMap, BTreeSet};
253
254    let active_by_id: BTreeMap<String, &cortex_store::repo::MemoryRecord> =
255        memories.iter().map(|m| (m.id.to_string(), m)).collect();
256
257    let contradictions = ContradictionRepo::new(pool)
258        .list_open()
259        .map_err(|e| format!("failed to read open contradictions: {e}"))?;
260
261    let mut affected_ids: BTreeSet<String> = BTreeSet::new();
262    let mut conflict_edges: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
263
264    for c in contradictions {
265        let left_active = active_by_id.contains_key(&c.left_ref);
266        let right_active = active_by_id.contains_key(&c.right_ref);
267        if !left_active && !right_active {
268            continue;
269        }
270        if !(left_active && right_active) {
271            return Err(format!(
272                "open contradiction {} references unavailable memory and cannot be resolved \
273                 for default context-pack use",
274                c.id
275            ));
276        }
277        affected_ids.insert(c.left_ref.clone());
278        affected_ids.insert(c.right_ref.clone());
279        conflict_edges
280            .entry(c.left_ref.clone())
281            .or_default()
282            .insert(c.right_ref.clone());
283        conflict_edges
284            .entry(c.right_ref)
285            .or_default()
286            .insert(c.left_ref);
287    }
288
289    if affected_ids.is_empty() {
290        return Ok(());
291    }
292
293    let inputs: Vec<ConflictingMemoryInput> = affected_ids
294        .iter()
295        .filter_map(|id| active_by_id.get(id.as_str()).copied())
296        .map(|m| {
297            ConflictingMemoryInput::new(
298                m.id.to_string(),
299                Some(m.id.to_string()),
300                m.claim.clone(),
301                AuthorityProofHint {
302                    authority: authority_level(&m.authority),
303                    proof: ProofClosureHint::FullChainVerified,
304                },
305            )
306            .with_conflicts(
307                conflict_edges
308                    .get(&m.id.to_string())
309                    .map(|ids| ids.iter().cloned().collect())
310                    .unwrap_or_default(),
311            )
312        })
313        .collect();
314
315    let output = resolve_conflicts(&inputs, &[]);
316    output
317        .require_default_use_allowed()
318        .map_err(|e| format!("open contradiction blocks default context-pack use: {e}"))
319}
320
321fn authority_level(authority: &str) -> AuthorityLevel {
322    match authority {
323        "user" | "operator" => AuthorityLevel::High,
324        "tool" | "system" => AuthorityLevel::Medium,
325        _ => AuthorityLevel::Low,
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn extract_domains_defaults_to_empty() {
335        let domains = extract_domains(&json!({})).unwrap();
336        assert!(domains.is_empty());
337    }
338
339    #[test]
340    fn extract_domains_accepts_string_array() {
341        let domains = extract_domains(&json!({"domains": ["arch", "memory"]})).unwrap();
342        assert_eq!(domains, vec!["arch", "memory"]);
343    }
344
345    #[test]
346    fn extract_domains_rejects_non_array() {
347        let err = extract_domains(&json!({"domains": "arch"})).unwrap_err();
348        assert!(matches!(err, ToolError::InvalidParams(_)));
349    }
350
351    #[test]
352    fn extract_domains_rejects_non_string_elements() {
353        let err = extract_domains(&json!({"domains": [1, 2]})).unwrap_err();
354        assert!(matches!(err, ToolError::InvalidParams(_)));
355    }
356
357    #[test]
358    fn gate_set_is_correct() {
359        use crate::tool_handler::GateId;
360        let gates: &[GateId] = &[GateId::ContextRead];
361        assert_eq!(gates.len(), 1);
362    }
363}