crtx-mcp 0.1.1

MCP stdio JSON-RPC 2.0 server for Cortex — tool dispatch, ToolHandler trait, gate wiring (ADR 0045).
Documentation
//! `cortex_context` — context-pack builder MCP tool.
//!
//! Mirrors the `cortex context build` command (ADR 0045 §3 gate-equivalence).
//! Uses [`cortex_context::ContextPackBuilder`] with the same proof-closure and
//! open-contradiction gates as the CLI path. Rows tagged `pending_mcp_commit`
//! are excluded per ADR 0047 §2.
//!
//! A `Reject`/`Quarantine`/`BreakGlass` composed pack policy returns
//! [`ToolError::PolicyRejected`] (ADR 0045 §3: BreakGlass is treated as Reject
//! at the MCP boundary).

use std::sync::{Arc, Mutex};

use cortex_context::{ContextPackBuilder, ContextRefCandidate, ContextRefId, Sensitivity};
use cortex_core::{AuthorityClass, ClaimCeiling, PolicyOutcome, RuntimeMode};
use cortex_retrieval::{
    resolve_conflicts, AuthorityLevel, AuthorityProofHint, ConflictingMemoryInput, ProofClosureHint,
};
use cortex_store::proof::verify_memory_proof_closure;
use cortex_store::repo::{ContradictionRepo, MemoryRepo};
use cortex_store::Pool;
use serde_json::json;

use crate::tool_handler::{GateId, ToolError, ToolHandler};

/// Default task description used when the caller does not supply one.
///
/// `cortex_context` does not expose a `task` parameter in its MCP schema
/// (ADR 0045 §4). The builder requires a non-empty task, so we use a
/// sentinel that identifies the MCP origin.
const MCP_TASK_SENTINEL: &str = "mcp_context_request";

/// Default token budget when the caller does not supply one.
const DEFAULT_MAX_TOKENS: usize = 4096;

/// MCP handler for `cortex_context`.
///
/// Schema (ADR 0045 §4):
/// ```jsonc
/// cortex_context(
///   domains: [string],        // default []
///   include_doctrine: bool,   // default false — accepted, ignored for now
///   session_id?: string       // optional, accepted and ignored
/// ) → { pack_id, entries, token_count, redacted_count }
/// ```
///
/// `rusqlite::Connection` is not `Sync`; the pool is wrapped in a `Mutex`
/// to satisfy the `Send + Sync` bound on [`ToolHandler`].
#[derive(Debug)]
pub struct CortexContextTool {
    /// SQLite connection pool, mutex-wrapped because `rusqlite::Connection`
    /// is not `Sync`.
    pub pool: Arc<Mutex<Pool>>,
}

