crtx-mcp 0.1.2

MCP stdio JSON-RPC 2.0 server for Cortex — tool dispatch, ToolHandler trait, gate wiring (ADR 0045).
Documentation
//! `cortex_principle_status` MCP tool handler.
//!
//! Read-only surface for inspecting principle lifecycle counts and per-row
//! status. Queries the `principles` table (candidates and promoted rows) and
//! the `doctrine` table, returning aggregate counts plus an optional filtered
//! view keyed by `principle_id`.
//!
//! Nothing is written. The handler composes [`PrincipleRepo`] and
//! [`MemoryRepo`] read paths only.
//!
//! Gate: [`GateId::FtsRead`] — read-only supervised tier.

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

use cortex_store::repo::{MemoryRepo, PrincipleRepo};
use cortex_store::Pool;
use serde_json::{json, Value};

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

/// MCP tool: `cortex_principle_status`.
///
/// Schema:
/// ```jsonc
/// cortex_principle_status(
///   principle_id?: string,  // optional — filter to a single principle row
/// ) → {
///   active_count:    int,
///   candidate_count: int,
///   doctrine_count:  int,
///   principles:      [{ id, claim, status, confidence }],
/// }
/// ```
///
/// When `principle_id` is supplied the `principles` array contains at most one
/// entry. When omitted all candidate rows are returned.
#[derive(Debug)]
pub struct CortexPrincipleStatusTool {
    pool: Arc<Mutex<Pool>>,
}

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

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

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

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

        let principle_id_filter = params["principle_id"]
            .as_str()
            .filter(|s| !s.trim().is_empty())
            .map(ToOwned::to_owned);

        let pool = self
            .pool
            .lock()
            .map_err(|err| ToolError::Internal(format!("pool lock poisoned: {err}")))?;

        // Active memory count (status = 'active').
        let active_count = MemoryRepo::new(&pool)
            .list_by_status("active")
            .map_err(|err| {
                tracing::error!(error = %err, "cortex_principle_status: failed to read active memories");
                ToolError::Internal(format!("failed to read active memories: {err}"))
            })?
            .len();

        let principle_repo = PrincipleRepo::new(&pool);

        // Candidate principle rows.
        let candidates = principle_repo.list_candidates().map_err(|err| {
            tracing::error!(error = %err, "cortex_principle_status: failed to list principle candidates");
            ToolError::Internal(format!("failed to list principle candidates: {err}"))
        })?;

        // Doctrine rows (promoted principles).
        let doctrine = principle_repo.list_doctrine().map_err(|err| {
            tracing::error!(error = %err, "cortex_principle_status: failed to list doctrine");
            ToolError::Internal(format!("failed to list doctrine: {err}"))
        })?;

        let candidate_count = candidates.len();
        let doctrine_count = doctrine.len();

        // Build the principles view, optionally filtered by id.
        let principles: Vec<Value> = if let Some(ref id) = principle_id_filter {
            // Attempt to parse and look up the specific row.
            let pid: cortex_core::PrincipleId = id.parse().map_err(|err| {
                ToolError::InvalidParams(format!("invalid principle_id `{id}`: {err}"))
            })?;

            match principle_repo.get_by_id(&pid).map_err(|err| {
                ToolError::Internal(format!("failed to fetch principle {id}: {err}"))
            })? {
                Some(row) => vec![json!({
                    "id": row.id.to_string(),
                    "claim": row.statement,
                    "status": row.status,
                    "confidence": row.confidence,
                })],
                None => vec![],
            }
        } else {
            candidates
                .into_iter()
                .map(|row| {
                    json!({
                        "id": row.id.to_string(),
                        "claim": row.statement,
                        "status": row.status,
                        "confidence": row.confidence,
                    })
                })
                .collect()
        };

        Ok(json!({
            "active_count":    active_count,
            "candidate_count": candidate_count,
            "doctrine_count":  doctrine_count,
            "principles":      principles,
        }))
    }
}

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

    fn make_tool() -> CortexPrincipleStatusTool {
        let pool = rusqlite::Connection::open_in_memory().expect("in-memory sqlite");
        cortex_store::migrate::apply_pending(&pool).expect("migrations");
        CortexPrincipleStatusTool::new(Arc::new(Mutex::new(pool)))
    }

    #[test]
    fn name_and_gate() {
        let tool = make_tool();
        assert_eq!(tool.name(), "cortex_principle_status");
        assert_eq!(tool.gate_set(), &[GateId::FtsRead]);
    }

    #[test]
    fn empty_store_returns_zero_counts() {
        let tool = make_tool();
        let result = tool.call(Value::Null).unwrap();
        assert_eq!(result["active_count"], 0);
        assert_eq!(result["candidate_count"], 0);
        assert_eq!(result["doctrine_count"], 0);
        assert_eq!(result["principles"], json!([]));
    }

    #[test]
    fn unknown_principle_id_returns_empty_principles() {
        let tool = make_tool();
        // A well-formed but non-existent ULID.
        let result = tool
            .call(json!({ "principle_id": "prn_01JQZZZZZZZZZZZZZZZZZZZZZZ" }))
            .unwrap();
        assert_eq!(result["principles"], json!([]));
    }

    #[test]
    fn invalid_principle_id_returns_invalid_params() {
        let tool = make_tool();
        let err = tool
            .call(json!({ "principle_id": "not-a-ulid" }))
            .unwrap_err();
        assert!(matches!(err, ToolError::InvalidParams(_)));
    }
}