Skip to main content

lean_ctx/core/gateway/
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;
10
11/// Which transport a downstream MCP server speaks.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Hash)]
13#[serde(rename_all = "snake_case")]
14pub enum TransportKind {
15    /// Spawn a local MCP server as a child process; speak MCP over stdio.
16    #[default]
17    Stdio,
18    /// Connect to a remote MCP server over streamable HTTP.
19    Http,
20}
21
22impl TransportKind {
23    pub fn as_str(self) -> &'static str {
24        match self {
25            TransportKind::Stdio => "stdio",
26            TransportKind::Http => "http",
27        }
28    }
29}
30
31/// A single downstream MCP server entry (`[[gateway.servers]]`).
32///
33/// Flat shape (rather than an internally-tagged enum) so it round-trips
34/// cleanly through TOML array-of-tables. Validated into a [`ResolvedTransport`]
35/// via [`GatewayServer::resolve`] before use.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(default)]
38pub struct GatewayServer {
39    /// Stable identifier; becomes the catalog namespace (`name::tool`).
40    pub name: String,
41    /// `stdio` (spawn `command`) or `http` (connect to `url`).
42    pub transport: TransportKind,
43    /// Per-server switch; lets you keep an entry but skip it.
44    pub enabled: bool,
45
46    // --- stdio transport ---
47    /// Executable to spawn (stdio transport).
48    pub command: String,
49    /// Arguments passed to `command`.
50    pub args: Vec<String>,
51    /// Extra environment variables for the child process.
52    pub env: BTreeMap<String, String>,
53
54    // --- http transport ---
55    /// Streamable-HTTP endpoint (http transport).
56    pub url: String,
57    /// Extra request headers (e.g. auth) for the http transport.
58    pub headers: BTreeMap<String, String>,
59}
60
61impl Default for GatewayServer {
62    fn default() -> Self {
63        Self {
64            name: String::new(),
65            transport: TransportKind::Stdio,
66            enabled: true,
67            command: String::new(),
68            args: Vec::new(),
69            env: BTreeMap::new(),
70            url: String::new(),
71            headers: BTreeMap::new(),
72        }
73    }
74}
75
76/// A validated transport ready to open a connection.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum ResolvedTransport {
79    Stdio {
80        command: String,
81        args: Vec<String>,
82        env: BTreeMap<String, String>,
83    },
84    Http {
85        url: String,
86        headers: BTreeMap<String, String>,
87    },
88}
89
90impl GatewayServer {
91    /// Validate the entry and produce a usable transport, or a human-readable
92    /// reason why it cannot be used.
93    pub fn resolve(&self) -> Result<ResolvedTransport, String> {
94        if self.name.trim().is_empty() {
95            return Err("gateway server is missing a `name`".to_string());
96        }
97        match self.transport {
98            TransportKind::Stdio => {
99                if self.command.trim().is_empty() {
100                    return Err(format!(
101                        "gateway server `{}` uses stdio transport but has no `command`",
102                        self.name
103                    ));
104                }
105                Ok(ResolvedTransport::Stdio {
106                    command: self.command.clone(),
107                    args: self.args.clone(),
108                    env: self.env.clone(),
109                })
110            }
111            TransportKind::Http => {
112                let url = self.url.trim();
113                if !(url.starts_with("http://") || url.starts_with("https://")) {
114                    return Err(format!(
115                        "gateway server `{}` uses http transport but `url` is not http(s)",
116                        self.name
117                    ));
118                }
119                Ok(ResolvedTransport::Http {
120                    url: url.to_string(),
121                    headers: self.headers.clone(),
122                })
123            }
124        }
125    }
126}
127
128/// `[gateway]` configuration block.
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(default)]
131pub struct GatewayConfig {
132    /// Master switch. `false` → fully no-op (default).
133    pub enabled: bool,
134    /// How many tools `ctx_tools find` returns per query.
135    pub top_n: usize,
136    /// Aggregated-catalog cache lifetime (seconds).
137    pub cache_ttl_secs: u64,
138    /// Per-operation timeout for downstream connect/list/call (seconds).
139    pub call_timeout_secs: u64,
140    /// Downstream MCP servers to aggregate.
141    pub servers: Vec<GatewayServer>,
142}
143
144impl Default for GatewayConfig {
145    fn default() -> Self {
146        Self {
147            enabled: false,
148            top_n: 5,
149            cache_ttl_secs: 300,
150            call_timeout_secs: 30,
151            servers: Vec::new(),
152        }
153    }
154}
155
156impl GatewayConfig {
157    /// Effective enabled flag, honoring the `LEAN_CTX_GATEWAY` env override
158    /// (`0|false|off` disables, anything else enables).
159    pub fn enabled_effective(&self) -> bool {
160        if let Ok(v) = std::env::var("LEAN_CTX_GATEWAY") {
161            return !matches!(v.trim(), "0" | "false" | "off");
162        }
163        self.enabled
164    }
165
166    /// Enabled servers in declaration order.
167    pub fn active_servers(&self) -> impl Iterator<Item = &GatewayServer> {
168        self.servers.iter().filter(|s| s.enabled)
169    }
170
171    /// Clamp `top_n` into a sane range (1..=50).
172    pub fn effective_top_n(&self) -> usize {
173        self.top_n.clamp(1, 50)
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn default_is_disabled_noop() {
183        let cfg = GatewayConfig::default();
184        assert!(!cfg.enabled);
185        assert!(!cfg.enabled_effective());
186        assert_eq!(cfg.effective_top_n(), 5);
187        assert!(cfg.servers.is_empty());
188    }
189
190    #[test]
191    fn stdio_server_resolves_with_command() {
192        let s = GatewayServer {
193            name: "fs".into(),
194            transport: TransportKind::Stdio,
195            command: "mcp-fs".into(),
196            args: vec!["/tmp".into()],
197            ..Default::default()
198        };
199        let r = s.resolve().expect("resolve");
200        assert_eq!(
201            r,
202            ResolvedTransport::Stdio {
203                command: "mcp-fs".into(),
204                args: vec!["/tmp".into()],
205                env: BTreeMap::new(),
206            }
207        );
208    }
209
210    #[test]
211    fn stdio_without_command_is_error() {
212        let s = GatewayServer {
213            name: "broken".into(),
214            transport: TransportKind::Stdio,
215            ..Default::default()
216        };
217        assert!(s.resolve().is_err());
218    }
219
220    #[test]
221    fn http_requires_http_scheme() {
222        let ok = GatewayServer {
223            name: "remote".into(),
224            transport: TransportKind::Http,
225            url: "https://example.com/mcp".into(),
226            ..Default::default()
227        };
228        assert!(ok.resolve().is_ok());
229
230        let bad = GatewayServer {
231            name: "remote".into(),
232            transport: TransportKind::Http,
233            url: "ftp://example.com".into(),
234            ..Default::default()
235        };
236        assert!(bad.resolve().is_err());
237    }
238
239    #[test]
240    fn unnamed_server_is_error() {
241        let s = GatewayServer {
242            transport: TransportKind::Stdio,
243            command: "x".into(),
244            ..Default::default()
245        };
246        assert!(s.resolve().is_err());
247    }
248
249    #[test]
250    fn active_servers_skips_disabled() {
251        let cfg = GatewayConfig {
252            enabled: true,
253            servers: vec![
254                GatewayServer {
255                    name: "a".into(),
256                    command: "a".into(),
257                    enabled: true,
258                    ..Default::default()
259                },
260                GatewayServer {
261                    name: "b".into(),
262                    command: "b".into(),
263                    enabled: false,
264                    ..Default::default()
265                },
266            ],
267            ..Default::default()
268        };
269        let active: Vec<_> = cfg.active_servers().map(|s| s.name.as_str()).collect();
270        assert_eq!(active, vec!["a"]);
271    }
272
273    #[test]
274    fn parses_array_of_tables_toml() {
275        let toml_src = r#"
276enabled = true
277top_n = 8
278
279[[servers]]
280name = "fs"
281transport = "stdio"
282command = "mcp-server-filesystem"
283args = ["/tmp"]
284
285[[servers]]
286name = "remote"
287transport = "http"
288url = "https://example.com/mcp"
289enabled = false
290"#;
291        let cfg: GatewayConfig = toml::from_str(toml_src).expect("parse");
292        assert!(cfg.enabled);
293        assert_eq!(cfg.top_n, 8);
294        assert_eq!(cfg.servers.len(), 2);
295        assert_eq!(cfg.servers[0].transport, TransportKind::Stdio);
296        assert_eq!(cfg.servers[0].command, "mcp-server-filesystem");
297        assert_eq!(cfg.servers[1].transport, TransportKind::Http);
298        assert!(!cfg.servers[1].enabled);
299    }
300}