1use std::collections::HashSet;
7
8use http::{HeaderValue, Method};
9use lambda_http::{Body as LambdaBody, Response as LambdaResponse};
10use tracing::debug;
11
12use crate::error::{LambdaError, Result};
13
14#[derive(Debug, Clone)]
16pub struct CorsConfig {
17 pub allowed_origins: Vec<String>,
20
21 pub allowed_methods: Vec<Method>,
23
24 pub allowed_headers: Vec<String>,
26
27 pub allow_credentials: bool,
29
30 pub max_age: Option<u32>,
32
33 pub expose_headers: Vec<String>,
35}
36
37impl Default for CorsConfig {
38 fn default() -> Self {
39 let mut allowed_headers = vec![
44 "Content-Type".to_string(),
45 "Accept".to_string(),
46 "Authorization".to_string(),
47 ];
48 #[cfg(feature = "protocol-2025-11-25")]
49 allowed_headers.push("Mcp-Session-Id".to_string());
50 allowed_headers.push("Mcp-Protocol-Version".to_string());
51 allowed_headers.push("Last-Event-ID".to_string());
52
53 let mut expose_headers = vec!["Mcp-Protocol-Version".to_string()];
54 #[cfg(feature = "protocol-2025-11-25")]
55 expose_headers.push("Mcp-Session-Id".to_string());
56 expose_headers.push("WWW-Authenticate".to_string());
59
60 Self {
61 allowed_origins: vec!["*".to_string()],
62 allowed_methods: vec![Method::GET, Method::POST, Method::DELETE, Method::OPTIONS],
63 allowed_headers,
64 allow_credentials: false,
65 max_age: Some(86400), expose_headers,
67 }
68 }
69}
70
71impl CorsConfig {
72 pub fn allow_all() -> Self {
74 Self::default()
75 }
76
77 pub fn for_origins(origins: Vec<String>) -> Self {
79 Self {
80 allowed_origins: origins,
81 ..Default::default()
82 }
83 }
84
85 pub fn from_env() -> Self {
87 let allowed_origins = std::env::var("MCP_CORS_ORIGINS")
88 .map(|s| s.split(',').map(|s| s.trim().to_string()).collect())
89 .unwrap_or_else(|_| vec!["*".to_string()]);
90
91 let allow_credentials = std::env::var("MCP_CORS_CREDENTIALS")
92 .map(|s| s.parse().unwrap_or(false))
93 .unwrap_or(false);
94
95 let max_age = std::env::var("MCP_CORS_MAX_AGE")
96 .ok()
97 .and_then(|s| s.parse().ok());
98
99 Self {
100 allowed_origins,
101 allow_credentials,
102 max_age,
103 ..Default::default()
104 }
105 }
106}
107
108pub fn inject_cors_headers<B>(
113 response: &mut lambda_http::Response<B>,
114 config: &CorsConfig,
115 request_origin: Option<&str>,
116) -> Result<()> {
117 debug!("Injecting CORS headers for origin: {:?}", request_origin);
118
119 let allowed_origin = determine_allowed_origin(config, request_origin);
121
122 if let Some(origin) = allowed_origin {
123 response.headers_mut().insert(
124 "Access-Control-Allow-Origin",
125 HeaderValue::from_str(&origin)
126 .map_err(|e| LambdaError::Cors(format!("Invalid origin: {}", e)))?,
127 );
128 }
129
130 let methods_str = config
132 .allowed_methods
133 .iter()
134 .map(|m| m.as_str())
135 .collect::<Vec<_>>()
136 .join(", ");
137 response.headers_mut().insert(
138 "Access-Control-Allow-Methods",
139 HeaderValue::from_str(&methods_str)
140 .map_err(|e| LambdaError::Cors(format!("Invalid methods: {}", e)))?,
141 );
142
143 if !config.allowed_headers.is_empty() {
145 let headers_str = config.allowed_headers.join(", ");
146 response.headers_mut().insert(
147 "Access-Control-Allow-Headers",
148 HeaderValue::from_str(&headers_str)
149 .map_err(|e| LambdaError::Cors(format!("Invalid headers: {}", e)))?,
150 );
151 }
152
153 if !config.expose_headers.is_empty() {
155 let expose_str = config.expose_headers.join(", ");
156 response.headers_mut().insert(
157 "Access-Control-Expose-Headers",
158 HeaderValue::from_str(&expose_str)
159 .map_err(|e| LambdaError::Cors(format!("Invalid expose headers: {}", e)))?,
160 );
161 }
162
163 if config.allow_credentials {
165 response.headers_mut().insert(
166 "Access-Control-Allow-Credentials",
167 HeaderValue::from_static("true"),
168 );
169 }
170
171 if let Some(max_age) = config.max_age {
173 response.headers_mut().insert(
174 "Access-Control-Max-Age",
175 HeaderValue::from_str(&max_age.to_string())
176 .map_err(|e| LambdaError::Cors(format!("Invalid max age: {}", e)))?,
177 );
178 }
179
180 debug!("CORS headers injected successfully");
181 Ok(())
182}
183
184pub fn create_preflight_response(
188 config: &CorsConfig,
189 request_origin: Option<&str>,
190) -> Result<LambdaResponse<LambdaBody>> {
191 debug!("Creating CORS preflight response");
192
193 let mut response = LambdaResponse::builder()
194 .status(200)
195 .body(LambdaBody::Empty)
196 .map_err(LambdaError::Http)?;
197
198 inject_cors_headers(&mut response, config, request_origin)?;
199
200 Ok(response)
201}
202
203fn determine_allowed_origin(config: &CorsConfig, request_origin: Option<&str>) -> Option<String> {
205 if config.allowed_origins.contains(&"*".to_string()) {
207 return Some("*".to_string());
208 }
209
210 let request_origin = request_origin?;
212
213 if config.allowed_origins.contains(&request_origin.to_string()) {
215 Some(request_origin.to_string())
216 } else {
217 None
219 }
220}
221
222pub fn validate_config(config: &CorsConfig) -> Result<()> {
224 if config.allow_credentials && config.allowed_origins.contains(&"*".to_string()) {
226 return Err(LambdaError::Cors(
227 "Cannot use wildcard origin (*) with credentials enabled".to_string(),
228 ));
229 }
230
231 for origin in &config.allowed_origins {
233 if origin != "*" && !origin.starts_with("http://") && !origin.starts_with("https://") {
234 return Err(LambdaError::Cors(format!(
235 "Invalid origin format: {}",
236 origin
237 )));
238 }
239 }
240
241 let headers_set: HashSet<_> = config.allowed_headers.iter().collect();
243 if headers_set.len() != config.allowed_headers.len() {
244 return Err(LambdaError::Cors(
245 "Duplicate headers in allowed_headers".to_string(),
246 ));
247 }
248
249 Ok(())
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use lambda_http::Body;
256
257 #[test]
258 fn test_default_config() {
259 let config = CorsConfig::default();
260 assert!(config.allowed_origins.contains(&"*".to_string()));
261 assert!(config.allowed_methods.contains(&Method::GET));
262 assert!(config.allowed_methods.contains(&Method::POST));
263 assert!(config.allowed_headers.contains(&"Content-Type".to_string()));
264 }
265
266 #[test]
267 fn test_config_validation() {
268 let mut config = CorsConfig::default();
269 assert!(validate_config(&config).is_ok());
270
271 config.allow_credentials = true;
273 assert!(validate_config(&config).is_err());
274
275 config.allow_credentials = false;
277 config.allowed_origins = vec!["invalid-origin".to_string()];
278 assert!(validate_config(&config).is_err());
279 }
280
281 #[tokio::test]
282 async fn test_cors_headers_injection() {
283 let config = CorsConfig::default();
284 let mut response = LambdaResponse::builder()
285 .status(200)
286 .body(Body::Empty)
287 .unwrap();
288
289 inject_cors_headers(&mut response, &config, Some("https://example.com")).unwrap();
290
291 assert_eq!(
292 response.headers().get("access-control-allow-origin"),
293 Some(&HeaderValue::from_static("*"))
294 );
295
296 assert!(
297 response
298 .headers()
299 .contains_key("access-control-allow-methods")
300 );
301 assert!(
302 response
303 .headers()
304 .contains_key("access-control-allow-headers")
305 );
306 }
307
308 #[test]
309 fn test_default_expose_headers_contains_www_authenticate() {
310 let config = CorsConfig::default();
315 assert!(
316 config
317 .expose_headers
318 .iter()
319 .any(|h| h.eq_ignore_ascii_case("WWW-Authenticate")),
320 "default expose_headers must include WWW-Authenticate; got {:?}",
321 config.expose_headers,
322 );
323 }
324
325 #[tokio::test]
326 async fn test_custom_expose_headers_not_mutated_by_injection() {
327 let config = CorsConfig {
330 expose_headers: vec!["X-Custom".to_string()],
331 ..Default::default()
332 };
333 let original = config.expose_headers.clone();
334
335 let mut response = LambdaResponse::builder()
336 .status(200)
337 .body(Body::Empty)
338 .unwrap();
339 inject_cors_headers(&mut response, &config, Some("https://example.com")).unwrap();
340
341 assert_eq!(config.expose_headers, original);
342 assert_eq!(
343 response.headers().get("access-control-expose-headers"),
344 Some(&HeaderValue::from_static("X-Custom"))
345 );
346 }
347
348 fn header_entries(response: &LambdaResponse<Body>, name: &str) -> Vec<String> {
350 response
351 .headers()
352 .get(name)
353 .map(|v| {
354 v.to_str()
355 .unwrap()
356 .split(',')
357 .map(|s| s.trim().to_ascii_lowercase())
358 .collect()
359 })
360 .unwrap_or_default()
361 }
362
363 fn injected_default_response() -> LambdaResponse<Body> {
364 let config = CorsConfig::default();
365 let mut response = LambdaResponse::builder()
366 .status(200)
367 .body(Body::Empty)
368 .unwrap();
369 inject_cors_headers(&mut response, &config, Some("https://example.com")).unwrap();
370 response
371 }
372
373 #[cfg(feature = "protocol-2026-07-28")]
374 #[tokio::test]
375 async fn test_stateless_response_does_not_advertise_session_header() {
376 let response = injected_default_response();
381
382 let allowed = header_entries(&response, "access-control-allow-headers");
383 assert!(
384 !allowed.iter().any(|h| h == "mcp-session-id"),
385 "2026-07-28 response must not advertise Mcp-Session-Id in \
386 Access-Control-Allow-Headers; got {allowed:?}",
387 );
388
389 let exposed = header_entries(&response, "access-control-expose-headers");
390 assert!(
391 !exposed.iter().any(|h| h == "mcp-session-id"),
392 "2026-07-28 response must not advertise Mcp-Session-Id in \
393 Access-Control-Expose-Headers; got {exposed:?}",
394 );
395
396 assert!(allowed.iter().any(|h| h == "mcp-protocol-version"));
399 assert!(exposed.iter().any(|h| h == "www-authenticate"));
400 }
401
402 #[cfg(feature = "protocol-2025-11-25")]
403 #[tokio::test]
404 async fn test_stateful_response_advertises_session_header() {
405 let response = injected_default_response();
408
409 assert!(
410 header_entries(&response, "access-control-allow-headers")
411 .iter()
412 .any(|h| h == "mcp-session-id"),
413 );
414 assert!(
415 header_entries(&response, "access-control-expose-headers")
416 .iter()
417 .any(|h| h == "mcp-session-id"),
418 );
419 }
420
421 #[tokio::test]
422 async fn test_preflight_response() {
423 let config = CorsConfig::default();
424 let response = create_preflight_response(&config, Some("https://example.com")).unwrap();
425
426 assert_eq!(response.status(), 200);
427 assert!(
428 response
429 .headers()
430 .contains_key("access-control-allow-origin")
431 );
432 assert!(
433 response
434 .headers()
435 .contains_key("access-control-allow-methods")
436 );
437 }
438}