1use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10use std::fmt;
11
12use crate::core::addons::capabilities::AddonCapabilities;
13use crate::core::mcp_catalog::memento::{SecretMementoStore, fingerprint};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Hash)]
17#[serde(rename_all = "snake_case")]
18pub enum TransportKind {
19 #[default]
21 Stdio,
22 Http,
24}
25
26impl TransportKind {
27 pub fn as_str(self) -> &'static str {
28 match self {
29 TransportKind::Stdio => "stdio",
30 TransportKind::Http => "http",
31 }
32 }
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct SecretMementoRef {
38 pub id: String,
40 #[serde(default, skip_serializing_if = "String::is_empty")]
42 pub format: String,
43}
44
45impl SecretMementoRef {
46 fn restore(&self, field: &str) -> Result<(String, String), String> {
47 if self.id.trim().is_empty() {
48 return Err(format!("secret memento for `{field}` has an empty `id`"));
49 }
50 if !self.format.is_empty() && !self.format.contains("{secret}") {
51 return Err(format!(
52 "secret memento `{}` for `{field}` has a format without `{{secret}}`",
53 self.id
54 ));
55 }
56 let secret = SecretMementoStore::global()
57 .restore(&self.id)
58 .ok_or_else(|| format!("missing secret memento `{}` for `{field}`", self.id))?;
59 let value = if self.format.is_empty() {
60 secret
61 } else {
62 self.format.replace("{secret}", &secret)
63 };
64 let fingerprint = fingerprint(&value);
65 Ok((value, fingerprint))
66 }
67}
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(default)]
75pub struct GatewayServer {
76 pub name: String,
78 pub transport: TransportKind,
80 pub enabled: bool,
82
83 pub command: String,
86 pub args: Vec<String>,
88 pub env: BTreeMap<String, String>,
90 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
92 pub secret_env: BTreeMap<String, SecretMementoRef>,
93 #[serde(default, skip_serializing_if = "String::is_empty")]
99 pub binary_sha256: String,
100
101 pub url: String,
104 pub headers: BTreeMap<String, String>,
106 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
108 pub secret_headers: BTreeMap<String, SecretMementoRef>,
109
110 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub capabilities: Option<AddonCapabilities>,
117
118 #[serde(default, skip_serializing_if = "String::is_empty")]
124 pub integration: String,
125}
126
127impl Default for GatewayServer {
128 fn default() -> Self {
129 Self {
130 name: String::new(),
131 transport: TransportKind::Stdio,
132 enabled: true,
133 command: String::new(),
134 args: Vec::new(),
135 env: BTreeMap::new(),
136 secret_env: BTreeMap::new(),
137 binary_sha256: String::new(),
138 url: String::new(),
139 headers: BTreeMap::new(),
140 secret_headers: BTreeMap::new(),
141 capabilities: None,
142 integration: String::new(),
143 }
144 }
145}
146
147#[derive(Clone, PartialEq, Eq)]
149pub enum ResolvedTransport {
150 Stdio {
151 command: String,
152 args: Vec<String>,
153 env: BTreeMap<String, String>,
154 binary_sha256: String,
156 capabilities: Option<AddonCapabilities>,
158 },
159 Http {
160 url: String,
161 headers: BTreeMap<String, String>,
162 secret_fingerprints: BTreeMap<String, String>,
163 },
164}
165
166impl fmt::Debug for ResolvedTransport {
167 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
168 match self {
169 Self::Stdio {
170 command,
171 args,
172 env,
173 binary_sha256,
174 capabilities,
175 } => formatter
176 .debug_struct("Stdio")
177 .field("command", command)
178 .field("args", args)
179 .field("env", &redacted_stdio_env(env))
180 .field("binary_sha256", binary_sha256)
181 .field("capabilities", capabilities)
182 .finish(),
183 Self::Http {
184 url,
185 headers,
186 secret_fingerprints,
187 } => formatter
188 .debug_struct("Http")
189 .field("url", url)
190 .field("headers", &redacted_values(headers, secret_fingerprints))
191 .field(
192 "secret_fields",
193 &secret_fingerprints.keys().collect::<Vec<_>>(),
194 )
195 .finish(),
196 }
197 }
198}
199
200fn redacted_values<'a>(
201 values: &'a BTreeMap<String, String>,
202 secret_fingerprints: &BTreeMap<String, String>,
203) -> BTreeMap<&'a str, &'a str> {
204 values
205 .iter()
206 .map(|(name, value)| {
207 let value = if secret_fingerprints
208 .keys()
209 .any(|secret| secret.eq_ignore_ascii_case(name))
210 {
211 "<redacted>"
212 } else {
213 value.as_str()
214 };
215 (name.as_str(), value)
216 })
217 .collect()
218}
219
220fn redacted_stdio_env(values: &BTreeMap<String, String>) -> BTreeMap<&str, &str> {
221 values
222 .iter()
223 .map(|(name, value)| {
224 let upper = name.to_ascii_uppercase();
225 let value = if upper.contains("TOKEN")
226 || upper.contains("SECRET")
227 || upper.contains("PASSWORD")
228 || upper.contains("KEY")
229 {
230 "<redacted>"
231 } else {
232 value.as_str()
233 };
234 (name.as_str(), value)
235 })
236 .collect()
237}
238
239impl GatewayServer {
240 pub fn resolve(&self) -> Result<ResolvedTransport, String> {
243 if self.name.trim().is_empty() {
244 return Err("gateway server is missing a `name`".to_string());
245 }
246 match self.transport {
247 TransportKind::Stdio => {
248 if self.command.trim().is_empty() {
249 return Err(format!(
250 "gateway server `{}` uses stdio transport but has no `command`",
251 self.name
252 ));
253 }
254 let mut env = self.env.clone();
255 for (name, memento) in &self.secret_env {
256 let (value, _) = memento.restore(name)?;
257 env.insert(name.clone(), value);
258 }
259
260 Ok(ResolvedTransport::Stdio {
261 command: self.command.clone(),
262 args: self.args.clone(),
263 env,
264 binary_sha256: self.binary_sha256.clone(),
265 capabilities: self.capabilities.clone(),
266 })
267 }
268 TransportKind::Http => {
269 let url = self.url.trim();
270 if !(url.starts_with("http://") || url.starts_with("https://")) {
271 return Err(format!(
272 "gateway server `{}` uses http transport but `url` is not http(s)",
273 self.name
274 ));
275 }
276 let mut headers = self.headers.clone();
277 let mut secret_fingerprints = BTreeMap::new();
278 for (name, memento) in &self.secret_headers {
279 if secret_fingerprints
280 .keys()
281 .any(|existing: &String| existing.eq_ignore_ascii_case(name))
282 {
283 return Err(format!(
284 "gateway server `{}` declares duplicate secret header `{name}`",
285 self.name
286 ));
287 }
288 let (value, fingerprint) = memento.restore(name)?;
289 headers.retain(|existing, _| !existing.eq_ignore_ascii_case(name));
290 headers.insert(name.clone(), value);
291 secret_fingerprints.insert(name.clone(), fingerprint);
292 }
293
294 Ok(ResolvedTransport::Http {
295 url: url.to_string(),
296 headers,
297 secret_fingerprints,
298 })
299 }
300 }
301 }
302}
303
304#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
306#[serde(default)]
307pub struct GatewayConfig {
308 pub enabled: bool,
310 pub top_n: usize,
312 pub cache_ttl_secs: u64,
314 pub call_timeout_secs: u64,
316 pub servers: Vec<GatewayServer>,
318
319 pub compress_output: bool,
325 pub handle_spill: bool,
329 pub index_output: bool,
333 pub output_budget_tokens: usize,
336}
337
338impl Default for GatewayConfig {
339 fn default() -> Self {
340 Self {
341 enabled: false,
342 top_n: 5,
343 cache_ttl_secs: 300,
344 call_timeout_secs: 30,
345 servers: Vec::new(),
346 compress_output: false,
347 handle_spill: false,
348 index_output: false,
349 output_budget_tokens: 2000,
350 }
351 }
352}
353
354impl GatewayConfig {
355 pub fn enabled_effective(&self) -> bool {
358 if let Ok(v) = std::env::var("LEAN_CTX_GATEWAY") {
359 return !matches!(v.trim(), "0" | "false" | "off");
360 }
361 self.enabled
362 }
363
364 pub fn active_servers(&self) -> impl Iterator<Item = &GatewayServer> {
366 self.servers.iter().filter(|s| s.enabled)
367 }
368
369 pub fn effective_top_n(&self) -> usize {
371 self.top_n.clamp(1, 50)
372 }
373
374 pub fn postprocess_active(&self) -> bool {
378 self.compress_output || self.handle_spill || self.index_output
379 }
380
381 pub fn effective_output_budget(&self) -> usize {
385 self.output_budget_tokens.max(256)
386 }
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392
393 #[test]
394 fn default_is_disabled_noop() {
395 let cfg = GatewayConfig::default();
396 assert!(!cfg.enabled);
397 assert!(!cfg.enabled_effective());
398 assert_eq!(cfg.effective_top_n(), 5);
399 assert!(cfg.servers.is_empty());
400 assert!(!cfg.compress_output);
402 assert!(!cfg.handle_spill);
403 assert!(!cfg.index_output);
404 assert!(!cfg.postprocess_active());
405 assert_eq!(cfg.effective_output_budget(), 2000);
406 }
407
408 #[test]
409 fn zero_budget_floors_to_sane_minimum() {
410 let cfg = GatewayConfig {
411 output_budget_tokens: 0,
412 ..Default::default()
413 };
414 assert_eq!(cfg.effective_output_budget(), 256);
415 }
416
417 #[test]
418 fn server_integration_field_round_trips() {
419 let toml_src = r#"
420enabled = true
421compress_output = true
422index_output = true
423output_budget_tokens = 1500
424
425[[servers]]
426name = "repomix"
427command = "npx"
428args = ["-y", "repomix", "--mcp"]
429integration = "codebase-pack"
430"#;
431 let cfg: GatewayConfig = toml::from_str(toml_src).expect("parse");
432 assert!(cfg.compress_output);
433 assert!(cfg.index_output);
434 assert!(cfg.postprocess_active());
435 assert_eq!(cfg.effective_output_budget(), 1500);
436 assert_eq!(cfg.servers[0].integration, "codebase-pack");
437 let back = toml::to_string(&cfg).expect("serialize");
439 assert!(back.contains("integration = \"codebase-pack\""));
440 }
441
442 #[test]
443 fn stdio_server_resolves_with_command() {
444 let s = GatewayServer {
445 name: "fs".into(),
446 transport: TransportKind::Stdio,
447 command: "mcp-fs".into(),
448 args: vec!["/tmp".into()],
449 ..Default::default()
450 };
451 let r = s.resolve().expect("resolve");
452 assert_eq!(
453 r,
454 ResolvedTransport::Stdio {
455 command: "mcp-fs".into(),
456 args: vec!["/tmp".into()],
457 env: BTreeMap::new(),
458 binary_sha256: String::new(),
459 capabilities: None,
460 }
461 );
462 }
463
464 #[test]
465 fn stdio_without_command_is_error() {
466 let s = GatewayServer {
467 name: "broken".into(),
468 transport: TransportKind::Stdio,
469 ..Default::default()
470 };
471 assert!(s.resolve().is_err());
472 }
473
474 #[test]
475 fn http_requires_http_scheme() {
476 let ok = GatewayServer {
477 name: "remote".into(),
478 transport: TransportKind::Http,
479 url: "https://example.com/mcp".into(),
480 ..Default::default()
481 };
482 assert!(ok.resolve().is_ok());
483
484 let bad = GatewayServer {
485 name: "remote".into(),
486 transport: TransportKind::Http,
487 url: "ftp://example.com".into(),
488 ..Default::default()
489 };
490 assert!(bad.resolve().is_err());
491 }
492
493 #[test]
494 fn stdio_secret_env_restores_from_memento() {
495 crate::core::mcp_catalog::memento::SecretMementoStore::global()
496 .put("mcp/gitlab/default", "test-secret");
497 let s = GatewayServer {
498 name: "gitlab".into(),
499 transport: TransportKind::Stdio,
500 command: "gitlab-mcp".into(),
501 secret_env: BTreeMap::from([(
502 "GITLAB_TOKEN".into(),
503 SecretMementoRef {
504 id: "mcp/gitlab/default".into(),
505 format: String::new(),
506 },
507 )]),
508 ..Default::default()
509 };
510 let resolved = s.resolve().expect("resolve");
511 match resolved {
512 ResolvedTransport::Stdio { env, .. } => {
513 assert_eq!(
514 env.get("GITLAB_TOKEN").map(String::as_str),
515 Some("test-secret")
516 );
517 }
518 ResolvedTransport::Http { .. } => panic!("expected stdio"),
519 }
520 crate::core::mcp_catalog::memento::SecretMementoStore::global()
521 .remove("mcp/gitlab/default");
522 }
523
524 #[test]
525 fn http_secret_header_uses_format_template() {
526 crate::core::mcp_catalog::memento::SecretMementoStore::global()
527 .put("mcp/gitlab/header", "test-secret");
528 let s = GatewayServer {
529 name: "gitlab".into(),
530 transport: TransportKind::Http,
531 url: "https://gitlab.example/mcp".into(),
532 secret_headers: BTreeMap::from([(
533 "Authorization".into(),
534 SecretMementoRef {
535 id: "mcp/gitlab/header".into(),
536 format: "Bearer {secret}".into(),
537 },
538 )]),
539 ..Default::default()
540 };
541 let resolved = s.resolve().expect("resolve");
542 match resolved {
543 ResolvedTransport::Http {
544 headers,
545 secret_fingerprints,
546 ..
547 } => {
548 assert_eq!(
549 headers.get("Authorization").map(String::as_str),
550 Some("Bearer test-secret")
551 );
552 assert!(secret_fingerprints.contains_key("Authorization"));
553 }
554 ResolvedTransport::Stdio { .. } => panic!("expected http"),
555 }
556 crate::core::mcp_catalog::memento::SecretMementoStore::global().remove("mcp/gitlab/header");
557 }
558
559 #[test]
560 fn http_secret_header_overrides_public_header_case_insensitively() {
561 let store = SecretMementoStore::global();
562 store.put("mcp/gitlab/header-case", "private-token");
563 let server = GatewayServer {
564 name: "gitlab".into(),
565 transport: TransportKind::Http,
566 url: "https://gitlab.example/mcp".into(),
567 headers: BTreeMap::from([("authorization".into(), "public-value".into())]),
568 secret_headers: BTreeMap::from([(
569 "Authorization".into(),
570 SecretMementoRef {
571 id: "mcp/gitlab/header-case".into(),
572 format: "Bearer {secret}".into(),
573 },
574 )]),
575 ..Default::default()
576 };
577
578 let resolved = server.resolve().expect("resolve");
579 match resolved {
580 ResolvedTransport::Http {
581 headers,
582 secret_fingerprints,
583 ..
584 } => {
585 assert_eq!(headers.len(), 1);
586 assert_eq!(
587 headers.get("Authorization").map(String::as_str),
588 Some("Bearer private-token")
589 );
590 assert_eq!(secret_fingerprints.len(), 1);
591 assert!(secret_fingerprints.contains_key("Authorization"));
592 }
593 ResolvedTransport::Stdio { .. } => panic!("expected http"),
594 }
595 store.remove("mcp/gitlab/header-case");
596 }
597
598 #[test]
599 fn secret_memento_toml_round_trip_never_serializes_value() {
600 let store = SecretMementoStore::global();
601 store.put("mcp/gitlab/toml", "private-token");
602 let source = r#"
603enabled = true
604
605[[servers]]
606name = "gitlab"
607transport = "http"
608url = "https://gitlab.example/mcp"
609secret_headers = { Authorization = { id = "mcp/gitlab/toml", format = "Bearer {secret}" } }
610"#;
611 let config: GatewayConfig = toml::from_str(source).expect("parse memento config");
612 config.servers[0].resolve().expect("restore memento");
613 let serialized = toml::to_string(&config).expect("serialize memento config");
614
615 assert!(serialized.contains("mcp/gitlab/toml"));
616 assert!(!serialized.contains("private-token"));
617 store.remove("mcp/gitlab/toml");
618 }
619
620 #[test]
621 fn malformed_secret_memento_fails_closed() {
622 let store = SecretMementoStore::global();
623 store.put("mcp/gitlab/malformed", "private-token");
624 let server = GatewayServer {
625 name: "gitlab".into(),
626 transport: TransportKind::Stdio,
627 command: "gitlab-mcp".into(),
628 secret_env: BTreeMap::from([(
629 "GITLAB_TOKEN".into(),
630 SecretMementoRef {
631 id: "mcp/gitlab/malformed".into(),
632 format: "Bearer".into(),
633 },
634 )]),
635 ..Default::default()
636 };
637
638 assert!(
639 server
640 .resolve()
641 .expect_err("invalid template")
642 .contains("{secret}")
643 );
644 store.remove("mcp/gitlab/malformed");
645 }
646
647 #[test]
648 fn resolved_transport_debug_redacts_memento_values() {
649 let transport = ResolvedTransport::Http {
650 url: "https://gitlab.example/mcp".into(),
651 headers: BTreeMap::from([
652 ("Accept".into(), "application/json".into()),
653 ("Authorization".into(), "private-value".into()),
654 ]),
655 secret_fingerprints: BTreeMap::from([("authorization".into(), "abc123".into())]),
656 };
657
658 let debug = format!("{transport:?}");
659 assert!(debug.contains("application/json"));
660 assert!(debug.contains("<redacted>"));
661 assert!(!debug.contains("private-value"));
662 }
663
664 #[test]
665 fn missing_secret_memento_fails_closed() {
666 let s = GatewayServer {
667 name: "gitlab".into(),
668 transport: TransportKind::Stdio,
669 command: "gitlab-mcp".into(),
670 secret_env: BTreeMap::from([(
671 "GITLAB_TOKEN".into(),
672 SecretMementoRef {
673 id: "mcp/gitlab/missing".into(),
674 format: String::new(),
675 },
676 )]),
677 ..Default::default()
678 };
679 assert!(
680 s.resolve()
681 .expect_err("missing secret")
682 .contains("missing secret memento")
683 );
684 }
685
686 #[test]
687 fn unnamed_server_is_error() {
688 let s = GatewayServer {
689 transport: TransportKind::Stdio,
690 command: "x".into(),
691 ..Default::default()
692 };
693 assert!(s.resolve().is_err());
694 }
695
696 #[test]
697 fn active_servers_skips_disabled() {
698 let cfg = GatewayConfig {
699 enabled: true,
700 servers: vec![
701 GatewayServer {
702 name: "a".into(),
703 command: "a".into(),
704 enabled: true,
705 ..Default::default()
706 },
707 GatewayServer {
708 name: "b".into(),
709 command: "b".into(),
710 enabled: false,
711 ..Default::default()
712 },
713 ],
714 ..Default::default()
715 };
716 let active: Vec<_> = cfg.active_servers().map(|s| s.name.as_str()).collect();
717 assert_eq!(active, vec!["a"]);
718 }
719
720 #[test]
721 fn parses_array_of_tables_toml() {
722 let toml_src = r#"
723enabled = true
724top_n = 8
725
726[[servers]]
727name = "fs"
728transport = "stdio"
729command = "mcp-server-filesystem"
730args = ["/tmp"]
731
732[[servers]]
733name = "remote"
734transport = "http"
735url = "https://example.com/mcp"
736enabled = false
737"#;
738 let cfg: GatewayConfig = toml::from_str(toml_src).expect("parse");
739 assert!(cfg.enabled);
740 assert_eq!(cfg.top_n, 8);
741 assert_eq!(cfg.servers.len(), 2);
742 assert_eq!(cfg.servers[0].transport, TransportKind::Stdio);
743 assert_eq!(cfg.servers[0].command, "mcp-server-filesystem");
744 assert_eq!(cfg.servers[1].transport, TransportKind::Http);
745 assert!(!cfg.servers[1].enabled);
746 }
747}