impl ToolHandler for CortexContextTool {
    fn name(&self) -> &'static str {
        "cortex_context"
    }

    fn gate_set(&self) -> &'static [GateId] {
        &[GateId::ContextRead]
    }

    fn call(&self, params: serde_json::Value) -> Result<serde_json::Value, ToolError> {
        // --- param extraction ---
        let domains = extract_domains(&params)?;
        let include_doctrine = params
            .get("include_doctrine")
            .and_then(|v| v.as_bool())
            .unwrap_or(false);

        if include_doctrine {
            // Doctrine injection is not yet wired in the MCP path. Accept the
            // flag, emit a diagnostic, and proceed without doctrine entries.
            tracing::warn!(
                "cortex_context: include_doctrine=true requested but doctrine wiring is \
                 deferred in the MCP path; proceeding without doctrine entries"
            );
        }

        // session_id is accepted per schema but not forwarded.
        let _ = params.get("session_id");

        // --- retrieval ---
        let pool = self
            .pool
            .lock()
            .map_err(|e| ToolError::Internal(format!("pool lock poisoned: {e}")))?;
        let repo = MemoryRepo::new(&pool);
        let active = repo.list_by_status("active").map_err(|e| {
            tracing::error!(error = %e, "cortex_context: failed to read active memories");
            ToolError::Internal(format!("failed to read active memories: {e}"))
        })?;

        // Filter out any pending_mcp_commit rows (ADR 0047 §2). The current
        // schema does not have this status, so this filter is a no-op until
        // the ADR 0047 migration lands.
        let active: Vec<_> = active
            .into_iter()
            .filter(|m| m.status != "pending_mcp_commit")
            .collect();

        // Apply domain filter when the caller supplies a non-empty list.
        let filtered: Vec<_> = if domains.is_empty() {
            active.iter().collect()
        } else {
            active
                .iter()
                .filter(|m| {
                    let mem_domains: Vec<String> = m
                        .domains_json
                        .as_array()
                        .map(|arr| {
                            arr.iter()
                                .filter_map(|v| v.as_str().map(str::to_owned))
                                .collect()
                        })
                        .unwrap_or_default();
                    domains.iter().any(|d| mem_domains.contains(d))
                })
                .collect()
        };

        // Gate open contradictions — mirrors `gate_open_contradictions_for_default_context`.
        gate_contradictions(&pool, &active).map_err(|e| {
            tracing::warn!(error = %e, "cortex_context: contradiction gate blocked");
            ToolError::PolicyRejected(e)
        })?;

        // Build the context pack.
        let mut builder = ContextPackBuilder::new(MCP_TASK_SENTINEL, DEFAULT_MAX_TOKENS);

        for memory in &filtered {
            let proof = verify_memory_proof_closure(&pool, &memory.id).map_err(|e| {
                tracing::error!(
                    memory_id = %memory.id,
                    error = %e,
                    "cortex_context: proof closure check failed"
                );
                ToolError::Internal(format!("proof closure check failed for {}: {e}", memory.id))
            })?;

            if let Err(e) = proof.require_current_use_allowed() {
                tracing::warn!(
                    memory_id = %memory.id,
                    error = %e,
                    "cortex_context: memory excluded from default context use"
                );
                return Err(ToolError::PolicyRejected(format!(
                    "memory {} excluded from default context use: {e}",
                    memory.id
                )));
            }

            builder = builder.select_ref(
                ContextRefCandidate::new(
                    ContextRefId::Memory {
                        memory_id: memory.id,
                    },
                    memory.claim.clone(),
                )
                .with_claim_metadata(
                    RuntimeMode::LocalUnsigned,
                    AuthorityClass::Derived,
                    proof.state().into(),
                    ClaimCeiling::LocalUnsigned,
                )
                .with_sensitivity(Sensitivity::Internal),
            );
        }

        let pack = builder.build().map_err(|e| {
            tracing::error!(error = %e, "cortex_context: context pack build failed");
            ToolError::Internal(format!("context pack build failed: {e}"))
        })?;

        // Enforce policy — Reject/Quarantine/BreakGlass all map to PolicyRejected.
        let policy = pack.policy_decision();
        match policy.final_outcome {
            PolicyOutcome::Reject | PolicyOutcome::Quarantine | PolicyOutcome::BreakGlass => {
                tracing::warn!(
                    outcome = ?policy.final_outcome,
                    "cortex_context: pack policy rejected"
                );
                return Err(ToolError::PolicyRejected(format!(
                    "context pack policy outcome: {:?}",
                    policy.final_outcome
                )));
            }
            PolicyOutcome::Allow | PolicyOutcome::Warn => {}
        }

        // Map to the ADR 0045 §4 wire shape.
        let entries: Vec<serde_json::Value> = pack
            .selected_refs
            .iter()
            .map(|r| {
                json!({
                    "id": ref_id_string(&r.ref_id),
                    "summary": r.summary,
                    "claim_ceiling": format!("{:?}", r.claim_ceiling),
                    "scope": r.scope,
                })
            })
            .collect();

        let token_count = pack.selection_audit.estimated_tokens;
        let redacted_count = pack.exclusions.len();

        Ok(json!({
            "pack_id": pack.context_pack_id.to_string(),
            "entries": entries,
            "token_count": token_count,
            "redacted_count": redacted_count,
        }))
    }
}

fn extract_domains(params: &serde_json::Value) -> Result<Vec<String>, ToolError> {
    match params.get("domains") {
        None => Ok(Vec::new()),
        Some(serde_json::Value::Array(arr)) => {
            let domains: Option<Vec<String>> =
                arr.iter().map(|v| v.as_str().map(str::to_owned)).collect();
            domains.ok_or_else(|| {
                ToolError::InvalidParams("domains must be an array of strings".to_string())
            })
        }
        Some(_) => Err(ToolError::InvalidParams(
            "domains must be an array of strings".to_string(),
        )),
    }
}

