Skip to main content

lean_ctx/tools/
ctx_tools.rs

1//! `ctx_tools` business logic (#210): the MCP Tool-Catalog meta-tool.
2//!
3//! Keeps the registered wrapper thin: this module owns config gating, action
4//! routing, and driving the async [`mcp_catalog`] 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::mcp_catalog;
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/// Dedicated single-threaded runtime for gateway async calls.
26///
27/// Tool handlers run inside `spawn_blocking` (#1018) where `Handle::block_on`
28/// panics. Always create a fresh current_thread runtime — it is independent of
29/// the server's multi-thread runtime and safe from any calling context.
30struct Rt(tokio::runtime::Runtime);
31
32impl Rt {
33    fn new() -> Result<Self, String> {
34        tokio::runtime::Builder::new_current_thread()
35            .enable_all()
36            .build()
37            .map(Self)
38            .map_err(|e| format!("failed to start runtime for gateway: {e}"))
39    }
40
41    fn block_on<F: std::future::Future>(&self, fut: F) -> F::Output {
42        self.0.block_on(fut)
43    }
44}
45
46/// Execute a `ctx_tools` action, returning response text or an error message.
47///
48/// `project_root` is the caller's project root (from `ToolContext`); it is
49/// forwarded to [`mcp_catalog::proxy`] so output post-processing (#1095) can index
50/// downstream results into the project's stores. Empty = no project scope.
51pub fn run(args: &Map<String, Value>, project_root: &str) -> Result<String, String> {
52    let cfg = Config::load();
53    if !cfg.gateway.enabled_effective() {
54        return Err(DISABLED_HINT.to_string());
55    }
56    if cfg.gateway.active_servers().next().is_none() {
57        return Err(
58            "gateway is enabled but no downstream servers are configured \
59             (add one or more [[gateway.servers]] entries)."
60                .to_string(),
61        );
62    }
63
64    // L4: expose any compression-integration addon as a named lean-ctx
65    // compressor (once per process). No-op when none are configured.
66    mcp_catalog::adapters::compression::ensure_registered(&cfg.gateway);
67
68    let action = args.get("action").and_then(Value::as_str).unwrap_or("find");
69    let rt = Rt::new()?;
70
71    match action {
72        "find" => {
73            let query = args
74                .get("query")
75                .and_then(Value::as_str)
76                .unwrap_or("")
77                .to_string();
78            let outcome = rt.block_on(mcp_catalog::find(&cfg.gateway, &query));
79            Ok(mcp_catalog::render_cards(&outcome))
80        }
81        "list" => Ok(rt.block_on(mcp_catalog::servers_overview(&cfg.gateway))),
82        "refresh" => {
83            mcp_catalog::catalog::invalidate();
84            let outcome = rt.block_on(mcp_catalog::find(&cfg.gateway, ""));
85            Ok(format!(
86                "gateway catalog refreshed.\n\n{}",
87                mcp_catalog::render_cards(&outcome)
88            ))
89        }
90        "call" => {
91            let tool = args.get("tool").and_then(Value::as_str).ok_or_else(|| {
92                "call requires 'tool' — a `server::tool` handle from `ctx_tools find`".to_string()
93            })?;
94            let arguments = match args.get("arguments") {
95                Some(Value::Object(m)) => m.clone(),
96                None | Some(Value::Null) => Map::new(),
97                Some(_) => return Err("'arguments' must be a JSON object".to_string()),
98            };
99            rt.block_on(mcp_catalog::proxy(
100                &cfg.gateway,
101                tool,
102                arguments,
103                project_root,
104            ))
105        }
106        other => Err(format!(
107            "invalid action '{other}' (use: find, call, list, refresh)"
108        )),
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use serde_json::json;
116
117    /// With the gateway disabled (default), every action returns the enable hint
118    /// without touching the network. We assert this in a fresh runtime since
119    /// `run` resolves a runtime handle.
120    #[test]
121    fn disabled_gateway_returns_hint() {
122        let _env_lock = crate::core::data_dir::test_env_lock();
123        // Ensure no env override flips it on.
124        crate::test_env::remove_var("LEAN_CTX_GATEWAY");
125        let rt = tokio::runtime::Builder::new_multi_thread()
126            .enable_all()
127            .build()
128            .unwrap();
129        let args = json!({ "action": "find", "query": "anything" });
130        let out = rt
131            .block_on(async { tokio::task::block_in_place(|| run(args.as_object().unwrap(), "")) });
132        // Either disabled (no global config) — the dominant case in CI — or, if a
133        // developer machine has it enabled, we still must not panic. We only
134        // assert the message shape in the disabled/empty case.
135        if let Err(e) = out {
136            assert!(e.contains("gateway is disabled") || e.contains("no downstream"));
137        }
138    }
139
140    /// CLI-Pfad-Regression (#ctx_tools): `run` wird OHNE ambienten Tokio-Runtime
141    /// aufgerufen (wie `lean-ctx call ctx_tools …`). Früher paniced
142    /// `Handle::current()` hier ("no reactor running"); jetzt baut `run` selbst
143    /// einen current_thread-Runtime. Erwartung: Ok | Err, aber NIEMALS Panik.
144    #[test]
145    fn run_without_ambient_runtime_does_not_panic() {
146        let _env_lock = crate::core::data_dir::test_env_lock();
147        crate::test_env::remove_var("LEAN_CTX_GATEWAY");
148        let args = json!({ "action": "list" });
149        let _ = run(args.as_object().unwrap(), ""); // Ok|Err, niemals Panic
150    }
151}