1use std::sync::{Arc, Mutex, Weak};
52use std::time::Duration;
53
54use crate::application::Application;
55use crate::config_check::ConfigReport;
56use crate::mcp::gateway::{McpGateway, Upstream};
57use crate::mcp::McpServer;
58use crate::request::Request;
59use crate::response::Response;
60use crate::server::ConnectionInfo;
61
62use super::parser::{get_array, get_str, get_u32, get_u64, section_exists, SectionMap};
63
64const RETRY: Duration = Duration::from_secs(10);
66
67#[derive(Debug, Clone, Default, PartialEq)]
68pub struct McpGatewayConfig {
69 pub name: String,
70 pub refresh_secs: u64,
71 pub audit_log: bool,
72 pub allowed_origins: Vec<String>,
73 pub upstreams: Vec<UpstreamConfig>,
74 pub apis: Vec<OpenApiConfig>,
75 pub callers: Vec<CallerConfig>,
76 pub allow: Vec<(String, Vec<String>)>,
77 pub limits: Vec<LimitConfig>,
78}
79
80#[derive(Debug, Clone, Default, PartialEq)]
81pub struct UpstreamConfig {
82 pub prefix: String,
83 pub url: String,
84 pub timeout_ms: u64,
85 pub headers_env: Vec<(String, String)>,
87}
88
89#[derive(Debug, Clone, Default, PartialEq)]
91pub struct OpenApiConfig {
92 pub prefix: String,
93 pub spec: String,
94 pub base_url: String,
95 pub include: Vec<String>,
96 pub exclude: Vec<String>,
97 pub timeout_ms: u64,
98 pub headers_env: Vec<(String, String)>,
99}
100
101#[derive(Debug, Clone, Default, PartialEq)]
102pub struct CallerConfig {
103 pub identity: String,
104 pub token_env: String,
105}
106
107#[derive(Debug, Clone, Default, PartialEq)]
108pub struct LimitConfig {
109 pub tools: String,
110 pub max_calls: u32,
111 pub window_secs: u64,
112}
113
114pub(crate) const GATEWAY_KEYS: &[&str] = &["name", "refresh_secs", "audit_log", "allowed_origins"];
116pub(crate) const UPSTREAM_KEYS: &[&str] = &["prefix", "url", "timeout_ms"];
117pub(crate) const CALLER_KEYS: &[&str] = &["identity", "token_env"];
118pub(crate) const OPENAPI_KEYS: &[&str] = &["prefix", "spec", "base_url", "include", "exclude", "timeout_ms"];
119pub(crate) const ALLOW_KEYS: &[&str] = &["identity", "tools"];
120pub(crate) const LIMIT_KEYS: &[&str] = &["tools", "max_calls", "window_secs"];
121
122pub(crate) fn parse(map: &SectionMap, section: &str) -> McpGatewayConfig {
124 let base = format!("{section}.mcp");
125 let indexed = |kind: &str| -> Vec<String> {
126 (0..).map(|i| format!("{base}.{kind}[{i}]")).take_while(|s| section_exists(map, s)).collect()
127 };
128 let name = get_str(map, &base, "name");
129 McpGatewayConfig {
130 name: if name.is_empty() { "rws-mcp-gateway".to_string() } else { name },
131 refresh_secs: get_u64(map, &base, "refresh_secs", crate::mcp::gateway::DEFAULT_REFRESH.as_secs()),
132 audit_log: get_str(map, &base, "audit_log").eq_ignore_ascii_case("true"),
133 allowed_origins: get_array(map, &base, "allowed_origins"),
134 upstreams: indexed("upstream")
135 .into_iter()
136 .map(|s| UpstreamConfig {
137 prefix: get_str(map, &s, "prefix"),
138 url: get_str(map, &s, "url"),
139 timeout_ms: get_u64(map, &s, "timeout_ms", 30_000),
140 headers_env: map.get(&format!("{s}.headers_env")).cloned().unwrap_or_default(),
141 })
142 .collect(),
143 apis: indexed("openapi")
144 .into_iter()
145 .map(|s| OpenApiConfig {
146 prefix: get_str(map, &s, "prefix"),
147 spec: get_str(map, &s, "spec"),
148 base_url: get_str(map, &s, "base_url"),
149 include: get_array(map, &s, "include"),
150 exclude: get_array(map, &s, "exclude"),
151 timeout_ms: get_u64(map, &s, "timeout_ms", 30_000),
152 headers_env: map.get(&format!("{s}.headers_env")).cloned().unwrap_or_default(),
153 })
154 .collect(),
155 callers: indexed("caller")
156 .into_iter()
157 .map(|s| CallerConfig { identity: get_str(map, &s, "identity"), token_env: get_str(map, &s, "token_env") })
158 .collect(),
159 allow: indexed("allow").into_iter().map(|s| (get_str(map, &s, "identity"), get_array(map, &s, "tools"))).collect(),
160 limits: indexed("limit")
161 .into_iter()
162 .map(|s| LimitConfig { tools: get_str(map, &s, "tools"), max_calls: get_u32(map, &s, "max_calls", 0), window_secs: get_u64(map, &s, "window_secs", 0) })
163 .collect(),
164 }
165}
166
167pub(crate) fn validate(config: &McpGatewayConfig, route_path: Option<&str>, label: &str, report: &mut ConfigReport) {
169 let offline = crate::config_check::offline();
170 match route_path {
171 Some(path) if path.ends_with('*') => report.error(format!("{label}: an mcp route needs an exact [route.match] path, e.g. \"/mcp\" (not {path:?})")),
172 None => report.error(format!("{label}: an mcp route needs [route.match] path, e.g. \"/mcp\"")),
173 _ => {}
174 }
175 if config.upstreams.is_empty() && config.apis.is_empty() {
176 report.error(format!("{label}: [route.action.mcp] has no [[route.action.mcp.upstream]] or [[route.action.mcp.openapi]]"));
177 }
178 let mut prefixes = Vec::new();
179 for (i, u) in config.upstreams.iter().enumerate() {
180 let what = format!("{label}: upstream #{}", i + 1);
181 if u.prefix.is_empty() || u.prefix.contains("__") || u.prefix.ends_with('_') || !u.prefix.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
182 report.error(format!("{what}: prefix {:?} must be letters, digits, '-' and single '_'", u.prefix));
183 } else if prefixes.contains(&u.prefix) {
184 report.error(format!("{what}: prefix {:?} is used twice", u.prefix));
185 } else {
186 prefixes.push(u.prefix.clone());
187 }
188 if !(u.url.starts_with("http://") || u.url.starts_with("https://")) {
189 report.error(format!("{what}: url {:?} must start with http:// or https://", u.url));
190 }
191 for (header, env) in &u.headers_env {
192 if !offline && std::env::var(env).unwrap_or_default().is_empty() {
193 report.error(format!("{what}: header {header} reads {env}, which is unset or empty"));
194 }
195 }
196 }
197 for (i, a) in config.apis.iter().enumerate() {
198 let what = format!("{label}: openapi #{}", i + 1);
199 if a.prefix.is_empty() || a.prefix.contains("__") || a.prefix.ends_with('_') || !a.prefix.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
200 report.error(format!("{what}: prefix {:?} must be letters, digits, '-' and single '_'", a.prefix));
201 } else if prefixes.contains(&a.prefix) {
202 report.error(format!("{what}: prefix {:?} is used twice", a.prefix));
203 } else {
204 prefixes.push(a.prefix.clone());
205 }
206 if a.spec.is_empty() {
207 report.error(format!("{what}: spec (an OpenAPI document's URL or file) is required"));
208 } else if !offline && !a.spec.starts_with("http") && !std::path::Path::new(&a.spec).is_file() {
209 report.error(format!("{what}: spec file {:?} doesn't exist", a.spec));
210 }
211 if !(a.base_url.starts_with("http://") || a.base_url.starts_with("https://")) {
212 report.error(format!("{what}: base_url {:?} must start with http:// or https://", a.base_url));
213 }
214 for (header, env) in &a.headers_env {
215 if !offline && std::env::var(env).unwrap_or_default().is_empty() {
216 report.error(format!("{what}: header {header} reads {env}, which is unset or empty"));
217 }
218 }
219 }
220 if config.callers.is_empty() {
221 report.error(format!("{label}: no [[route.action.mcp.caller]]: nobody can call the gateway"));
222 }
223 for c in &config.callers {
224 if c.identity.is_empty() || c.token_env.is_empty() {
225 report.error(format!("{label}: a caller needs identity and token_env"));
226 } else if !offline && std::env::var(&c.token_env).unwrap_or_default().trim().is_empty() {
227 report.error(format!("{label}: caller {} reads its token from {}, which is unset or empty", c.identity, c.token_env));
228 }
229 }
230 if config.allow.is_empty() {
231 report.warn(format!("{label}: no [[route.action.mcp.allow]] rule: callers see no tools"));
232 }
233 for (identity, tools) in &config.allow {
234 if identity.is_empty() || tools.is_empty() {
235 report.error(format!("{label}: an allow rule needs identity and tools"));
236 }
237 }
238 for l in &config.limits {
239 if l.tools.is_empty() || l.max_calls == 0 || l.window_secs == 0 {
240 report.error(format!("{label}: a limit needs tools, max_calls and window_secs above 0"));
241 }
242 }
243}
244
245fn build(config: &McpGatewayConfig, path: &str) -> Result<McpServer, String> {
247 let mut gateway = McpGateway::new(config.name.clone(), env!("CARGO_PKG_VERSION"))
248 .refresh_every((config.refresh_secs > 0).then(|| Duration::from_secs(config.refresh_secs)))
249 .audit_log(config.audit_log);
250 for u in &config.upstreams {
251 let mut upstream = Upstream::new(u.prefix.clone(), u.url.clone()).timeout_ms(u.timeout_ms);
252 for (header, env) in &u.headers_env {
253 if let Ok(value) = std::env::var(env) {
254 upstream = upstream.header(header.clone(), value);
255 }
256 }
257 gateway = gateway.upstream(upstream);
258 }
259 for a in &config.apis {
260 let mut api = crate::mcp::gateway::OpenApiUpstream::new(a.prefix.clone(), a.spec.clone(), a.base_url.clone()).include(a.include.clone()).exclude(a.exclude.clone()).timeout_ms(a.timeout_ms);
261 for (header, env) in &a.headers_env {
262 if let Ok(value) = std::env::var(env) {
263 api = api.header(header.clone(), value);
264 }
265 }
266 gateway = gateway.openapi(api);
267 }
268 for (identity, tools) in &config.allow {
269 gateway = gateway.allow(identity.clone(), tools.clone());
270 }
271 for l in &config.limits {
272 gateway = gateway.limit(l.tools.clone(), l.max_calls, l.window_secs);
273 }
274 let tokens: Vec<(String, String)> = config
275 .callers
276 .iter()
277 .filter_map(|c| std::env::var(&c.token_env).ok().filter(|t| !t.trim().is_empty()).map(|t| (t, c.identity.clone())))
278 .collect();
279 let mut server = gateway.build()?.at(path).require_bearer_tokens(tokens);
280 if !config.allowed_origins.is_empty() {
281 server = server.allowed_origins(config.allowed_origins.clone());
282 }
283 Ok(server)
284}
285
286pub(crate) struct GatewayAdapter {
288 slot: Arc<Mutex<Option<McpServer>>>,
289}
290
291impl GatewayAdapter {
292 pub(crate) fn start(config: McpGatewayConfig, path: String) -> GatewayAdapter {
295 let slot: Arc<Mutex<Option<McpServer>>> = Arc::new(Mutex::new(None));
296 let weak: Weak<Mutex<Option<McpServer>>> = Arc::downgrade(&slot);
297 let spawned = std::thread::Builder::new().name("rws-mcp-gateway-build".to_string()).spawn(move || loop {
298 match build(&config, &path) {
299 Ok(server) => {
300 if let Some(slot) = weak.upgrade() {
301 *slot.lock().unwrap_or_else(|e| e.into_inner()) = Some(server);
302 println!("mcp gateway at {path}: {} upstream(s) federated", config.upstreams.len());
303 }
304 return;
305 }
306 Err(e) => {
307 eprintln!("mcp gateway at {path}: {e}; retrying in {} s", RETRY.as_secs());
308 std::thread::sleep(RETRY);
309 if weak.strong_count() == 0 {
310 return;
311 }
312 }
313 }
314 });
315 if let Err(e) = spawned {
316 eprintln!("mcp gateway: not started: {e}");
317 }
318 GatewayAdapter { slot }
319 }
320}
321
322impl Application for GatewayAdapter {
323 fn execute(&self, request: &Request, connection: &ConnectionInfo) -> Result<Response, String> {
324 let server = self.slot.lock().unwrap_or_else(|e| e.into_inner()).clone();
325 match server {
326 Some(server) => server.execute(request, connection),
327 None => Ok(unavailable()),
328 }
329 }
330}
331
332fn unavailable() -> Response {
333 let mut response = super::service_unavailable();
334 response.headers.retain(|h| !h.name.eq_ignore_ascii_case("retry-after"));
335 response.headers.push(crate::header::Header::new("Retry-After", RETRY.as_secs().to_string()));
336 response
337}
338
339#[cfg(test)]
340mod tests;