fn ref_id_string(ref_id: &ContextRefId) -> String {
    match ref_id {
        ContextRefId::Memory { memory_id } => memory_id.to_string(),
        ContextRefId::Principle { principle_id } => principle_id.to_string(),
        ContextRefId::Event { event_id } => event_id.to_string(),
    }
}

/// Checks that no open contradictions block default context-pack use.
///
/// Mirrors `gate_open_contradictions_for_default_context` from
/// `cortex-cli/src/cmd/context.rs`.
fn gate_contradictions(
    pool: &Pool,
    memories: &[cortex_store::repo::MemoryRecord],
) -> Result<(), String> {
    use std::collections::{BTreeMap, BTreeSet};

    let active_by_id: BTreeMap<String, &cortex_store::repo::MemoryRecord> =
        memories.iter().map(|m| (m.id.to_string(), m)).collect();

    let contradictions = ContradictionRepo::new(pool)
        .list_open()
        .map_err(|e| format!("failed to read open contradictions: {e}"))?;

    let mut affected_ids: BTreeSet<String> = BTreeSet::new();
    let mut conflict_edges: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();

    for c in contradictions {
        let left_active = active_by_id.contains_key(&c.left_ref);
        let right_active = active_by_id.contains_key(&c.right_ref);
        if !left_active && !right_active {
            continue;
        }
        if !(left_active && right_active) {
            return Err(format!(
                "open contradiction {} references unavailable memory and cannot be resolved \
                 for default context-pack use",
                c.id
            ));
        }
        affected_ids.insert(c.left_ref.clone());
        affected_ids.insert(c.right_ref.clone());
        conflict_edges
            .entry(c.left_ref.clone())
            .or_default()
            .insert(c.right_ref.clone());
        conflict_edges
            .entry(c.right_ref)
            .or_default()
            .insert(c.left_ref);
    }

    if affected_ids.is_empty() {
        return Ok(());
    }

    let inputs: Vec<ConflictingMemoryInput> = affected_ids
        .iter()
        .filter_map(|id| active_by_id.get(id.as_str()).copied())
        .map(|m| {
            ConflictingMemoryInput::new(
                m.id.to_string(),
                Some(m.id.to_string()),
                m.claim.clone(),
                AuthorityProofHint {
                    authority: authority_level(&m.authority),
                    proof: ProofClosureHint::FullChainVerified,
                },
            )
            .with_conflicts(
                conflict_edges
                    .get(&m.id.to_string())
                    .map(|ids| ids.iter().cloned().collect())
                    .unwrap_or_default(),
            )
        })
        .collect();

    let output = resolve_conflicts(&inputs, &[]);
    output
        .require_default_use_allowed()
        .map_err(|e| format!("open contradiction blocks default context-pack use: {e}"))
}

fn authority_level(authority: &str) -> AuthorityLevel {
    match authority {
        "user" | "operator" => AuthorityLevel::High,
        "tool" | "system" => AuthorityLevel::Medium,
        _ => AuthorityLevel::Low,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn extract_domains_defaults_to_empty() {
        let domains = extract_domains(&json!({})).unwrap();
        assert!(domains.is_empty());
    }

    #[test]
    fn extract_domains_accepts_string_array() {
        let domains = extract_domains(&json!({"domains": ["arch", "memory"]})).unwrap();
        assert_eq!(domains, vec!["arch", "memory"]);
    }

    #[test]
    fn extract_domains_rejects_non_array() {
        let err = extract_domains(&json!({"domains": "arch"})).unwrap_err();
        assert!(matches!(err, ToolError::InvalidParams(_)));
    }

    #[test]
    fn extract_domains_rejects_non_string_elements() {
        let err = extract_domains(&json!({"domains": [1, 2]})).unwrap_err();
        assert!(matches!(err, ToolError::InvalidParams(_)));
    }

    #[test]
    fn gate_set_is_correct() {
        use crate::tool_handler::GateId;
        let gates: &[GateId] = &[GateId::ContextRead];
        assert_eq!(gates.len(), 1);
    }
}