Skip to main content

cortex_mcp/tools/
principle_status.rs

1//! `cortex_principle_status` MCP tool handler.
2//!
3//! Read-only surface for inspecting principle lifecycle counts and per-row
4//! status. Queries the `principles` table (candidates and promoted rows) and
5//! the `doctrine` table, returning aggregate counts plus an optional filtered
6//! view keyed by `principle_id`.
7//!
8//! Nothing is written. The handler composes [`PrincipleRepo`] and
9//! [`MemoryRepo`] read paths only.
10//!
11//! Gate: [`GateId::FtsRead`] — read-only supervised tier.
12
13use std::sync::{Arc, Mutex};
14
15use cortex_store::repo::{MemoryRepo, PrincipleRepo};
16use cortex_store::Pool;
17use serde_json::{json, Value};
18
19use crate::tool_handler::{GateId, ToolError, ToolHandler};
20
21/// MCP tool: `cortex_principle_status`.
22///
23/// Schema:
24/// ```jsonc
25/// cortex_principle_status(
26///   principle_id?: string,  // optional — filter to a single principle row
27/// ) → {
28///   active_count:    int,
29///   candidate_count: int,
30///   doctrine_count:  int,
31///   principles:      [{ id, claim, status, confidence }],
32/// }
33/// ```
34///
35/// When `principle_id` is supplied the `principles` array contains at most one
36/// entry. When omitted all candidate rows are returned.
37#[derive(Debug)]
38pub struct CortexPrincipleStatusTool {
39    pool: Arc<Mutex<Pool>>,
40}
41
42impl CortexPrincipleStatusTool {
43    /// Construct the tool over a shared store connection.
44    #[must_use]
45    pub fn new(pool: Arc<Mutex<Pool>>) -> Self {
46        Self { pool }
47    }
48}
49
50impl ToolHandler for CortexPrincipleStatusTool {
51    fn name(&self) -> &'static str {
52        "cortex_principle_status"
53    }
54
55    fn gate_set(&self) -> &'static [GateId] {
56        &[GateId::FtsRead]
57    }
58
59    fn call(&self, params: Value) -> Result<Value, ToolError> {
60        tracing::info!("cortex_principle_status called via MCP");
61
62        let principle_id_filter = params["principle_id"]
63            .as_str()
64            .filter(|s| !s.trim().is_empty())
65            .map(ToOwned::to_owned);
66
67        let pool = self
68            .pool
69            .lock()
70            .map_err(|err| ToolError::Internal(format!("pool lock poisoned: {err}")))?;
71
72        // Active memory count (status = 'active').
73        let active_count = MemoryRepo::new(&pool)
74            .list_by_status("active")
75            .map_err(|err| {
76                tracing::error!(error = %err, "cortex_principle_status: failed to read active memories");
77                ToolError::Internal(format!("failed to read active memories: {err}"))
78            })?
79            .len();
80
81        let principle_repo = PrincipleRepo::new(&pool);
82
83        // Candidate principle rows.
84        let candidates = principle_repo.list_candidates().map_err(|err| {
85            tracing::error!(error = %err, "cortex_principle_status: failed to list principle candidates");
86            ToolError::Internal(format!("failed to list principle candidates: {err}"))
87        })?;
88
89        // Doctrine rows (promoted principles).
90        let doctrine = principle_repo.list_doctrine().map_err(|err| {
91            tracing::error!(error = %err, "cortex_principle_status: failed to list doctrine");
92            ToolError::Internal(format!("failed to list doctrine: {err}"))
93        })?;
94
95        let candidate_count = candidates.len();
96        let doctrine_count = doctrine.len();
97
98        // Build the principles view, optionally filtered by id.
99        let principles: Vec<Value> = if let Some(ref id) = principle_id_filter {
100            // Attempt to parse and look up the specific row.
101            let pid: cortex_core::PrincipleId = id.parse().map_err(|err| {
102                ToolError::InvalidParams(format!("invalid principle_id `{id}`: {err}"))
103            })?;
104
105            match principle_repo.get_by_id(&pid).map_err(|err| {
106                ToolError::Internal(format!("failed to fetch principle {id}: {err}"))
107            })? {
108                Some(row) => vec![json!({
109                    "id": row.id.to_string(),
110                    "claim": row.statement,
111                    "status": row.status,
112                    "confidence": row.confidence,
113                })],
114                None => vec![],
115            }
116        } else {
117            candidates
118                .into_iter()
119                .map(|row| {
120                    json!({
121                        "id": row.id.to_string(),
122                        "claim": row.statement,
123                        "status": row.status,
124                        "confidence": row.confidence,
125                    })
126                })
127                .collect()
128        };
129
130        Ok(json!({
131            "active_count":    active_count,
132            "candidate_count": candidate_count,
133            "doctrine_count":  doctrine_count,
134            "principles":      principles,
135        }))
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    fn make_tool() -> CortexPrincipleStatusTool {
144        let pool = rusqlite::Connection::open_in_memory().expect("in-memory sqlite");
145        cortex_store::migrate::apply_pending(&pool).expect("migrations");
146        CortexPrincipleStatusTool::new(Arc::new(Mutex::new(pool)))
147    }
148
149    #[test]
150    fn name_and_gate() {
151        let tool = make_tool();
152        assert_eq!(tool.name(), "cortex_principle_status");
153        assert_eq!(tool.gate_set(), &[GateId::FtsRead]);
154    }
155
156    #[test]
157    fn empty_store_returns_zero_counts() {
158        let tool = make_tool();
159        let result = tool.call(Value::Null).unwrap();
160        assert_eq!(result["active_count"], 0);
161        assert_eq!(result["candidate_count"], 0);
162        assert_eq!(result["doctrine_count"], 0);
163        assert_eq!(result["principles"], json!([]));
164    }
165
166    #[test]
167    fn unknown_principle_id_returns_empty_principles() {
168        let tool = make_tool();
169        // A well-formed but non-existent ULID.
170        let result = tool
171            .call(json!({ "principle_id": "prn_01JQZZZZZZZZZZZZZZZZZZZZZZ" }))
172            .unwrap();
173        assert_eq!(result["principles"], json!([]));
174    }
175
176    #[test]
177    fn invalid_principle_id_returns_invalid_params() {
178        let tool = make_tool();
179        let err = tool
180            .call(json!({ "principle_id": "not-a-ulid" }))
181            .unwrap_err();
182        assert!(matches!(err, ToolError::InvalidParams(_)));
183    }
184}