Skip to main content

haematite_cli/mcp/http/
config.rs

1use std::collections::BTreeSet;
2use std::net::{IpAddr, SocketAddr};
3use std::num::{NonZeroU64, NonZeroUsize};
4use std::time::Duration;
5
6use serde::Deserialize;
7use ureq::http::{HeaderValue, Uri};
8
9use super::error::Error;
10
11/// The HTTP MCP's own settings.
12///
13/// Who may come in is not among them: each request's bearer sign-in is the
14/// caller's own Lys pass for haematite, and the store judges it, and every
15/// act under it, by that caller's grants.
16#[derive(Debug, Deserialize)]
17#[serde(deny_unknown_fields)]
18pub struct Config {
19    pub listen: SocketAddr,
20    pub allowed_origins: BTreeSet<String>,
21    /// The one request path answered, `/mcp` unless set, so two listeners
22    /// behind one hostname can each own a path (`/mcp`, `/mcp/studio`).
23    #[serde(default = "mcp_path")]
24    pub path: String,
25    /// Where an MCP client finds how to sign in, named in the challenge of
26    /// every 401 (RFC 9728).
27    #[serde(default)]
28    pub resource_metadata_url: Option<String>,
29    #[serde(default = "connections")]
30    pub max_connections: NonZeroUsize,
31    #[serde(default = "header_timeout")]
32    pub header_timeout_ms: NonZeroU64,
33    #[serde(default = "body_timeout")]
34    pub body_timeout_ms: NonZeroU64,
35}
36
37fn mcp_path() -> String {
38    "/mcp".to_owned()
39}
40
41const fn connections() -> NonZeroUsize {
42    NonZeroUsize::MIN.saturating_add(63)
43}
44
45const fn header_timeout() -> NonZeroU64 {
46    NonZeroU64::MIN.saturating_add(1_999)
47}
48
49const fn body_timeout() -> NonZeroU64 {
50    NonZeroU64::MIN.saturating_add(4_999)
51}
52
53impl Config {
54    pub(super) fn validate(&self) -> Result<(), Error> {
55        if !self.listen.ip().is_loopback() {
56            return Err(Error::config(
57                "listen must be a loopback address behind a TLS proxy",
58            ));
59        }
60        if !self.path.starts_with('/')
61            || self.path.len() > 256
62            || self.path.ends_with('/')
63            || self.path.contains("//")
64            || !self.path.bytes().all(|byte| {
65                byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'-' | b'_' | b'.')
66            })
67        {
68            return Err(Error::config(
69                "path must be an absolute request path of plain segments",
70            ));
71        }
72        for origin in &self.allowed_origins {
73            canonical_origin(origin)?;
74        }
75        if let Some(url) = &self.resource_metadata_url {
76            trusted(url)?;
77            if url.len() > 2_048 || HeaderValue::from_str(url).is_err() {
78                return Err(Error::config(
79                    "resource_metadata_url must fit one safe challenge header",
80                ));
81            }
82        }
83        for millis in [self.header_timeout_ms.get(), self.body_timeout_ms.get()] {
84            if std::time::Instant::now()
85                .checked_add(Duration::from_millis(millis))
86                .is_none()
87            {
88                return Err(Error::config("HTTP deadline is not representable"));
89            }
90        }
91        Ok(())
92    }
93}
94
95fn canonical_origin(value: &str) -> Result<(), Error> {
96    let uri = trusted(value)?;
97    let scheme = uri
98        .scheme_str()
99        .ok_or_else(|| Error::config("origin scheme is missing"))?;
100    let authority = uri
101        .authority()
102        .ok_or_else(|| Error::config("origin authority is missing"))?;
103    let host = authority.host();
104    let normalized = match host.trim_matches(['[', ']']).parse::<IpAddr>() {
105        Ok(IpAddr::V4(address)) => address.to_string(),
106        Ok(IpAddr::V6(address)) => format!("[{address}]"),
107        Err(_) => host.to_ascii_lowercase(),
108    };
109    let port = authority.port_u16();
110    let suffix = match port {
111        Some(80) if scheme == "http" => String::new(),
112        Some(443) if scheme == "https" => String::new(),
113        Some(port) => format!(":{port}"),
114        None => String::new(),
115    };
116    if uri.path() != "/"
117        || uri.query().is_some()
118        || value != format!("{scheme}://{normalized}{suffix}")
119    {
120        return Err(Error::config(
121            "allowed_origins must contain canonical origins without paths",
122        ));
123    }
124    Ok(())
125}
126
127fn trusted(value: &str) -> Result<Uri, Error> {
128    if value.contains('#') || !value.is_ascii() || value.chars().any(|ch| matches!(ch, '"' | '\\'))
129    {
130        return Err(Error::config(
131            "configured URLs must be absolute ASCII URLs without fragments",
132        ));
133    }
134    let uri: Uri = value
135        .parse()
136        .map_err(|_| Error::config("configured URL is not valid"))?;
137    let host = uri
138        .host()
139        .ok_or_else(|| Error::config("configured URL host is missing"))?;
140    let authority = uri
141        .authority()
142        .ok_or_else(|| Error::config("configured URL authority is missing"))?;
143    let loopback = host
144        .trim_matches(['[', ']'])
145        .parse::<IpAddr>()
146        .is_ok_and(|address| address.is_loopback());
147    if authority.as_str().contains('@')
148        || !(uri.scheme_str() == Some("https") || (uri.scheme_str() == Some("http") && loopback))
149    {
150        return Err(Error::config(
151            "configured URL requires HTTPS or a literal loopback HTTP address without credentials",
152        ));
153    }
154    Ok(uri)
155}