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    let resolved = server.resolve()?;
78    let timeout = std::time::Duration::from_secs(cfg.call_timeout_secs.max(1));
79    let call = client::proxy_call(&resolved, tool, arguments, timeout).await;
80    // Per-addon usage metering (P5): attribute every proxied call to its server +
81    // tool. A transport failure or a downstream `is_error` counts as an error.
82    // Side-channel only — never touches the returned text (output determinism).
83    let ok = matches!(&call, Ok(r) if !r.is_error.unwrap_or(false));
84    crate::core::addons::meter::record(server_name, tool, ok);
85    let result = call?;
86    // Downstream output is untrusted content (#866): redact secrets + audit it
87    // before it enters the model context.
88    let scrubbed =
89        crate::core::addons::runtime::scrub_output(server_name, &client::result_to_text(&result));
90    if result.is_error.unwrap_or(false) {
91        // Error text is surfaced verbatim (already scrubbed) — never compressed
92        // or spilled, so the failure reason stays fully legible.
93        return Err(format!(
94            "downstream `{handle}` reported an error:\n{scrubbed}"
95        ));
96    }
97    // Deeper addon integration: apply lean-ctx's own context-engineering to the
98    // downstream output (compress / spill+handle / index). No-op unless any
99    // `gateway.*_output` flag is set.
100    let text = postprocess::process(cfg, server, tool, scrubbed, project_root);
101    Ok(text)
102}
103
104/// Per-server tool counts (for `ctx_tools list`).
105pub async fn servers_overview(cfg: &GatewayConfig) -> String {
106    let cat = catalog::get(cfg).await;
107    let mut out = String::new();
108    let configured: Vec<&GatewayServer> = cfg.servers.iter().collect();
109    out.push_str(&format!(
110        "gateway: {} configured server(s), {} tool(s) aggregated\n\n",
111        configured.len(),
112        cat.entries.len()
113    ));
114    for s in configured {
115        let count = cat.entries.iter().filter(|e| e.server == s.name).count();
116        let state = if s.enabled { "enabled" } else { "disabled" };
117        out.push_str(&format!(
118            "- {name} [{transport}, {state}] — {count} tool(s)\n",
119            name = s.name,
120            transport = s.transport.as_str(),
121        ));
122    }
123    if !cat.errors.is_empty() {
124        out.push_str("\nunavailable:\n");
125        for e in &cat.errors {
126            out.push_str(&format!("  ⚠ {e}\n"));
127        }
128    }
129    out
130}
131
132/// Render a [`FindOutcome`] as compact ChoiceCards for the model.
133pub fn render_cards(outcome: &FindOutcome) -> String {
134    let mut out = String::new();
135    out.push_str(&format!(
136        "gateway: {matched} tool(s) for \"{query}\" (catalog: {total} tool(s) across {servers} server(s))\n",
137        matched = outcome.scored.len(),
138        query = outcome.query.trim(),
139        total = outcome.catalog_size,
140        servers = outcome.server_count,
141    ));
142
143    if outcome.scored.is_empty() {
144        out.push_str("\nNo matching downstream tools. Try broader terms, or `ctx_tools {\"action\":\"list\"}`.\n");
145    } else {
146        out.push('\n');
147        for (i, st) in outcome.scored.iter().enumerate() {
148            let desc = first_line(&st.entry.description);
149            out.push_str(&format!(
150                "{n}. {handle}",
151                n = i + 1,
152                handle = st.entry.namespaced
153            ));
154            if !desc.is_empty() {
155                out.push_str(&format!(" — {desc}"));
156            }
157            out.push('\n');
158            if !st.entry.params.is_empty() {
159                out.push_str(&format!("   params: {}\n", st.entry.params));
160            }
161        }
162        out.push_str(
163            "\nInvoke one with:\n  ctx_tools {\"action\":\"call\",\"tool\":\"<server::tool>\",\"arguments\":{ ... }}\n",
164        );
165    }
166
167    if !outcome.errors.is_empty() {
168        out.push_str("\nunavailable:\n");
169        for e in &outcome.errors {
170            out.push_str(&format!("  ⚠ {e}\n"));
171        }
172    }
173    out
174}
175
176fn first_line(s: &str) -> String {
177    let line = s.lines().next().unwrap_or("").trim();
178    if line.len() > 100 {
179        format!("{}…", &line[..line.floor_char_boundary(100)])
180    } else {
181        line.to_string()
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use crate::core::mcp_catalog::catalog::CatalogEntry;
189
190    fn outcome() -> FindOutcome {
191        FindOutcome {
192            query: "commit".into(),
193            scored: vec![ScoredTool {
194                entry: CatalogEntry {
195                    server: "git".into(),
196                    tool: "commit".into(),
197                    namespaced: "git::commit".into(),
198                    description: "Create a git commit with a message".into(),
199                    params: "message*, all".into(),
200                },
201                score: 3.2,
202            }],
203            errors: vec![],
204            catalog_size: 24,
205            server_count: 3,
206        }
207    }
208
209    #[test]
210    fn render_cards_includes_handle_and_params() {
211        let s = render_cards(&outcome());
212        assert!(s.contains("git::commit"));
213        assert!(s.contains("params: message*, all"));
214        assert!(s.contains("catalog: 24 tool(s) across 3 server(s)"));
215        assert!(s.contains("\"action\":\"call\""));
216    }
217
218    #[test]
219    fn render_cards_handles_empty_match() {
220        let mut o = outcome();
221        o.scored.clear();
222        let s = render_cards(&o);
223        assert!(s.contains("No matching downstream tools"));
224    }
225
226    #[test]
227    fn first_line_truncates() {
228        let long = "a".repeat(200);
229        assert!(first_line(&long).ends_with('…'));
230        assert_eq!(first_line("one\ntwo"), "one");
231    }
232}