lean_ctx/core/gateway/
mod.rs1pub mod adapters;
16pub mod catalog;
17pub mod client;
18pub mod config;
19pub mod pool;
20pub mod postprocess;
21pub mod router;
22
23pub use catalog::Catalog;
24pub use config::{GatewayConfig, GatewayServer, ResolvedTransport, TransportKind};
25pub use router::ScoredTool;
26
27use serde_json::{Map, Value};
28
29pub struct FindOutcome {
31 pub query: String,
32 pub scored: Vec<ScoredTool>,
33 pub errors: Vec<String>,
34 pub catalog_size: usize,
35 pub server_count: usize,
36}
37
38pub async fn find(cfg: &GatewayConfig, query: &str) -> FindOutcome {
40 let cat = catalog::get(cfg).await;
41 let scored = router::shortlist(&cat, query, cfg.effective_top_n());
42 FindOutcome {
43 query: query.to_string(),
44 catalog_size: cat.entries.len(),
45 server_count: cat.server_names().len(),
46 errors: cat.errors.clone(),
47 scored,
48 }
49}
50
51pub async fn proxy(
57 cfg: &GatewayConfig,
58 handle: &str,
59 arguments: Map<String, Value>,
60 project_root: &str,
61) -> Result<String, String> {
62 let (server_name, tool) = catalog::split_namespaced(handle)
63 .ok_or_else(|| format!("invalid tool handle `{handle}` (expected `server::tool`)"))?;
64 let server = cfg
65 .active_servers()
66 .find(|s| s.name == server_name)
67 .ok_or_else(|| format!("unknown or disabled gateway server `{server_name}`"))?;
68 if let Some(reason) = crate::core::addons::revocation::blocked_reason(server_name) {
70 return Err(format!(
71 "gateway server `{server_name}` is revoked and will not run: {reason}"
72 ));
73 }
74 let resolved = server.resolve()?;
75 let timeout = std::time::Duration::from_secs(cfg.call_timeout_secs.max(1));
76 let call = client::proxy_call(&resolved, tool, arguments, timeout).await;
77 let ok = matches!(&call, Ok(r) if !r.is_error.unwrap_or(false));
81 crate::core::addons::meter::record(server_name, tool, ok);
82 let result = call?;
83 let scrubbed =
86 crate::core::addons::runtime::scrub_output(server_name, &client::result_to_text(&result));
87 if result.is_error.unwrap_or(false) {
88 return Err(format!(
91 "downstream `{handle}` reported an error:\n{scrubbed}"
92 ));
93 }
94 let text = postprocess::process(cfg, server, tool, scrubbed, project_root);
98 Ok(text)
99}
100
101pub async fn servers_overview(cfg: &GatewayConfig) -> String {
103 let cat = catalog::get(cfg).await;
104 let mut out = String::new();
105 let configured: Vec<&GatewayServer> = cfg.servers.iter().collect();
106 out.push_str(&format!(
107 "gateway: {} configured server(s), {} tool(s) aggregated\n\n",
108 configured.len(),
109 cat.entries.len()
110 ));
111 for s in configured {
112 let count = cat.entries.iter().filter(|e| e.server == s.name).count();
113 let state = if s.enabled { "enabled" } else { "disabled" };
114 out.push_str(&format!(
115 "- {name} [{transport}, {state}] — {count} tool(s)\n",
116 name = s.name,
117 transport = s.transport.as_str(),
118 ));
119 }
120 if !cat.errors.is_empty() {
121 out.push_str("\nunavailable:\n");
122 for e in &cat.errors {
123 out.push_str(&format!(" ⚠ {e}\n"));
124 }
125 }
126 out
127}
128
129pub fn render_cards(outcome: &FindOutcome) -> String {
131 let mut out = String::new();
132 out.push_str(&format!(
133 "gateway: {matched} tool(s) for \"{query}\" (catalog: {total} tool(s) across {servers} server(s))\n",
134 matched = outcome.scored.len(),
135 query = outcome.query.trim(),
136 total = outcome.catalog_size,
137 servers = outcome.server_count,
138 ));
139
140 if outcome.scored.is_empty() {
141 out.push_str("\nNo matching downstream tools. Try broader terms, or `ctx_tools {\"action\":\"list\"}`.\n");
142 } else {
143 out.push('\n');
144 for (i, st) in outcome.scored.iter().enumerate() {
145 let desc = first_line(&st.entry.description);
146 out.push_str(&format!(
147 "{n}. {handle}",
148 n = i + 1,
149 handle = st.entry.namespaced
150 ));
151 if !desc.is_empty() {
152 out.push_str(&format!(" — {desc}"));
153 }
154 out.push('\n');
155 if !st.entry.params.is_empty() {
156 out.push_str(&format!(" params: {}\n", st.entry.params));
157 }
158 }
159 out.push_str(
160 "\nInvoke one with:\n ctx_tools {\"action\":\"call\",\"tool\":\"<server::tool>\",\"arguments\":{ ... }}\n",
161 );
162 }
163
164 if !outcome.errors.is_empty() {
165 out.push_str("\nunavailable:\n");
166 for e in &outcome.errors {
167 out.push_str(&format!(" ⚠ {e}\n"));
168 }
169 }
170 out
171}
172
173fn first_line(s: &str) -> String {
174 let line = s.lines().next().unwrap_or("").trim();
175 if line.len() > 100 {
176 format!("{}…", &line[..line.floor_char_boundary(100)])
177 } else {
178 line.to_string()
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185 use crate::core::gateway::catalog::CatalogEntry;
186
187 fn outcome() -> FindOutcome {
188 FindOutcome {
189 query: "commit".into(),
190 scored: vec![ScoredTool {
191 entry: CatalogEntry {
192 server: "git".into(),
193 tool: "commit".into(),
194 namespaced: "git::commit".into(),
195 description: "Create a git commit with a message".into(),
196 params: "message*, all".into(),
197 },
198 score: 3.2,
199 }],
200 errors: vec![],
201 catalog_size: 24,
202 server_count: 3,
203 }
204 }
205
206 #[test]
207 fn render_cards_includes_handle_and_params() {
208 let s = render_cards(&outcome());
209 assert!(s.contains("git::commit"));
210 assert!(s.contains("params: message*, all"));
211 assert!(s.contains("catalog: 24 tool(s) across 3 server(s)"));
212 assert!(s.contains("\"action\":\"call\""));
213 }
214
215 #[test]
216 fn render_cards_handles_empty_match() {
217 let mut o = outcome();
218 o.scored.clear();
219 let s = render_cards(&o);
220 assert!(s.contains("No matching downstream tools"));
221 }
222
223 #[test]
224 fn first_line_truncates() {
225 let long = "a".repeat(200);
226 assert!(first_line(&long).ends_with('…'));
227 assert_eq!(first_line("one\ntwo"), "one");
228 }
229}