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