use std::net::IpAddr;
use http::header::{AUTHORIZATION, CONTENT_LENGTH, HOST, ORIGIN};
use http::{HeaderMap, StatusCode, Uri};
pub const TOKEN_ENV: &str = crate::cli::MCP_TOKEN_ENV;
pub const MAX_REQUEST_BODY_BYTES: usize = 4 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Refusal {
pub status: StatusCode,
pub reason: &'static str,
}
impl Refusal {
fn forbidden(reason: &'static str) -> Self {
Self {
status: StatusCode::FORBIDDEN,
reason,
}
}
}
#[derive(Debug, Clone)]
pub struct HttpGuard {
bind: IpAddr,
token: Option<String>,
}
impl HttpGuard {
pub fn new(bind: IpAddr, token: Option<String>) -> anyhow::Result<Self> {
if let Some(token) = &token {
anyhow::ensure!(
!token.is_empty() && token.bytes().all(|b| b.is_ascii_graphic()),
"the MCP token must be non-empty printable ASCII without spaces"
);
}
if !bind.is_loopback() && token.is_none() {
anyhow::bail!(
"--mcp-http on {} would expose an unauthenticated control plane to the network \
(every MCP tool runs with this process's privileges, and start_server accepts \
script handlers). Bind a loopback address, or set --mcp-token / {} and require \
a bearer token.",
bind,
TOKEN_ENV
);
}
Ok(Self { bind, token })
}
pub fn requires_token(&self) -> bool {
self.token.is_some()
}
pub fn check(&self, headers: &HeaderMap, uri: &Uri) -> Result<(), Refusal> {
if let Some(length) = headers
.get(CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.trim().parse::<u64>().ok())
{
if length > MAX_REQUEST_BODY_BYTES as u64 {
return Err(Refusal {
status: StatusCode::PAYLOAD_TOO_LARGE,
reason: "request body exceeds the MCP endpoint's limit",
});
}
}
if let Some(expected) = &self.token {
let presented = headers
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(bearer_of);
return match presented {
Some(presented) if constant_time_eq(presented.as_bytes(), expected.as_bytes()) => {
Ok(())
}
_ => Err(Refusal {
status: StatusCode::UNAUTHORIZED,
reason: "missing or invalid bearer token",
}),
};
}
let authority = headers
.get(HOST)
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
.or_else(|| uri.authority().map(|a| a.as_str().to_owned()));
match authority.as_deref().map(host_of) {
Some(host) if self.is_local_host(host) => {}
Some(_) => {
return Err(Refusal::forbidden(
"Host does not name this loopback listener (DNS rebinding?)",
))
}
None => return Err(Refusal::forbidden("request names no host")),
}
if let Some(origin) = headers.get(ORIGIN) {
let origin = origin.to_str().unwrap_or("");
let allowed = origin
.split_once("://")
.map(|(_, rest)| self.is_local_host(host_of(rest)))
.unwrap_or(false);
if !allowed {
return Err(Refusal::forbidden(
"Origin is not a loopback origin; set --mcp-token to serve other origins",
));
}
}
Ok(())
}
fn is_local_host(&self, host: &str) -> bool {
let host = host.trim_matches(|c| c == '[' || c == ']');
if host.eq_ignore_ascii_case("localhost") {
return true;
}
match host.parse::<IpAddr>() {
Ok(ip) => ip.is_loopback() || ip == self.bind,
Err(_) => false,
}
}
}
fn host_of(authority: &str) -> &str {
let authority = authority.split('/').next().unwrap_or("");
if let Some(rest) = authority.strip_prefix('[') {
return match rest.find(']') {
Some(end) => &authority[..end + 2],
None => authority,
};
}
authority.split(':').next().unwrap_or("")
}
fn bearer_of(value: &str) -> Option<&str> {
let (scheme, token) = value.trim().split_once(' ')?;
if !scheme.eq_ignore_ascii_case("bearer") {
return None;
}
let token = token.trim();
(!token.is_empty()).then_some(token)
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}