Skip to main content

lean_ctx/core/mcp_catalog/
mod.rs

1//! MCP Tool-Catalog Federation (#210) — **Engine pillar**.
2//!
3//! Federates downstream MCP servers into lean-ctx's tool surface. Instead of injecting every downstream tool schema into the system
4//! prompt (the "more tools → less adoption" tax), the catalog:
5//!
6//! 1. aggregates the downstream catalogs ([`catalog`]) behind a TTL cache,
7//! 2. ranks them per query with BM25 ([`router`]) into a top-N **ChoiceCard**
8//!    shortlist, and
9//! 3. proxies the actual call to the owning server ([`client`]).
10//!
11//! Net effect: unlimited downstream tools at (roughly) constant context cost.
12//! Fully no-op until `[mcp_catalog] enabled = true` in config.
13
14pub 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
28/// Outcome of a `find` query: the ranked shortlist plus catalog context.
29pub 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
37/// Rank the downstream catalog against `query` and return the top-N shortlist.
38pub 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
50/// Proxy a `server::tool` call to its owning downstream server.
51///
52/// `project_root` is the caller's project root, forwarded to the output
53/// post-processor so L3 consolidation (#1095) can index the result into the
54/// project's stores. Empty disables project-scoped indexing.
55pub 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    // Kill-switch (P2): refuse to proxy a call to a revoked server.
68    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    // Per-addon usage metering (P5): attribute every proxied call to its server +
77    // tool. A transport failure or a downstream `is_error` counts as an error.
78    // Side-channel only — never touches the returned text (output determinism).
79    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    // Downstream output is untrusted content (#866): redact secrets + audit it
83    // before it enters the model context.
84    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        // Error text is surfaced verbatim (already scrubbed) — never compressed
88        // or spilled, so the failure reason stays fully legible.
89        return Err(format!(
90            "downstream `{handle}` reported an error:\n{scrubbed}"
91        ));
92    }
93    // Deeper addon integration: apply lean-ctx's own context-engineering to the
94    // downstream output (compress / spill+handle / index). No-op unless any
95    // `gateway.*_output` flag is set.
96    let text = postprocess::process(cfg, server, tool, scrubbed, project_root);
97    Ok(text)
98}
99
100/// Per-server tool counts (for `ctx_tools list`).
101pub 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
128/// Render a [`FindOutcome`] as compact ChoiceCards for the model.
129pub 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}