use std::{ borrow::Cow, collections::{HashMap, HashSet}, sync::Arc, time::Duration, }; use futures::{Stream, StreamExt, future::BoxFuture, stream::BoxStream}; use http::{HeaderName, HeaderValue}; pub use sse_stream::Error as SseError; use sse_stream::Sse; use thiserror::Error; use tokio_util::sync::CancellationToken; use tracing::debug; use super::common::client_side_sse::{ExponentialBackoff, SseRetryPolicy, SseStreamReconnect}; use crate::{ RoleClient, model::{ ClientJsonRpcMessage, ClientNotification, ErrorData, InitializedNotification, RequestId, ServerJsonRpcMessage, ServerResult, }, transport::{ common::client_side_sse::SseAutoReconnectStream, worker::{Worker, WorkerQuitReason, WorkerSendRequest, WorkerTransport}, }, }; type BoxedSseStream = BoxStream<'static, Result>; #[derive(Debug)] #[non_exhaustive] pub struct AuthRequiredError { pub www_authenticate_header: String, } impl AuthRequiredError { /// Create a new `AuthRequiredError` instance. pub fn new(www_authenticate_header: String) -> Self { Self { www_authenticate_header, } } } #[derive(Debug)] #[non_exhaustive] pub struct InsufficientScopeError { pub www_authenticate_header: String, pub required_scope: Option, } impl InsufficientScopeError { /// Create a new `InsufficientScopeError` instance. pub fn new(www_authenticate_header: String, required_scope: Option) -> Self { Self { www_authenticate_header, required_scope, } } /// check if scope upgrade is possible (i.e., we know what scope is required) pub fn can_upgrade(&self) -> bool { self.required_scope.is_some() } /// get the required scope for upgrade pub fn get_required_scope(&self) -> Option<&str> { self.required_scope.as_deref() } } #[derive(Error, Debug)] #[non_exhaustive] pub enum StreamableHttpError { #[error("SSE error: {0}")] Sse(#[from] SseError), #[error("Io error: {0}")] Io(#[from] std::io::Error), #[error("Client error: {0}")] Client(E), #[error("unexpected end of stream")] UnexpectedEndOfStream, #[error("unexpected server response: {0}")] UnexpectedServerResponse(Cow<'static, str>), #[error("Unexpected content type: {0:?}")] UnexpectedContentType(Option), #[error("Server does not support SSE")] ServerDoesNotSupportSse, #[error("Server does not support delete session")] ServerDoesNotSupportDeleteSession, #[error("Tokio join error: {0}")] TokioJoinError(#[from] tokio::task::JoinError), #[error("Deserialize error: {0}")] Deserialize(#[from] serde_json::Error), #[error("Transport channel closed")] TransportChannelClosed, #[error("Missing session id in HTTP response")] MissingSessionIdInResponse, #[cfg(feature = "auth")] #[error("Auth error: {0}")] Auth(#[from] crate::transport::auth::AuthError), #[error("Auth required")] AuthRequired(AuthRequiredError), #[error("Insufficient scope")] InsufficientScope(InsufficientScopeError), #[error("Header name '{0}' is reserved and conflicts with default headers")] ReservedHeaderConflict(String), #[error("Session expired (HTTP 404)")] SessionExpired, } #[derive(Debug, Clone, Error)] #[non_exhaustive] pub enum StreamableHttpProtocolError { #[error("Missing session id in response")] MissingSessionIdInResponse, } #[allow(clippy::large_enum_variant)] #[non_exhaustive] pub enum StreamableHttpPostResponse { Accepted, Json(ServerJsonRpcMessage, Option), Sse(BoxedSseStream, Option), } impl std::fmt::Debug for StreamableHttpPostResponse { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Accepted => write!(f, "Accepted"), Self::Json(arg0, arg1) => f.debug_tuple("Json").field(arg0).field(arg1).finish(), Self::Sse(_, arg1) => f.debug_tuple("Sse").field(arg1).finish(), } } } impl StreamableHttpPostResponse { pub async fn expect_initialized( self, ) -> Result<(ServerJsonRpcMessage, Option), StreamableHttpError> where E: std::error::Error + Send + Sync + 'static, { match self { Self::Json(message, session_id) => Ok((message, session_id)), Self::Sse(mut stream, session_id) => { while let Some(event) = stream.next().await { let event = event?; let payload = event.data.unwrap_or_default(); if payload.trim().is_empty() { continue; } let message: ServerJsonRpcMessage = serde_json::from_str(&payload)?; if matches!(message, ServerJsonRpcMessage::Response(_)) { return Ok((message, session_id)); } debug!( ?message, "received message before initialize response; continuing to drain stream" ); } Err(StreamableHttpError::UnexpectedServerResponse( "empty sse stream".into(), )) } _ => Err(StreamableHttpError::UnexpectedServerResponse( "expect initialized, accepted".into(), )), } } pub fn expect_json(self) -> Result> where E: std::error::Error + Send + Sync + 'static, { match self { Self::Json(message, ..) => Ok(message), got => Err(StreamableHttpError::UnexpectedServerResponse( format!("expect json, got {got:?}").into(), )), } } pub fn expect_accepted_or_json(self) -> Result<(), StreamableHttpError> where E: std::error::Error + Send + Sync + 'static, { match self { Self::Accepted => Ok(()), // Tolerate servers that return 200 with JSON for notifications Self::Json(..) => Ok(()), got => Err(StreamableHttpError::UnexpectedServerResponse( format!("expect accepted or json, got {got:?}").into(), )), } } } pub trait StreamableHttpClient: Clone + Send + 'static { type Error: std::error::Error + Send + Sync + 'static; fn post_message( &self, uri: Arc, message: ClientJsonRpcMessage, session_id: Option>, auth_header: Option, custom_headers: HashMap, ) -> impl Future>> + Send + '_; fn delete_session( &self, uri: Arc, session_id: Arc, auth_header: Option, custom_headers: HashMap, ) -> impl Future>> + Send + '_; fn get_stream( &self, uri: Arc, session_id: Arc, last_event_id: Option, auth_header: Option, custom_headers: HashMap, ) -> impl Future< Output = Result< BoxStream<'static, Result>, StreamableHttpError, >, > + Send + '_; } #[non_exhaustive] pub struct RetryConfig { pub max_times: Option, pub min_duration: Duration, } struct StreamableHttpClientReconnect { pub client: C, pub session_id: Arc, pub uri: Arc, pub auth_header: Option, pub custom_headers: HashMap, } impl SseStreamReconnect for StreamableHttpClientReconnect { type Error = StreamableHttpError; type Future = BoxFuture<'static, Result>; fn retry_connection(&mut self, last_event_id: Option<&str>) -> Self::Future { let client = self.client.clone(); let uri = self.uri.clone(); let session_id = self.session_id.clone(); let auth_header = self.auth_header.clone(); let custom_headers = self.custom_headers.clone(); let last_event_id = last_event_id.map(|s| s.to_owned()); Box::pin(async move { client .get_stream(uri, session_id, last_event_id, auth_header, custom_headers) .await }) } } /// Info retained for cleaning up the session when the worker exits. struct SessionCleanupInfo { client: C, uri: Arc, session_id: Arc, auth_header: Option, protocol_headers: HashMap, } #[derive(Debug, Clone, Default)] #[non_exhaustive] pub struct StreamableHttpClientWorker { pub client: C, pub config: StreamableHttpClientTransportConfig, } impl StreamableHttpClientWorker { pub fn new_simple(url: impl Into>) -> Self { Self { client: C::default(), config: StreamableHttpClientTransportConfig { uri: url.into(), ..Default::default() }, } } } impl StreamableHttpClientWorker { pub fn new(client: C, config: StreamableHttpClientTransportConfig) -> Self { Self { client, config } } } impl StreamableHttpClientWorker { fn client_request_id(message: &ClientJsonRpcMessage) -> Option { match message { ClientJsonRpcMessage::Request(request) => Some(request.id.clone()), _ => None, } } fn server_response_id(message: &ServerJsonRpcMessage) -> Option<&RequestId> { match message { ServerJsonRpcMessage::Response(response) => Some(&response.id), ServerJsonRpcMessage::Error(error) => error.id.as_ref(), _ => None, } } fn mark_stream_response_pending( pending_stream_response_ids: &mut HashSet, request_id: Option, ) { if let Some(request_id) = request_id { pending_stream_response_ids.insert(request_id); } } fn clear_stream_response_pending( pending_stream_response_ids: &mut HashSet, message: &ServerJsonRpcMessage, ) { if let Some(id) = Self::server_response_id(message) { pending_stream_response_ids.remove(id); } } async fn drain_queued_stream_messages( sse_worker_rx: &mut tokio::sync::mpsc::Receiver, context: &mut super::worker::WorkerContext, pending_stream_response_ids: &mut HashSet, ) -> Result<(), WorkerQuitReason>> { loop { match sse_worker_rx.try_recv() { Ok(message) => { Self::clear_stream_response_pending(pending_stream_response_ids, &message); context.send_to_handler(message).await?; } Err(tokio::sync::mpsc::error::TryRecvError::Empty) => return Ok(()), Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => return Ok(()), } } } async fn fail_pending_stream_responses( context: &mut super::worker::WorkerContext, pending_stream_response_ids: &mut HashSet, ) -> Result<(), WorkerQuitReason>> { if pending_stream_response_ids.is_empty() { return Ok(()); } let pending_ids = std::mem::take(pending_stream_response_ids); for id in pending_ids { context .send_to_handler(ServerJsonRpcMessage::error( ErrorData::internal_error( "streamable HTTP session was re-initialized before the response arrived", None, ), Some(id), )) .await?; } Ok(()) } /// Convert a raw SSE stream into a JSON-RPC message stream without /// reconnection logic. fn raw_sse_to_jsonrpc( stream: BoxedSseStream, ) -> impl Stream>> + Send + 'static { stream.filter_map(|event| async { match event { Err(e) => Some(Err(StreamableHttpError::Sse(e))), Ok(sse) => { let is_message = matches!(sse.event.as_deref(), None | Some("") | Some("message")); if !is_message { return None; } let data = sse.data?; if data.trim().is_empty() { return None; } match serde_json::from_str::(&data) { Ok(msg) => Some(Ok(msg)), Err(e) => { tracing::debug!("failed to deserialize server message: {e}"); None } } } } }) } /// Convert an SSE stream into JSON-RPC messages with reconnect semantics. /// /// This is used for request-scoped SSE responses as well as the standalone /// GET stream. A request-scoped stream can close before its response arrives, /// and SEP-1699 requires the client to honor `retry` and resume with /// `Last-Event-ID` in that case. fn reconnecting_sse_to_jsonrpc( stream: BoxedSseStream, client: C, session_id: Arc, uri: Arc, auth_header: Option, custom_headers: HashMap, retry_config: Arc, ) -> impl Stream>> + Send + 'static { SseAutoReconnectStream::new( stream, StreamableHttpClientReconnect { client, session_id, uri, auth_header, custom_headers, }, retry_config, ) } /// Convert a POST response SSE stream into JSON-RPC messages. /// /// Stateful sessions can resume via GET when the response stream closes /// before the server sends the matching JSON-RPC response. Stateless /// transports do not have enough state to resume, so they keep the raw /// SSE-to-JSON-RPC mapping. fn response_sse_to_jsonrpc( stream: BoxedSseStream, session_id: Option>, client: C, uri: Arc, auth_header: Option, custom_headers: HashMap, retry_config: Arc, ) -> BoxStream<'static, Result>> { match session_id { Some(session_id) => Self::reconnecting_sse_to_jsonrpc( stream, client, session_id, uri, auth_header, custom_headers, retry_config, ) .boxed(), None => Self::raw_sse_to_jsonrpc(stream).boxed(), } } async fn execute_sse_stream( sse_stream: impl Stream>> + Send + 'static, sse_worker_tx: tokio::sync::mpsc::Sender, close_on_response: bool, ct: CancellationToken, ) -> Result<(), StreamableHttpError> { let mut sse_stream = std::pin::pin!(sse_stream); loop { let message = tokio::select! { event = sse_stream.next() => { event } _ = ct.cancelled() => { tracing::debug!("cancelled"); break; } }; let Some(message) = message.transpose()? else { break; }; let is_response = matches!( message, ServerJsonRpcMessage::Response(_) | ServerJsonRpcMessage::Error(_) ); let yield_result = sse_worker_tx.send(message).await; if yield_result.is_err() { tracing::trace!("streamable http transport worker dropped, exiting"); break; } if close_on_response && is_response { tracing::debug!("got response, draining sse stream for connection reuse"); // Consume the remaining stream so the HTTP/1.1 connection // returns to the pool cleanly. let _ = tokio::time::timeout(std::time::Duration::from_millis(50), async { while sse_stream.next().await.is_some() {} }) .await; break; } } Ok(()) } /// Performs a transparent re-initialization handshake after a session-expired 404. /// /// Takes an owned clone of the client (avoiding `&self` across `.await` so the /// future remains `Send` without requiring `C: Sync`). POSTs the saved /// initialize request without a session ID, extracts the new session ID and /// protocol version, sends `notifications/initialized`, and returns the new /// `(session_id, protocol_headers)` pair. The init result message is **not** /// forwarded to the handler because the handler already processed the original /// initialization. async fn perform_reinitialization( client: C, saved_init_request: ClientJsonRpcMessage, uri: Arc, auth_header: Option, custom_headers: HashMap, ) -> Result<(Option>, HashMap), StreamableHttpError> { let (init_msg, new_session_id_str) = client .post_message( uri.clone(), saved_init_request, None, auth_header.clone(), custom_headers.clone(), ) .await? .expect_initialized::() .await?; let new_session_id: Option> = new_session_id_str.map(|s| Arc::from(s.as_str())); // Start from custom_headers, then inject the negotiated MCP-Protocol-Version // so all subsequent requests carry the right version (MCP 2025-06-18 spec). let mut new_protocol_headers = custom_headers; if let ServerJsonRpcMessage::Response(response) = &init_msg { if let ServerResult::InitializeResult(init_result) = &response.result { if let Ok(hv) = HeaderValue::from_str(init_result.protocol_version.as_str()) { new_protocol_headers .insert(HeaderName::from_static("mcp-protocol-version"), hv); } } } let initialized_notification = ClientJsonRpcMessage::notification( ClientNotification::InitializedNotification(InitializedNotification { method: Default::default(), extensions: Default::default(), }), ); client .post_message( uri, initialized_notification, new_session_id.clone(), auth_header, new_protocol_headers.clone(), ) .await? .expect_accepted_or_json::()?; Ok((new_session_id, new_protocol_headers)) } } impl Worker for StreamableHttpClientWorker { type Role = RoleClient; type Error = StreamableHttpError; fn err_closed() -> Self::Error { StreamableHttpError::TransportChannelClosed } fn err_join(e: tokio::task::JoinError) -> Self::Error { StreamableHttpError::TokioJoinError(e) } fn config(&self) -> super::worker::WorkerConfig { super::worker::WorkerConfig { name: Some("StreamableHttpClientWorker".into()), channel_buffer_capacity: self.config.channel_buffer_capacity, } } async fn run( self, mut context: super::worker::WorkerContext, ) -> Result<(), WorkerQuitReason> { let channel_buffer_capacity = self.config.channel_buffer_capacity; let (sse_worker_tx, mut sse_worker_rx) = tokio::sync::mpsc::channel::(channel_buffer_capacity); let config = self.config.clone(); let transport_task_ct = context.cancellation_token.clone(); let _drop_guard = transport_task_ct.clone().drop_guard(); let WorkerSendRequest { responder, message: initialize_request, } = context.recv_from_handler().await?; let saved_init_request = initialize_request.clone(); let (message, session_id) = match self .client .post_message( config.uri.clone(), initialize_request, None, config.auth_header.clone(), config.custom_headers.clone(), ) .await { Ok(res) => { let _ = responder.send(Ok(())); res.expect_initialized::().await.map_err( WorkerQuitReason::fatal_context("process initialize response"), )? } Err(err) => { let msg = format!("{:?}", err); let _ = responder.send(Err(err)); return Err(WorkerQuitReason::fatal( StreamableHttpError::TransportChannelClosed, msg, )); } }; let mut session_id: Option> = if let Some(session_id) = session_id { Some(session_id.into()) } else { if !self.config.allow_stateless { return Err(WorkerQuitReason::fatal( StreamableHttpError::::MissingSessionIdInResponse, "process initialize response", )); } None }; // Extract the negotiated protocol version from the init response // and build a custom headers map that includes MCP-Protocol-Version // for all subsequent HTTP requests (per MCP 2025-06-18 spec). let mut protocol_headers = { let mut headers = config.custom_headers.clone(); if let ServerJsonRpcMessage::Response(response) = &message { if let ServerResult::InitializeResult(init_result) = &response.result { if let Ok(hv) = HeaderValue::from_str(init_result.protocol_version.as_str()) { // HeaderName::from_static requires lowercase headers.insert(HeaderName::from_static("mcp-protocol-version"), hv); } } } headers }; // Store session info for cleanup when run() exits (not spawned, so cleanup completes before close() returns) let mut session_cleanup_info = session_id.as_ref().map(|sid| SessionCleanupInfo { client: self.client.clone(), uri: config.uri.clone(), session_id: sid.clone(), auth_header: config.auth_header.clone(), protocol_headers: protocol_headers.clone(), }); context.send_to_handler(message).await?; let initialized_notification = context.recv_from_handler().await?; // expect a initialized response self.client .post_message( config.uri.clone(), initialized_notification.message, session_id.clone(), config.auth_header.clone(), protocol_headers.clone(), ) .await .map_err(WorkerQuitReason::fatal_context( "send initialized notification", ))? .expect_accepted_or_json::() .map_err(WorkerQuitReason::fatal_context( "process initialized notification response", ))?; let _ = initialized_notification.responder.send(Ok(())); #[allow(clippy::large_enum_variant)] enum Event { ClientMessage(WorkerSendRequest), ServerMessage(ServerJsonRpcMessage), StreamResult(Result<(), StreamableHttpError>), } let mut streams = tokio::task::JoinSet::new(); let mut pending_stream_response_ids = HashSet::new(); if let Some(session_id) = &session_id { let client = self.client.clone(); let uri = config.uri.clone(); let session_id = session_id.clone(); let auth_header = config.auth_header.clone(); let retry_config = self.config.retry_config.clone(); let sse_worker_tx = sse_worker_tx.clone(); let transport_task_ct = transport_task_ct.clone(); let config_uri = config.uri.clone(); let config_auth_header = config.auth_header.clone(); let spawn_headers = protocol_headers.clone(); streams.spawn(async move { match client .get_stream( uri.clone(), session_id.clone(), None, auth_header.clone(), spawn_headers.clone(), ) .await { Ok(stream) => { let sse_stream = SseAutoReconnectStream::new( stream, StreamableHttpClientReconnect { client: client.clone(), session_id: session_id.clone(), uri: config_uri, auth_header: config_auth_header, custom_headers: spawn_headers, }, retry_config, ); Self::execute_sse_stream( sse_stream, sse_worker_tx, false, transport_task_ct.child_token(), ) .await } Err(StreamableHttpError::ServerDoesNotSupportSse) => { tracing::debug!("server doesn't support sse, skip common stream"); Ok(()) } Err(e) => { // fail to get common stream tracing::error!("fail to get common stream: {e}"); Err(e) } } }); } // Main event loop - capture exit reason so we can do cleanup before returning let loop_result: Result<(), WorkerQuitReason> = 'main_loop: loop { let event = tokio::select! { _ = transport_task_ct.cancelled() => { tracing::debug!("cancelled"); break 'main_loop Err(WorkerQuitReason::Cancelled); } message = context.recv_from_handler() => { match message { Ok(msg) => Event::ClientMessage(msg), Err(e) => break 'main_loop Err(e), } }, message = sse_worker_rx.recv() => { let Some(message) = message else { tracing::trace!("transport dropped, exiting"); break 'main_loop Err(WorkerQuitReason::HandlerTerminated); }; Event::ServerMessage(message) }, terminated_stream = streams.join_next(), if !streams.is_empty() => { match terminated_stream { Some(result) => { Event::StreamResult(result.map_err(StreamableHttpError::TokioJoinError).and_then(std::convert::identity)) } None => { continue } } } }; match event { Event::ClientMessage(send_request) => { let WorkerSendRequest { message, responder } = send_request; let request_id = Self::client_request_id(&message); // Pass a clone to the first attempt so `message` is retained for a // potential re-init retry. `post_message` takes ownership and the // trait cannot be changed, so the clone is unavoidable. let response = self .client .post_message( config.uri.clone(), message.clone(), session_id.clone(), config.auth_header.clone(), protocol_headers.clone(), ) .await; let send_result = match response { Err(StreamableHttpError::SessionExpired) => { if !config.reinit_on_expired_session { Err(StreamableHttpError::SessionExpired) } else { // The server discarded the session (HTTP 404). Perform a // fresh handshake once and replay the original message. tracing::info!( "session expired (HTTP 404), attempting transparent re-initialization" ); match Self::perform_reinitialization( self.client.clone(), saved_init_request.clone(), config.uri.clone(), config.auth_header.clone(), config.custom_headers.clone(), ) .await { Ok((new_session_id, new_protocol_headers)) => { // Old streams hold the stale session ID. Stop them first // so no late stale-session messages can arrive after the // pending requests below are completed. streams.abort_all(); while streams.join_next().await.is_some() {} // Forward any already queued response messages and fail // the remaining accepted requests so callers do not wait // forever for responses that can no longer arrive. Self::drain_queued_stream_messages( &mut sse_worker_rx, &mut context, &mut pending_stream_response_ids, ) .await?; Self::fail_pending_stream_responses( &mut context, &mut pending_stream_response_ids, ) .await?; session_id = new_session_id; protocol_headers = new_protocol_headers; session_cleanup_info = session_id.as_ref().map(|sid| SessionCleanupInfo { client: self.client.clone(), uri: config.uri.clone(), session_id: sid.clone(), auth_header: config.auth_header.clone(), protocol_headers: protocol_headers.clone(), }); if let Some(new_sid) = &session_id { let client = self.client.clone(); let uri = config.uri.clone(); let new_sid = new_sid.clone(); let auth_header = config.auth_header.clone(); let retry_config = self.config.retry_config.clone(); let sse_tx = sse_worker_tx.clone(); let task_ct = transport_task_ct.clone(); let config_uri = config.uri.clone(); let config_auth = config.auth_header.clone(); let spawn_headers = protocol_headers.clone(); streams.spawn(async move { match client .get_stream( uri, new_sid.clone(), None, auth_header.clone(), spawn_headers.clone(), ) .await { Ok(stream) => { let sse_stream = SseAutoReconnectStream::new( stream, StreamableHttpClientReconnect { client: client.clone(), session_id: new_sid, uri: config_uri, auth_header: config_auth, custom_headers: spawn_headers, }, retry_config, ); Self::execute_sse_stream( sse_stream, sse_tx, false, task_ct.child_token(), ) .await } Err(StreamableHttpError::ServerDoesNotSupportSse) => { tracing::debug!( "server doesn't support sse after re-init" ); Ok(()) } Err(e) => { tracing::error!( "fail to get common stream after re-init: {e}" ); Err(e) } } }); } let retry_response = self .client .post_message( config.uri.clone(), message, session_id.clone(), config.auth_header.clone(), protocol_headers.clone(), ) .await; match retry_response { Err(e) => Err(e), Ok(StreamableHttpPostResponse::Accepted) => { Self::mark_stream_response_pending( &mut pending_stream_response_ids, request_id, ); tracing::trace!( "client message accepted after re-init" ); Ok(()) } Ok(StreamableHttpPostResponse::Json(msg, ..)) => { context.send_to_handler(msg).await?; Ok(()) } Ok(StreamableHttpPostResponse::Sse(stream, ..)) => { Self::mark_stream_response_pending( &mut pending_stream_response_ids, request_id, ); let sse_stream = Self::response_sse_to_jsonrpc( stream, session_id.clone(), self.client.clone(), config.uri.clone(), config.auth_header.clone(), protocol_headers.clone(), self.config.retry_config.clone(), ); streams.spawn(Self::execute_sse_stream( sse_stream, sse_worker_tx.clone(), true, transport_task_ct.child_token(), )); tracing::trace!("got new sse stream after re-init"); Ok(()) } } } Err(reinit_err) => Err(reinit_err), } } // else enable_reinit_on_expired_session } Err(e) => Err(e), Ok(StreamableHttpPostResponse::Accepted) => { Self::mark_stream_response_pending( &mut pending_stream_response_ids, request_id, ); tracing::trace!("client message accepted"); Ok(()) } Ok(StreamableHttpPostResponse::Json(message, ..)) => { context.send_to_handler(message).await?; Ok(()) } Ok(StreamableHttpPostResponse::Sse(stream, ..)) => { Self::mark_stream_response_pending( &mut pending_stream_response_ids, request_id, ); let sse_stream = Self::response_sse_to_jsonrpc( stream, session_id.clone(), self.client.clone(), config.uri.clone(), config.auth_header.clone(), protocol_headers.clone(), self.config.retry_config.clone(), ); streams.spawn(Self::execute_sse_stream( sse_stream, sse_worker_tx.clone(), true, transport_task_ct.child_token(), )); tracing::trace!("got new sse stream"); Ok(()) } }; let _ = responder.send(send_result); } Event::ServerMessage(json_rpc_message) => { Self::clear_stream_response_pending( &mut pending_stream_response_ids, &json_rpc_message, ); // send the message to the handler if let Err(e) = context.send_to_handler(json_rpc_message).await { break 'main_loop Err(e); } } Event::StreamResult(result) => { if result.is_err() { tracing::warn!( "sse client event stream terminated with error: {:?}", result ); } } } }; // Cleanup session before returning (ensures close() waits for session deletion) // Use a timeout to prevent indefinite hangs if the server is unresponsive if let Some(cleanup) = session_cleanup_info { const SESSION_CLEANUP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); let cleanup_session_id = cleanup.session_id.clone(); match tokio::time::timeout( SESSION_CLEANUP_TIMEOUT, cleanup.client.delete_session( cleanup.uri, cleanup.session_id, cleanup.auth_header, cleanup.protocol_headers, ), ) .await { Ok(Ok(_)) => { tracing::info!( session_id = cleanup_session_id.as_ref(), "delete session success" ) } Ok(Err(StreamableHttpError::ServerDoesNotSupportDeleteSession)) => { tracing::info!( session_id = cleanup_session_id.as_ref(), "server doesn't support delete session" ) } Ok(Err(e)) => { tracing::error!( session_id = cleanup_session_id.as_ref(), "fail to delete session: {e}" ); } Err(_elapsed) => { tracing::warn!( session_id = cleanup_session_id.as_ref(), "session cleanup timed out after {:?}", SESSION_CLEANUP_TIMEOUT ); } } } loop_result } } /// A client-agnostic HTTP transport for RMCP that supports streaming responses. /// /// This transport allows you to choose your preferred HTTP client implementation /// by implementing the [`StreamableHttpClient`] trait. The transport handles /// session management, SSE streaming, and automatic reconnection. /// /// # Usage /// /// ## Using reqwest /// /// ```rust,no_run /// use rmcp::transport::StreamableHttpClientTransport; /// /// // Enable the reqwest feature in Cargo.toml: /// // rmcp = { version = "0.5", features = ["transport-streamable-http-client-reqwest"] } /// /// let transport = StreamableHttpClientTransport::from_uri("http://localhost:8000/mcp"); /// ``` /// /// ## Using a custom HTTP client /// /// ```rust,no_run /// use rmcp::transport::streamable_http_client::{ /// StreamableHttpClient, /// StreamableHttpClientTransport, /// StreamableHttpClientTransportConfig /// }; /// use std::sync::Arc; /// use std::collections::HashMap; /// use futures::stream::BoxStream; /// use rmcp::model::ClientJsonRpcMessage; /// use http::{HeaderName, HeaderValue}; /// use sse_stream::{Sse, Error as SseError}; /// /// #[derive(Clone)] /// struct MyHttpClient; /// /// #[derive(Debug, thiserror::Error)] /// struct MyError; /// /// impl std::fmt::Display for MyError { /// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { /// write!(f, "MyError") /// } /// } /// /// impl StreamableHttpClient for MyHttpClient { /// type Error = MyError; /// /// async fn post_message( /// &self, /// _uri: Arc, /// _message: ClientJsonRpcMessage, /// _session_id: Option>, /// _auth_header: Option, /// _custom_headers: HashMap, /// ) -> Result> { /// todo!() /// } /// /// async fn delete_session( /// &self, /// _uri: Arc, /// _session_id: Arc, /// _auth_header: Option, /// _custom_headers: HashMap, /// ) -> Result<(), rmcp::transport::streamable_http_client::StreamableHttpError> { /// todo!() /// } /// /// async fn get_stream( /// &self, /// _uri: Arc, /// _session_id: Arc, /// _last_event_id: Option, /// _auth_header: Option, /// _custom_headers: HashMap, /// ) -> Result>, rmcp::transport::streamable_http_client::StreamableHttpError> { /// todo!() /// } /// } /// /// let transport = StreamableHttpClientTransport::with_client( /// MyHttpClient, /// StreamableHttpClientTransportConfig::with_uri("http://localhost:8000/mcp") /// ); /// ``` /// /// # Feature Flags /// /// - `transport-streamable-http-client`: Base feature providing the generic transport infrastructure /// - `transport-streamable-http-client-reqwest`: Includes reqwest HTTP client support with convenience methods pub type StreamableHttpClientTransport = WorkerTransport>; impl StreamableHttpClientTransport { /// Creates a new transport with a custom HTTP client implementation. /// /// This method allows you to use any HTTP client that implements the [`StreamableHttpClient`] trait. /// Use this when you want to use a custom HTTP client or when the reqwest feature is not enabled. /// /// # Arguments /// /// * `client` - Your HTTP client implementation /// * `config` - Transport configuration including the server URI /// /// # Example /// /// ```rust,no_run /// use rmcp::transport::streamable_http_client::{ /// StreamableHttpClient, /// StreamableHttpClientTransport, /// StreamableHttpClientTransportConfig /// }; /// use std::sync::Arc; /// use std::collections::HashMap; /// use futures::stream::BoxStream; /// use rmcp::model::ClientJsonRpcMessage; /// use http::{HeaderName, HeaderValue}; /// use sse_stream::{Sse, Error as SseError}; /// /// // Define your custom client /// #[derive(Clone)] /// struct MyHttpClient; /// /// #[derive(Debug, thiserror::Error)] /// struct MyError; /// /// impl std::fmt::Display for MyError { /// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { /// write!(f, "MyError") /// } /// } /// /// impl StreamableHttpClient for MyHttpClient { /// type Error = MyError; /// /// async fn post_message( /// &self, /// _uri: Arc, /// _message: ClientJsonRpcMessage, /// _session_id: Option>, /// _auth_header: Option, /// _custom_headers: HashMap, /// ) -> Result> { /// todo!() /// } /// /// async fn delete_session( /// &self, /// _uri: Arc, /// _session_id: Arc, /// _auth_header: Option, /// _custom_headers: HashMap, /// ) -> Result<(), rmcp::transport::streamable_http_client::StreamableHttpError> { /// todo!() /// } /// /// async fn get_stream( /// &self, /// _uri: Arc, /// _session_id: Arc, /// _last_event_id: Option, /// _auth_header: Option, /// _custom_headers: HashMap, /// ) -> Result>, rmcp::transport::streamable_http_client::StreamableHttpError> { /// todo!() /// } /// } /// /// let transport = StreamableHttpClientTransport::with_client( /// MyHttpClient, /// StreamableHttpClientTransportConfig::with_uri("http://localhost:8000/mcp") /// ); /// ``` pub fn with_client(client: C, config: StreamableHttpClientTransportConfig) -> Self { let worker = StreamableHttpClientWorker::new(client, config); WorkerTransport::spawn(worker) } } #[derive(Debug, Clone)] #[non_exhaustive] pub struct StreamableHttpClientTransportConfig { pub uri: Arc, pub retry_config: Arc, pub channel_buffer_capacity: usize, /// if true, the transport will not require a session to be established pub allow_stateless: bool, /// The value to send in the authorization header pub auth_header: Option, /// Custom HTTP headers to include with every request pub custom_headers: HashMap, /// Enables transparent recovery when the server reports an expired session (`HTTP 404`). /// /// When enabled, the transport performs one automatic recovery attempt: /// 1. Replays the original `initialize` handshake to create a new session. /// 2. Re-establishes streaming state for that session. /// 3. Retries the in-flight request that failed with `SessionExpired`. /// /// This recovery is best-effort and bounded to a single attempt. If recovery fails, /// the original failure path is preserved and the error is returned to the caller. pub reinit_on_expired_session: bool, } impl StreamableHttpClientTransportConfig { pub fn with_uri(uri: impl Into>) -> Self { Self { uri: uri.into(), ..Default::default() } } /// Set the authorization header to send with requests /// /// # Arguments /// /// * `value` - A bearer token without the `Bearer ` prefix pub fn auth_header>(mut self, value: T) -> Self { // set our authorization header self.auth_header = Some(value.into()); self } /// Set custom HTTP headers to include with every request /// /// # Arguments /// /// * `custom_headers` - A HashMap of header names to header values /// /// # Example /// /// ```rust,no_run /// use std::collections::HashMap; /// use http::{HeaderName, HeaderValue}; /// use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; /// /// let mut headers = HashMap::new(); /// headers.insert( /// HeaderName::from_static("x-custom-header"), /// HeaderValue::from_static("custom-value") /// ); /// /// let config = StreamableHttpClientTransportConfig::with_uri("http://localhost:8000") /// .custom_headers(headers); /// ``` pub fn custom_headers(mut self, custom_headers: HashMap) -> Self { self.custom_headers = custom_headers; self } /// Set whether the transport should attempt transparent re-initialization on session expiration /// See [`Self::reinit_on_expired_session`] for details. /// # Example /// ```rust,no_run /// use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; /// let config = StreamableHttpClientTransportConfig::with_uri("http://localhost:8000") /// .reinit_on_expired_session(true); /// ``` pub fn reinit_on_expired_session(mut self, enable: bool) -> Self { self.reinit_on_expired_session = enable; self } } impl Default for StreamableHttpClientTransportConfig { fn default() -> Self { Self { uri: "localhost".into(), retry_config: Arc::new(ExponentialBackoff::default()), channel_buffer_capacity: 16, allow_stateless: true, auth_header: None, custom_headers: HashMap::new(), reinit_on_expired_session: true, } } }