crtx-mcp 0.1.2

MCP stdio JSON-RPC 2.0 server for Cortex — tool dispatch, ToolHandler trait, gate wiring (ADR 0045).
Documentation
//! `cortex_decay_status` MCP tool handler.
//!
//! Read-only listing of decay job queue state. Mirrors the read path used by
//! `cortex decay list` (`crates/cortex-cli/src/cmd/decay.rs` `run_list` fn)
//! via [`DecayJobRepo::list_by_state`].
//!
//! Gate: [`GateId::HealthRead`].

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

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

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

/// Wire state tokens accepted by the optional `state` parameter.
const VALID_STATES: &[&str] = &["pending", "in_progress", "completed", "failed", "cancelled"];

/// MCP tool: `cortex_decay_status`.
///
/// Schema:
/// ```text
/// cortex_decay_status(
///   state?: "pending" | "in_progress" | "completed" | "failed" | "cancelled",
/// ) -> {
///   jobs: [{ id, kind, state, scheduled_for, created_at }],
///   total: int,
/// }
/// ```
#[derive(Debug)]
pub struct CortexDecayStatusTool {
    pool: Arc<Mutex<Pool>>,
}

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

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

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

    fn call(&self, params: Value) -> Result<Value, ToolError> {
        // Optional state filter. Absent or JSON null means "all states".
        let state_wire_owned: Option<String> = match params["state"].as_str() {
            Some("") | None => None,
            Some(s) => {
                // Normalise the hyphenated CLI alias "in-progress" to the
                // wire token "in_progress" so both spellings are accepted.
                let wire = if s == "in-progress" { "in_progress" } else { s };
                if !VALID_STATES.contains(&wire) {
                    return Err(ToolError::InvalidParams(format!(
                        "state must be one of {VALID_STATES:?}, got `{s}`"
                    )));
                }
                Some(wire.to_owned())
            }
        };

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

        let mut jobs: Vec<Value> = Vec::new();
        let states_to_query: Vec<&str> = match state_wire_owned.as_deref() {
            Some(s) => vec![s],
            None => VALID_STATES.to_vec(),
        };
        for wire in &states_to_query {
            let records = repo.list_by_state(wire).map_err(|err| {
                ToolError::Internal(format!("failed to read decay jobs (state={wire}): {err}"))
            })?;
            for record in records {
                jobs.push(json!({
                    "id": record.id.to_string(),
                    "kind": record.kind_wire,
                    "state": record.state_wire,
                    "scheduled_for": record.scheduled_for.to_rfc3339(),
                    "created_at": record.created_at.to_rfc3339(),
                }));
            }
        }

        let total = jobs.len();
        Ok(json!({ "jobs": jobs, "total": total }))
    }
}