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 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
32/// Outcome of a `find` query: the ranked shortlist plus catalog context.
33pub 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
41/// Rank the downstream catalog against `query` and return the top-N shortlist.
42pub 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
54/// Proxy a `server::tool` call to its owning downstream server.
55///
56/// `project_root` is the caller's project root, forwarded to the output
57/// post-processor so L3 consolidation (#1095) can index the result into the
58/// project's stores. Empty disables project-scoped indexing.
59pub 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    // Kill-switch (P2): refuse to proxy a call to a revoked server.
72    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    // Per-addon usage metering (P5): attribute every proxied call to its server +
86    // tool. A transport failure or a downstream `is_error` counts as an error.
87    // Side-channel only — never touches the returned text (output determinism).
88    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    // Downstream output is untrusted content (#866): redact secrets + audit it
92    // before it enters the model context.
93    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        // Error text is surfaced verbatim (already scrubbed) — never compressed
97        // or spilled, so the failure reason stays fully legible.
98        return Err(format!(
99            "downstream `{handle}` reported an error:\n{scrubbed}"
100        ));
101    }
102    // Deeper addon integration: apply lean-ctx's own context-engineering to the
103    // downstream output (compress / spill+handle / index). No-op unless any
104    // `gateway.*_output` flag is set.
105    let text = postprocess::process(cfg, server, tool, scrubbed, project_root);
106    Ok(text)
107}
108
109/// Per-server tool counts (for `ctx_tools list`).
110pub 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
137/// Render a [`FindOutcome`] as compact ChoiceCards for the model.
138pub 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}