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