| 1 |
|
| 2 |
|
| 3 |
pub mod health; |
| 4 |
pub mod orient; |
| 5 |
pub mod tests; |
| 6 |
|
| 7 |
use rmcp::handler::server::router::tool::ToolRouter; |
| 8 |
use rmcp::handler::server::wrapper::Parameters; |
| 9 |
use rmcp::model::{ServerCapabilities, ServerInfo}; |
| 10 |
use rmcp::{ServerHandler, tool, tool_handler, tool_router}; |
| 11 |
use sqlx::SqlitePool; |
| 12 |
|
| 13 |
use crate::config::Config; |
| 14 |
|
| 15 |
#[derive(Clone)] |
| 16 |
pub struct PomServer { |
| 17 |
pub pool: SqlitePool, |
| 18 |
pub config: Config, |
| 19 |
tool_router: ToolRouter<Self>, |
| 20 |
} |
| 21 |
|
| 22 |
impl PomServer { |
| 23 |
pub fn new(pool: SqlitePool, config: Config) -> Self { |
| 24 |
Self { |
| 25 |
pool, |
| 26 |
config, |
| 27 |
tool_router: Self::tool_router(), |
| 28 |
} |
| 29 |
} |
| 30 |
} |
| 31 |
|
| 32 |
|
| 33 |
|
| 34 |
#[tool_router] |
| 35 |
impl PomServer { |
| 36 |
|
| 37 |
#[tool( |
| 38 |
description = "Get overall status dashboard: all targets' latest health check and test run results. Use this for a quick overview." |
| 39 |
)] |
| 40 |
pub async fn get_status(&self) -> String { |
| 41 |
match self.get_status_impl().await { |
| 42 |
Ok(result) => result, |
| 43 |
Err(e) => format!("Error getting status: {e}"), |
| 44 |
} |
| 45 |
} |
| 46 |
|
| 47 |
|
| 48 |
#[tool( |
| 49 |
description = "Run a live health check against a target's health endpoint. Stores the result and returns the snapshot. Omit target to check all." |
| 50 |
)] |
| 51 |
pub async fn check_health( |
| 52 |
&self, |
| 53 |
Parameters(params): Parameters<health::CheckHealthParams>, |
| 54 |
) -> String { |
| 55 |
match self.check_health_impl(params).await { |
| 56 |
Ok(result) => result, |
| 57 |
Err(e) => format!("Error checking health: {e}"), |
| 58 |
} |
| 59 |
} |
| 60 |
|
| 61 |
#[tool( |
| 62 |
description = "Get recent health check history. Optionally filter by target and limit results (default 10)." |
| 63 |
)] |
| 64 |
pub async fn health_history( |
| 65 |
&self, |
| 66 |
Parameters(params): Parameters<health::HealthHistoryParams>, |
| 67 |
) -> String { |
| 68 |
match self.health_history_impl(params).await { |
| 69 |
Ok(result) => result, |
| 70 |
Err(e) => format!("Error getting health history: {e}"), |
| 71 |
} |
| 72 |
} |
| 73 |
|
| 74 |
#[tool( |
| 75 |
description = "List all configured targets with their labels and capabilities (health check, test suite)." |
| 76 |
)] |
| 77 |
pub async fn list_targets(&self) -> String { |
| 78 |
match self.list_targets_impl().await { |
| 79 |
Ok(result) => result, |
| 80 |
Err(e) => format!("Error listing targets: {e}"), |
| 81 |
} |
| 82 |
} |
| 83 |
|
| 84 |
|
| 85 |
#[tool( |
| 86 |
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." |
| 87 |
)] |
| 88 |
pub async fn run_tests(&self, Parameters(params): Parameters<tests::RunTestsParams>) -> String { |
| 89 |
match self.run_tests_impl(params).await { |
| 90 |
Ok(result) => result, |
| 91 |
Err(e) => format!("Error running tests: {e}"), |
| 92 |
} |
| 93 |
} |
| 94 |
|
| 95 |
#[tool( |
| 96 |
description = "Get recent test run history (without raw output). Optionally filter by target and limit results (default 10)." |
| 97 |
)] |
| 98 |
pub async fn test_history( |
| 99 |
&self, |
| 100 |
Parameters(params): Parameters<tests::TestHistoryParams>, |
| 101 |
) -> String { |
| 102 |
match self.test_history_impl(params).await { |
| 103 |
Ok(result) => result, |
| 104 |
Err(e) => format!("Error getting test history: {e}"), |
| 105 |
} |
| 106 |
} |
| 107 |
|
| 108 |
|
| 109 |
#[tool( |
| 110 |
description = "Get the full raw stdout/stderr output of the most recent test run for a target. Useful for debugging failures." |
| 111 |
)] |
| 112 |
pub async fn last_test_output( |
| 113 |
&self, |
| 114 |
Parameters(params): Parameters<tests::LastTestOutputParams>, |
| 115 |
) -> String { |
| 116 |
match self.last_test_output_impl(params).await { |
| 117 |
Ok(result) => result, |
| 118 |
Err(e) => format!("Error getting test output: {e}"), |
| 119 |
} |
| 120 |
} |
| 121 |
|
| 122 |
|
| 123 |
|
| 124 |
|
| 125 |
|
| 126 |
#[tool( |
| 127 |
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." |
| 128 |
)] |
| 129 |
pub async fn status_table( |
| 130 |
&self, |
| 131 |
Parameters(params): Parameters<orient::InstanceParams>, |
| 132 |
) -> String { |
| 133 |
match self.status_table_impl(params).await { |
| 134 |
Ok(result) => result, |
| 135 |
Err(e) => format!("Error getting status table: {e}"), |
| 136 |
} |
| 137 |
} |
| 138 |
|
| 139 |
#[tool( |
| 140 |
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." |
| 141 |
)] |
| 142 |
pub async fn target_status( |
| 143 |
&self, |
| 144 |
Parameters(params): Parameters<orient::TargetInstanceParams>, |
| 145 |
) -> String { |
| 146 |
match self.target_status_impl(params).await { |
| 147 |
Ok(result) => result, |
| 148 |
Err(e) => format!("Error getting target status: {e}"), |
| 149 |
} |
| 150 |
} |
| 151 |
|
| 152 |
#[tool( |
| 153 |
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'." |
| 154 |
)] |
| 155 |
pub async fn versions(&self, Parameters(params): Parameters<orient::InstanceParams>) -> String { |
| 156 |
match self.versions_impl(params).await { |
| 157 |
Ok(result) => result, |
| 158 |
Err(e) => format!("Error getting versions: {e}"), |
| 159 |
} |
| 160 |
} |
| 161 |
|
| 162 |
#[tool( |
| 163 |
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." |
| 164 |
)] |
| 165 |
pub async fn incidents( |
| 166 |
&self, |
| 167 |
Parameters(params): Parameters<orient::InstanceParams>, |
| 168 |
) -> String { |
| 169 |
match self.incidents_impl(params).await { |
| 170 |
Ok(result) => result, |
| 171 |
Err(e) => format!("Error getting incidents: {e}"), |
| 172 |
} |
| 173 |
} |
| 174 |
|
| 175 |
#[tool( |
| 176 |
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." |
| 177 |
)] |
| 178 |
pub async fn trends(&self, Parameters(params): Parameters<orient::TrendsParams>) -> String { |
| 179 |
match self.trends_impl(params).await { |
| 180 |
Ok(result) => result, |
| 181 |
Err(e) => format!("Error getting trends: {e}"), |
| 182 |
} |
| 183 |
} |
| 184 |
|
| 185 |
#[tool( |
| 186 |
description = "Get the peer mesh status showing all PoM instances, their connectivity, versions, and target health. Requires serve mode to be running." |
| 187 |
)] |
| 188 |
pub async fn get_mesh_status(&self) -> String { |
| 189 |
match self.get_mesh_status_impl().await { |
| 190 |
Ok(result) => result, |
| 191 |
Err(e) => format!("Error getting mesh status: {e}"), |
| 192 |
} |
| 193 |
} |
| 194 |
} |
| 195 |
|
| 196 |
#[tool_handler(router = self.tool_router)] |
| 197 |
impl ServerHandler for PomServer { |
| 198 |
fn get_info(&self) -> ServerInfo { |
| 199 |
ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_instructions( |
| 200 |
"Peace of Mind (PoM) server for monitoring production health and running tests. \ |
| 201 |
To orient on what is live, start with status_table, then target_status for one \ |
| 202 |
target, versions for what is deployed where, incidents for what is wrong, and \ |
| 203 |
trends for latency over time. Those five are read-only and take an optional \ |
| 204 |
`instance`: omitted they read this machine, named they read that configured peer, \ |
| 205 |
which is the only way to see checks local to that host (systemd, backups). \ |
| 206 |
Also: get_status (verbose per-target dump), check_health (probes the target live \ |
| 207 |
and records the result), health_history, list_targets, run_tests (SSH test \ |
| 208 |
execution), test_history, last_test_output, get_mesh_status (peer mesh overview).", |
| 209 |
) |
| 210 |
} |
| 211 |
} |
| 212 |
|
| 213 |
#[cfg(test)] |
| 214 |
mod router_smoke { |
| 215 |
use super::PomServer; |
| 216 |
|
| 217 |
#[test] |
| 218 |
fn all_tools_register_with_schemas() { |
| 219 |
let router = PomServer::tool_router(); |
| 220 |
let tools = router.list_all(); |
| 221 |
let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); |
| 222 |
eprintln!("registered tools: {names:?}"); |
| 223 |
assert_eq!(tools.len(), 13, "expected 13 tools, got {}", tools.len()); |
| 224 |
for expected in [ |
| 225 |
"get_status", |
| 226 |
"check_health", |
| 227 |
"health_history", |
| 228 |
"list_targets", |
| 229 |
"run_tests", |
| 230 |
"test_history", |
| 231 |
"last_test_output", |
| 232 |
"get_mesh_status", |
| 233 |
"status_table", |
| 234 |
"target_status", |
| 235 |
"versions", |
| 236 |
"incidents", |
| 237 |
"trends", |
| 238 |
] { |
| 239 |
let t = tools |
| 240 |
.iter() |
| 241 |
.find(|t| t.name == expected) |
| 242 |
.unwrap_or_else(|| panic!("missing tool {expected}")); |
| 243 |
assert!( |
| 244 |
t.description.as_ref().is_some_and(|d| !d.is_empty()), |
| 245 |
"{expected} has no description" |
| 246 |
); |
| 247 |
|
| 248 |
assert_eq!( |
| 249 |
t.input_schema.get("type").and_then(|v| v.as_str()), |
| 250 |
Some("object"), |
| 251 |
"{expected} schema not an object" |
| 252 |
); |
| 253 |
} |
| 254 |
|
| 255 |
let ch = tools.iter().find(|t| t.name == "check_health").unwrap(); |
| 256 |
assert!( |
| 257 |
ch.input_schema |
| 258 |
.get("properties") |
| 259 |
.and_then(|p| p.get("target")) |
| 260 |
.is_some(), |
| 261 |
"check_health schema missing 'target' property: {:?}", |
| 262 |
ch.input_schema |
| 263 |
); |
| 264 |
|
| 265 |
|
| 266 |
|
| 267 |
for expected in [ |
| 268 |
"status_table", |
| 269 |
"target_status", |
| 270 |
"versions", |
| 271 |
"incidents", |
| 272 |
"trends", |
| 273 |
] { |
| 274 |
let t = tools.iter().find(|t| t.name == expected).unwrap(); |
| 275 |
assert!( |
| 276 |
t.input_schema |
| 277 |
.get("properties") |
| 278 |
.and_then(|p| p.get("instance")) |
| 279 |
.is_some(), |
| 280 |
"{expected} schema missing 'instance' property: {:?}", |
| 281 |
t.input_schema |
| 282 |
); |
| 283 |
} |
| 284 |
} |
| 285 |
} |
| 286 |
|