1use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Hash)]
13#[serde(rename_all = "snake_case")]
14pub enum TransportKind {
15 #[default]
17 Stdio,
18 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(default)]
38pub struct GatewayServer {
39 pub name: String,
41 pub transport: TransportKind,
43 pub enabled: bool,
45
46 pub command: String,
49 pub args: Vec<String>,
51 pub env: BTreeMap<String, String>,
53
54 pub url: String,
57 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#[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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(default)]
131pub struct GatewayConfig {
132 pub enabled: bool,
134 pub top_n: usize,
136 pub cache_ttl_secs: u64,
138 pub call_timeout_secs: u64,
140 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 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 pub fn active_servers(&self) -> impl Iterator<Item = &GatewayServer> {
168 self.servers.iter().filter(|s| s.enabled)
169 }
170
171 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}