use crate::error::packop_error;
use grex_core::doctor::{self, DoctorOpts, DoctorReport, Severity};
use rmcp::{
handler::server::wrapper::Parameters,
model::{CallToolResult, Content},
ErrorData as McpError,
};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::json;
#[derive(Debug, Deserialize, JsonSchema, Default)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct DoctorParams {
#[serde(default)]
pub lint_config: bool,
}
pub(crate) async fn handle(
state: &crate::ServerState,
Parameters(p): Parameters<DoctorParams>,
) -> Result<CallToolResult, McpError> {
let workspace = (*state.workspace).clone();
let opts = DoctorOpts { fix: false, lint_config: p.lint_config, ..DoctorOpts::default() };
let ws_c = workspace.clone();
let joined = tokio::task::spawn_blocking(move || doctor::run_doctor(&ws_c, &opts)).await;
match joined {
Ok(Ok(report)) => Ok(report_envelope(&report)),
Ok(Err(e)) => Ok(packop_error(&format!("{e}"))),
Err(e) => Ok(packop_error(&format!("internal: blocking task failed: {e}"))),
}
}
fn report_envelope(report: &DoctorReport) -> CallToolResult {
CallToolResult::success(vec![Content::text(render_report_json(report).to_string())])
}
pub(crate) fn render_report_json(report: &DoctorReport) -> serde_json::Value {
let findings: Vec<_> = report
.findings
.iter()
.map(|f| {
json!({
"check": f.check.label(),
"severity": severity_label(f.severity),
"pack": f.pack,
"detail": f.detail,
"auto_fixable": f.auto_fixable,
"synthetic": f.synthetic,
})
})
.collect();
json!({
"exit_code": report.exit_code(),
"worst_severity": severity_label(report.worst()),
"findings": findings,
})
}
fn severity_label(s: Severity) -> &'static str {
match s {
Severity::Ok => "ok",
Severity::Warning => "warning",
Severity::Error => "error",
}
}
#[cfg(test)]
mod tests {
use super::*;
use rmcp::handler::server::tool::schema_for_type;
use serde_json::Value;
use tempfile::tempdir;
#[test]
fn doctor_params_schema_resolves() {
let _ = schema_for_type::<DoctorParams>();
}
#[test]
fn doctor_params_rejects_fix_and_workspace() {
let bad_fix: Result<DoctorParams, _> = serde_json::from_value(json!({ "fix": true }));
assert!(bad_fix.is_err(), "`fix` must be rejected by the MCP schema");
let bad_ws: Result<DoctorParams, _> =
serde_json::from_value(json!({ "workspace": "/tmp" }));
assert!(bad_ws.is_err(), "`workspace` must be rejected by the MCP schema");
}
#[tokio::test]
async fn doctor_empty_workspace_returns_ok_report() {
let dir = tempdir().unwrap();
let state = crate::ServerState::new(
grex_core::Scheduler::new(1),
grex_core::Registry::default(),
dir.path().join(".grex").join("events.jsonl"),
dir.path().to_path_buf(),
);
let r = handle(&state, Parameters(DoctorParams::default())).await.unwrap();
assert_ne!(r.is_error, Some(true), "expected success envelope");
let text = r.content.first().unwrap().as_text().unwrap().text.clone();
let v: Value = serde_json::from_str(&text).unwrap();
assert_eq!(v["exit_code"], json!(0));
assert_eq!(v["worst_severity"], json!("ok"));
}
#[tokio::test]
async fn doctor_corrupt_manifest_surfaces_error_severity() {
let dir = tempdir().unwrap();
let m = dir.path().join(".grex").join("events.jsonl");
std::fs::create_dir_all(m.parent().unwrap()).unwrap();
std::fs::write(
&m,
"not-json\n{\"schema_version\":\"1\",\"kind\":\"add\",\"ts\":\"2026-04-22T10:00:00Z\",\"id\":\"x\",\"url\":\"u\",\"path\":\"x\",\"pack_type\":\"declarative\"}\n",
)
.unwrap();
let state = crate::ServerState::new(
grex_core::Scheduler::new(1),
grex_core::Registry::default(),
dir.path().join(".grex").join("events.jsonl"),
dir.path().to_path_buf(),
);
let r = handle(&state, Parameters(DoctorParams::default())).await.unwrap();
assert_ne!(r.is_error, Some(true));
let text = r.content.first().unwrap().as_text().unwrap().text.clone();
let v: Value = serde_json::from_str(&text).unwrap();
assert_eq!(v["exit_code"], json!(2));
assert_eq!(v["worst_severity"], json!("error"));
assert!(v["findings"]
.as_array()
.unwrap()
.iter()
.any(|f| f["check"] == json!("manifest-schema") && f["severity"] == json!("error")));
}
}