Skip to main content

cortex_mcp/tools/
doctor.rs

1//! `cortex_doctor` MCP tool handler.
2//!
3//! Runs store precondition checks — the same schema-version gates as
4//! `cortex doctor --strict` (`crates/cortex-cli/src/cmd/doctor.rs`) — and
5//! returns a structured result. Write-nothing, read-only.
6//!
7//! Supervised tier tool (council recommendation): logs prominently on every
8//! call so operators can audit MCP-initiated doctor invocations.
9//!
10//! Gate: [`GateId::HealthRead`].
11
12use std::path::PathBuf;
13use std::sync::{Arc, Mutex};
14
15use cortex_ledger::{audit::verify_schema_migration_v1_to_v2_boundary, JsonlLog};
16use serde_json::{json, Value};
17
18use crate::{GateId, ToolError, ToolHandler};
19
20/// MCP tool: `cortex_doctor`.
21///
22/// Schema:
23/// ```text
24/// cortex_doctor() → { ok: bool, issues: [string] }
25/// ```
26///
27/// If all precondition checks pass, `ok: true, issues: []`.
28/// If any check fails, `ok: false, issues: ["<description>", ...]`.
29#[derive(Debug)]
30pub struct CortexDoctorTool {
31    pool: Arc<Mutex<cortex_store::Pool>>,
32    event_log: PathBuf,
33}
34
35impl CortexDoctorTool {
36    /// Construct the tool over a shared store connection and event log path.
37    #[must_use]
38    pub fn new(pool: Arc<Mutex<cortex_store::Pool>>, event_log: PathBuf) -> Self {
39        Self { pool, event_log }
40    }
41}
42
43impl ToolHandler for CortexDoctorTool {
44    fn name(&self) -> &'static str {
45        "cortex_doctor"
46    }
47
48    fn gate_set(&self) -> &'static [GateId] {
49        &[GateId::HealthRead]
50    }
51
52    fn call(&self, _params: Value) -> Result<Value, ToolError> {
53        tracing::info!("cortex_doctor called via MCP");
54
55        let pool = self
56            .pool
57            .lock()
58            .map_err(|err| ToolError::Internal(format!("failed to acquire store lock: {err}")))?;
59
60        let mut issues: Vec<String> = Vec::new();
61
62        // Schema version check — same gate as `cortex doctor --strict`.
63        let schema_report =
64            cortex_store::verify::verify_schema_version(&pool, cortex_core::SCHEMA_VERSION)
65                .map_err(|err| {
66                    ToolError::Internal(format!("failed to verify schema version: {err}"))
67                })?;
68
69        if !schema_report.is_ok() {
70            for failure in &schema_report.failures {
71                issues.push(format!("{}: {}", failure.invariant(), failure.detail()));
72            }
73        } else {
74            // Schema v2 boundary check (mirrors `doctor --strict` logic):
75            // only required when the event log contains pre-cutover v1 rows.
76            let needs_boundary = if cortex_core::SCHEMA_VERSION >= 2 {
77                contains_pre_cutover_v1_rows(&self.event_log).map_err(|err| {
78                    ToolError::Internal(format!(
79                        "failed to inspect event log for pre-cutover v1 rows: {err}"
80                    ))
81                })?
82            } else {
83                false
84            };
85
86            match verify_schema_migration_v1_to_v2_boundary(&self.event_log, needs_boundary) {
87                Ok(boundary_report) if boundary_report.ok() => {}
88                Ok(boundary_report) => {
89                    for failure in &boundary_report.failures {
90                        issues.push(format!("{}: {:?}", failure.invariant, failure.detail));
91                    }
92                }
93                Err(err) => {
94                    issues.push(format!("failed to verify schema boundary events: {err}"));
95                }
96            }
97        }
98
99        let ok = issues.is_empty();
100        Ok(json!({ "ok": ok, "issues": issues }))
101    }
102}
103
104/// Return `true` when the JSONL log contains at least one event row whose
105/// `schema_version` is strictly less than the cut-over target.
106///
107/// Mirrors `contains_pre_cutover_v1_rows` in `crates/cortex-cli/src/cmd/doctor.rs`.
108fn contains_pre_cutover_v1_rows(
109    event_log_path: &std::path::Path,
110) -> Result<bool, cortex_ledger::JsonlError> {
111    let log = JsonlLog::open(event_log_path)?;
112    for item in log.iter()? {
113        let event = item?;
114        if event.schema_version < cortex_core::SCHEMA_VERSION {
115            return Ok(true);
116        }
117    }
118    Ok(false)
119}