Skip to main content

lean_ctx_server/tools/
ctx_tools.rs

1// SPDX-License-Identifier: Apache-2.0
2//! `ctx_tools` business logic (#210): the MCP Tool-Catalog meta-tool.
3//!
4//! Keeps the registered wrapper thin: this module owns config gating, action
5//! routing, and driving the async [`mcp_catalog`] from the synchronous tool
6//! handler. Every action runs on the gateway's process-lifetime runtime
7//! ([`mcp_catalog::runtime`]), from the MCP dispatch path and the CLI `call`
8//! path alike, so the downstream sessions the pool keeps stay alive between
9//! calls (#2040).
10
11use serde_json::{Map, Value};
12
13use lean_ctx::core::config::Config;
14use lean_ctx::core::mcp_catalog;
15
16const DISABLED_HINT: &str = "gateway is disabled. Enable it in ~/.lean-ctx/config.toml:\n\
17     [gateway]\n\
18     enabled = true\n\n\
19     [[gateway.servers]]\n\
20     name = \"fs\"\n\
21     transport = \"stdio\"\n\
22     command = \"mcp-server-filesystem\"\n\
23     args = [\"/path/to/dir\"]";
24
25use mcp_catalog::runtime::block_on;
26
27/// Execute a `ctx_tools` action, returning response text or an error message.
28///
29/// `project_root` is the caller's project root (from `ToolContext`); it is
30/// forwarded to [`mcp_catalog::proxy`] so output post-processing (#1095) can index
31/// downstream results into the project's stores. Empty = no project scope.
32pub fn run(args: &Map<String, Value>, project_root: &str) -> Result<String, String> {
33    let cfg = Config::load();
34    if !cfg.gateway.enabled_effective() {
35        return Err(DISABLED_HINT.to_string());
36    }
37    if cfg.gateway.active_servers().next().is_none() {
38        return Err(
39            "gateway is enabled but no downstream servers are configured \
40             (add one or more [[gateway.servers]] entries)."
41                .to_string(),
42        );
43    }
44
45    // L4: expose any compression-integration addon as a named lean-ctx
46    // compressor (once per process). No-op when none are configured.
47    mcp_catalog::adapters::compression::ensure_registered(&cfg.gateway);
48
49    let action = args.get("action").and_then(Value::as_str).unwrap_or("find");
50
51    match action {
52        "find" => {
53            let query = args
54                .get("query")
55                .and_then(Value::as_str)
56                .unwrap_or("")
57                .to_string();
58            let outcome = block_on(mcp_catalog::find(&cfg.gateway, &query))?;
59            Ok(mcp_catalog::render_cards(&outcome))
60        }
61        "list" => block_on(mcp_catalog::servers_overview(&cfg.gateway)),
62        "refresh" => {
63            mcp_catalog::catalog::invalidate();
64            let outcome = block_on(mcp_catalog::find(&cfg.gateway, ""))?;
65            Ok(format!(
66                "gateway catalog refreshed.\n\n{}",
67                mcp_catalog::render_cards(&outcome)
68            ))
69        }
70        "call" => {
71            let tool = args.get("tool").and_then(Value::as_str).ok_or_else(|| {
72                "call requires 'tool' — a `server::tool` handle from `ctx_tools find`".to_string()
73            })?;
74            let arguments = match args.get("arguments") {
75                Some(Value::Object(m)) => m.clone(),
76                None | Some(Value::Null) => Map::new(),
77                Some(_) => return Err("'arguments' must be a JSON object".to_string()),
78            };
79            // K13: no engine rule regulates a downstream server's tools.
80            lean_ctx_hive::metrics_v2::signals::mcp_relay_call(tool);
81            block_on(mcp_catalog::proxy(
82                &cfg.gateway,
83                tool,
84                arguments,
85                project_root,
86            ))?
87        }
88        other => Err(format!(
89            "invalid action '{other}' (use: find, call, list, refresh)"
90        )),
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use serde_json::json;
98
99    /// With the gateway disabled (default), every action returns the enable hint
100    /// without touching the network. We assert this in a fresh runtime since
101    /// `run` resolves a runtime handle.
102    #[test]
103    fn disabled_gateway_returns_hint() {
104        let _env_lock = lean_ctx::core::data_dir::test_env_lock();
105        // Ensure no env override flips it on.
106        lean_ctx::test_env::remove_var("LEAN_CTX_GATEWAY");
107        let rt = tokio::runtime::Builder::new_multi_thread()
108            .enable_all()
109            .build()
110            .unwrap();
111        let args = json!({ "action": "find", "query": "anything" });
112        let out = rt
113            .block_on(async { tokio::task::block_in_place(|| run(args.as_object().unwrap(), "")) });
114        // Either disabled (no global config) — the dominant case in CI — or, if a
115        // developer machine has it enabled, we still must not panic. We only
116        // assert the message shape in the disabled/empty case.
117        if let Err(e) = out {
118            assert!(e.contains("gateway is disabled") || e.contains("no downstream"));
119        }
120    }
121
122    /// CLI-Pfad-Regression (#ctx_tools): `run` wird OHNE ambienten Tokio-Runtime
123    /// aufgerufen (wie `lean-ctx call ctx_tools …`). Früher paniced
124    /// `Handle::current()` hier ("no reactor running"); jetzt läuft `run` auf der
125    /// prozessweiten Gateway-Runtime. Erwartung: Ok | Err, aber NIEMALS Panik.
126    #[test]
127    fn run_without_ambient_runtime_does_not_panic() {
128        let _env_lock = lean_ctx::core::data_dir::test_env_lock();
129        lean_ctx::test_env::remove_var("LEAN_CTX_GATEWAY");
130        let args = json!({ "action": "list" });
131        let _ = run(args.as_object().unwrap(), ""); // Ok|Err, niemals Panic
132    }
133}