Skip to main content

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}