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 `[gateway] enabled = true` in config.
13
14pub mod adapters;
15mod binary_pin;
16pub mod catalog;
17pub mod client;
18pub mod config;
19pub mod memento;
20/// Browser OAuth for HTTP servers (#1391); needs the reqwest client that ships
21/// with `http-server`.
22#[cfg(feature = "http-server")]
23pub mod oauth;
24pub mod pool;
25pub mod postprocess;
26pub mod router;
27pub mod runtime;
28
29pub use catalog::Catalog;
30pub use config::{
31    GatewayConfig, GatewayServer, ResolvedTransport, SecretMementoRef, TransportKind,
32};
33pub use memento::SecretMementoStore;
34pub use router::ScoredTool;
35
36use serde_json::{Map, Value};
37
38/// Outcome of a `find` query: the ranked shortlist plus catalog context.
39pub struct FindOutcome {
40    pub query: String,
41    pub scored: Vec<ScoredTool>,
42    pub errors: Vec<String>,
43    pub catalog_size: usize,
44    pub server_count: usize,
45}
46
47/// Rank the downstream catalog against `query` and return the top-N shortlist.
48pub async fn find(cfg: &GatewayConfig, query: &str) -> FindOutcome {
49    let cat = catalog::get(cfg).await;
50    let scored = router::shortlist(&cat, query, cfg.effective_top_n());
51    FindOutcome {
52        query: query.to_string(),
53        catalog_size: cat.entries.len(),
54        server_count: cat.server_names().len(),
55        errors: cat.errors.clone(),
56        scored,
57    }
58}
59
60/// Proxy a `server::tool` call to its owning downstream server.
61///
62/// `project_root` is the caller's project root, forwarded to the output
63/// post-processor so L3 consolidation (#1095) can index the result into the
64/// project's stores. Empty disables project-scoped indexing.
65pub async fn proxy(
66    cfg: &GatewayConfig,
67    handle: &str,
68    arguments: Map<String, Value>,
69    project_root: &str,
70) -> Result<String, String> {
71    let (server_name, tool) = catalog::split_namespaced(handle)
72        .ok_or_else(|| format!("invalid tool handle `{handle}` (expected `server::tool`)"))?;
73    let server = cfg
74        .active_servers()
75        .find(|s| s.name == server_name)
76        .ok_or_else(|| format!("unknown or disabled gateway server `{server_name}`"))?;
77    let resolved = server.resolve()?;
78    let timeout = std::time::Duration::from_secs(cfg.call_timeout_secs.max(1));
79    let call = admitted_call(
80        &downstream(server_name, &resolved, tool),
81        arguments,
82        |arguments| client::proxy_call(&resolved, tool, arguments, timeout),
83    )
84    .await;
85    let source = format!("mcp://{handle}");
86    // A foreign MCP server authored this text (pz21 §1.1, `tool_output`).
87    let class = crate::core::context_admission::origin::SourceClass::External(
88        crate::core::context_admission::origin::InputOrigin::new(
89            crate::core::context_admission::origin::InputKind::ToolOutput,
90            format!("mcp:{handle}"),
91        ),
92    );
93    // Transport failures may contain remote response text, too. Admit them
94    // before returning an error rather than treating errors as trusted output.
95    let result = call.map_err(|message| {
96        let admitted =
97            crate::core::context_admission::admit_source(&message, &source, false, &class)
98                .unwrap_or_else(|error| error.to_string());
99        redact_downstream_output(server_name, &admitted)
100    })?;
101    // Admit before compression, spill or indexing, including tool error text.
102    let scrubbed = crate::core::context_admission::admit_source(
103        &client::result_to_text(&result),
104        &source,
105        false,
106        &class,
107    )
108    .map_err(|error| error.to_string())?;
109    // GHSA-gvg8-285q-m62g: the configured secret scanner as well, whatever
110    // the gateway setting, with a security event for every hit.
111    let scrubbed = redact_downstream_output(server_name, &scrubbed);
112    if result.is_error.unwrap_or(false) {
113        // Error text is surfaced verbatim (already scrubbed) — never compressed
114        // or spilled, so the failure reason stays fully legible.
115        return Err(format!(
116            "downstream `{handle}` reported an error:\n{scrubbed}"
117        ));
118    }
119    // Deeper addon integration: apply lean-ctx's own context-engineering to the
120    // downstream output (compress / spill+handle / index). No-op unless any
121    // `gateway.*_output` flag is set.
122    let text = postprocess::process(cfg, server, tool, scrubbed, project_root);
123    crate::core::context_admission::admit_source(&text, &source, false, &class)
124        .map_err(|error| error.to_string())
125}
126
127/// The egress destination of a call to `server` over `transport`.
128pub(crate) fn downstream<'a>(
129    server: &'a str,
130    transport: &'a ResolvedTransport,
131    tool: &'a str,
132) -> crate::core::context_admission::carrier::Downstream<'a> {
133    crate::core::context_admission::carrier::Downstream {
134        server,
135        endpoint: match transport {
136            ResolvedTransport::Http { url, .. } => Some(url.as_str()),
137            ResolvedTransport::Stdio { .. } => None,
138        },
139        tool,
140    }
141}
142
143/// The only way a `tools/call` leaves for a downstream server: tool name and
144/// arguments are admitted for that destination first, and `send` receives the
145/// ADMITTED arguments (a value the gateway redacts is sent redacted). A
146/// refusal is typed and content-free, and `send` is not invoked — no session
147/// is acquired, nothing is spawned or connected for a withheld argument.
148pub(crate) async fn admitted_call<Call, Pending>(
149    downstream: &crate::core::context_admission::carrier::Downstream<'_>,
150    mut arguments: Map<String, Value>,
151    send: Call,
152) -> Result<rmcp::model::CallToolResult, String>
153where
154    Call: FnOnce(Map<String, Value>) -> Pending,
155    Pending: std::future::Future<Output = Result<rmcp::model::CallToolResult, String>>,
156{
157    crate::core::context_admission::carrier::admit_downstream_arguments(downstream, &mut arguments)
158        .map_err(|withheld| withheld.message("downstream tool arguments"))?;
159    send(arguments).await
160}
161
162/// Redact secrets in downstream MCP output before it reaches the model
163/// (GHSA-gvg8-285q-m62g). Same layers as `ctx_shell` output: the role-aware
164/// redaction pass, then the configured secret scanner, with a security event
165/// for every hit. Applied to success *and* error text of [`proxy`], and to
166/// the answer of a compression addon (`adapters::compression`).
167fn redact_downstream_output(server_name: &str, text: &str) -> String {
168    let cfg = crate::core::config::Config::load();
169    redact_downstream_output_with(server_name, text, &cfg.secret_detection)
170}
171
172fn redact_downstream_output_with(
173    server_name: &str,
174    text: &str,
175    secret_cfg: &crate::core::config::SecretDetectionConfig,
176) -> String {
177    let text = crate::core::redaction::redact_text_if_enabled(text);
178    let (redacted, matches) = crate::core::secret_detection::scan_and_redact(&text, secret_cfg);
179    if !matches.is_empty() {
180        crate::core::security_events::note(
181            crate::core::security_events::SecurityKind::SecretRedacted,
182            matches.len(),
183        );
184        let names: Vec<&str> = matches.iter().map(|m| m.pattern_name).collect();
185        tracing::warn!(
186            "[GATEWAY SECRET REDACTION] {} secret(s) redacted from `{server_name}` output: {}",
187            matches.len(),
188            names.join(", ")
189        );
190    }
191    redacted
192}
193
194/// Per-server tool counts (for `ctx_tools list`).
195pub async fn servers_overview(cfg: &GatewayConfig) -> String {
196    let cat = catalog::get(cfg).await;
197    let mut out = String::new();
198    let configured: Vec<&GatewayServer> = cfg.servers.iter().collect();
199    out.push_str(&format!(
200        "gateway: {} configured server(s), {} tool(s) aggregated\n\n",
201        configured.len(),
202        cat.entries.len()
203    ));
204    for s in configured {
205        let count = cat.entries.iter().filter(|e| e.server == s.name).count();
206        let state = if s.enabled { "enabled" } else { "disabled" };
207        out.push_str(&format!(
208            "- {name} [{transport}, {state}] — {count} tool(s)\n",
209            name = s.name,
210            transport = s.transport.as_str(),
211        ));
212    }
213    if !cat.errors.is_empty() {
214        out.push_str("\nunavailable:\n");
215        for e in &cat.errors {
216            out.push_str(&format!("  ⚠ {e}\n"));
217        }
218    }
219    out
220}
221
222/// Render a [`FindOutcome`] as compact ChoiceCards for the model.
223pub fn render_cards(outcome: &FindOutcome) -> String {
224    let mut out = String::new();
225    out.push_str(&format!(
226        "gateway: {matched} tool(s) for \"{query}\" (catalog: {total} tool(s) across {servers} server(s))\n",
227        matched = outcome.scored.len(),
228        query = outcome.query.trim(),
229        total = outcome.catalog_size,
230        servers = outcome.server_count,
231    ));
232
233    if outcome.scored.is_empty() {
234        out.push_str("\nNo matching downstream tools. Try broader terms, or `ctx_tools {\"action\":\"list\"}`.\n");
235    } else {
236        out.push('\n');
237        for (i, st) in outcome.scored.iter().enumerate() {
238            let desc = first_line(&st.entry.description);
239            out.push_str(&format!(
240                "{n}. {handle}",
241                n = i + 1,
242                handle = st.entry.namespaced
243            ));
244            if !desc.is_empty() {
245                out.push_str(&format!(" — {desc}"));
246            }
247            out.push('\n');
248            if !st.entry.params.is_empty() {
249                out.push_str(&format!("   params: {}\n", st.entry.params));
250            }
251        }
252        out.push_str(
253            "\nInvoke one with:\n  ctx_tools {\"action\":\"call\",\"tool\":\"<server::tool>\",\"arguments\":{ ... }}\n",
254        );
255    }
256
257    if !outcome.errors.is_empty() {
258        out.push_str("\nunavailable:\n");
259        for e in &outcome.errors {
260            out.push_str(&format!("  ⚠ {e}\n"));
261        }
262    }
263    out
264}
265
266fn first_line(s: &str) -> String {
267    let line = s.lines().next().unwrap_or("").trim();
268    if line.len() > 100 {
269        format!("{}…", &line[..line.floor_char_boundary(100)])
270    } else {
271        line.to_string()
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use crate::core::mcp_catalog::catalog::CatalogEntry;
279
280    fn outcome() -> FindOutcome {
281        FindOutcome {
282            query: "commit".into(),
283            scored: vec![ScoredTool {
284                entry: CatalogEntry {
285                    server: "git".into(),
286                    tool: "commit".into(),
287                    namespaced: "git::commit".into(),
288                    description: "Create a git commit with a message".into(),
289                    params: "message*, all".into(),
290                },
291                score: 3.2,
292            }],
293            errors: vec![],
294            catalog_size: 24,
295            server_count: 3,
296        }
297    }
298
299    #[test]
300    fn render_cards_includes_handle_and_params() {
301        let s = render_cards(&outcome());
302        assert!(s.contains("git::commit"));
303        assert!(s.contains("params: message*, all"));
304        assert!(s.contains("catalog: 24 tool(s) across 3 server(s)"));
305        assert!(s.contains("\"action\":\"call\""));
306    }
307
308    #[test]
309    fn render_cards_handles_empty_match() {
310        let mut o = outcome();
311        o.scored.clear();
312        let s = render_cards(&o);
313        assert!(s.contains("No matching downstream tools"));
314    }
315
316    /// A downstream stand-in that records every `tools/call` it is handed.
317    fn call_recording(
318        arguments: Map<String, Value>,
319    ) -> (
320        Result<rmcp::model::CallToolResult, String>,
321        Vec<Map<String, Value>>,
322    ) {
323        let _data = crate::core::data_dir::isolated_data_dir();
324        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
325        let received = std::sync::Mutex::new(Vec::new());
326        let target = crate::core::context_admission::carrier::Downstream {
327            server: "standin",
328            endpoint: None,
329            tool: "echo",
330        };
331        let outcome = tokio::runtime::Builder::new_current_thread()
332            .build()
333            .expect("runtime")
334            .block_on(admitted_call(&target, arguments, |sent| {
335                received.lock().expect("recording").push(sent);
336                async {
337                    Ok(rmcp::model::CallToolResult::success(vec![
338                        rmcp::model::ContentBlock::text("ok"),
339                    ]))
340                }
341            }));
342        let requests = received.lock().expect("recording").clone();
343        (outcome, requests)
344    }
345
346    /// Arguments are admitted before the send. Old code: the stand-in received
347    /// every argument exactly as the caller wrote it.
348    #[test]
349    fn downstream_server_receives_only_admitted_arguments() {
350        // Split so the repository's secret scanners never see a literal key.
351        let key = concat!("AK", "IAIOSFODNN7EXAMPLE");
352
353        // Redacted (the default `secrets = "redact"`): the call proceeds and
354        // the server receives the masked value, never the credential.
355        let mut leaking = Map::new();
356        leaking.insert("note".into(), serde_json::json!({"nested": [key, "kept"]}));
357        let (outcome, requests) = call_recording(leaking);
358        assert!(outcome.is_ok(), "{outcome:?}");
359        assert_eq!(requests.len(), 1);
360        let sent = serde_json::to_string(&requests[0]).expect("sent arguments");
361        assert!(!sent.contains(key), "the credential was sent: {sent}");
362        assert!(
363            sent.contains("[REDACTED:") && sent.contains("kept"),
364            "{sent}"
365        );
366
367        // Withheld: restricted content refuses the call; nothing is sent.
368        let mut restricted = Map::new();
369        restricted.insert("doc".into(), serde_json::json!("*** TOP SECRET ***\nplan"));
370        let (outcome, requests) = call_recording(restricted);
371        let refusal = outcome.expect_err("restricted content is never sent");
372        assert!(requests.is_empty(), "zero requests for a withheld argument");
373        assert!(refusal.contains("egress.restricted_content"), "{refusal}");
374        assert!(!refusal.contains("plan"), "the refusal is content-free");
375
376        // A finding in a key cannot be redacted in place: refused as well.
377        let mut keyed = Map::new();
378        keyed.insert(key.into(), serde_json::json!(1));
379        let (outcome, requests) = call_recording(keyed);
380        let refusal = outcome.expect_err("a credential as an argument name");
381        assert!(requests.is_empty());
382        assert!(refusal.contains("egress.immutable_position"), "{refusal}");
383
384        // Allowed: sent once, byte-identical.
385        let mut clean = Map::new();
386        clean.insert("note".into(), serde_json::json!("build the release notes"));
387        let (outcome, requests) = call_recording(clean.clone());
388        assert!(outcome.is_ok(), "{outcome:?}");
389        assert_eq!(requests, [clean]);
390    }
391
392    /// GHSA-gvg8-285q-m62g: downstream output (success and `is_error` text go
393    /// through the same helper) never carries a detected secret verbatim.
394    #[test]
395    fn downstream_output_secrets_are_redacted() {
396        let cfg = crate::core::config::SecretDetectionConfig {
397            enabled: true,
398            redact: true,
399            custom_patterns: Vec::new(),
400            exclude_patterns: Vec::new(),
401        };
402        let secret = concat!("AK", "IAIOSFODNN7EXAMPLE");
403        let error_text = format!("permission denied for aws_key = {secret} (retry later)");
404        let out = redact_downstream_output_with("srv", &error_text, &cfg);
405        assert!(!out.contains(secret), "secret leaked: {out}");
406        assert!(
407            out.contains("retry later"),
408            "redaction must be surgical: {out}"
409        );
410    }
411
412    #[test]
413    fn first_line_truncates() {
414        let long = "a".repeat(200);
415        assert!(first_line(&long).ends_with('…'));
416        assert_eq!(first_line("one\ntwo"), "one");
417    }
418}