cortex_mcp/tools/
context.rs1use 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
26const MCP_TASK_SENTINEL: &str = "mcp_context_request";
32
33const DEFAULT_MAX_TOKENS: usize = 4096;
35
36#[derive(Debug)]
50pub struct CortexContextTool {
51 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 let domains = extract_domains(¶ms)?;
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 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 let _ = params.get("session_id");
84
85 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 let active: Vec<_> = active
100 .into_iter()
101 .filter(|m| m.status != "pending_mcp_commit")
102 .collect();
103
104 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_contradictions(&pool, &active).map_err(|e| {
127 tracing::warn!(error = %e, "cortex_context: contradiction gate blocked");
128 ToolError::PolicyRejected(e)
129 })?;
130
131 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 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 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
244fn 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}