1pub mod adapters;
15mod binary_pin;
16pub mod catalog;
17pub mod client;
18pub mod config;
19pub mod memento;
20#[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
38pub 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
47pub 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
60pub 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 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 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 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 let scrubbed = redact_downstream_output(server_name, &scrubbed);
112 if result.is_error.unwrap_or(false) {
113 return Err(format!(
116 "downstream `{handle}` reported an error:\n{scrubbed}"
117 ));
118 }
119 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
127pub(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
143pub(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
162fn 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
194pub 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
222pub 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 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 #[test]
349 fn downstream_server_receives_only_admitted_arguments() {
350 let key = concat!("AK", "IAIOSFODNN7EXAMPLE");
352
353 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 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 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 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 #[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}