1use 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
31const DEFAULT_AUTH_HEADER_NAME: &str = "Authorization";
33
34const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
38
39const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
42
43const LOOPBACK_HOSTS: [&str; 4] = ["localhost", "127.0.0.1", "::1", "[::1]"];
47
48#[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 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 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 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 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 pub(crate) fn pii_bearing_tools(&self) -> &HashSet<String> {
115 &self.pii_bearing_tools
116 }
117}
118
119pub(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
138fn 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
181fn 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
211async 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}