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.
42///
43/// `project_root` is the caller's project root (from `ToolContext`); it is
44/// forwarded to [`gateway::proxy`] so output post-processing (#1095) can index
45/// downstream results into the project's stores. Empty = no project scope.
46pub fn run(args: &Map<String, Value>, project_root: &str) -> Result<String, String> {
47    let cfg = Config::load();
48    if !cfg.gateway.enabled_effective() {
49        return Err(DISABLED_HINT.to_string());
50    }
51    if cfg.gateway.active_servers().next().is_none() {
52        return Err(
53            "gateway is enabled but no downstream servers are configured \
54             (add one or more [[gateway.servers]] entries)."
55                .to_string(),
56        );
57    }
58
59    // L4: expose any compression-integration addon as a named lean-ctx
60    // compressor (once per process). No-op when none are configured.
61    gateway::adapters::compression::ensure_registered(&cfg.gateway);
62
63    let action = args.get("action").and_then(Value::as_str).unwrap_or("find");
64    let rt = match tokio::runtime::Handle::try_current() {
65        Ok(h) => Rt::Handle(h), // MCP path: dispatch already did block_in_place
66        Err(_) => Rt::Owned(
67            // CLI path: no ambient runtime → make a one-shot current_thread one
68            tokio::runtime::Builder::new_current_thread()
69                .enable_all()
70                .build()
71                .map_err(|e| format!("failed to start runtime for gateway: {e}"))?,
72        ),
73    };
74
75    match action {
76        "find" => {
77            let query = args
78                .get("query")
79                .and_then(Value::as_str)
80                .unwrap_or("")
81                .to_string();
82            let outcome = rt.block_on(gateway::find(&cfg.gateway, &query));
83            Ok(gateway::render_cards(&outcome))
84        }
85        "list" => Ok(rt.block_on(gateway::servers_overview(&cfg.gateway))),
86        "refresh" => {
87            gateway::catalog::invalidate();
88            let outcome = rt.block_on(gateway::find(&cfg.gateway, ""));
89            Ok(format!(
90                "gateway catalog refreshed.\n\n{}",
91                gateway::render_cards(&outcome)
92            ))
93        }
94        "call" => {
95            let tool = args.get("tool").and_then(Value::as_str).ok_or_else(|| {
96                "call requires 'tool' — a `server::tool` handle from `ctx_tools find`".to_string()
97            })?;
98            let arguments = match args.get("arguments") {
99                Some(Value::Object(m)) => m.clone(),
100                None | Some(Value::Null) => Map::new(),
101                Some(_) => return Err("'arguments' must be a JSON object".to_string()),
102            };
103            rt.block_on(gateway::proxy(&cfg.gateway, tool, arguments, project_root))
104        }
105        other => Err(format!(
106            "invalid action '{other}' (use: find, call, list, refresh)"
107        )),
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use serde_json::json;
115
116    /// With the gateway disabled (default), every action returns the enable hint
117    /// without touching the network. We assert this in a fresh runtime since
118    /// `run` resolves a runtime handle.
119    #[test]
120    fn disabled_gateway_returns_hint() {
121        // Ensure no env override flips it on.
122        crate::test_env::remove_var("LEAN_CTX_GATEWAY");
123        let rt = tokio::runtime::Builder::new_multi_thread()
124            .enable_all()
125            .build()
126            .unwrap();
127        let args = json!({ "action": "find", "query": "anything" });
128        let out = rt
129            .block_on(async { tokio::task::block_in_place(|| run(args.as_object().unwrap(), "")) });
130        // Either disabled (no global config) — the dominant case in CI — or, if a
131        // developer machine has it enabled, we still must not panic. We only
132        // assert the message shape in the disabled/empty case.
133        if let Err(e) = out {
134            assert!(e.contains("gateway is disabled") || e.contains("no downstream"));
135        }
136    }
137
138    /// CLI-Pfad-Regression (#ctx_tools): `run` wird OHNE ambienten Tokio-Runtime
139    /// aufgerufen (wie `lean-ctx call ctx_tools …`). Früher paniced
140    /// `Handle::current()` hier ("no reactor running"); jetzt baut `run` selbst
141    /// einen current_thread-Runtime. Erwartung: Ok | Err, aber NIEMALS Panik.
142    #[test]
143    fn run_without_ambient_runtime_does_not_panic() {
144        crate::test_env::remove_var("LEAN_CTX_GATEWAY");
145        let args = json!({ "action": "list" });
146        let _ = run(args.as_object().unwrap(), ""); // Ok|Err, niemals Panic
147    }
148}