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::{
15    ContextPackBuilder, ContextRefCandidate, ContextRefId, ExclusionReason, Sensitivity,
16};
17use cortex_core::{AuthorityClass, ClaimCeiling, PolicyOutcome, RuntimeMode};
18use cortex_retrieval::{
19    resolve_conflicts, AuthorityLevel, AuthorityProofHint, ConflictingMemoryInput, ProofClosureHint,
20};
21use cortex_store::proof::verify_memory_proof_closure;
22use cortex_store::repo::{ContradictionRepo, MemoryRepo};
23use cortex_store::Pool;
24use serde_json::json;
25
26use crate::tool_handler::{GateId, ToolError, ToolHandler};
27
28/// Default task description used when the caller does not supply one.
29///
30/// `cortex_context` does not expose a `task` parameter in its MCP schema
31/// (ADR 0045 §4). The builder requires a non-empty task, so we use a
32/// sentinel that identifies the MCP origin.
33const MCP_TASK_SENTINEL: &str = "mcp_context_request";
34
35/// Default token budget when the caller does not supply one.
36const DEFAULT_MAX_TOKENS: usize = 4096;
37
38/// MCP handler for `cortex_context`.
39///
40/// Schema (ADR 0045 §4):
41/// ```jsonc
42/// cortex_context(
43///   domains: [string],        // default []
44///   include_doctrine: bool,   // default false — accepted, ignored for now
45///   session_id?: string       // optional, accepted and ignored
46/// ) → { pack_id, entries, token_count, redacted_count }
47/// ```
48///
49/// `rusqlite::Connection` is not `Sync`; the pool is wrapped in a `Mutex`
50/// to satisfy the `Send + Sync` bound on [`ToolHandler`].
51#[derive(Debug)]
52pub struct CortexContextTool {
53    /// SQLite connection pool, mutex-wrapped because `rusqlite::Connection`
54    /// is not `Sync`.
55    pub pool: Arc<Mutex<Pool>>,
56}
57
58impl ToolHandler for CortexContextTool {
59    fn name(&self) -> &'static str {
60        "cortex_context"
61    }
62
63    fn gate_set(&self) -> &'static [GateId] {
64        &[GateId::ContextRead]
65    }
66
67    fn call(&self, params: serde_json::Value) -> Result<serde_json::Value, ToolError> {
68        // --- param extraction ---
69        let domains = extract_domains(&params)?;
70        let include_doctrine = params
71            .get("include_doctrine")
72            .and_then(|v| v.as_bool())
73            .unwrap_or(false);
74
75        if include_doctrine {
76            // Doctrine injection is not yet wired in the MCP path. Accept the
77            // flag, emit a diagnostic, and proceed without doctrine entries.
78            tracing::warn!(
79                "cortex_context: include_doctrine=true requested but doctrine wiring is \
80                 deferred in the MCP path; proceeding without doctrine entries"
81            );
82        }
83
84        // session_id is accepted per schema but not forwarded.
85        let _ = params.get("session_id");
86
87        // --- retrieval ---
88        let pool = self
89            .pool
90            .lock()
91            .map_err(|e| ToolError::Internal(format!("pool lock poisoned: {e}")))?;
92        let repo = MemoryRepo::new(&pool);
93        let active = repo.list_by_status("active").map_err(|e| {
94            tracing::error!(error = %e, "cortex_context: failed to read active memories");
95            ToolError::Internal(format!("failed to read active memories: {e}"))
96        })?;
97
98        // Filter out any pending_mcp_commit rows (ADR 0047 §2). The current
99        // schema does not have this status, so this filter is a no-op until
100        // the ADR 0047 migration lands.
101        let active: Vec<_> = active
102            .into_iter()
103            .filter(|m| m.status != "pending_mcp_commit")
104            .collect();
105
106        // Apply domain filter when the caller supplies a non-empty list.
107        let filtered: Vec<_> = if domains.is_empty() {
108            active.iter().collect()
109        } else {
110            active
111                .iter()
112                .filter(|m| {
113                    let mem_domains: Vec<String> = m
114                        .domains_json
115                        .as_array()
116                        .map(|arr| {
117                            arr.iter()
118                                .filter_map(|v| v.as_str().map(str::to_owned))
119                                .collect()
120                        })
121                        .unwrap_or_default();
122                    domains.iter().any(|d| mem_domains.contains(d))
123                })
124                .collect()
125        };
126
127        // Gate open contradictions — mirrors `gate_open_contradictions_for_default_context`.
128        gate_contradictions(&pool, &active).map_err(|e| {
129            tracing::warn!(error = %e, "cortex_context: contradiction gate blocked");
130            ToolError::PolicyRejected(e)
131        })?;
132
133        // Build the context pack.
134        let mut builder = ContextPackBuilder::new(MCP_TASK_SENTINEL, DEFAULT_MAX_TOKENS);
135
136        let mut exclusions = Vec::new();
137
138        for memory in &filtered {
139            let proof = verify_memory_proof_closure(&pool, &memory.id).map_err(|e| {
140                tracing::error!(
141                    memory_id = %memory.id,
142                    error = %e,
143                    "cortex_context: proof closure check failed"
144                );
145                ToolError::Internal(format!("proof closure check failed for {}: {e}", memory.id))
146            })?;
147
148            if let Err(e) = proof.require_current_use_allowed() {
149                let detail = e.to_string();
150                tracing::warn!(
151                    memory_id = %memory.id,
152                    error = %detail,
153                    "cortex_context: memory excluded from default context use"
154                );
155                builder = builder.exclude_ref(
156                    ContextRefId::Memory {
157                        memory_id: memory.id,
158                    },
159                    ExclusionReason::Other,
160                    format!("default context use blocked: {detail}"),
161                    Sensitivity::Internal,
162                );
163                exclusions.push(default_use_exclusion(memory.id.to_string(), detail));
164                continue;
165            }
166
167            builder = builder.select_ref(
168                ContextRefCandidate::new(
169                    ContextRefId::Memory {
170                        memory_id: memory.id,
171                    },
172                    memory.claim.clone(),
173                )
174                .with_claim_metadata(
175                    RuntimeMode::LocalUnsigned,
176                    AuthorityClass::Derived,
177                    proof.state().into(),
178                    ClaimCeiling::LocalUnsigned,
179                )
180                .with_sensitivity(Sensitivity::Internal),
181            );
182        }
183
184        let pack = builder.build().map_err(|e| {
185            tracing::error!(error = %e, "cortex_context: context pack build failed");
186            ToolError::Internal(format!("context pack build failed: {e}"))
187        })?;
188
189        // Enforce policy — Reject/Quarantine/BreakGlass all map to PolicyRejected.
190        let policy = pack.policy_decision();
191        match policy.final_outcome {
192            PolicyOutcome::Reject | PolicyOutcome::Quarantine | PolicyOutcome::BreakGlass => {
193                tracing::warn!(
194                    outcome = ?policy.final_outcome,
195                    "cortex_context: pack policy rejected"
196                );
197                return Err(ToolError::PolicyRejected(format!(
198                    "context pack policy outcome: {:?}",
199                    policy.final_outcome
200                )));
201            }
202            PolicyOutcome::Allow | PolicyOutcome::Warn => {}
203        }
204
205        // Map to the ADR 0045 §4 wire shape.
206        let entries: Vec<serde_json::Value> = pack
207            .selected_refs
208            .iter()
209            .map(|r| {
210                json!({
211                    "id": ref_id_string(&r.ref_id),
212                    "summary": r.summary,
213                    "claim_ceiling": format!("{:?}", r.claim_ceiling),
214                    "scope": r.scope,
215                })
216            })
217            .collect();
218
219        let token_count = pack.selection_audit.estimated_tokens;
220        let redacted_count = pack.exclusions.len();
221
222        Ok(json!({
223            "pack_id": pack.context_pack_id.to_string(),
224            "entries": entries,
225            "token_count": token_count,
226            "redacted_count": redacted_count,
227            "excluded_count": pack.exclusions.len(),
228            "exclusions": exclusions,
229        }))
230    }
231}
232
233fn default_use_exclusion(memory_id: String, detail: String) -> serde_json::Value {
234    json!({
235        "id": memory_id,
236        "reason": "proof_closure_current_use_blocked",
237        "detail": detail,
238        "resolution": {
239            "schema": "cortex_refusal_resolution.v1",
240            "kind": "policy_rejected",
241            "summary": "Memory was excluded from the context pack, but safe context is still returned.",
242            "detail": "proof closure does not currently permit default context use",
243            "next_actions": [
244                "Inspect this memory with cortex_memory_list or cortex_search.",
245                "If the memory is stale or wrong, mark its outcome with cortex_memory_outcome.",
246                "If the memory is still useful, repair or re-admit it with valid source evidence through the normal Cortex admission flow.",
247                "Re-run cortex_context; the memory will remain excluded until proof closure permits current use."
248            ]
249        }
250    })
251}
252
253fn extract_domains(params: &serde_json::Value) -> Result<Vec<String>, ToolError> {
254    match params.get("domains") {
255        None => Ok(Vec::new()),
256        Some(serde_json::Value::Array(arr)) => {
257            let domains: Option<Vec<String>> =
258                arr.iter().map(|v| v.as_str().map(str::to_owned)).collect();
259            domains.ok_or_else(|| {
260                ToolError::InvalidParams("domains must be an array of strings".to_string())
261            })
262        }
263        Some(_) => Err(ToolError::InvalidParams(
264            "domains must be an array of strings".to_string(),
265        )),
266    }
267}
268
269fn ref_id_string(ref_id: &ContextRefId) -> String {
270    match ref_id {
271        ContextRefId::Memory { memory_id } => memory_id.to_string(),
272        ContextRefId::Principle { principle_id } => principle_id.to_string(),
273        ContextRefId::Event { event_id } => event_id.to_string(),
274    }
275}
276
277/// Checks that no open contradictions block default context-pack use.
278///
279/// Mirrors `gate_open_contradictions_for_default_context` from
280/// `cortex-cli/src/cmd/context.rs`.
281fn gate_contradictions(
282    pool: &Pool,
283    memories: &[cortex_store::repo::MemoryRecord],
284) -> Result<(), String> {
285    use std::collections::{BTreeMap, BTreeSet};
286
287    let active_by_id: BTreeMap<String, &cortex_store::repo::MemoryRecord> =
288        memories.iter().map(|m| (m.id.to_string(), m)).collect();
289
290    let contradictions = ContradictionRepo::new(pool)
291        .list_open()
292        .map_err(|e| format!("failed to read open contradictions: {e}"))?;
293
294    let mut affected_ids: BTreeSet<String> = BTreeSet::new();
295    let mut conflict_edges: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
296
297    for c in contradictions {
298        let left_active = active_by_id.contains_key(&c.left_ref);
299        let right_active = active_by_id.contains_key(&c.right_ref);
300        if !left_active && !right_active {
301            continue;
302        }
303        if !(left_active && right_active) {
304            return Err(format!(
305                "open contradiction {} references unavailable memory and cannot be resolved \
306                 for default context-pack use",
307                c.id
308            ));
309        }
310        affected_ids.insert(c.left_ref.clone());
311        affected_ids.insert(c.right_ref.clone());
312        conflict_edges
313            .entry(c.left_ref.clone())
314            .or_default()
315            .insert(c.right_ref.clone());
316        conflict_edges
317            .entry(c.right_ref)
318            .or_default()
319            .insert(c.left_ref);
320    }
321
322    if affected_ids.is_empty() {
323        return Ok(());
324    }
325
326    let inputs: Vec<ConflictingMemoryInput> = affected_ids
327        .iter()
328        .filter_map(|id| active_by_id.get(id.as_str()).copied())
329        .map(|m| {
330            ConflictingMemoryInput::new(
331                m.id.to_string(),
332                Some(m.id.to_string()),
333                m.claim.clone(),
334                AuthorityProofHint {
335                    authority: authority_level(&m.authority),
336                    proof: ProofClosureHint::FullChainVerified,
337                },
338            )
339            .with_conflicts(
340                conflict_edges
341                    .get(&m.id.to_string())
342                    .map(|ids| ids.iter().cloned().collect())
343                    .unwrap_or_default(),
344            )
345        })
346        .collect();
347
348    let output = resolve_conflicts(&inputs, &[]);
349    output
350        .require_default_use_allowed()
351        .map_err(|e| format!("open contradiction blocks default context-pack use: {e}"))
352}
353
354fn authority_level(authority: &str) -> AuthorityLevel {
355    match authority {
356        "user" | "operator" => AuthorityLevel::High,
357        "tool" | "system" => AuthorityLevel::Medium,
358        _ => AuthorityLevel::Low,
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use chrono::{TimeZone, Utc};
366    use cortex_core::{AuditRecordId, MemoryId};
367    use cortex_store::migrate::apply_pending;
368    use cortex_store::repo::memories::accept_candidate_policy_decision_test_allow;
369    use cortex_store::repo::{MemoryAcceptanceAudit, MemoryCandidate, MemoryRepo};
370    use rusqlite::Connection;
371    use std::sync::{Arc, Mutex};
372
373    #[test]
374    fn extract_domains_defaults_to_empty() {
375        let domains = extract_domains(&json!({})).unwrap();
376        assert!(domains.is_empty());
377    }
378
379    #[test]
380    fn extract_domains_accepts_string_array() {
381        let domains = extract_domains(&json!({"domains": ["arch", "memory"]})).unwrap();
382        assert_eq!(domains, vec!["arch", "memory"]);
383    }
384
385    #[test]
386    fn extract_domains_rejects_non_array() {
387        let err = extract_domains(&json!({"domains": "arch"})).unwrap_err();
388        assert!(matches!(err, ToolError::InvalidParams(_)));
389    }
390
391    #[test]
392    fn extract_domains_rejects_non_string_elements() {
393        let err = extract_domains(&json!({"domains": [1, 2]})).unwrap_err();
394        assert!(matches!(err, ToolError::InvalidParams(_)));
395    }
396
397    #[test]
398    fn gate_set_is_correct() {
399        use crate::tool_handler::GateId;
400        let gates: &[GateId] = &[GateId::ContextRead];
401        assert_eq!(gates.len(), 1);
402    }
403
404    fn test_pool() -> Pool {
405        let pool = Connection::open_in_memory().expect("open in-memory sqlite");
406        apply_pending(&pool).expect("apply migrations");
407        pool
408    }
409
410    fn memory(
411        id: &str,
412        memory_type: &str,
413        claim: &str,
414        source_events_json: serde_json::Value,
415    ) -> MemoryCandidate {
416        MemoryCandidate {
417            id: id.parse().expect("memory id"),
418            memory_type: memory_type.into(),
419            claim: claim.into(),
420            source_episodes_json: json!([]),
421            source_events_json,
422            domains_json: json!(["ux"]),
423            salience_json: json!({"score": 0.7}),
424            confidence: 0.8,
425            authority: "operator".into(),
426            applies_when_json: json!(["testing cortex_context refusal UX"]),
427            does_not_apply_when_json: json!([]),
428            created_at: Utc.with_ymd_and_hms(2026, 5, 18, 12, 0, 0).unwrap(),
429            updated_at: Utc.with_ymd_and_hms(2026, 5, 18, 12, 0, 0).unwrap(),
430        }
431    }
432
433    fn activate(repo: &MemoryRepo<'_>, id: &MemoryId) {
434        let now = Utc.with_ymd_and_hms(2026, 5, 18, 12, 1, 0).unwrap();
435        let audit = MemoryAcceptanceAudit {
436            id: AuditRecordId::new(),
437            actor_json: json!({"kind": "test"}),
438            reason: "test fixture activation".into(),
439            source_refs_json: json!([]),
440            created_at: now,
441        };
442        repo.accept_candidate(
443            id,
444            now,
445            &audit,
446            &accept_candidate_policy_decision_test_allow(),
447        )
448        .expect("activate candidate");
449    }
450
451    #[test]
452    fn context_skips_unusable_memory_and_returns_resolution_exclusion() {
453        let pool = test_pool();
454        {
455            let repo = MemoryRepo::new(&pool);
456            let bad = memory(
457                "mem_01JVD8N3J9CN9TQ91M7A49V9AA",
458                "semantic",
459                "This memory has missing proof lineage and must not block the whole pack.",
460                json!(["evt_01JVD8N3J9CN9TQ91M7A49V9AA"]),
461            );
462            let good = memory(
463                "mem_01JVD8N3J9CN9TQ91M7A49V9AB",
464                "operator_note",
465                "Operator note remains available because it is self-attesting.",
466                json!([]),
467            );
468            repo.insert_candidate(&bad).expect("insert bad memory");
469            repo.insert_candidate(&good).expect("insert good memory");
470            activate(&repo, &bad.id);
471            activate(&repo, &good.id);
472        }
473
474        let tool = CortexContextTool {
475            pool: Arc::new(Mutex::new(pool)),
476        };
477        let result = tool.call(json!({})).expect(
478            "unusable memories should be excluded with remediation, not hard-refuse the pack",
479        );
480
481        assert_eq!(result["entries"].as_array().expect("entries").len(), 1);
482        assert_eq!(result["excluded_count"], 1);
483        assert_eq!(
484            result["exclusions"][0]["id"],
485            "mem_01JVD8N3J9CN9TQ91M7A49V9AA"
486        );
487        assert_eq!(
488            result["exclusions"][0]["resolution"]["schema"],
489            "cortex_refusal_resolution.v1"
490        );
491        assert!(
492            result["exclusions"][0]["resolution"]["next_actions"]
493                .as_array()
494                .is_some_and(|actions| !actions.is_empty()),
495            "exclusion should carry concrete next actions: {result}"
496        );
497    }
498}