Skip to main content

klieo_tools_mcp/
http.rs

1//! Streamable-HTTP client transport — connect to a REMOTE MCP server.
2//!
3//! Mirrors the stdio connector but speaks the MCP Streamable-HTTP
4//! transport over the network. The wrapping, validation, and namespacing
5//! of the remote tool catalogue is shared with the stdio path; only the
6//! transport construction differs.
7//!
8//! # Security
9//!
10//! The remote URL and auth secret are a trust boundary:
11//!
12//! - The URL scheme must be `https` (encrypted in transit). Plain `http`
13//!   is rejected unless the host is loopback (`localhost`/`127.0.0.1`/
14//!   `::1`), an explicit local-development affordance.
15//! - The auth secret is resolved from a caller-named environment variable
16//!   at connect time and fails closed: a configured-but-absent or empty
17//!   variable is an error, never a silent "no auth". The secret value is
18//!   never stored in [`HttpConnectOptions`], never logged, and never
19//!   appears in `Debug` output (the options hold only the variable
20//!   *name*); the header value carrying it is marked sensitive.
21
22use http::header::{HeaderName, HeaderValue};
23use klieo_core::error::ToolError;
24use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
25use rmcp::transport::StreamableHttpClientTransport;
26use std::collections::{HashMap, HashSet};
27use std::path::{Path, PathBuf};
28use std::str::FromStr;
29use std::time::Duration;
30
31/// Default auth header name when the caller does not override it.
32const DEFAULT_AUTH_HEADER_NAME: &str = "Authorization";
33
34/// TCP connect-phase timeout. Bounds DNS + handshake so a black-holed
35/// host cannot hang the connect indefinitely (reqwest's own default is
36/// unbounded).
37const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
38
39/// Total per-request timeout covering the whole request/response,
40/// including streamed body. Bounds a slow-loris server.
41const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
42
43/// Loopback hosts for which plain `http` is permitted as a
44/// local-development affordance. The IPv6 loopback appears both with and
45/// without the URL-authority brackets `url::Url::host_str` may emit.
46const LOOPBACK_HOSTS: [&str; 4] = ["localhost", "127.0.0.1", "::1", "[::1]"];
47
48/// Connection options for [`crate::McpToolset::connect_http`].
49///
50/// Holds only non-secret configuration: the auth header *name*, the
51/// *name* of the environment variable supplying the secret value, and an
52/// optional custom CA bundle path. The secret value itself is resolved at
53/// connect time and is never retained here.
54#[derive(Debug, Clone)]
55pub struct HttpConnectOptions {
56    auth_header_name: String,
57    auth_env_var: Option<String>,
58    ca_bundle_path: Option<PathBuf>,
59    pii_bearing_tools: HashSet<String>,
60}
61
62impl Default for HttpConnectOptions {
63    fn default() -> Self {
64        Self {
65            auth_header_name: DEFAULT_AUTH_HEADER_NAME.to_string(),
66            auth_env_var: None,
67            ca_bundle_path: None,
68            pii_bearing_tools: HashSet::new(),
69        }
70    }
71}
72
73impl HttpConnectOptions {
74    /// Supply the auth secret from the named environment variable,
75    /// injected under [`Self::with_auth_header_name`] (default
76    /// `Authorization`). Resolution happens at connect time and fails
77    /// closed if the variable is unset or empty.
78    ///
79    /// The value is sent verbatim — include any scheme prefix (e.g.
80    /// `Bearer `) in the environment variable itself. Unlike some MCP
81    /// clients, no `Bearer ` prefix is added for you.
82    pub fn with_auth_env_var(mut self, env_var: impl Into<String>) -> Self {
83        self.auth_env_var = Some(env_var.into());
84        self
85    }
86
87    /// Override the header name the resolved secret is sent under.
88    pub fn with_auth_header_name(mut self, name: impl Into<String>) -> Self {
89        self.auth_header_name = name.into();
90        self
91    }
92
93    /// Add a custom CA bundle (PEM) to the client's root store, for
94    /// servers presenting a certificate from a private CA.
95    pub fn with_ca_bundle(mut self, path: impl Into<PathBuf>) -> Self {
96        self.ca_bundle_path = Some(path.into());
97        self
98    }
99
100    /// Declare which server-advertised tools handle PII so dispatch
101    /// redacts their audit records. Pass the ORIGINAL (un-namespaced)
102    /// tool names the remote server advertises — e.g. `verify_claim`,
103    /// NOT the `mcp:<server_id>.verify_claim` form the wrapped tool
104    /// reports from [`klieo_core::Tool::name`]. A flagged tool's wrapped
105    /// form returns `true` from `redacts_audit`, so dispatch records a
106    /// redacted projection of its args/result instead of the raw values.
107    pub fn with_pii_bearing_tools(mut self, names: impl IntoIterator<Item = String>) -> Self {
108        self.pii_bearing_tools = names.into_iter().collect();
109        self
110    }
111
112    /// The set of original tool names the operator flagged as
113    /// PII-bearing, consumed by the wrap path.
114    pub(crate) fn pii_bearing_tools(&self) -> &HashSet<String> {
115        &self.pii_bearing_tools
116    }
117}
118
119/// Connect to a remote MCP server over Streamable HTTP and return the
120/// connected `rmcp` running service before its tools are wrapped.
121///
122/// Performs URL-scheme validation and auth resolution (both fail closed)
123/// before any network I/O, then completes the rmcp handshake.
124pub(crate) async fn serve_http(
125    url: &str,
126    opts: &HttpConnectOptions,
127) -> Result<rmcp::service::RunningService<rmcp::service::RoleClient, ()>, ToolError> {
128    validate_url(url)?;
129    let custom_headers = build_auth_headers(opts)?;
130    let client = build_http_client(opts).await?;
131    let config = StreamableHttpClientTransportConfig::with_uri(url).custom_headers(custom_headers);
132    let transport = StreamableHttpClientTransport::with_client(client, config);
133    crate::toolset::serve_transport(transport)
134        .await
135        .map_err(|e| ToolError::Permanent(format!("MCP HTTP handshake failed: {e}")))
136}
137
138/// Reject any URL that is not `https`, with a loopback-only exception for
139/// plain `http`. Client-side configuration errors map to
140/// [`ToolError::InvalidArgs`].
141fn validate_url(url: &str) -> Result<(), ToolError> {
142    let parsed = reqwest::Url::parse(url)
143        .map_err(|e| ToolError::InvalidArgs(format!("invalid MCP server URL: {e}")))?;
144    match parsed.scheme() {
145        "https" => Ok(()),
146        "http" if is_loopback(parsed.host_str()) => Ok(()),
147        "http" => Err(ToolError::InvalidArgs(
148            "MCP server URL must use https (plain http allowed only for loopback hosts)".into(),
149        )),
150        other => Err(ToolError::InvalidArgs(format!(
151            "unsupported MCP server URL scheme '{other}' (expected https)"
152        ))),
153    }
154}
155
156fn is_loopback(host: Option<&str>) -> bool {
157    host.is_some_and(|h| LOOPBACK_HOSTS.contains(&h))
158}
159
160fn build_auth_headers(opts: &HttpConnectOptions) -> Result<HeaderMap, ToolError> {
161    let mut headers = HashMap::new();
162    let Some(env_var) = &opts.auth_env_var else {
163        return Ok(headers);
164    };
165    let auth_value = resolve_auth_secret(env_var)?;
166    let name = HeaderName::from_str(&opts.auth_header_name).map_err(|_| {
167        ToolError::InvalidArgs(format!(
168            "invalid auth header name: {:?}",
169            opts.auth_header_name
170        ))
171    })?;
172    let mut value = HeaderValue::from_str(&auth_value)
173        .map_err(|_| ToolError::InvalidArgs("auth secret is not a valid header value".into()))?;
174    value.set_sensitive(true);
175    headers.insert(name, value);
176    Ok(headers)
177}
178
179type HeaderMap = HashMap<HeaderName, HeaderValue>;
180
181/// `NotPresent` (variable unset) and `NotUnicode` (variable holds
182/// non-UTF-8 bytes) map to distinct typed errors rather than collapsing
183/// into a generic default. An empty value is treated as unset.
184fn resolve_auth_secret(env_var: &str) -> Result<String, ToolError> {
185    match std::env::var(env_var) {
186        Ok(value) if value.is_empty() => Err(ToolError::InvalidArgs(format!(
187            "auth env var {env_var} is set but empty"
188        ))),
189        Ok(value) => Ok(value),
190        Err(std::env::VarError::NotPresent) => Err(ToolError::InvalidArgs(format!(
191            "auth env var {env_var} is configured but not set"
192        ))),
193        Err(std::env::VarError::NotUnicode(_)) => Err(ToolError::InvalidArgs(format!(
194            "auth env var {env_var} holds non-UTF-8 bytes"
195        ))),
196    }
197}
198
199async fn build_http_client(opts: &HttpConnectOptions) -> Result<reqwest::Client, ToolError> {
200    let mut builder = reqwest::Client::builder()
201        .connect_timeout(CONNECT_TIMEOUT)
202        .timeout(REQUEST_TIMEOUT);
203    if let Some(path) = &opts.ca_bundle_path {
204        builder = builder.tls_certs_merge(load_ca_bundle(path.as_path()).await?);
205    }
206    builder
207        .build()
208        .map_err(|e| ToolError::Permanent(format!("failed to build HTTP client: {e}")))
209}
210
211/// A missing/unreadable file or malformed PEM is a client-side config
212/// error ([`ToolError::InvalidArgs`]).
213async fn load_ca_bundle(path: &Path) -> Result<Vec<reqwest::Certificate>, ToolError> {
214    let pem = tokio::fs::read(path)
215        .await
216        .map_err(|e| ToolError::InvalidArgs(format!("cannot read CA bundle {path:?}: {e}")))?;
217    reqwest::Certificate::from_pem_bundle(&pem)
218        .map_err(|e| ToolError::InvalidArgs(format!("invalid CA bundle {path:?}: {e}")))
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn https_url_accepted() {
227        assert!(validate_url("https://mcp.example.com/mcp").is_ok());
228    }
229
230    #[test]
231    fn plain_http_rejected_for_remote_host() {
232        let err = validate_url("http://mcp.example.com/mcp").unwrap_err();
233        assert!(matches!(err, ToolError::InvalidArgs(_)), "got {err:?}");
234    }
235
236    #[test]
237    fn plain_http_allowed_for_loopback_hosts() {
238        assert!(validate_url("http://localhost:8000/mcp").is_ok());
239        assert!(validate_url("http://127.0.0.1:8000/mcp").is_ok());
240        assert!(validate_url("http://[::1]:8000/mcp").is_ok());
241    }
242
243    #[test]
244    fn non_http_scheme_rejected() {
245        let err = validate_url("ftp://mcp.example.com/mcp").unwrap_err();
246        assert!(matches!(err, ToolError::InvalidArgs(_)), "got {err:?}");
247    }
248
249    #[test]
250    fn garbage_url_rejected() {
251        let err = validate_url("not a url").unwrap_err();
252        assert!(matches!(err, ToolError::InvalidArgs(_)), "got {err:?}");
253    }
254
255    #[test]
256    fn present_env_var_resolves_to_value() {
257        let var = "KLIEO_TEST_AUTH_PRESENT";
258        std::env::set_var(var, "secret-token");
259        let resolved = resolve_auth_secret(var).expect("present var must resolve");
260        assert_eq!(resolved, "secret-token");
261        std::env::remove_var(var);
262    }
263
264    #[test]
265    fn absent_env_var_fails_closed_with_not_present() {
266        let var = "KLIEO_TEST_AUTH_ABSENT";
267        std::env::remove_var(var);
268        let err = resolve_auth_secret(var).unwrap_err();
269        match err {
270            ToolError::InvalidArgs(message) => assert!(message.contains("not set"), "{message}"),
271            other => panic!("expected InvalidArgs, got {other:?}"),
272        }
273    }
274
275    #[test]
276    fn empty_env_var_fails_closed() {
277        let var = "KLIEO_TEST_AUTH_EMPTY";
278        std::env::set_var(var, "");
279        let err = resolve_auth_secret(var).unwrap_err();
280        match err {
281            ToolError::InvalidArgs(message) => assert!(message.contains("empty"), "{message}"),
282            other => panic!("expected InvalidArgs, got {other:?}"),
283        }
284        std::env::remove_var(var);
285    }
286
287    #[test]
288    fn no_auth_env_var_yields_empty_header_map() {
289        let opts = HttpConnectOptions::default();
290        let headers = build_auth_headers(&opts).expect("no-auth must succeed");
291        assert!(headers.is_empty());
292    }
293
294    #[test]
295    fn configured_auth_builds_sensitive_header_under_named_header() {
296        let var = "KLIEO_TEST_AUTH_HEADER";
297        std::env::set_var(var, "token-xyz");
298        let opts = HttpConnectOptions::default()
299            .with_auth_env_var(var)
300            .with_auth_header_name("X-Api-Key");
301        let headers = build_auth_headers(&opts).expect("configured auth must build");
302        let value = headers
303            .get(&HeaderName::from_static("x-api-key"))
304            .expect("header must be present under the configured name");
305        assert!(value.is_sensitive(), "auth header value must be sensitive");
306        assert_eq!(value.to_str().unwrap(), "token-xyz");
307        std::env::remove_var(var);
308    }
309
310    #[test]
311    fn options_debug_never_contains_secret_value() {
312        let opts = HttpConnectOptions::default().with_auth_env_var("SOME_ENV_NAME");
313        let rendered = format!("{opts:?}");
314        assert!(rendered.contains("SOME_ENV_NAME"));
315        assert!(!rendered.contains("token"));
316    }
317
318    #[test]
319    fn default_options_have_no_pii_bearing_tools() {
320        assert!(HttpConnectOptions::default().pii_bearing_tools().is_empty());
321    }
322
323    #[test]
324    fn with_pii_bearing_tools_collects_the_declared_names() {
325        let opts =
326            HttpConnectOptions::default().with_pii_bearing_tools(["verify_claim".to_string()]);
327        assert!(opts.pii_bearing_tools().contains("verify_claim"));
328        assert!(!opts.pii_bearing_tools().contains("ping"));
329    }
330}