lean_ctx/tools/
ctx_tools.rs1use 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
24enum 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
41pub 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 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), Err(_) => Rt::Owned(
67 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 #[test]
120 fn disabled_gateway_returns_hint() {
121 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 if let Err(e) = out {
134 assert!(e.contains("gateway is disabled") || e.contains("no downstream"));
135 }
136 }
137
138 #[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(), ""); }
148}