//! MCP server surface, exposing PoM's health and test data as MCP tools. pub mod health; pub mod orient; pub mod tests; use rmcp::handler::server::router::tool::ToolRouter; use rmcp::handler::server::wrapper::Parameters; use rmcp::model::{ServerCapabilities, ServerInfo}; use rmcp::{ServerHandler, tool, tool_handler, tool_router}; use sqlx::SqlitePool; use crate::config::Config; #[derive(Clone)] pub struct PomServer { pub pool: SqlitePool, pub config: Config, tool_router: ToolRouter, } impl PomServer { pub fn new(pool: SqlitePool, config: Config) -> Self { Self { pool, config, tool_router: Self::tool_router(), } } } // Each tool method delegates straight to its `*_impl` counterpart, which carries // its own `#[instrument(skip_all)]`, so the thin wrappers stay untraced by design. #[tool_router] impl PomServer { /// Get overall status dashboard for all configured targets. #[tool( description = "Get overall status dashboard: all targets' latest health check and test run results. Use this for a quick overview." )] pub async fn get_status(&self) -> String { match self.get_status_impl().await { Ok(result) => result, Err(e) => format!("Error getting status: {e}"), } } /// Check health of a target (or all targets). #[tool( description = "Run a live health check against a target's health endpoint. Stores the result and returns the snapshot. Omit target to check all." )] pub async fn check_health( &self, Parameters(params): Parameters, ) -> String { match self.check_health_impl(params).await { Ok(result) => result, Err(e) => format!("Error checking health: {e}"), } } #[tool( description = "Get recent health check history. Optionally filter by target and limit results (default 10)." )] pub async fn health_history( &self, Parameters(params): Parameters, ) -> String { match self.health_history_impl(params).await { Ok(result) => result, Err(e) => format!("Error getting health history: {e}"), } } #[tool( description = "List all configured targets with their labels and capabilities (health check, test suite)." )] pub async fn list_targets(&self) -> String { match self.list_targets_impl().await { Ok(result) => result, Err(e) => format!("Error listing targets: {e}"), } } /// Run tests on a target via SSH. #[tool( description = "Run the test suite on a target via SSH. Returns a summary with pass/fail counts. Optionally provide a filter to run specific tests." )] pub async fn run_tests(&self, Parameters(params): Parameters) -> String { match self.run_tests_impl(params).await { Ok(result) => result, Err(e) => format!("Error running tests: {e}"), } } #[tool( description = "Get recent test run history (without raw output). Optionally filter by target and limit results (default 10)." )] pub async fn test_history( &self, Parameters(params): Parameters, ) -> String { match self.test_history_impl(params).await { Ok(result) => result, Err(e) => format!("Error getting test history: {e}"), } } /// Get raw output of the most recent test run. #[tool( description = "Get the full raw stdout/stderr output of the most recent test run for a target. Useful for debugging failures." )] pub async fn last_test_output( &self, Parameters(params): Parameters, ) -> String { match self.last_test_output_impl(params).await { Ok(result) => result, Err(e) => format!("Error getting test output: {e}"), } } // The orientation tools below are read-only by design: they answer "what is // live and what is wrong with it" without being able to change any of it. // Promoting, deploying, and closing incidents stay with Sando and the CLI. #[tool( description = "One compact table of every monitored target: status, live version, when it was last checked, and the worst thing currently wrong with it. Start here when orienting on production. Omit instance to read this machine, or name a configured peer to read that host's view." )] pub async fn status_table( &self, Parameters(params): Parameters, ) -> String { match self.status_table_impl(params).await { Ok(result) => result, Err(e) => format!("Error getting status table: {e}"), } } #[tool( description = "Everything known about one target: health, TLS, DNS, WHOIS, backups, systemd units, scan pipeline, tests, and any open incident, each with its status and why. Reads stored check results; does not probe the target." )] pub async fn target_status( &self, Parameters(params): Parameters, ) -> String { match self.target_status_impl(params).await { Ok(result) => result, Err(e) => format!("Error getting target status: {e}"), } } #[tool( description = "What version and commit each target is running, when it was last seen, and how many commits behind the local checkout it is. Answers 'is what I am looking at what is deployed'." )] pub async fn versions(&self, Parameters(params): Parameters) -> String { match self.versions_impl(params).await { Ok(result) => result, Err(e) => format!("Error getting versions: {e}"), } } #[tool( description = "Open incidents across every target, plus any check that is currently not passing. Read-only: this reports incidents, it does not open or close them." )] pub async fn incidents( &self, Parameters(params): Parameters, ) -> String { match self.incidents_impl(params).await { Ok(result) => result, Err(e) => format!("Error getting incidents: {e}"), } } #[tool( description = "Latency trend for one target: per-bucket stats over a window (default 24h, 60-minute buckets) against the 7-day baseline. Use when something feels slower than it was." )] pub async fn trends(&self, Parameters(params): Parameters) -> String { match self.trends_impl(params).await { Ok(result) => result, Err(e) => format!("Error getting trends: {e}"), } } #[tool( description = "Get the peer mesh status showing all PoM instances, their connectivity, versions, and target health. Requires serve mode to be running." )] pub async fn get_mesh_status(&self) -> String { match self.get_mesh_status_impl().await { Ok(result) => result, Err(e) => format!("Error getting mesh status: {e}"), } } } #[tool_handler(router = self.tool_router)] impl ServerHandler for PomServer { fn get_info(&self) -> ServerInfo { ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_instructions( "Peace of Mind (PoM) server for monitoring production health and running tests. \ To orient on what is live, start with status_table, then target_status for one \ target, versions for what is deployed where, incidents for what is wrong, and \ trends for latency over time. Those five are read-only and take an optional \ `instance`: omitted they read this machine, named they read that configured peer, \ which is the only way to see checks local to that host (systemd, backups). \ Also: get_status (verbose per-target dump), check_health (probes the target live \ and records the result), health_history, list_targets, run_tests (SSH test \ execution), test_history, last_test_output, get_mesh_status (peer mesh overview).", ) } } #[cfg(test)] mod router_smoke { use super::PomServer; #[test] fn all_tools_register_with_schemas() { let router = PomServer::tool_router(); let tools = router.list_all(); let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); eprintln!("registered tools: {names:?}"); assert_eq!(tools.len(), 13, "expected 13 tools, got {}", tools.len()); for expected in [ "get_status", "check_health", "health_history", "list_targets", "run_tests", "test_history", "last_test_output", "get_mesh_status", "status_table", "target_status", "versions", "incidents", "trends", ] { let t = tools .iter() .find(|t| t.name == expected) .unwrap_or_else(|| panic!("missing tool {expected}")); assert!( t.description.as_ref().is_some_and(|d| !d.is_empty()), "{expected} has no description" ); // input_schema is an Arc; every tool must have an object schema. assert_eq!( t.input_schema.get("type").and_then(|v| v.as_str()), Some("object"), "{expected} schema not an object" ); } // Tools that take Parameters must expose their properties in the schema. let ch = tools.iter().find(|t| t.name == "check_health").unwrap(); assert!( ch.input_schema .get("properties") .and_then(|p| p.get("target")) .is_some(), "check_health schema missing 'target' property: {:?}", ch.input_schema ); // The read-only tools are only reachable against a remote instance if // the caller can see that the parameter exists. for expected in [ "status_table", "target_status", "versions", "incidents", "trends", ] { let t = tools.iter().find(|t| t.name == expected).unwrap(); assert!( t.input_schema .get("properties") .and_then(|p| p.get("instance")) .is_some(), "{expected} schema missing 'instance' property: {:?}", t.input_schema ); } } }