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 let resolved = server.resolve()?;
61 let timeout = std::time::Duration::from_secs(cfg.call_timeout_secs.max(1));
62 let result = client::proxy_call(&resolved, tool, arguments, timeout).await?;
63 let text = client::result_to_text(&result);
64 if result.is_error.unwrap_or(false) {
65 return Err(format!("downstream `{handle}` reported an error:\n{text}"));
66 }
67 Ok(text)
68}
69
70pub async fn servers_overview(cfg: &GatewayConfig) -> String {
72 let cat = catalog::get(cfg).await;
73 let mut out = String::new();
74 let configured: Vec<&GatewayServer> = cfg.servers.iter().collect();
75 out.push_str(&format!(
76 "gateway: {} configured server(s), {} tool(s) aggregated\n\n",
77 configured.len(),
78 cat.entries.len()
79 ));
80 for s in configured {
81 let count = cat.entries.iter().filter(|e| e.server == s.name).count();
82 let state = if s.enabled { "enabled" } else { "disabled" };
83 out.push_str(&format!(
84 "- {name} [{transport}, {state}] — {count} tool(s)\n",
85 name = s.name,
86 transport = s.transport.as_str(),
87 ));
88 }
89 if !cat.errors.is_empty() {
90 out.push_str("\nunavailable:\n");
91 for e in &cat.errors {
92 out.push_str(&format!(" ⚠ {e}\n"));
93 }
94 }
95 out
96}
97
98pub fn render_cards(outcome: &FindOutcome) -> String {
100 let mut out = String::new();
101 out.push_str(&format!(
102 "gateway: {matched} tool(s) for \"{query}\" (catalog: {total} tool(s) across {servers} server(s))\n",
103 matched = outcome.scored.len(),
104 query = outcome.query.trim(),
105 total = outcome.catalog_size,
106 servers = outcome.server_count,
107 ));
108
109 if outcome.scored.is_empty() {
110 out.push_str("\nNo matching downstream tools. Try broader terms, or `ctx_tools {\"action\":\"list\"}`.\n");
111 } else {
112 out.push('\n');
113 for (i, st) in outcome.scored.iter().enumerate() {
114 let desc = first_line(&st.entry.description);
115 out.push_str(&format!(
116 "{n}. {handle}",
117 n = i + 1,
118 handle = st.entry.namespaced
119 ));
120 if !desc.is_empty() {
121 out.push_str(&format!(" — {desc}"));
122 }
123 out.push('\n');
124 if !st.entry.params.is_empty() {
125 out.push_str(&format!(" params: {}\n", st.entry.params));
126 }
127 }
128 out.push_str(
129 "\nInvoke one with:\n ctx_tools {\"action\":\"call\",\"tool\":\"<server::tool>\",\"arguments\":{ ... }}\n",
130 );
131 }
132
133 if !outcome.errors.is_empty() {
134 out.push_str("\nunavailable:\n");
135 for e in &outcome.errors {
136 out.push_str(&format!(" ⚠ {e}\n"));
137 }
138 }
139 out
140}
141
142fn first_line(s: &str) -> String {
143 let line = s.lines().next().unwrap_or("").trim();
144 if line.len() > 100 {
145 format!("{}…", &line[..line.floor_char_boundary(100)])
146 } else {
147 line.to_string()
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use crate::core::gateway::catalog::CatalogEntry;
155
156 fn outcome() -> FindOutcome {
157 FindOutcome {
158 query: "commit".into(),
159 scored: vec![ScoredTool {
160 entry: CatalogEntry {
161 server: "git".into(),
162 tool: "commit".into(),
163 namespaced: "git::commit".into(),
164 description: "Create a git commit with a message".into(),
165 params: "message*, all".into(),
166 },
167 score: 3.2,
168 }],
169 errors: vec![],
170 catalog_size: 24,
171 server_count: 3,
172 }
173 }
174
175 #[test]
176 fn render_cards_includes_handle_and_params() {
177 let s = render_cards(&outcome());
178 assert!(s.contains("git::commit"));
179 assert!(s.contains("params: message*, all"));
180 assert!(s.contains("catalog: 24 tool(s) across 3 server(s)"));
181 assert!(s.contains("\"action\":\"call\""));
182 }
183
184 #[test]
185 fn render_cards_handles_empty_match() {
186 let mut o = outcome();
187 o.scored.clear();
188 let s = render_cards(&o);
189 assert!(s.contains("No matching downstream tools"));
190 }
191
192 #[test]
193 fn first_line_truncates() {
194 let long = "a".repeat(200);
195 assert!(first_line(&long).ends_with('…'));
196 assert_eq!(first_line("one\ntwo"), "one");
197 }
198}