use std::collections::BTreeSet;
use std::net::{IpAddr, SocketAddr};
use std::num::{NonZeroU64, NonZeroUsize};
use std::time::Duration;
use serde::Deserialize;
use ureq::http::{HeaderValue, Uri};
use super::error::Error;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
pub listen: SocketAddr,
pub allowed_origins: BTreeSet<String>,
#[serde(default = "mcp_path")]
pub path: String,
#[serde(default)]
pub resource_metadata_url: Option<String>,
#[serde(default = "connections")]
pub max_connections: NonZeroUsize,
#[serde(default = "header_timeout")]
pub header_timeout_ms: NonZeroU64,
#[serde(default = "body_timeout")]
pub body_timeout_ms: NonZeroU64,
}
fn mcp_path() -> String {
"/mcp".to_owned()
}
const fn connections() -> NonZeroUsize {
NonZeroUsize::MIN.saturating_add(63)
}
const fn header_timeout() -> NonZeroU64 {
NonZeroU64::MIN.saturating_add(1_999)
}
const fn body_timeout() -> NonZeroU64 {
NonZeroU64::MIN.saturating_add(4_999)
}
impl Config {
pub(super) fn validate(&self) -> Result<(), Error> {
if !self.listen.ip().is_loopback() {
return Err(Error::config(
"listen must be a loopback address behind a TLS proxy",
));
}
if !self.path.starts_with('/')
|| self.path.len() > 256
|| self.path.ends_with('/')
|| self.path.contains("//")
|| !self.path.bytes().all(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'-' | b'_' | b'.')
})
{
return Err(Error::config(
"path must be an absolute request path of plain segments",
));
}
for origin in &self.allowed_origins {
canonical_origin(origin)?;
}
if let Some(url) = &self.resource_metadata_url {
trusted(url)?;
if url.len() > 2_048 || HeaderValue::from_str(url).is_err() {
return Err(Error::config(
"resource_metadata_url must fit one safe challenge header",
));
}
}
for millis in [self.header_timeout_ms.get(), self.body_timeout_ms.get()] {
if std::time::Instant::now()
.checked_add(Duration::from_millis(millis))
.is_none()
{
return Err(Error::config("HTTP deadline is not representable"));
}
}
Ok(())
}
}
fn canonical_origin(value: &str) -> Result<(), Error> {
let uri = trusted(value)?;
let scheme = uri
.scheme_str()
.ok_or_else(|| Error::config("origin scheme is missing"))?;
let authority = uri
.authority()
.ok_or_else(|| Error::config("origin authority is missing"))?;
let host = authority.host();
let normalized = match host.trim_matches(['[', ']']).parse::<IpAddr>() {
Ok(IpAddr::V4(address)) => address.to_string(),
Ok(IpAddr::V6(address)) => format!("[{address}]"),
Err(_) => host.to_ascii_lowercase(),
};
let port = authority.port_u16();
let suffix = match port {
Some(80) if scheme == "http" => String::new(),
Some(443) if scheme == "https" => String::new(),
Some(port) => format!(":{port}"),
None => String::new(),
};
if uri.path() != "/"
|| uri.query().is_some()
|| value != format!("{scheme}://{normalized}{suffix}")
{
return Err(Error::config(
"allowed_origins must contain canonical origins without paths",
));
}
Ok(())
}
fn trusted(value: &str) -> Result<Uri, Error> {
if value.contains('#') || !value.is_ascii() || value.chars().any(|ch| matches!(ch, '"' | '\\'))
{
return Err(Error::config(
"configured URLs must be absolute ASCII URLs without fragments",
));
}
let uri: Uri = value
.parse()
.map_err(|_| Error::config("configured URL is not valid"))?;
let host = uri
.host()
.ok_or_else(|| Error::config("configured URL host is missing"))?;
let authority = uri
.authority()
.ok_or_else(|| Error::config("configured URL authority is missing"))?;
let loopback = host
.trim_matches(['[', ']'])
.parse::<IpAddr>()
.is_ok_and(|address| address.is_loopback());
if authority.as_str().contains('@')
|| !(uri.scheme_str() == Some("https") || (uri.scheme_str() == Some("http") && loopback))
{
return Err(Error::config(
"configured URL requires HTTPS or a literal loopback HTTP address without credentials",
));
}
Ok(uri)
}