use hyper::HeaderMap;
pub(crate) const CORS_ALLOW_METHODS: &str = "GET, POST, DELETE, OPTIONS";
pub(crate) const CORS_ALLOW_HEADERS: &str =
"Content-Type, Accept, Authorization, Mcp-Session-Id, MCP-Protocol-Version, Last-Event-ID";
pub(crate) const CORS_EXPOSE_HEADERS: &str = "Mcp-Session-Id, WWW-Authenticate";
pub(crate) const CORS_MAX_AGE: &str = "86400";
pub struct CorsLayer;
impl CorsLayer {
pub fn apply_cors_headers(headers: &mut HeaderMap) {
headers.insert("Access-Control-Allow-Origin", "*".parse().unwrap());
headers.insert(
"Access-Control-Allow-Methods",
CORS_ALLOW_METHODS.parse().unwrap(),
);
headers.insert(
"Access-Control-Allow-Headers",
CORS_ALLOW_HEADERS.parse().unwrap(),
);
headers.insert(
"Access-Control-Expose-Headers",
CORS_EXPOSE_HEADERS.parse().unwrap(),
);
headers.insert("Access-Control-Max-Age", CORS_MAX_AGE.parse().unwrap());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_apply_cors_headers() {
let mut headers = HeaderMap::new();
CorsLayer::apply_cors_headers(&mut headers);
assert_eq!(headers.get("Access-Control-Allow-Origin").unwrap(), "*");
let methods = headers
.get("Access-Control-Allow-Methods")
.unwrap()
.to_str()
.unwrap();
assert!(methods.contains("GET"), "Must include GET");
assert!(methods.contains("POST"), "Must include POST");
assert!(methods.contains("DELETE"), "Must include DELETE");
assert!(methods.contains("OPTIONS"), "Must include OPTIONS");
let allowed = headers
.get("Access-Control-Allow-Headers")
.unwrap()
.to_str()
.unwrap();
assert!(
allowed.contains("Content-Type"),
"Must include Content-Type"
);
assert!(allowed.contains("Accept"), "Must include Accept");
assert!(
allowed.contains("Authorization"),
"Must include Authorization"
);
assert!(
allowed.contains("Mcp-Session-Id"),
"Must include Mcp-Session-Id"
);
assert!(
allowed.contains("MCP-Protocol-Version"),
"Must include MCP-Protocol-Version"
);
assert!(
allowed.contains("Last-Event-ID"),
"Must include Last-Event-ID"
);
let exposed = headers
.get("Access-Control-Expose-Headers")
.unwrap()
.to_str()
.unwrap();
let exposed_set: Vec<&str> = exposed.split(',').map(str::trim).collect();
assert!(
exposed_set
.iter()
.any(|h| h.eq_ignore_ascii_case("Mcp-Session-Id")),
"Expose-Headers must include Mcp-Session-Id; got {exposed:?}",
);
assert!(
exposed_set
.iter()
.any(|h| h.eq_ignore_ascii_case("WWW-Authenticate")),
"Expose-Headers must include WWW-Authenticate so browser OAuth clients can read RFC 9728 challenges; got {exposed:?}",
);
assert_eq!(headers.get("Access-Control-Max-Age").unwrap(), CORS_MAX_AGE);
assert!(
!headers.contains_key("Access-Control-Allow-Credentials"),
"Wildcard origin must not set Allow-Credentials"
);
}
#[test]
fn test_options_response_then_cors_layer() {
use crate::json_rpc_responses::options_response;
let mut response = options_response();
assert_eq!(response.status(), hyper::StatusCode::OK);
assert!(
response
.headers()
.get("Access-Control-Allow-Origin")
.is_none(),
"options_response() must not set CORS headers (CorsLayer handles that)"
);
assert!(
response
.headers()
.get("Access-Control-Allow-Methods")
.is_none(),
"options_response() must not set CORS methods"
);
CorsLayer::apply_cors_headers(response.headers_mut());
assert_eq!(
response
.headers()
.get("Access-Control-Allow-Origin")
.unwrap(),
"*"
);
let methods = response
.headers()
.get("Access-Control-Allow-Methods")
.unwrap()
.to_str()
.unwrap();
assert!(methods.contains("DELETE"), "OPTIONS must expose DELETE");
assert!(methods.contains("POST"), "OPTIONS must expose POST");
let allowed = response
.headers()
.get("Access-Control-Allow-Headers")
.unwrap()
.to_str()
.unwrap();
assert!(allowed.contains("Authorization"));
assert!(allowed.contains("Mcp-Session-Id"));
assert!(allowed.contains("MCP-Protocol-Version"));
assert!(allowed.contains("Last-Event-ID"));
let exposed = response
.headers()
.get("Access-Control-Expose-Headers")
.unwrap()
.to_str()
.unwrap();
assert!(
exposed
.split(',')
.map(str::trim)
.any(|h| h.eq_ignore_ascii_case("Mcp-Session-Id")),
"Expose-Headers must include Mcp-Session-Id; got {exposed:?}",
);
assert!(
exposed
.split(',')
.map(str::trim)
.any(|h| h.eq_ignore_ascii_case("WWW-Authenticate")),
"Expose-Headers must include WWW-Authenticate; got {exposed:?}",
);
}
#[test]
fn test_options_response_without_cors_has_no_cors_headers() {
use crate::json_rpc_responses::options_response;
let response = options_response();
assert!(
response
.headers()
.get("Access-Control-Allow-Origin")
.is_none()
);
assert!(
response
.headers()
.get("Access-Control-Allow-Methods")
.is_none()
);
assert!(
response
.headers()
.get("Access-Control-Allow-Headers")
.is_none()
);
assert!(
response
.headers()
.get("Access-Control-Expose-Headers")
.is_none()
);
}
}