crtx-mcp 0.1.2

MCP stdio JSON-RPC 2.0 server for Cortex — tool dispatch, ToolHandler trait, gate wiring (ADR 0045).
Documentation
//! `cortex_memory_health` MCP tool handler.
//!
//! Returns aggregate counts for active and quarantined memories. Mirrors the
//! query logic in `cortex memory health` (`crates/cortex-cli/src/cmd/memory.rs`
//! `health` fn) but returns counts only — no per-memory ids, no mutations.
//!
//! Gate: [`GateId::HealthRead`].
//! Tier: read-only informational; must not be labelled as proof-closure evidence.

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

use serde_json::{json, Value};

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

/// MCP tool: `cortex_memory_health`.
///
/// Schema:
/// ```text
/// cortex_memory_health() → { total: int, stale: int, unvalidated: int, quarantined: int }
/// ```
///
/// - `total`: active memory count.
/// - `stale`: active memories whose `created_at` is more than 30 days ago.
/// - `unvalidated`: active memories whose `validation_epoch` is 0 (never
///   validated via an ADR 0020 §6 gated `Validated` outcome edge).
/// - `quarantined`: memories with `status = 'quarantined'`.
#[derive(Debug)]
pub struct CortexMemoryHealthTool {
    pool: Arc<Mutex<cortex_store::Pool>>,
}

impl CortexMemoryHealthTool {
    /// Construct the tool over a shared store connection.
    #[must_use]
    pub fn new(pool: Arc<Mutex<cortex_store::Pool>>) -> Self {
        Self { pool }
    }
}

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

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

    fn call(&self, _params: Value) -> Result<Value, ToolError> {
        let pool = self
            .pool
            .lock()
            .map_err(|err| ToolError::Internal(format!("failed to acquire store lock: {err}")))?;

        let cutoff_dt = chrono::Utc::now() - chrono::Duration::days(30);
        let cutoff_rfc = cutoff_dt.to_rfc3339();

        // Count active memories and compute sub-counts in a single scan.
        let mut stmt = pool
            .prepare(
                "SELECT created_at, validation_epoch \
                 FROM memories WHERE status = 'active';",
            )
            .map_err(|err| ToolError::Internal(format!("failed to prepare health query: {err}")))?;

        let rows = stmt
            .query_map([], |row| {
                Ok((row.get::<_, String>(0)?, row.get::<_, Option<i64>>(1)?))
            })
            .map_err(|err| {
                ToolError::Internal(format!("failed to query active memories: {err}"))
            })?;

        let mut total: i64 = 0;
        let mut stale: i64 = 0;
        let mut unvalidated: i64 = 0;

        for row_result in rows {
            let (created_at, validation_epoch) = row_result
                .map_err(|err| ToolError::Internal(format!("failed to read health row: {err}")))?;

            total += 1;

            if created_at.as_str() < cutoff_rfc.as_str() {
                stale += 1;
            }

            if validation_epoch.unwrap_or(0) == 0 {
                unvalidated += 1;
            }
        }

        // Quarantined count is a separate status value.
        let quarantined: i64 = pool
            .query_row(
                "SELECT COUNT(*) FROM memories WHERE status = 'quarantined';",
                [],
                |row| row.get(0),
            )
            .map_err(|err| {
                ToolError::Internal(format!("failed to query quarantined count: {err}"))
            })?;

        Ok(json!({
            "total": total,
            "stale": stale,
            "unvalidated": unvalidated,
            "quarantined": quarantined,
        }))
    }
}