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 MCP dispatch layer wraps handlers in `block_in_place`, so an ambient
6//! runtime handle is available there. The CLI `call` path has no ambient
7//! runtime, so `run` falls back to building its own current_thread runtime
8//! (see `Rt`) instead of panicking on `Handle::current()`.
9
10use serde_json::{Map, Value};
11
12use crate::core::config::Config;
13use crate::core::gateway;
14
15const DISABLED_HINT: &str = "gateway is disabled. Enable it in ~/.lean-ctx/config.toml:\n\
16     [gateway]\n\
17     enabled = true\n\n\
18     [[gateway.servers]]\n\
19     name = \"fs\"\n\
20     transport = \"stdio\"\n\
21     command = \"mcp-server-filesystem\"\n\
22     args = [\"/path/to/dir\"]";
23
24/// Runtime adapter so the `rt.block_on(...)` call sites stay identical whether
25/// we run inside the MCP server's runtime (ambient `Handle`) or from the CLI
26/// `call` path, which has no ambient runtime and needs its own.
27enum Rt {
28    Handle(tokio::runtime::Handle),
29    Owned(tokio::runtime::Runtime),
30}
31
32impl Rt {
33    fn block_on<F: std::future::Future>(&self, fut: F) -> F::Output {
34        match self {
35            Rt::Handle(h) => h.block_on(fut),
36            Rt::Owned(r) => r.block_on(fut),
37        }
38    }
39}
40
41/// Execute a `ctx_tools` action, returning response text or an error message.
42pub fn run(args: &Map<String, Value>) -> Result<String, String> {
43    let cfg = Config::load();
44    if !cfg.gateway.enabled_effective() {
45        return Err(DISABLED_HINT.to_string());
46    }
47    if cfg.gateway.active_servers().next().is_none() {
48        return Err(
49            "gateway is enabled but no downstream servers are configured \
50             (add one or more [[gateway.servers]] entries)."
51                .to_string(),
52        );
53    }
54
55    let action = args.get("action").and_then(Value::as_str).unwrap_or("find");
56    let rt = match tokio::runtime::Handle::try_current() {
57        Ok(h) => Rt::Handle(h), // MCP path: dispatch already did block_in_place
58        Err(_) => Rt::Owned(
59            // CLI path: no ambient runtime → make a one-shot current_thread one
60            tokio::runtime::Builder::new_current_thread()
61                .enable_all()
62                .build()
63                .map_err(|e| format!("failed to start runtime for gateway: {e}"))?,
64        ),
65    };
66
67    match action {
68        "find" => {
69            let query = args
70                .get("query")
71                .and_then(Value::as_str)
72                .unwrap_or("")
73                .to_string();
74            let outcome = rt.block_on(gateway::find(&cfg.gateway, &query));
75            Ok(gateway::render_cards(&outcome))
76        }
77        "list" => Ok(rt.block_on(gateway::servers_overview(&cfg.gateway))),
78        "refresh" => {
79            gateway::catalog::invalidate();
80            let outcome = rt.block_on(gateway::find(&cfg.gateway, ""));
81            Ok(format!(
82                "gateway catalog refreshed.\n\n{}",
83                gateway::render_cards(&outcome)
84            ))
85        }
86        "call" => {
87            let tool = args.get("tool").and_then(Value::as_str).ok_or_else(|| {
88                "call requires 'tool' — a `server::tool` handle from `ctx_tools find`".to_string()
89            })?;
90            let arguments = match args.get("arguments") {
91                Some(Value::Object(m)) => m.clone(),
92                None | Some(Value::Null) => Map::new(),
93                Some(_) => return Err("'arguments' must be a JSON object".to_string()),
94            };
95            rt.block_on(gateway::proxy(&cfg.gateway, tool, arguments))
96        }
97        other => Err(format!(
98            "invalid action '{other}' (use: find, call, list, refresh)"
99        )),
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use serde_json::json;
107
108    /// With the gateway disabled (default), every action returns the enable hint
109    /// without touching the network. We assert this in a fresh runtime since
110    /// `run` resolves a runtime handle.
111    #[test]
112    fn disabled_gateway_returns_hint() {
113        // Ensure no env override flips it on.
114        crate::test_env::remove_var("LEAN_CTX_GATEWAY");
115        let rt = tokio::runtime::Builder::new_multi_thread()
116            .enable_all()
117            .build()
118            .unwrap();
119        let args = json!({ "action": "find", "query": "anything" });
120        let out =
121            rt.block_on(async { tokio::task::block_in_place(|| run(args.as_object().unwrap())) });
122        // Either disabled (no global config) — the dominant case in CI — or, if a
123        // developer machine has it enabled, we still must not panic. We only
124        // assert the message shape in the disabled/empty case.
125        if let Err(e) = out {
126            assert!(e.contains("gateway is disabled") || e.contains("no downstream"));
127        }
128    }
129
130    /// CLI-Pfad-Regression (#ctx_tools): `run` wird OHNE ambienten Tokio-Runtime
131    /// aufgerufen (wie `lean-ctx call ctx_tools …`). Früher paniced
132    /// `Handle::current()` hier ("no reactor running"); jetzt baut `run` selbst
133    /// einen current_thread-Runtime. Erwartung: Ok | Err, aber NIEMALS Panik.
134    #[test]
135    fn run_without_ambient_runtime_does_not_panic() {
136        crate::test_env::remove_var("LEAN_CTX_GATEWAY");
137        let args = json!({ "action": "list" });
138        let _ = run(args.as_object().unwrap()); // Ok|Err, niemals Panic
139    }
140}