lean_ctx/tools/
ctx_tools.rs1use 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
24struct 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
46pub 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 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 #[test]
121 fn disabled_gateway_returns_hint() {
122 let _env_lock = crate::core::data_dir::test_env_lock();
123 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 if let Err(e) = out {
136 assert!(e.contains("gateway is disabled") || e.contains("no downstream"));
137 }
138 }
139
140 #[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(), ""); }
151}