1use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10
11use crate::core::addons::capabilities::AddonCapabilities;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Hash)]
15#[serde(rename_all = "snake_case")]
16pub enum TransportKind {
17 #[default]
19 Stdio,
20 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(default)]
40pub struct GatewayServer {
41 pub name: String,
43 pub transport: TransportKind,
45 pub enabled: bool,
47
48 pub command: String,
51 pub args: Vec<String>,
53 pub env: BTreeMap<String, String>,
55 #[serde(default, skip_serializing_if = "String::is_empty")]
61 pub binary_sha256: String,
62
63 pub url: String,
66 pub headers: BTreeMap<String, String>,
68
69 #[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#[derive(Debug, Clone, PartialEq, Eq)]
97pub enum ResolvedTransport {
98 Stdio {
99 command: String,
100 args: Vec<String>,
101 env: BTreeMap<String, String>,
102 binary_sha256: String,
104 capabilities: Option<AddonCapabilities>,
106 },
107 Http {
108 url: String,
109 headers: BTreeMap<String, String>,
110 },
111}
112
113impl GatewayServer {
114 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(default)]
156pub struct GatewayConfig {
157 pub enabled: bool,
159 pub top_n: usize,
161 pub cache_ttl_secs: u64,
163 pub call_timeout_secs: u64,
165 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 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 pub fn active_servers(&self) -> impl Iterator<Item = &GatewayServer> {
193 self.servers.iter().filter(|s| s.enabled)
194 }
195
196 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}