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