haematite_cli/mcp/http/
config.rs1use 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#[derive(Debug, Deserialize)]
17#[serde(deny_unknown_fields)]
18pub struct Config {
19 pub listen: SocketAddr,
20 pub allowed_origins: BTreeSet<String>,
21 #[serde(default = "mcp_path")]
24 pub path: String,
25 #[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 #[serde(default)]
39 pub store_endpoint: Option<StoreEndpoint>,
40}
41
42#[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}