crtx-mcp 0.1.2

MCP stdio JSON-RPC 2.0 server for Cortex — tool dispatch, ToolHandler trait, gate wiring (ADR 0045).
Documentation
//! `cortex_audit_verify` MCP tool handler.
//!
//! Read-only hash-chain verification of the JSONL event log. Mirrors the
//! verify pass used by `cortex audit verify`
//! (`crates/cortex-cli/src/cmd/audit.rs` `run_verify_inner` fn) via
//! [`cortex_ledger::verify_chain`].
//!
//! Returns `{ ok, chain_length, issues }` where `issues` is a list of
//! human-readable failure strings — one per [`cortex_ledger::audit::RowFailure`]
//! that the chain walk collected.
//!
//! Gate: [`GateId::HealthRead`].
//! Tier: supervised — logs at every entry.

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

use cortex_ledger::verify_chain;
use cortex_store::Pool;
use serde_json::{json, Value};

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

/// MCP tool: `cortex_audit_verify`.
///
/// Schema:
/// ```text
/// cortex_audit_verify() -> {
///   ok:           bool,
///   chain_length: int,
///   issues:       [string],
/// }
/// ```
///
/// Calls [`cortex_ledger::verify_chain`] over the configured JSONL event-log
/// path. `ok` is `true` and `issues` is empty when the chain verified clean.
/// Any per-row failures are collected into `issues` as human-readable strings.
///
/// The `pool` is held to satisfy the [`ToolHandler`] contract for tools that
/// carry an open store connection; it is not read during the verify pass
/// (the JSONL file is the authoritative audit surface).
pub struct CortexAuditVerifyTool {
    /// Shared store connection (carried for contract symmetry; not used
    /// during the hash-chain walk).
    #[allow(dead_code)]
    pool: Arc<Mutex<Pool>>,
    /// Path to the JSONL event-log file to verify.
    event_log: PathBuf,
}

impl std::fmt::Debug for CortexAuditVerifyTool {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("CortexAuditVerifyTool")
            .field("event_log", &self.event_log)
            .finish_non_exhaustive()
    }
}

impl CortexAuditVerifyTool {
    /// Construct the tool over a shared store connection and the JSONL event-log path.
    #[must_use]
    pub fn new(pool: Arc<Mutex<Pool>>, event_log: PathBuf) -> Self {
        Self { pool, event_log }
    }
}

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

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

    fn call(&self, _params: Value) -> Result<Value, ToolError> {
        tracing::info!("cortex_audit_verify called via MCP");

        match verify_chain(&self.event_log) {
            Ok(report) => {
                let issues: Vec<String> = report
                    .failures
                    .iter()
                    .map(|f| {
                        let event_part = f
                            .event_id
                            .as_ref()
                            .map_or_else(|| String::from("(no event id)"), ToString::to_string);
                        format!("line {}: event {}{:?}", f.line, event_part, f.reason)
                    })
                    .collect();

                Ok(json!({
                    "ok": report.ok(),
                    "chain_length": report.rows_scanned,
                    "issues": issues,
                }))
            }
            Err(err) => {
                // I/O or structural JSONL corruption — not a per-row chain
                // failure. Surface as a single-element issues list so the
                // caller can distinguish "file unreadable" from "chain broken".
                Ok(json!({
                    "ok": false,
                    "chain_length": 0,
                    "issues": [format!("chain verification failed: {err}")],
                }))
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::{Arc, Mutex};

    fn make_tool(event_log: PathBuf) -> CortexAuditVerifyTool {
        // Pool = rusqlite::Connection; open_in_memory() is available on the type alias.
        let pool = cortex_store::Pool::open_in_memory().expect("in-memory sqlite");
        CortexAuditVerifyTool::new(Arc::new(Mutex::new(pool)), event_log)
    }

    #[test]
    fn name_and_gate() {
        let tool = make_tool(PathBuf::from("/nonexistent/event.jsonl"));
        assert_eq!(tool.name(), "cortex_audit_verify");
        assert!(!tool.gate_set().is_empty());
        assert_eq!(tool.gate_set(), &[GateId::HealthRead]);
    }

    #[test]
    fn missing_event_log_returns_ok_false() {
        let tool = make_tool(PathBuf::from("/nonexistent/event.jsonl"));
        let result = tool.call(serde_json::Value::Null).unwrap();
        assert_eq!(result["ok"], false);
        assert_eq!(result["chain_length"], 0);
        let issues = result["issues"].as_array().unwrap();
        assert!(
            !issues.is_empty(),
            "missing file should produce at least one issue"
        );
    }
}