Skip to main content

cortex_mcp/tools/
health.rs

1//! `cortex_memory_health` MCP tool handler.
2//!
3//! Returns aggregate counts for active and quarantined memories. Mirrors the
4//! query logic in `cortex memory health` (`crates/cortex-cli/src/cmd/memory.rs`
5//! `health` fn) but returns counts only โ€” no per-memory ids, no mutations.
6//!
7//! Gate: [`GateId::HealthRead`].
8//! Tier: read-only informational; must not be labelled as proof-closure evidence.
9
10use std::sync::{Arc, Mutex};
11
12use serde_json::{json, Value};
13
14use crate::{GateId, ToolError, ToolHandler};
15
16/// MCP tool: `cortex_memory_health`.
17///
18/// Schema:
19/// ```text
20/// cortex_memory_health() โ†’ { total: int, stale: int, unvalidated: int, quarantined: int }
21/// ```
22///
23/// - `total`: active memory count.
24/// - `stale`: active memories whose `created_at` is more than 30 days ago.
25/// - `unvalidated`: active memories whose `validation_epoch` is 0 (never
26///   validated via an ADR 0020 ยง6 gated `Validated` outcome edge).
27/// - `quarantined`: memories with `status = 'quarantined'`.
28#[derive(Debug)]
29pub struct CortexMemoryHealthTool {
30    pool: Arc<Mutex<cortex_store::Pool>>,
31}
32
33impl CortexMemoryHealthTool {
34    /// Construct the tool over a shared store connection.
35    #[must_use]
36    pub fn new(pool: Arc<Mutex<cortex_store::Pool>>) -> Self {
37        Self { pool }
38    }
39}
40
41impl ToolHandler for CortexMemoryHealthTool {
42    fn name(&self) -> &'static str {
43        "cortex_memory_health"
44    }
45
46    fn gate_set(&self) -> &'static [GateId] {
47        &[GateId::HealthRead]
48    }
49
50    fn call(&self, _params: Value) -> Result<Value, ToolError> {
51        let pool = self
52            .pool
53            .lock()
54            .map_err(|err| ToolError::Internal(format!("failed to acquire store lock: {err}")))?;
55
56        let cutoff_dt = chrono::Utc::now() - chrono::Duration::days(30);
57        let cutoff_rfc = cutoff_dt.to_rfc3339();
58
59        // Count active memories and compute sub-counts in a single scan.
60        let mut stmt = pool
61            .prepare(
62                "SELECT created_at, validation_epoch \
63                 FROM memories WHERE status = 'active';",
64            )
65            .map_err(|err| ToolError::Internal(format!("failed to prepare health query: {err}")))?;
66
67        let rows = stmt
68            .query_map([], |row| {
69                Ok((row.get::<_, String>(0)?, row.get::<_, Option<i64>>(1)?))
70            })
71            .map_err(|err| {
72                ToolError::Internal(format!("failed to query active memories: {err}"))
73            })?;
74
75        let mut total: i64 = 0;
76        let mut stale: i64 = 0;
77        let mut unvalidated: i64 = 0;
78
79        for row_result in rows {
80            let (created_at, validation_epoch) = row_result
81                .map_err(|err| ToolError::Internal(format!("failed to read health row: {err}")))?;
82
83            total += 1;
84
85            if created_at.as_str() < cutoff_rfc.as_str() {
86                stale += 1;
87            }
88
89            if validation_epoch.unwrap_or(0) == 0 {
90                unvalidated += 1;
91            }
92        }
93
94        // Quarantined count is a separate status value.
95        let quarantined: i64 = pool
96            .query_row(
97                "SELECT COUNT(*) FROM memories WHERE status = 'quarantined';",
98                [],
99                |row| row.get(0),
100            )
101            .map_err(|err| {
102                ToolError::Internal(format!("failed to query quarantined count: {err}"))
103            })?;
104
105        Ok(json!({
106            "total": total,
107            "stale": stale,
108            "unvalidated": unvalidated,
109            "quarantined": quarantined,
110        }))
111    }
112}