Skip to main content

soma_mcp_server/
http.rs

1//! Reusable inbound HTTP MCP transport lifecycle helpers.
2//!
3//! Computes the RMCP Streamable HTTP allow-listed `Host`/`Origin` values and
4//! builds the transport config/service from primitive bind/config values —
5//! no product config type required. A product adapter owns extracting those
6//! primitives from its own config struct; this module owns the deterministic
7//! computation and the RMCP transport wiring.
8
9use std::net::Ipv6Addr;
10
11use rmcp::{
12    transport::streamable_http_server::{
13        session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService,
14    },
15    ServerHandler,
16};
17
18// ── allowed hosts ─────────────────────────────────────────────────────────────
19
20/// Inputs needed to compute the allow-listed `Host` header values for an
21/// inbound MCP HTTP server.
22#[derive(Clone, Copy, Debug)]
23pub struct AllowedHostsInput<'a> {
24    pub bind_host: &'a str,
25    pub port: u16,
26    pub extra_hosts: &'a [String],
27    pub public_url: Option<&'a str>,
28    /// Label used in diagnostic logs when `public_url` fails to parse or
29    /// contains a wildcard host — typically the product's env var name (for
30    /// example `SOMA_MCP_PUBLIC_URL`) so operators can trace a warning back
31    /// to the setting that produced it. Pass a generic label such as
32    /// `"public_url"` if no product-specific name applies.
33    pub public_url_label: &'a str,
34}
35
36pub fn allowed_hosts(input: AllowedHostsInput<'_>) -> Vec<String> {
37    let mut hosts = vec!["localhost".to_string(), "127.0.0.1".to_string()];
38    push_host_variants(&mut hosts, input.bind_host, input.port);
39    push_host_variants(&mut hosts, "localhost", input.port);
40    push_host_variants(&mut hosts, "127.0.0.1", input.port);
41    push_host_variants(&mut hosts, "::1", input.port);
42    for host in input.extra_hosts {
43        push_host_variants(&mut hosts, host, input.port);
44    }
45    if let Some(public_url) = input.public_url {
46        push_public_url_hosts(&mut hosts, public_url, input.port, input.public_url_label);
47    }
48    hosts.sort();
49    hosts.dedup();
50    hosts
51}
52
53// ── allowed origins ───────────────────────────────────────────────────────────
54
55/// Inputs needed to compute the allow-listed `Origin` header values for an
56/// inbound MCP HTTP server.
57#[derive(Clone, Copy, Debug)]
58pub struct AllowedOriginsInput<'a> {
59    pub port: u16,
60    pub extra_origins: &'a [String],
61    pub public_url: Option<&'a str>,
62    /// Label used in diagnostic logs for a rejected entry in `extra_origins`
63    /// — typically the product's env var name (for example
64    /// `SOMA_MCP_ALLOWED_ORIGINS`). Pass a generic label such as
65    /// `"extra_origins"` if no product-specific name applies.
66    pub extra_origins_label: &'a str,
67    /// Label used in diagnostic logs when `public_url` fails to parse —
68    /// typically the product's env var name (for example
69    /// `SOMA_MCP_PUBLIC_URL`). Pass a generic label such as `"public_url"`
70    /// if no product-specific name applies.
71    pub public_url_label: &'a str,
72}
73
74pub fn allowed_origins(input: AllowedOriginsInput<'_>) -> Vec<String> {
75    let mut origins = vec![
76        format!("http://localhost:{}", input.port),
77        format!("http://127.0.0.1:{}", input.port),
78    ];
79    for origin in input.extra_origins {
80        push_configured_origin(&mut origins, origin, input.extra_origins_label);
81    }
82    if let Some(public_url) = input.public_url {
83        if let Some(origin) = extract_origin_with_label(public_url, input.public_url_label) {
84            origins.push(origin);
85        }
86    }
87    origins.sort();
88    origins.dedup();
89    origins
90}
91
92// ── transport builders ────────────────────────────────────────────────────────
93
94pub fn streamable_http_config(
95    hosts: Vec<String>,
96    origins: Vec<String>,
97) -> StreamableHttpServerConfig {
98    StreamableHttpServerConfig::default()
99        .with_stateful_mode(false)
100        .with_json_response(true)
101        .with_allowed_hosts(hosts)
102        .with_allowed_origins(origins)
103}
104
105/// Build a [`StreamableHttpService`] for any [`ServerHandler`], given a
106/// factory that produces a fresh handler per session and a transport config
107/// (see [`streamable_http_config`]).
108pub fn streamable_http_service<S, F>(
109    factory: F,
110    config: StreamableHttpServerConfig,
111) -> StreamableHttpService<S, LocalSessionManager>
112where
113    S: ServerHandler,
114    F: Fn() -> Result<S, std::io::Error> + Send + Sync + 'static,
115{
116    StreamableHttpService::new(factory, Default::default(), config)
117}
118
119// ── private helpers ───────────────────────────────────────────────────────────
120
121fn push_configured_origin(origins: &mut Vec<String>, origin: &str, label: &str) {
122    let Some(origin) = extract_configured_origin_with_label(origin, label) else {
123        return;
124    };
125    origins.push(origin);
126}
127
128fn push_host_variants(hosts: &mut Vec<String>, host: &str, port: u16) {
129    let host = host.trim();
130    if host.is_empty() {
131        return;
132    }
133    hosts.push(host.to_string());
134    if host.starts_with('[') && host.contains("]:") {
135        return;
136    }
137    if let Some(inner) = host.strip_prefix('[').and_then(|v| v.strip_suffix(']')) {
138        if !inner.is_empty() {
139            hosts.push(format!("[{inner}]:{port}"));
140        }
141    } else if host.parse::<Ipv6Addr>().is_ok() {
142        hosts.push(format!("[{host}]"));
143        hosts.push(format!("[{host}]:{port}"));
144    } else if !has_port(host) {
145        hosts.push(format!("{host}:{port}"));
146    }
147}
148
149fn push_public_url_hosts(hosts: &mut Vec<String>, url: &str, listen_port: u16, label: &str) {
150    let Ok(parsed) = url::Url::parse(url) else {
151        tracing::warn!(
152            setting = label,
153            public_url = url,
154            "MCP public URL is not a valid URL"
155        );
156        return;
157    };
158    let Some(host) = parsed.host_str() else {
159        return;
160    };
161    if host.contains('*') {
162        tracing::warn!(
163            setting = label,
164            host,
165            "MCP public URL host contains wildcard; skipping"
166        );
167        return;
168    }
169    let explicit_port = parsed.port();
170    let scheme_default = match parsed.scheme() {
171        "https" => Some(443u16),
172        "http" => Some(80u16),
173        _ => None,
174    };
175    if let Some(p) = explicit_port {
176        push_host_variants(hosts, host, p);
177        let with_port = format!("{host}:{p}");
178        if !hosts.contains(&with_port) {
179            hosts.push(with_port);
180        }
181    } else if let Some(default_port) = scheme_default {
182        let bare = host.to_string();
183        if !hosts.contains(&bare) {
184            hosts.push(bare);
185        }
186        let with_default = format!("{host}:{default_port}");
187        if !hosts.contains(&with_default) {
188            hosts.push(with_default);
189        }
190    } else {
191        push_host_variants(hosts, host, listen_port);
192    }
193}
194
195fn has_port(host: &str) -> bool {
196    host.rsplit_once(':')
197        .and_then(|(_, p)| p.parse::<u16>().ok())
198        .is_some()
199}
200
201fn extract_origin_with_label(url: &str, label: &str) -> Option<String> {
202    let parsed = url::Url::parse(url)
203        .map_err(|e| tracing::warn!(setting = label, url, error = %e, "invalid MCP origin URL"))
204        .ok()?;
205    let scheme = parsed.scheme();
206    let host = parsed.host()?;
207    let host_text = format_origin_host(host);
208    if host_text.contains('*') {
209        tracing::warn!(
210            setting = label,
211            host = %host_text,
212            "MCP origin host contains wildcard; skipping"
213        );
214        return None;
215    }
216    let default_port = match scheme {
217        "http" => Some(80u16),
218        "https" => Some(443u16),
219        _ => {
220            tracing::warn!(
221                setting = label,
222                scheme,
223                "MCP origin URL must use http or https"
224            );
225            return None;
226        }
227    };
228    let origin = match parsed.port() {
229        Some(port) if default_port != Some(port) => format!("{scheme}://{host_text}:{port}"),
230        _ => format!("{scheme}://{host_text}"),
231    };
232    Some(origin)
233}
234
235fn extract_configured_origin_with_label(url: &str, label: &str) -> Option<String> {
236    match extract_origin_with_label(url, label) {
237        Some(origin) => Some(origin),
238        None => {
239            let parsed = url::Url::parse(url).ok()?;
240            if matches!(parsed.scheme(), "http" | "https") {
241                return None;
242            }
243            Some(url.trim().to_string())
244        }
245    }
246}
247
248fn format_origin_host(host: url::Host<&str>) -> String {
249    match host {
250        url::Host::Domain(domain) => domain.to_string(),
251        url::Host::Ipv4(addr) => addr.to_string(),
252        url::Host::Ipv6(addr) => format!("[{addr}]"),
253    }
254}
255
256#[cfg(test)]
257#[path = "http_tests.rs"]
258mod tests;