rust-sdk/crates/rmcp/src/transport.rs
Will Pfleger ee1c63c53f
feat(transport): add Unix domain socket client for streamable HTTP (#749)
* feat(transport): add Unix domain socket client for streamable HTTP

MCP hosts in Kubernetes environments with Envoy sidecars need to route
HTTP through Unix domain sockets because DNS-based URIs only resolve
via the proxy. Adds UnixSocketHttpClient implementing StreamableHttpClient
using hyper over tokio::net::UnixStream, gated behind the
transport-streamable-http-client-unix-socket feature.

Also extracts RESERVED_HEADERS, extract_scope_from_header, and
validate_custom_header into common/http_header.rs to share header
validation logic between the reqwest and unix socket implementations.

* fix(transport): address review feedback for unix socket transport

- Document one-connection-per-request behavior on UnixSocketHttpClient
- Reject empty socket paths and bare '@' in constructor with assert
- Add explicit dep:http to unix-socket feature for self-documenting deps
- Document MCP-Protocol-Version exception on RESERVED_HEADERS constant
- Fix test catch-all to echo request id instead of hardcoding 1
- Remove leftover sleep(100ms) in test_unix_socket_custom_headers
- Add blank line before macro comment in Cargo.toml

* fix(transport): fix CI failures for unix socket transport

- Use std::io::Error::other() instead of Error::new(ErrorKind::Other)
  to satisfy clippy::io_other_error on newer nightly
- Use #[tokio::test(flavor = "current_thread")] for unix socket tests
  since axum's serve(UnixListener) requires spawn_local
- Gate validate_custom_header behind client-side-sse feature since it
  references http::HeaderName which isn't available with default features

* fix(transport): fix CI failures for unix socket transport

axum::serve(UnixListener) uses spawn_local on Linux, which panics
outside a LocalSet. Replace with manual hyper HTTP/1.1 server that
accepts connections directly from the UnixListener, avoiding the
spawn_local requirement entirely.

* fix(transport): skip unix socket tests when local feature is enabled

The local feature causes ().serve(transport) to use spawn_local, which
requires a LocalSet. Gate the integration tests with not(feature = "local")
to match every other integration test in the repo.
2026-03-24 09:50:32 -04:00

265 lines
9.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! # Transport
//! The transport type must implemented [`Transport`] trait, which allow it send message concurrently and receive message sequentially.
//!
//! ## Standard Transport Types
//! There are 2 pairs of standard transport types:
//!
//! | transport | client | server |
//! |:-: |:-: |:-: |
//! | std IO | [`child_process::TokioChildProcess`] | [`io::stdio`] |
//! | streamable http | [`streamable_http_client::StreamableHttpClientTransport`] | `streamable_http_server::StreamableHttpService` |
//!
//!## Helper Transport Types
//! Thers are several helper transport types that can help you to create transport quickly.
//!
//! ### [Worker Transport](`worker::WorkerTransport`)
//! Which allows you to run a worker and process messages in another tokio task.
//!
//! ### [Async Read/Write Transport](`async_rw::AsyncRwTransport`)
//! You need to enable `transport-async-rw` feature to use this transport.
//!
//! This transport is used to create a transport from a byte stream which implemented [`tokio::io::AsyncRead`] and [`tokio::io::AsyncWrite`].
//!
//! This could be very helpful when you want to create a transport from a byte stream, such as a file or a tcp connection.
//!
//! ### [Sink/Stream Transport](`sink_stream::SinkStreamTransport`)
//! This transport is used to create a transport from a sink and a stream.
//!
//! This could be very helpful when you want to create a transport from a duplex object stream, such as a websocket connection.
//!
//! ## [IntoTransport](`IntoTransport`) trait
//! [`IntoTransport`] is a helper trait that implicitly convert a type into a transport type.
//!
//! ### These types is automatically implemented [`IntoTransport`] trait
//! 1. A type that already implement both [`futures::Sink`] and [`futures::Stream`] trait, or a tuple `(Tx, Rx)` where `Tx` is [`futures::Sink`] and `Rx` is [`futures::Stream`].
//! 2. A type that implement both [`tokio::io::AsyncRead`] and [`tokio::io::AsyncWrite`] trait. or a tuple `(R, W)` where `R` is [`tokio::io::AsyncRead`] and `W` is [`tokio::io::AsyncWrite`].
//! 3. A type that implement [Worker](`worker::Worker`) trait.
//! 4. A type that implement [`Transport`] trait.
//!
//! ## Examples
//!
//! ```rust
//! # use rmcp::{
//! # ServiceExt, serve_server,
//! # };
//! #[cfg(feature = "client")]
//! # use rmcp::serve_client;
//!
//! // create transport from tcp stream
//! #[cfg(feature = "client")]
//! async fn client() -> Result<(), Box<dyn std::error::Error>> {
//! let stream = tokio::net::TcpSocket::new_v4()?
//! .connect("127.0.0.1:8001".parse()?)
//! .await?;
//! let client = ().serve(stream).await?;
//! let tools = client.peer().list_tools(Default::default()).await?;
//! println!("{:?}", tools);
//! Ok(())
//! }
//!
//! // create transport from std io
//! #[cfg(feature = "client")]
//! async fn io() -> Result<(), Box<dyn std::error::Error>> {
//! let client = ().serve((tokio::io::stdin(), tokio::io::stdout())).await?;
//! let tools = client.peer().list_tools(Default::default()).await?;
//! println!("{:?}", tools);
//! Ok(())
//! }
//! ```
use std::{borrow::Cow, sync::Arc};
use crate::service::{RxJsonRpcMessage, ServiceRole, TxJsonRpcMessage};
pub mod sink_stream;
#[cfg(feature = "transport-async-rw")]
pub mod async_rw;
#[cfg(feature = "transport-worker")]
pub mod worker;
#[cfg(feature = "transport-worker")]
pub use worker::WorkerTransport;
#[cfg(feature = "transport-child-process")]
pub mod child_process;
#[cfg(feature = "transport-child-process")]
pub use child_process::{ConfigureCommandExt, TokioChildProcess};
#[cfg(feature = "transport-io")]
pub mod io;
#[cfg(feature = "transport-io")]
pub use io::stdio;
#[cfg(feature = "auth")]
pub mod auth;
#[cfg(feature = "auth-client-credentials-jwt")]
pub use auth::JwtSigningAlgorithm;
#[cfg(feature = "auth")]
pub use auth::{
AuthClient, AuthError, AuthorizationManager, AuthorizationSession, AuthorizedHttpClient,
ClientCredentialsConfig, CredentialStore, EXTENSION_OAUTH_CLIENT_CREDENTIALS,
InMemoryCredentialStore, InMemoryStateStore, ScopeUpgradeConfig, StateStore,
StoredAuthorizationState, StoredCredentials, WWWAuthenticateParams,
};
// #[cfg(feature = "transport-ws")]
// pub mod ws;
#[cfg(feature = "transport-streamable-http-server-session")]
pub mod streamable_http_server;
#[cfg(all(feature = "transport-streamable-http-server", not(feature = "local")))]
pub use streamable_http_server::tower::{StreamableHttpServerConfig, StreamableHttpService};
#[cfg(feature = "transport-streamable-http-client")]
pub mod streamable_http_client;
#[cfg(all(unix, feature = "transport-streamable-http-client-unix-socket"))]
pub use common::unix_socket::UnixSocketHttpClient;
#[cfg(feature = "transport-streamable-http-client")]
pub use streamable_http_client::StreamableHttpClientTransport;
/// Common use codes
pub mod common;
pub trait Transport<R>: Send
where
R: ServiceRole,
{
type Error: std::error::Error + Send + Sync + 'static;
fn name() -> Cow<'static, str> {
std::any::type_name::<Self>().into()
}
/// Send a message to the transport
///
/// Notice that the future returned by this function should be `Send` and `'static`.
/// It's because the sending message could be executed concurrently.
///
fn send(
&mut self,
item: TxJsonRpcMessage<R>,
) -> impl Future<Output = Result<(), Self::Error>> + Send + 'static;
/// Receive a message from the transport, this operation is sequential.
fn receive(&mut self) -> impl Future<Output = Option<RxJsonRpcMessage<R>>> + Send;
/// Close the transport
fn close(&mut self) -> impl Future<Output = Result<(), Self::Error>> + Send;
}
pub trait IntoTransport<R, E, A>: Send + 'static
where
R: ServiceRole,
E: std::error::Error + Send + 'static,
{
fn into_transport(self) -> impl Transport<R, Error = E> + 'static;
}
pub enum TransportAdapterIdentity {}
impl<R, T, E> IntoTransport<R, E, TransportAdapterIdentity> for T
where
T: Transport<R, Error = E> + Send + 'static,
R: ServiceRole,
E: std::error::Error + Send + Sync + 'static,
{
fn into_transport(self) -> impl Transport<R, Error = E> + 'static {
self
}
}
/// A transport that can send a single message and then close itself
pub struct OneshotTransport<R>
where
R: ServiceRole,
{
message: Option<RxJsonRpcMessage<R>>,
sender: tokio::sync::mpsc::Sender<TxJsonRpcMessage<R>>,
termination: Arc<tokio::sync::Semaphore>,
}
impl<R> OneshotTransport<R>
where
R: ServiceRole,
{
pub fn new(
message: RxJsonRpcMessage<R>,
) -> (Self, tokio::sync::mpsc::Receiver<TxJsonRpcMessage<R>>) {
let (sender, receiver) = tokio::sync::mpsc::channel(16);
(
Self {
message: Some(message),
sender,
termination: Arc::new(tokio::sync::Semaphore::new(0)),
},
receiver,
)
}
}
impl<R> Transport<R> for OneshotTransport<R>
where
R: ServiceRole,
{
type Error = tokio::sync::mpsc::error::SendError<TxJsonRpcMessage<R>>;
fn send(
&mut self,
item: TxJsonRpcMessage<R>,
) -> impl Future<Output = Result<(), Self::Error>> + Send + 'static {
let sender = self.sender.clone();
let terminate = matches!(item, TxJsonRpcMessage::<R>::Response(_))
|| matches!(item, TxJsonRpcMessage::<R>::Error(_));
let termination = self.termination.clone();
async move {
sender.send(item).await?;
if terminate {
termination.add_permits(1);
}
Ok(())
}
}
async fn receive(&mut self) -> Option<RxJsonRpcMessage<R>> {
if let Some(msg) = self.message.take() {
return Some(msg);
}
let _ = self.termination.acquire().await;
None
}
fn close(&mut self) -> impl Future<Output = Result<(), Self::Error>> + Send {
self.message.take();
std::future::ready(Ok(()))
}
}
#[derive(Debug, thiserror::Error)]
#[error("Transport [{transport_name}] error: {error}")]
pub struct DynamicTransportError {
pub transport_name: Cow<'static, str>,
pub transport_type_id: std::any::TypeId,
#[source]
pub error: Box<dyn std::error::Error + Send + Sync>,
}
impl DynamicTransportError {
pub fn new<T: Transport<R> + 'static, R: ServiceRole>(e: T::Error) -> Self {
Self {
transport_name: T::name(),
transport_type_id: std::any::TypeId::of::<T>(),
error: Box::new(e),
}
}
pub fn downcast<T: Transport<R> + 'static, R: ServiceRole>(self) -> Result<T::Error, Self> {
if !self.is::<T, R>() {
Err(self)
} else {
Ok(self
.error
.downcast::<T::Error>()
.map(|e| *e)
.expect("type is checked"))
}
}
pub fn is<T: Transport<R> + 'static, R: ServiceRole>(&self) -> bool {
self.error.is::<T::Error>() && self.transport_type_id == std::any::TypeId::of::<T>()
}
}