lean_ctx/core/addons/health.rs
1//! Post-install health probe (#1076).
2//!
3//! After an addon is wired, [`probe`] connects to its MCP server exactly the way
4//! the gateway will (resolve → spawn under the same sandbox → MCP `initialize` →
5//! `tools/list`) and reports the discovered tools. This turns a broken
6//! `command`/`args` (or under-provisioned capabilities) into a clear failure at
7//! install time instead of an opaque error at first `ctx_tools` use.
8//!
9//! Impure by nature (spawns a process / opens a connection), so it lives outside
10//! the pure [`super::install`] path and is driven from the CLI.
11
12use std::time::Duration;
13
14use crate::core::mcp_catalog::{GatewayServer, client};
15
16/// What a successful [`probe`] found on the downstream server.
17#[derive(Debug, Clone)]
18pub struct ProbeReport {
19 /// Number of tools the server advertised via `tools/list`.
20 pub tool_count: usize,
21 /// Tool names, sorted (for a stable, human-friendly summary).
22 pub tools: Vec<String>,
23}
24
25/// Connect to `server` and list its tools, bounded by `timeout`. Returns a
26/// [`ProbeReport`] on success or a human-readable reason it could not be reached
27/// (spawn failure, handshake failure, timeout, sandbox block, …).
28pub fn probe(server: &GatewayServer, timeout: Duration) -> Result<ProbeReport, String> {
29 let resolved = server.resolve()?;
30 // The CLI has no ambient Tokio runtime; build a one-shot current-thread one
31 // (the same approach `ctx_tools` uses for its CLI call path).
32 let rt = tokio::runtime::Builder::new_current_thread()
33 .enable_all()
34 .build()
35 .map_err(|e| format!("failed to start runtime for the health probe: {e}"))?;
36 let tools = rt.block_on(client::fetch_tools(&resolved, timeout))?;
37 let mut names: Vec<String> = tools.iter().map(|t| t.name.to_string()).collect();
38 names.sort();
39 Ok(ProbeReport {
40 tool_count: names.len(),
41 tools: names,
42 })
43}
44
45#[cfg(test)]
46mod tests {
47 use super::*;
48 use crate::core::mcp_catalog::TransportKind;
49
50 #[test]
51 fn probe_reports_a_clear_error_for_a_missing_binary() {
52 // A command that does not exist must surface a readable spawn failure
53 // rather than panicking — the whole point of the install-time probe.
54 let server = GatewayServer {
55 name: "ghost".into(),
56 transport: TransportKind::Stdio,
57 command: "lean-ctx-no-such-mcp-binary-xyz".into(),
58 ..Default::default()
59 };
60 let err = probe(&server, Duration::from_secs(5)).expect_err("missing binary must fail");
61 assert!(!err.is_empty());
62 }
63
64 #[test]
65 fn probe_rejects_an_unresolvable_server() {
66 // stdio transport without a command can't resolve → clear error.
67 let server = GatewayServer {
68 name: "broken".into(),
69 transport: TransportKind::Stdio,
70 ..Default::default()
71 };
72 assert!(probe(&server, Duration::from_secs(1)).is_err());
73 }
74}