Skip to main content

lean_ctx/tools/
ctx_tools.rs

1//! `ctx_tools` business logic (#210): the MCP Tool-Catalog Gateway meta-tool.
2//!
3//! Keeps the registered wrapper thin: this module owns config gating, action
4//! routing, and driving the async [`gateway`] from the synchronous tool
5//! handler. The dispatch layer already wraps handlers in `block_in_place`, so
6//! blocking on the current runtime here is safe (same pattern as `ctx_read`).
7
8use serde_json::{Map, Value};
9
10use crate::core::config::Config;
11use crate::core::gateway;
12
13const DISABLED_HINT: &str = "gateway is disabled. Enable it in ~/.lean-ctx/config.toml:\n\
14     [gateway]\n\
15     enabled = true\n\n\
16     [[gateway.servers]]\n\
17     name = \"fs\"\n\
18     transport = \"stdio\"\n\
19     command = \"mcp-server-filesystem\"\n\
20     args = [\"/path/to/dir\"]";
21
22/// Execute a `ctx_tools` action, returning response text or an error message.
23pub fn run(args: &Map<String, Value>) -> Result<String, String> {
24    let cfg = Config::load();
25    if !cfg.gateway.enabled_effective() {
26        return Err(DISABLED_HINT.to_string());
27    }
28    if cfg.gateway.active_servers().next().is_none() {
29        return Err(
30            "gateway is enabled but no downstream servers are configured \
31             (add one or more [[gateway.servers]] entries)."
32                .to_string(),
33        );
34    }
35
36    let action = args.get("action").and_then(Value::as_str).unwrap_or("find");
37    let rt = tokio::runtime::Handle::current();
38
39    match action {
40        "find" => {
41            let query = args
42                .get("query")
43                .and_then(Value::as_str)
44                .unwrap_or("")
45                .to_string();
46            let outcome = rt.block_on(gateway::find(&cfg.gateway, &query));
47            Ok(gateway::render_cards(&outcome))
48        }
49        "list" => Ok(rt.block_on(gateway::servers_overview(&cfg.gateway))),
50        "refresh" => {
51            gateway::catalog::invalidate();
52            let outcome = rt.block_on(gateway::find(&cfg.gateway, ""));
53            Ok(format!(
54                "gateway catalog refreshed.\n\n{}",
55                gateway::render_cards(&outcome)
56            ))
57        }
58        "call" => {
59            let tool = args.get("tool").and_then(Value::as_str).ok_or_else(|| {
60                "call requires 'tool' — a `server::tool` handle from `ctx_tools find`".to_string()
61            })?;
62            let arguments = match args.get("arguments") {
63                Some(Value::Object(m)) => m.clone(),
64                None | Some(Value::Null) => Map::new(),
65                Some(_) => return Err("'arguments' must be a JSON object".to_string()),
66            };
67            rt.block_on(gateway::proxy(&cfg.gateway, tool, arguments))
68        }
69        other => Err(format!(
70            "invalid action '{other}' (use: find, call, list, refresh)"
71        )),
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use serde_json::json;
79
80    /// With the gateway disabled (default), every action returns the enable hint
81    /// without touching the network. We assert this in a fresh runtime since
82    /// `run` resolves a runtime handle.
83    #[test]
84    fn disabled_gateway_returns_hint() {
85        // Ensure no env override flips it on.
86        crate::test_env::remove_var("LEAN_CTX_GATEWAY");
87        let rt = tokio::runtime::Builder::new_multi_thread()
88            .enable_all()
89            .build()
90            .unwrap();
91        let args = json!({ "action": "find", "query": "anything" });
92        let out =
93            rt.block_on(async { tokio::task::block_in_place(|| run(args.as_object().unwrap())) });
94        // Either disabled (no global config) — the dominant case in CI — or, if a
95        // developer machine has it enabled, we still must not panic. We only
96        // assert the message shape in the disabled/empty case.
97        if let Err(e) = out {
98            assert!(e.contains("gateway is disabled") || e.contains("no downstream"));
99        }
100    }
101}