Skip to main content

cortex_mcp/tools/
decay_status.rs

1//! `cortex_decay_status` MCP tool handler.
2//!
3//! Read-only listing of decay job queue state. Mirrors the read path used by
4//! `cortex decay list` (`crates/cortex-cli/src/cmd/decay.rs` `run_list` fn)
5//! via [`DecayJobRepo::list_by_state`].
6//!
7//! Gate: [`GateId::HealthRead`].
8
9use std::sync::{Arc, Mutex};
10
11use cortex_store::repo::DecayJobRepo;
12use cortex_store::Pool;
13use serde_json::{json, Value};
14
15use crate::tool_handler::{GateId, ToolError, ToolHandler};
16
17/// Wire state tokens accepted by the optional `state` parameter.
18const VALID_STATES: &[&str] = &["pending", "in_progress", "completed", "failed", "cancelled"];
19
20/// MCP tool: `cortex_decay_status`.
21///
22/// Schema:
23/// ```text
24/// cortex_decay_status(
25///   state?: "pending" | "in_progress" | "completed" | "failed" | "cancelled",
26/// ) -> {
27///   jobs: [{ id, kind, state, scheduled_for, created_at }],
28///   total: int,
29/// }
30/// ```
31#[derive(Debug)]
32pub struct CortexDecayStatusTool {
33    pool: Arc<Mutex<Pool>>,
34}
35
36impl CortexDecayStatusTool {
37    /// Construct the tool over a shared, mutex-guarded store connection.
38    #[must_use]
39    pub fn new(pool: Arc<Mutex<Pool>>) -> Self {
40        Self { pool }
41    }
42}
43
44impl ToolHandler for CortexDecayStatusTool {
45    fn name(&self) -> &'static str {
46        "cortex_decay_status"
47    }
48
49    fn gate_set(&self) -> &'static [GateId] {
50        &[GateId::HealthRead]
51    }
52
53    fn call(&self, params: Value) -> Result<Value, ToolError> {
54        // Optional state filter. Absent or JSON null means "all states".
55        let state_wire_owned: Option<String> = match params["state"].as_str() {
56            Some("") | None => None,
57            Some(s) => {
58                // Normalise the hyphenated CLI alias "in-progress" to the
59                // wire token "in_progress" so both spellings are accepted.
60                let wire = if s == "in-progress" { "in_progress" } else { s };
61                if !VALID_STATES.contains(&wire) {
62                    return Err(ToolError::InvalidParams(format!(
63                        "state must be one of {VALID_STATES:?}, got `{s}`"
64                    )));
65                }
66                Some(wire.to_owned())
67            }
68        };
69
70        let pool = self
71            .pool
72            .lock()
73            .map_err(|err| ToolError::Internal(format!("pool lock poisoned: {err}")))?;
74        let repo = DecayJobRepo::new(&pool);
75
76        let mut jobs: Vec<Value> = Vec::new();
77        let states_to_query: Vec<&str> = match state_wire_owned.as_deref() {
78            Some(s) => vec![s],
79            None => VALID_STATES.to_vec(),
80        };
81        for wire in &states_to_query {
82            let records = repo.list_by_state(wire).map_err(|err| {
83                ToolError::Internal(format!("failed to read decay jobs (state={wire}): {err}"))
84            })?;
85            for record in records {
86                jobs.push(json!({
87                    "id": record.id.to_string(),
88                    "kind": record.kind_wire,
89                    "state": record.state_wire,
90                    "scheduled_for": record.scheduled_for.to_rfc3339(),
91                    "created_at": record.created_at.to_rfc3339(),
92                }));
93            }
94        }
95
96        let total = jobs.len();
97        Ok(json!({ "jobs": jobs, "total": total }))
98    }
99}