Skip to main content

lean_ctx/core/mcp_catalog/
config.rs

1//! Gateway configuration (#210): downstream MCP servers + routing knobs.
2//!
3//! `[gateway]` is **global-only** (never merged from a project-local
4//! `.lean-ctx.toml`) because it spawns child processes / opens network
5//! connections — an untrusted repo must not be able to point the gateway at
6//! arbitrary commands. It is a full no-op until `gateway.enabled = true`.
7
8use 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/// Which transport a downstream MCP server speaks.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Hash)]
17#[serde(rename_all = "snake_case")]
18pub enum TransportKind {
19    /// Spawn a local MCP server as a child process; speak MCP over stdio.
20    #[default]
21    Stdio,
22    /// Connect to a remote MCP server over streamable HTTP.
23    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/// Opaque reference to a runtime-restored secret.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct SecretMementoRef {
38    /// Opaque identifier restored by [`SecretMementoStore`].
39    pub id: String,
40    /// Optional template containing `{secret}` for the restored value.
41    #[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/// A single downstream MCP server entry (`[[gateway.servers]]`).
69///
70/// Flat shape (rather than an internally-tagged enum) so it round-trips
71/// cleanly through TOML array-of-tables. Validated into a [`ResolvedTransport`]
72/// via [`GatewayServer::resolve`] before use.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(default)]
75pub struct GatewayServer {
76    /// Stable identifier; becomes the catalog namespace (`name::tool`).
77    pub name: String,
78    /// `stdio` (spawn `command`) or `http` (connect to `url`).
79    pub transport: TransportKind,
80    /// Per-server switch; lets you keep an entry but skip it.
81    pub enabled: bool,
82
83    // --- stdio transport ---
84    /// Executable to spawn (stdio transport).
85    pub command: String,
86    /// Arguments passed to `command`.
87    pub args: Vec<String>,
88    /// Extra environment variables for the child process.
89    pub env: BTreeMap<String, String>,
90    /// Environment variable names mapped to secret memento references.
91    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
92    pub secret_env: BTreeMap<String, SecretMementoRef>,
93    /// Optional SHA-256 pin of the stdio `command` binary (P3). When set, the
94    /// spawn point ([`crate::core::mcp_catalog::client`]) verifies the resolved
95    /// binary's hash and refuses to launch a swapped executable. Empty =
96    /// unpinned (legacy behaviour). Part of the wiring, so it is covered by the
97    /// install-time integrity hash ([`crate::core::addons::integrity`]).
98    #[serde(default, skip_serializing_if = "String::is_empty")]
99    pub binary_sha256: String,
100
101    // --- http transport ---
102    /// Streamable-HTTP endpoint (http transport).
103    pub url: String,
104    /// Extra request headers (e.g. auth) for the http transport.
105    pub headers: BTreeMap<String, String>,
106    /// HTTP header names mapped to secret memento references.
107    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
108    pub secret_headers: BTreeMap<String, SecretMementoRef>,
109
110    /// Declared capabilities (P1). `None` keeps the legacy `addons.sandbox`
111    /// behaviour; `Some` enforces a per-server OS sandbox + env allowlist
112    /// derived from the declared permissions at the spawn point. Carried here so
113    /// the live `[[gateway.servers]]` config — the single source of truth for
114    /// what runs — also records what each server is allowed to do.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub capabilities: Option<AddonCapabilities>,
117
118    /// Typed-integration adapter override (#1096, L4). Empty = *auto*: derive the
119    /// adapter from the owning addon's category in the installed store. An
120    /// explicit value forces a specific adapter and bypasses the lookup:
121    /// `codebase-pack` | `code-graph` | `code-symbols` | `memory` |
122    /// `compression` | `none`. Drives routing in [`super::postprocess`].
123    #[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/// A validated transport ready to open a connection.
148#[derive(Clone, PartialEq, Eq)]
149pub enum ResolvedTransport {
150    Stdio {
151        command: String,
152        args: Vec<String>,
153        env: BTreeMap<String, String>,
154        /// SHA-256 pin of `command` to verify before spawn (empty = unpinned).
155        binary_sha256: String,
156        /// Declared capabilities to enforce at spawn (`None` = legacy path).
157        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    /// Validate the entry and produce a usable transport, or a human-readable
241    /// reason why it cannot be used.
242    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/// `[gateway]` configuration block.
305#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
306#[serde(default)]
307pub struct GatewayConfig {
308    /// Master switch. `false` → fully no-op (default).
309    pub enabled: bool,
310    /// How many tools `ctx_tools find` returns per query.
311    pub top_n: usize,
312    /// Aggregated-catalog cache lifetime (seconds).
313    pub cache_ttl_secs: u64,
314    /// Per-operation timeout for downstream connect/list/call (seconds).
315    pub call_timeout_secs: u64,
316    /// Downstream MCP servers to aggregate.
317    pub servers: Vec<GatewayServer>,
318
319    // --- output post-processing (deeper addon integration) ---
320    /// L1 (#1093): run downstream tool output through lean-ctx's format-aware
321    /// compressor before it reaches the model. `false` → output passes through
322    /// unchanged (legacy). The transform is a deterministic function of
323    /// (content, budget) so it never defeats provider prompt-caching (#498).
324    pub compress_output: bool,
325    /// L2 (#1094): when output exceeds `output_budget_tokens`, spill the verbatim
326    /// blob to the content-addressed archive and hand the model a `ctx_expand`
327    /// handle + summary instead of the full payload.
328    pub handle_spill: bool,
329    /// L3 (#1095): side-channel — consolidate downstream output into the BM25
330    /// index, property graph, and knowledge store (so `ctx_search` /
331    /// `ctx_semantic_search` find it later), without altering the returned text.
332    pub index_output: bool,
333    /// Token budget driving the L1 compression target and the L2 spill
334    /// threshold. Inert while every post-processing flag is off.
335    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    /// Effective enabled flag, honoring the `LEAN_CTX_GATEWAY` env override
356    /// (`0|false|off` disables, anything else enables).
357    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    /// Enabled servers in declaration order.
365    pub fn active_servers(&self) -> impl Iterator<Item = &GatewayServer> {
366        self.servers.iter().filter(|s| s.enabled)
367    }
368
369    /// Clamp `top_n` into a sane range (1..=50).
370    pub fn effective_top_n(&self) -> usize {
371        self.top_n.clamp(1, 50)
372    }
373
374    /// Whether any output post-processing is active (L1 compress / L2 spill /
375    /// L3 index). When `false`, [`super::postprocess`] is a pure pass-through
376    /// and the proxy hot-path pays nothing.
377    pub fn postprocess_active(&self) -> bool {
378        self.compress_output || self.handle_spill || self.index_output
379    }
380
381    /// Effective output token budget, clamped away from the degenerate `0`
382    /// (which would make L1 target nothing and L2 spill everything). Floors at
383    /// 256 tokens so a misconfigured `0` still yields sane behaviour.
384    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        // Output post-processing is opt-in: every flag off by default.
401        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        // Re-serialize and ensure the integration override survives the trip.
438        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}