Skip to main content

lean_ctx/core/gateway/
mod.rs

1//! MCP Tool-Catalog Gateway (#210).
2//!
3//! Turns lean-ctx into a gateway in front of any number of downstream MCP
4//! servers. Instead of injecting every downstream tool schema into the system
5//! prompt (the "more tools → less adoption" tax), the gateway:
6//!
7//! 1. aggregates the downstream catalogs ([`catalog`]) behind a TTL cache,
8//! 2. ranks them per query with BM25 ([`router`]) into a top-N **ChoiceCard**
9//!    shortlist, and
10//! 3. proxies the actual call to the owning server ([`client`]).
11//!
12//! Net effect: unlimited downstream tools at (roughly) constant context cost.
13//! Fully no-op until `gateway.enabled = true`.
14
15pub mod adapters;
16pub mod catalog;
17pub mod client;
18pub mod config;
19pub mod pool;
20pub mod postprocess;
21pub mod router;
22
23pub use catalog::Catalog;
24pub use config::{GatewayConfig, GatewayServer, ResolvedTransport, TransportKind};
25pub use router::ScoredTool;
26
27use serde_json::{Map, Value};
28
29/// Outcome of a `find` query: the ranked shortlist plus catalog context.
30pub struct FindOutcome {
31    pub query: String,
32    pub scored: Vec<ScoredTool>,
33    pub errors: Vec<String>,
34    pub catalog_size: usize,
35    pub server_count: usize,
36}
37
38/// Rank the downstream catalog against `query` and return the top-N shortlist.
39pub async fn find(cfg: &GatewayConfig, query: &str) -> FindOutcome {
40    let cat = catalog::get(cfg).await;
41    let scored = router::shortlist(&cat, query, cfg.effective_top_n());
42    FindOutcome {
43        query: query.to_string(),
44        catalog_size: cat.entries.len(),
45        server_count: cat.server_names().len(),
46        errors: cat.errors.clone(),
47        scored,
48    }
49}
50
51/// Proxy a `server::tool` call to its owning downstream server.
52///
53/// `project_root` is the caller's project root, forwarded to the output
54/// post-processor so L3 consolidation (#1095) can index the result into the
55/// project's stores. Empty disables project-scoped indexing.
56pub async fn proxy(
57    cfg: &GatewayConfig,
58    handle: &str,
59    arguments: Map<String, Value>,
60    project_root: &str,
61) -> Result<String, String> {
62    let (server_name, tool) = catalog::split_namespaced(handle)
63        .ok_or_else(|| format!("invalid tool handle `{handle}` (expected `server::tool`)"))?;
64    let server = cfg
65        .active_servers()
66        .find(|s| s.name == server_name)
67        .ok_or_else(|| format!("unknown or disabled gateway server `{server_name}`"))?;
68    // Kill-switch (P2): refuse to proxy a call to a revoked server.
69    if let Some(reason) = crate::core::addons::revocation::blocked_reason(server_name) {
70        return Err(format!(
71            "gateway server `{server_name}` is revoked and will not run: {reason}"
72        ));
73    }
74    let resolved = server.resolve()?;
75    let timeout = std::time::Duration::from_secs(cfg.call_timeout_secs.max(1));
76    let call = client::proxy_call(&resolved, tool, arguments, timeout).await;
77    // Per-addon usage metering (P5): attribute every proxied call to its server +
78    // tool. A transport failure or a downstream `is_error` counts as an error.
79    // Side-channel only — never touches the returned text (output determinism).
80    let ok = matches!(&call, Ok(r) if !r.is_error.unwrap_or(false));
81    crate::core::addons::meter::record(server_name, tool, ok);
82    let result = call?;
83    // Downstream output is untrusted content (#866): redact secrets + audit it
84    // before it enters the model context.
85    let scrubbed =
86        crate::core::addons::runtime::scrub_output(server_name, &client::result_to_text(&result));
87    if result.is_error.unwrap_or(false) {
88        // Error text is surfaced verbatim (already scrubbed) — never compressed
89        // or spilled, so the failure reason stays fully legible.
90        return Err(format!(
91            "downstream `{handle}` reported an error:\n{scrubbed}"
92        ));
93    }
94    // Deeper addon integration: apply lean-ctx's own context-engineering to the
95    // downstream output (compress / spill+handle / index). No-op unless any
96    // `gateway.*_output` flag is set.
97    let text = postprocess::process(cfg, server, tool, scrubbed, project_root);
98    Ok(text)
99}
100
101/// Per-server tool counts (for `ctx_tools list`).
102pub async fn servers_overview(cfg: &GatewayConfig) -> String {
103    let cat = catalog::get(cfg).await;
104    let mut out = String::new();
105    let configured: Vec<&GatewayServer> = cfg.servers.iter().collect();
106    out.push_str(&format!(
107        "gateway: {} configured server(s), {} tool(s) aggregated\n\n",
108        configured.len(),
109        cat.entries.len()
110    ));
111    for s in configured {
112        let count = cat.entries.iter().filter(|e| e.server == s.name).count();
113        let state = if s.enabled { "enabled" } else { "disabled" };
114        out.push_str(&format!(
115            "- {name} [{transport}, {state}] — {count} tool(s)\n",
116            name = s.name,
117            transport = s.transport.as_str(),
118        ));
119    }
120    if !cat.errors.is_empty() {
121        out.push_str("\nunavailable:\n");
122        for e in &cat.errors {
123            out.push_str(&format!("  ⚠ {e}\n"));
124        }
125    }
126    out
127}
128
129/// Render a [`FindOutcome`] as compact ChoiceCards for the model.
130pub fn render_cards(outcome: &FindOutcome) -> String {
131    let mut out = String::new();
132    out.push_str(&format!(
133        "gateway: {matched} tool(s) for \"{query}\" (catalog: {total} tool(s) across {servers} server(s))\n",
134        matched = outcome.scored.len(),
135        query = outcome.query.trim(),
136        total = outcome.catalog_size,
137        servers = outcome.server_count,
138    ));
139
140    if outcome.scored.is_empty() {
141        out.push_str("\nNo matching downstream tools. Try broader terms, or `ctx_tools {\"action\":\"list\"}`.\n");
142    } else {
143        out.push('\n');
144        for (i, st) in outcome.scored.iter().enumerate() {
145            let desc = first_line(&st.entry.description);
146            out.push_str(&format!(
147                "{n}. {handle}",
148                n = i + 1,
149                handle = st.entry.namespaced
150            ));
151            if !desc.is_empty() {
152                out.push_str(&format!(" — {desc}"));
153            }
154            out.push('\n');
155            if !st.entry.params.is_empty() {
156                out.push_str(&format!("   params: {}\n", st.entry.params));
157            }
158        }
159        out.push_str(
160            "\nInvoke one with:\n  ctx_tools {\"action\":\"call\",\"tool\":\"<server::tool>\",\"arguments\":{ ... }}\n",
161        );
162    }
163
164    if !outcome.errors.is_empty() {
165        out.push_str("\nunavailable:\n");
166        for e in &outcome.errors {
167            out.push_str(&format!("  ⚠ {e}\n"));
168        }
169    }
170    out
171}
172
173fn first_line(s: &str) -> String {
174    let line = s.lines().next().unwrap_or("").trim();
175    if line.len() > 100 {
176        format!("{}…", &line[..line.floor_char_boundary(100)])
177    } else {
178        line.to_string()
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::core::gateway::catalog::CatalogEntry;
186
187    fn outcome() -> FindOutcome {
188        FindOutcome {
189            query: "commit".into(),
190            scored: vec![ScoredTool {
191                entry: CatalogEntry {
192                    server: "git".into(),
193                    tool: "commit".into(),
194                    namespaced: "git::commit".into(),
195                    description: "Create a git commit with a message".into(),
196                    params: "message*, all".into(),
197                },
198                score: 3.2,
199            }],
200            errors: vec![],
201            catalog_size: 24,
202            server_count: 3,
203        }
204    }
205
206    #[test]
207    fn render_cards_includes_handle_and_params() {
208        let s = render_cards(&outcome());
209        assert!(s.contains("git::commit"));
210        assert!(s.contains("params: message*, all"));
211        assert!(s.contains("catalog: 24 tool(s) across 3 server(s)"));
212        assert!(s.contains("\"action\":\"call\""));
213    }
214
215    #[test]
216    fn render_cards_handles_empty_match() {
217        let mut o = outcome();
218        o.scored.clear();
219        let s = render_cards(&o);
220        assert!(s.contains("No matching downstream tools"));
221    }
222
223    #[test]
224    fn first_line_truncates() {
225        let long = "a".repeat(200);
226        assert!(first_line(&long).ends_with('…'));
227        assert_eq!(first_line("one\ntwo"), "one");
228    }
229}