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