use std::{borrow::Cow, collections::HashMap, sync::Arc}; use bytes::Bytes; use futures::stream::BoxStream; use http::{HeaderName, HeaderValue, Method, Request, StatusCode, header::WWW_AUTHENTICATE}; use http_body_util::{BodyExt, Full}; use hyper::body::Incoming; use hyper_util::rt::TokioIo; use sse_stream::Sse; use tokio::net::UnixStream; use crate::{ model::{ClientJsonRpcMessage, ServerJsonRpcMessage}, transport::{ common::{ client_side_sse::{DEFAULT_MAX_SSE_EVENT_SIZE, bounded_sse_stream}, http_header::{ EVENT_STREAM_MIME_TYPE, HEADER_LAST_EVENT_ID, HEADER_SESSION_ID, JSON_MIME_TYPE, extract_scope_from_header, validate_custom_header, }, }, streamable_http_client::*, }, }; #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum UnixSocketError { #[error("hyper error: {0}")] Hyper(#[from] hyper::Error), #[error("IO error: {0}")] Io(#[from] std::io::Error), #[error("HTTP error: {0}")] Http(#[from] http::Error), #[error("JSON error: {0}")] Json(#[from] serde_json::Error), } impl From for StreamableHttpError { fn from(e: UnixSocketError) -> Self { StreamableHttpError::Client(e) } } /// HTTP client that routes requests through a Unix domain socket. /// /// Implements [`StreamableHttpClient`] using `hyper` over `tokio::net::UnixStream`, /// enabling MCP hosts in Kubernetes environments to connect through Envoy sidecars /// or other Unix socket-based proxies. /// /// Each request opens a new Unix socket connection (no connection pooling). /// This is appropriate when connecting through a sidecar proxy that manages /// its own upstream connection pool. /// /// # Example /// /// ```rust,no_run /// use rmcp::transport::{StreamableHttpClientTransport, UnixSocketHttpClient}; /// use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; /// /// let client = UnixSocketHttpClient::new("/var/run/envoy.sock", "http://mcp-server.internal/mcp"); /// let config = StreamableHttpClientTransportConfig::with_uri("http://mcp-server.internal/mcp"); /// let transport = StreamableHttpClientTransport::with_client(client, config); /// ``` #[derive(Clone, Debug)] pub struct UnixSocketHttpClient { socket_path: Arc, host_header: HeaderValue, } impl UnixSocketHttpClient { /// Creates a new Unix socket HTTP client. /// /// # Arguments /// /// * `socket_path` - Path to the Unix domain socket. Use `@name` syntax for Linux /// abstract sockets (e.g., `@egress.sock` becomes `\0egress.sock`). /// * `uri` - The MCP server URI. The authority (host:port) is extracted for the /// HTTP `Host` header, since hyper does not auto-set it for Unix socket connections. /// /// # Panics /// /// Panics if `socket_path` is empty or is `@` with no name (empty abstract socket). pub fn new(socket_path: &str, uri: &str) -> Self { assert!( !socket_path.is_empty() && socket_path != "@", "socket_path must not be empty or a bare '@' (empty abstract socket name)" ); let host_header = uri .parse::() .ok() .and_then(|u| u.authority().cloned()) .and_then(|a| HeaderValue::from_str(a.as_str()).ok()) .unwrap_or_else(|| HeaderValue::from_static("localhost")); Self { socket_path: resolve_socket_path(socket_path).into(), host_header, } } } /// Converts the `@`-prefixed abstract socket notation to the null-byte prefix /// expected by the Linux kernel. Filesystem socket paths are returned unchanged. fn resolve_socket_path(raw: &str) -> String { if let Some(name) = raw.strip_prefix('@') { format!("\0{name}") } else { raw.to_string() } } async fn connect_unix(socket_path: &str) -> Result { #[cfg(target_os = "linux")] if let Some(abstract_name) = socket_path.strip_prefix('\0') { let abstract_name = abstract_name.to_string(); let std_stream = tokio::task::spawn_blocking(move || { use std::os::linux::net::SocketAddrExt; let addr = std::os::unix::net::SocketAddr::from_abstract_name(&abstract_name)?; let stream = std::os::unix::net::UnixStream::connect_addr(&addr)?; stream.set_nonblocking(true)?; Ok::<_, std::io::Error>(stream) }) .await .map_err(std::io::Error::other)??; return UnixStream::from_std(std_stream); } UnixStream::connect(socket_path).await } /// Opens a new Unix socket connection and sends the HTTP request. /// One connection per request — the sidecar proxy handles connection pooling. async fn send_http_request( socket_path: &str, request: Request>, ) -> Result, UnixSocketError> { let stream = connect_unix(socket_path).await?; let io = TokioIo::new(stream); let (mut sender, conn) = hyper::client::conn::http1::handshake(io).await?; tokio::spawn(async move { if let Err(e) = conn.await { tracing::warn!("unix socket HTTP/1.1 connection error: {e}"); } }); Ok(sender.send_request(request).await?) } /// Applies custom headers to a request builder, rejecting reserved headers. fn apply_custom_headers( mut builder: http::request::Builder, custom_headers: HashMap, ) -> Result> { for (name, value) in custom_headers { validate_custom_header(&name).map_err(StreamableHttpError::ReservedHeaderConflict)?; builder = builder.header(name, value); } Ok(builder) } impl StreamableHttpClient for UnixSocketHttpClient { type Error = UnixSocketError; async fn post_message( &self, uri: Arc, message: ClientJsonRpcMessage, session_id: Option>, auth_token: Option, custom_headers: HashMap, ) -> Result> { self.post_message_with_max_sse_event_size( uri, message, session_id, auth_token, custom_headers, DEFAULT_MAX_SSE_EVENT_SIZE, ) .await } async fn post_message_with_max_sse_event_size( &self, uri: Arc, message: ClientJsonRpcMessage, session_id: Option>, auth_token: Option, custom_headers: HashMap, max_sse_event_size: usize, ) -> Result> { let json_body = serde_json::to_string(&message) .map_err(|e| StreamableHttpError::Client(UnixSocketError::Json(e)))?; let mut builder = Request::builder() .method(Method::POST) .uri(uri.as_ref()) .header(http::header::HOST, self.host_header.clone()) .header(http::header::CONTENT_TYPE, JSON_MIME_TYPE) .header( http::header::ACCEPT, format!("{EVENT_STREAM_MIME_TYPE}, {JSON_MIME_TYPE}"), ); if let Some(auth) = auth_token { builder = builder.header(http::header::AUTHORIZATION, format!("Bearer {auth}")); } builder = apply_custom_headers(builder, custom_headers)?; let session_was_attached = session_id.is_some(); if let Some(sid) = session_id { builder = builder.header(HEADER_SESSION_ID, sid.as_ref()); } let request = builder .body(Full::new(Bytes::from(json_body))) .map_err(|e| StreamableHttpError::Client(UnixSocketError::Http(e)))?; let response = send_http_request(&self.socket_path, request) .await .map_err(StreamableHttpError::Client)?; let status = response.status(); if status == StatusCode::UNAUTHORIZED { if let Some(header) = response.headers().get(WWW_AUTHENTICATE) { let www_authenticate_header = header .to_str() .map_err(|_| { StreamableHttpError::UnexpectedServerResponse(Cow::from( "invalid www-authenticate header value", )) })? .to_string(); return Err(StreamableHttpError::AuthRequired(AuthRequiredError { www_authenticate_header, })); } } if status == StatusCode::FORBIDDEN { if let Some(header) = response.headers().get(WWW_AUTHENTICATE) { let header_str = header.to_str().map_err(|_| { StreamableHttpError::UnexpectedServerResponse(Cow::from( "invalid www-authenticate header value", )) })?; let scope = extract_scope_from_header(header_str); return Err(StreamableHttpError::InsufficientScope( InsufficientScopeError { www_authenticate_header: header_str.to_string(), required_scope: scope, }, )); } } if matches!(status, StatusCode::ACCEPTED | StatusCode::NO_CONTENT) { return Ok(StreamableHttpPostResponse::Accepted); } if status == StatusCode::NOT_FOUND && session_was_attached { return Err(StreamableHttpError::SessionExpired); } if !status.is_success() { let body = response .into_body() .collect() .await .map(|c| String::from_utf8_lossy(&c.to_bytes()).into_owned()) .unwrap_or_else(|_| "".to_owned()); return Err(StreamableHttpError::UnexpectedServerResponse(Cow::Owned( format!("HTTP {status}: {body}"), ))); } let content_type = response.headers().get(http::header::CONTENT_TYPE).cloned(); let content_length = response .headers() .get(http::header::CONTENT_LENGTH) .and_then(|v| v.to_str().ok()) .and_then(|v| v.parse::().ok()); let session_id = response .headers() .get(HEADER_SESSION_ID) .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); if status.is_success() && content_length == Some(0) && matches!( message, ClientJsonRpcMessage::Notification(_) | ClientJsonRpcMessage::Response(_) | ClientJsonRpcMessage::Error(_) ) { return Ok(StreamableHttpPostResponse::Accepted); } match content_type { Some(ref ct) if ct.as_bytes().starts_with(EVENT_STREAM_MIME_TYPE.as_bytes()) => { let sse_stream = bounded_sse_stream(response.into_body().into_data_stream(), max_sse_event_size); Ok(StreamableHttpPostResponse::Sse(sse_stream, session_id)) } Some(ref ct) if ct.as_bytes().starts_with(JSON_MIME_TYPE.as_bytes()) => { let body = response .into_body() .collect() .await .map_err(|e| StreamableHttpError::Client(UnixSocketError::Hyper(e)))? .to_bytes(); match serde_json::from_slice::(&body) { Ok(message) => Ok(StreamableHttpPostResponse::Json(message, session_id)), Err(e) => { tracing::warn!( "could not parse JSON response as ServerJsonRpcMessage, treating as accepted: {e}" ); Ok(StreamableHttpPostResponse::Accepted) } } } _ => Err(StreamableHttpError::UnexpectedContentType( content_type.map(|ct| String::from_utf8_lossy(ct.as_bytes()).into_owned()), )), } } async fn delete_session( &self, uri: Arc, session_id: Arc, auth_token: Option, custom_headers: HashMap, ) -> Result<(), StreamableHttpError> { let mut builder = Request::builder() .method(Method::DELETE) .uri(uri.as_ref()) .header(http::header::HOST, self.host_header.clone()) .header(HEADER_SESSION_ID, session_id.as_ref()); if let Some(auth) = auth_token { builder = builder.header(http::header::AUTHORIZATION, format!("Bearer {auth}")); } builder = apply_custom_headers(builder, custom_headers)?; let request = builder .body(Full::new(Bytes::new())) .map_err(|e| StreamableHttpError::Client(UnixSocketError::Http(e)))?; let response = send_http_request(&self.socket_path, request) .await .map_err(StreamableHttpError::Client)?; if response.status() == StatusCode::METHOD_NOT_ALLOWED { tracing::debug!("this server doesn't support deleting session"); return Ok(()); } if !response.status().is_success() { return Err(StreamableHttpError::UnexpectedServerResponse(Cow::Owned( format!("delete_session returned {}", response.status()), ))); } Ok(()) } async fn get_stream( &self, uri: Arc, session_id: Option>, last_event_id: Option, auth_token: Option, custom_headers: HashMap, ) -> Result>, StreamableHttpError> { self.get_stream_with_max_sse_event_size( uri, session_id, last_event_id, auth_token, custom_headers, DEFAULT_MAX_SSE_EVENT_SIZE, ) .await } async fn get_stream_with_max_sse_event_size( &self, uri: Arc, session_id: Option>, last_event_id: Option, auth_token: Option, custom_headers: HashMap, max_sse_event_size: usize, ) -> Result>, StreamableHttpError> { let mut builder = Request::builder() .method(Method::GET) .uri(uri.as_ref()) .header(http::header::HOST, self.host_header.clone()) .header( http::header::ACCEPT, format!("{EVENT_STREAM_MIME_TYPE}, {JSON_MIME_TYPE}"), ); if let Some(session_id) = session_id { builder = builder.header(HEADER_SESSION_ID, session_id.as_ref()); } if let Some(last_id) = last_event_id { builder = builder.header(HEADER_LAST_EVENT_ID, last_id); } if let Some(auth) = auth_token { builder = builder.header(http::header::AUTHORIZATION, format!("Bearer {auth}")); } builder = apply_custom_headers(builder, custom_headers)?; let request = builder .body(Full::new(Bytes::new())) .map_err(|e| StreamableHttpError::Client(UnixSocketError::Http(e)))?; let response = send_http_request(&self.socket_path, request) .await .map_err(StreamableHttpError::Client)?; if response.status() == StatusCode::METHOD_NOT_ALLOWED { return Err(StreamableHttpError::ServerDoesNotSupportSse); } if response.status() == StatusCode::UNAUTHORIZED { if let Some(header) = response.headers().get(WWW_AUTHENTICATE) { let www_authenticate_header = header .to_str() .map_err(|_| { StreamableHttpError::UnexpectedServerResponse(Cow::from( "invalid www-authenticate header value", )) })? .to_string(); return Err(StreamableHttpError::AuthRequired(AuthRequiredError { www_authenticate_header, })); } } if response.status() == StatusCode::FORBIDDEN { if let Some(header) = response.headers().get(WWW_AUTHENTICATE) { let header_str = header.to_str().map_err(|_| { StreamableHttpError::UnexpectedServerResponse(Cow::from( "invalid www-authenticate header value", )) })?; let scope = extract_scope_from_header(header_str); return Err(StreamableHttpError::InsufficientScope( InsufficientScopeError { www_authenticate_header: header_str.to_string(), required_scope: scope, }, )); } } if !response.status().is_success() { return Err(StreamableHttpError::UnexpectedServerResponse(Cow::Owned( format!("get_stream returned {}", response.status()), ))); } match response.headers().get(http::header::CONTENT_TYPE) { Some(ct) => { if !ct.as_bytes().starts_with(EVENT_STREAM_MIME_TYPE.as_bytes()) && !ct.as_bytes().starts_with(JSON_MIME_TYPE.as_bytes()) { return Err(StreamableHttpError::UnexpectedContentType(Some( String::from_utf8_lossy(ct.as_bytes()).to_string(), ))); } } None => { return Err(StreamableHttpError::UnexpectedContentType(None)); } } Ok(bounded_sse_stream( response.into_body().into_data_stream(), max_sse_event_size, )) } } impl StreamableHttpClientTransport { /// Creates a new transport connecting through a Unix domain socket. /// /// # Arguments /// /// * `socket_path` - Path to the Unix domain socket. Use `@name` for Linux abstract sockets. /// * `uri` - The MCP server URI (used for HTTP Host header and request path). pub fn from_unix_socket(socket_path: &str, uri: impl Into>) -> Self { let uri: Arc = uri.into(); let client = UnixSocketHttpClient::new(socket_path, &uri); let config = StreamableHttpClientTransportConfig { uri, ..Default::default() }; StreamableHttpClientTransport::with_client(client, config) } /// Creates a new transport connecting through a Unix domain socket with custom config. /// /// # Arguments /// /// * `socket_path` - Path to the Unix domain socket. Use `@name` for Linux abstract sockets. /// * `config` - Transport configuration (URI, retry policy, custom headers, etc.). pub fn from_unix_socket_with_config( socket_path: &str, config: StreamableHttpClientTransportConfig, ) -> Self { let client = UnixSocketHttpClient::new(socket_path, &config.uri); StreamableHttpClientTransport::with_client(client, config) } } #[cfg(test)] mod tests { use super::*; #[test] fn resolve_abstract_socket() { assert_eq!(resolve_socket_path("@egress.sock"), "\0egress.sock"); } #[test] fn resolve_filesystem_socket() { assert_eq!( resolve_socket_path("/var/run/envoy.sock"), "/var/run/envoy.sock" ); } #[test] fn resolve_empty_abstract() { assert_eq!(resolve_socket_path("@"), "\0"); } #[test] #[should_panic(expected = "socket_path must not be empty")] fn rejects_bare_at_symbol() { UnixSocketHttpClient::new("@", "http://localhost/mcp"); } #[test] #[should_panic(expected = "socket_path must not be empty")] fn rejects_empty_path() { UnixSocketHttpClient::new("", "http://localhost/mcp"); } #[test] fn host_header_auto_derived() { let client = UnixSocketHttpClient::new("/var/run/envoy.sock", "http://mcp-server.internal/mcp"); assert_eq!(client.host_header, "mcp-server.internal"); } #[test] fn host_header_with_port() { let client = UnixSocketHttpClient::new("/var/run/envoy.sock", "http://mcp-server.internal:8080/mcp"); assert_eq!(client.host_header, "mcp-server.internal:8080"); } #[test] fn host_header_fallback_on_path_only_uri() { let client = UnixSocketHttpClient::new("/var/run/envoy.sock", "/mcp"); assert_eq!(client.host_header, "localhost"); } #[test] fn reserved_header_rejected() { let mut headers = HashMap::new(); headers.insert( HeaderName::from_static("accept"), HeaderValue::from_static("text/plain"), ); let builder = Request::builder(); let result = apply_custom_headers(builder, headers); assert!(matches!( result, Err(StreamableHttpError::ReservedHeaderConflict(_)) )); } #[test] fn mcp_protocol_version_allowed_through() { let mut headers = HashMap::new(); headers.insert( HeaderName::from_static("mcp-protocol-version"), HeaderValue::from_static("2025-03-26"), ); let builder = Request::builder().uri("http://localhost/mcp").method("GET"); let result = apply_custom_headers(builder, headers); assert!(result.is_ok()); } }