use std::future::Future;
use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::error::Error;
use crate::router::McpRouter;
use crate::transport::http::{HttpTransport, SessionConfig, SessionHandle};
use crate::{ProtocolSupport, ProtocolSupportError};
pub struct UnixSocketTransport {
inner: HttpTransport,
cleanup_on_bind: bool,
drain_timeout: Option<Duration>,
}
impl UnixSocketTransport {
pub fn new(router: McpRouter) -> Self {
Self {
inner: HttpTransport::new(router),
cleanup_on_bind: true,
drain_timeout: None,
}
}
pub fn from_service<S>(service: S) -> Self
where
S: tower::Service<
crate::router::RouterRequest,
Response = crate::router::RouterResponse,
Error = std::convert::Infallible,
> + Clone
+ Send
+ 'static,
S::Future: Send,
{
Self {
inner: HttpTransport::from_service(service),
cleanup_on_bind: true,
drain_timeout: None,
}
}
pub fn with_sampling(mut self) -> Self {
self.inner = self.inner.with_sampling();
self
}
pub fn require_sessions(mut self) -> Self {
self.inner = self.inner.require_sessions();
self
}
pub fn protocol_support(mut self, support: ProtocolSupport) -> Self {
self.inner = self.inner.protocol_support(support);
self
}
pub fn protocol_versions<I, V>(
mut self,
versions: I,
) -> std::result::Result<Self, ProtocolSupportError>
where
I: IntoIterator<Item = V>,
V: Into<String>,
{
self.inner = self.inner.protocol_versions(versions)?;
Ok(self)
}
pub fn session_config(mut self, config: SessionConfig) -> Self {
self.inner = self.inner.session_config(config);
self
}
pub fn session_ttl(mut self, ttl: std::time::Duration) -> Self {
self.inner = self.inner.session_ttl(ttl);
self
}
pub fn max_sessions(mut self, max: usize) -> Self {
self.inner = self.inner.max_sessions(max);
self
}
pub fn session_store(
mut self,
store: std::sync::Arc<dyn crate::session_store::SessionStore>,
) -> Self {
self.inner = self.inner.session_store(store);
self
}
pub fn event_store(
mut self,
store: std::sync::Arc<dyn crate::event_store::EventStore>,
) -> Self {
self.inner = self.inner.event_store(store);
self
}
pub fn auto_reinitialize_sessions(mut self, enabled: bool) -> Self {
self.inner = self.inner.auto_reinitialize_sessions(enabled);
self
}
pub fn disable_origin_validation(mut self) -> Self {
self.inner = self.inner.disable_origin_validation();
self
}
pub fn allowed_origins(mut self, origins: Vec<String>) -> Self {
self.inner = self.inner.allowed_origins(origins);
self
}
pub fn disable_host_validation(mut self) -> Self {
self.inner = self.inner.disable_host_validation();
self
}
pub fn allowed_hosts(mut self, hosts: Vec<String>) -> Self {
self.inner = self.inner.allowed_hosts(hosts);
self
}
pub fn layer<L>(mut self, layer: L) -> Self
where
L: tower::Layer<McpRouter> + Send + Sync + 'static,
L::Service: tower::Service<crate::router::RouterRequest, Response = crate::router::RouterResponse>
+ Clone
+ Send
+ 'static,
<L::Service as tower::Service<crate::router::RouterRequest>>::Error:
std::fmt::Display + Send,
<L::Service as tower::Service<crate::router::RouterRequest>>::Future: Send,
{
self.inner = self.inner.layer(layer);
self
}
pub fn cleanup_on_bind(mut self, cleanup: bool) -> Self {
self.cleanup_on_bind = cleanup;
self
}
pub fn drain_timeout(mut self, timeout: Duration) -> Self {
self.drain_timeout = Some(timeout);
self
}
pub fn into_router(self) -> axum::Router {
self.inner.into_router()
}
pub fn into_router_with_handle(self) -> (axum::Router, SessionHandle) {
self.inner.into_router_with_handle()
}
pub async fn serve<P: AsRef<Path>>(self, path: P) -> crate::Result<()> {
self.serve_with_shutdown(path, std::future::pending::<()>())
.await
}
pub async fn serve_with_shutdown<P, F>(self, path: P, signal: F) -> crate::Result<()>
where
P: AsRef<Path>,
F: Future<Output = ()> + Send + 'static,
{
let path = path.as_ref().to_path_buf();
if self.cleanup_on_bind {
cleanup_socket(&path);
}
let listener = tokio::net::UnixListener::bind(&path).map_err(|e| {
Error::Transport(format!(
"Failed to bind Unix socket {}: {}",
path.display(),
e
))
})?;
tracing::info!("MCP Unix socket transport listening on {}", path.display());
let drain_timeout = self.drain_timeout;
let router = self.inner.into_router();
crate::transport::graceful::serve_with_shutdown(listener, router, signal, drain_timeout)
.await
}
pub async fn serve_with_listener<L>(self, listener: L) -> crate::Result<()>
where
L: axum::serve::Listener,
L::Addr: std::fmt::Debug,
{
self.serve_with_listener_and_shutdown(listener, std::future::pending::<()>())
.await
}
pub async fn serve_with_listener_and_shutdown<L, F>(
self,
listener: L,
signal: F,
) -> crate::Result<()>
where
L: axum::serve::Listener,
L::Addr: std::fmt::Debug,
F: Future<Output = ()> + Send + 'static,
{
let drain_timeout = self.drain_timeout;
let router = self.inner.into_router();
crate::transport::graceful::serve_with_shutdown(listener, router, signal, drain_timeout)
.await
}
}
fn cleanup_socket(path: &PathBuf) {
match std::fs::remove_file(path) {
Ok(()) => {
tracing::debug!("Removed existing socket file: {}", path.display());
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
tracing::warn!(
"Failed to remove existing socket file {}: {}",
path.display(),
e
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{UnixListener, UnixStream};
fn socket_path() -> PathBuf {
static NEXT: AtomicU32 = AtomicU32::new(0);
std::env::temp_dir().join(format!(
"tm-{}-{}.sock",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
))
}
fn test_transport() -> UnixSocketTransport {
UnixSocketTransport::new(McpRouter::new().server_info("unix-listener-test", "0.0.0"))
.disable_origin_validation()
.disable_host_validation()
}
fn initialize_frame() -> String {
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {"name": "unix-test", "version": "1.0.0"}
}
})
.to_string()
}
async fn post(path: &Path, body: &str) -> String {
let mut stream = UnixStream::connect(path).await.expect("connect");
let request = format!(
"POST / HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\n\
Accept: application/json, text/event-stream\r\nContent-Length: {}\r\n\
Connection: close\r\n\r\n{body}",
body.len(),
);
stream.write_all(request.as_bytes()).await.expect("write");
let mut response = String::new();
match stream.read_to_string(&mut response).await {
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::ConnectionReset => {}
Err(error) => panic!("read: {error}"),
}
response
}
#[tokio::test]
async fn serves_on_a_caller_owned_listener() {
let path = socket_path();
let listener = UnixListener::bind(&path).expect("bind");
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
let server = tokio::spawn(test_transport().serve_with_listener_and_shutdown(
listener,
async {
rx.await.ok();
},
));
let response = post(&path, &initialize_frame()).await;
assert!(
response.contains("200 OK"),
"expected a served response, got: {response}"
);
assert!(
response.contains("serverInfo"),
"expected an initialize result, got: {response}"
);
tx.send(()).ok();
let served = tokio::time::timeout(Duration::from_secs(5), server)
.await
.expect("shutdown signal must stop the server");
served.expect("join").expect("serve");
cleanup_socket(&path);
}
#[tokio::test]
async fn a_rejecting_listener_wrapper_is_consulted() {
struct RefuseAll {
inner: UnixListener,
seen: Arc<AtomicUsize>,
}
impl axum::serve::Listener for RefuseAll {
type Io = UnixStream;
type Addr = tokio::net::unix::SocketAddr;
async fn accept(&mut self) -> (Self::Io, Self::Addr) {
loop {
if let Ok((stream, _addr)) = self.inner.accept().await {
self.seen.fetch_add(1, Ordering::SeqCst);
drop(stream);
}
}
}
fn local_addr(&self) -> std::io::Result<Self::Addr> {
self.inner.local_addr()
}
}
let path = socket_path();
let seen = Arc::new(AtomicUsize::new(0));
let listener = RefuseAll {
inner: UnixListener::bind(&path).expect("bind"),
seen: seen.clone(),
};
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
let server = tokio::spawn(test_transport().serve_with_listener_and_shutdown(
listener,
async {
rx.await.ok();
},
));
let response = post(&path, &initialize_frame()).await;
assert!(
response.is_empty(),
"a refused connection must not be served: {response}"
);
assert_eq!(
seen.load(Ordering::SeqCst),
1,
"the wrapper must be the one accepting"
);
tx.send(()).ok();
server.abort();
cleanup_socket(&path);
}
#[tokio::test]
async fn a_peer_credential_filter_admits_the_expected_uid() {
use std::os::unix::fs::MetadataExt;
struct PeerUid {
inner: UnixListener,
uid: u32,
}
impl axum::serve::Listener for PeerUid {
type Io = UnixStream;
type Addr = tokio::net::unix::SocketAddr;
async fn accept(&mut self) -> (Self::Io, Self::Addr) {
loop {
let Ok((stream, addr)) = self.inner.accept().await else {
continue;
};
match stream.peer_cred() {
Ok(cred) if cred.uid() == self.uid => return (stream, addr),
_ => continue,
}
}
}
fn local_addr(&self) -> std::io::Result<Self::Addr> {
self.inner.local_addr()
}
}
let marker = socket_path().with_extension("uid");
std::fs::write(&marker, b"").expect("write marker");
let uid = std::fs::metadata(&marker).expect("stat marker").uid();
std::fs::remove_file(&marker).ok();
let path = socket_path();
let listener = PeerUid {
inner: UnixListener::bind(&path).expect("bind"),
uid,
};
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
let server = tokio::spawn(test_transport().serve_with_listener_and_shutdown(
listener,
async {
rx.await.ok();
},
));
let response = post(&path, &initialize_frame()).await;
assert!(
response.contains("200 OK") && response.contains("serverInfo"),
"a peer with the expected uid must be served: {response}"
);
tx.send(()).ok();
let served = tokio::time::timeout(Duration::from_secs(5), server)
.await
.expect("shutdown signal must stop the server");
served.expect("join").expect("serve");
cleanup_socket(&path);
}
#[tokio::test]
async fn delegates_runtime_protocol_configuration_to_http() {
let transport =
UnixSocketTransport::new(McpRouter::new().server_info("unix-protocol-test", "0.0.0"))
.protocol_support(ProtocolSupport::stable())
.protocol_versions(["2025-11-25"])
.unwrap();
let _router = transport.into_router();
}
#[cfg(feature = "stateless")]
#[tokio::test]
async fn delegated_http_binding_enforces_protocol_selection() {
use axum::body::Body;
use axum::http::Request;
use tower::ServiceExt;
let app =
UnixSocketTransport::new(McpRouter::new().server_info("unix-protocol-test", "0.0.0"))
.protocol_support(ProtocolSupport::stable())
.disable_origin_validation()
.into_router();
let request = Request::builder()
.method("POST")
.uri("/")
.header("content-type", "application/json")
.header("accept", "application/json")
.header("mcp-protocol-version", "2026-07-28")
.header("mcp-method", "server/discover")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "server/discover",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
})
.to_string(),
))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(body["error"]["code"], -32022);
assert_eq!(body["error"]["data"]["requested"], "2026-07-28");
}
}