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    /// The store's HAEMNET-001 network endpoint, reached over TLS verified
36    /// against this machine's roots in place of the service's socket, so
37    /// mcp-http need not run on the store's machine (HAEMNET-004 R2).
38    #[serde(default)]
39    pub store_endpoint: Option<StoreEndpoint>,
40}
41
42/// Where a store's network endpoint is, and the name its certificate must
43/// carry.
44#[derive(Debug, Deserialize)]
45#[serde(deny_unknown_fields)]
46pub struct StoreEndpoint {
47    pub endpoint: String,
48    pub server_name: String,
49}
50
51fn mcp_path() -> String {
52    "/mcp".to_owned()
53}
54
55const fn connections() -> NonZeroUsize {
56    NonZeroUsize::MIN.saturating_add(63)
57}
58
59const fn header_timeout() -> NonZeroU64 {
60    NonZeroU64::MIN.saturating_add(1_999)
61}
62
63const fn body_timeout() -> NonZeroU64 {
64    NonZeroU64::MIN.saturating_add(4_999)
65}
66
67impl Config {
68    pub(super) fn validate(&self) -> Result<(), Error> {
69        if !self.listen.ip().is_loopback() {
70            return Err(Error::config(
71                "listen must be a loopback address behind a TLS proxy",
72            ));
73        }
74        if !self.path.starts_with('/')
75            || self.path.len() > 256
76            || self.path.ends_with('/')
77            || self.path.contains("//")
78            || !self.path.bytes().all(|byte| {
79                byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'-' | b'_' | b'.')
80            })
81        {
82            return Err(Error::config(
83                "path must be an absolute request path of plain segments",
84            ));
85        }
86        for origin in &self.allowed_origins {
87            canonical_origin(origin)?;
88        }
89        if let Some(url) = &self.resource_metadata_url {
90            trusted(url)?;
91            if url.len() > 2_048 || HeaderValue::from_str(url).is_err() {
92                return Err(Error::config(
93                    "resource_metadata_url must fit one safe challenge header",
94                ));
95            }
96        }
97        for millis in [self.header_timeout_ms.get(), self.body_timeout_ms.get()] {
98            if std::time::Instant::now()
99                .checked_add(Duration::from_millis(millis))
100                .is_none()
101            {
102                return Err(Error::config("HTTP deadline is not representable"));
103            }
104        }
105        Ok(())
106    }
107}
108
109fn canonical_origin(value: &str) -> Result<(), Error> {
110    let uri = trusted(value)?;
111    let scheme = uri
112        .scheme_str()
113        .ok_or_else(|| Error::config("origin scheme is missing"))?;
114    let authority = uri
115        .authority()
116        .ok_or_else(|| Error::config("origin authority is missing"))?;
117    let host = authority.host();
118    let normalized = match host.trim_matches(['[', ']']).parse::<IpAddr>() {
119        Ok(IpAddr::V4(address)) => address.to_string(),
120        Ok(IpAddr::V6(address)) => format!("[{address}]"),
121        Err(_) => host.to_ascii_lowercase(),
122    };
123    let port = authority.port_u16();
124    let suffix = match port {
125        Some(80) if scheme == "http" => String::new(),
126        Some(443) if scheme == "https" => String::new(),
127        Some(port) => format!(":{port}"),
128        None => String::new(),
129    };
130    if uri.path() != "/"
131        || uri.query().is_some()
132        || value != format!("{scheme}://{normalized}{suffix}")
133    {
134        return Err(Error::config(
135            "allowed_origins must contain canonical origins without paths",
136        ));
137    }
138    Ok(())
139}
140
141fn trusted(value: &str) -> Result<Uri, Error> {
142    if value.contains('#') || !value.is_ascii() || value.chars().any(|ch| matches!(ch, '"' | '\\'))
143    {
144        return Err(Error::config(
145            "configured URLs must be absolute ASCII URLs without fragments",
146        ));
147    }
148    let uri: Uri = value
149        .parse()
150        .map_err(|_| Error::config("configured URL is not valid"))?;
151    let host = uri
152        .host()
153        .ok_or_else(|| Error::config("configured URL host is missing"))?;
154    let authority = uri
155        .authority()
156        .ok_or_else(|| Error::config("configured URL authority is missing"))?;
157    let loopback = host
158        .trim_matches(['[', ']'])
159        .parse::<IpAddr>()
160        .is_ok_and(|address| address.is_loopback());
161    if authority.as_str().contains('@')
162        || !(uri.scheme_str() == Some("https") || (uri.scheme_str() == Some("http") && loopback))
163    {
164        return Err(Error::config(
165            "configured URL requires HTTPS or a literal loopback HTTP address without credentials",
166        ));
167    }
168    Ok(uri)
169}