Skip to main content

max / makenotwork

10.7 KB · 286 lines History Blame Raw
1 //! MCP server surface, exposing PoM's health and test data as MCP tools.
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 // Each tool method delegates straight to its `*_impl` counterpart, which carries
33 // its own `#[instrument(skip_all)]`, so the thin wrappers stay untraced by design.
34 #[tool_router]
35 impl PomServer {
36 /// Get overall status dashboard for all configured targets.
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 /// Check health of a target (or all targets).
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 /// Run tests on a target via SSH.
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 /// Get raw output of the most recent test run.
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 // The orientation tools below are read-only by design: they answer "what is
123 // live and what is wrong with it" without being able to change any of it.
124 // Promoting, deploying, and closing incidents stay with Sando and the CLI.
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 // input_schema is an Arc<Map>; every tool must have an object schema.
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 // Tools that take Parameters<T> must expose their properties in the schema.
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 // The read-only tools are only reachable against a remote instance if
266 // the caller can see that the parameter exists.
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