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