#[cfg(test)]
mod tests;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use futures_util::StreamExt;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use serde::de::DeserializeOwned;
use serde_json::Value as JsonValue;
use url::Url;
use super::config::{
McpStreamableHttpConfigError, McpStreamableHttpLimits, McpStreamableHttpServerConfig,
};
use crate::mcp::protocol::*;
use crate::mcp::sse::endpoint::EndpointError;
use crate::mcp::sse::wire::{SseParser, SseWireError};
const PROTOCOL_VERSION: &str = "2025-06-18";
const SUPPORTED_PROTOCOL_VERSIONS: [&str; 2] = ["2025-06-18", "2025-03-26"];
const ACCEPT_BOTH: &str = "application/json, text/event-stream";
const SESSION_HEADER: &str = "mcp-session-id";
const PROTOCOL_HEADER: &str = "mcp-protocol-version";
const MAX_DIAGNOSTIC_BODY_BYTES: usize = 8 * 1024;
const MAX_SESSION_ID_BYTES: usize = 512;
#[derive(Debug, thiserror::Error)]
pub enum McpStreamableHttpError {
#[error("invalid MCP Streamable HTTP configuration: {0}")]
Config(#[from] McpStreamableHttpConfigError),
#[error("invalid MCP endpoint: {0}")]
Endpoint(#[from] EndpointError),
#[error("failed to reach the MCP server: {0}")]
Transport(String),
#[error("MCP server answered the {method} request with HTTP {status}")]
HttpStatus {
method: &'static str,
status: reqwest::StatusCode,
},
#[error(
"MCP server answered with a redirect, which is not followed because it would send \
credentials to an unvalidated origin"
)]
RedirectRefused,
#[error(
"MCP server answered with content type '{content_type}', expected application/json or \
text/event-stream"
)]
UnexpectedContentType { content_type: String },
#[error("MCP Streamable HTTP stream framing error: {0}")]
Wire(#[from] SseWireError),
#[error("MCP server's reply exceeded the {limit} byte limit")]
ReplyTooLarge { limit: usize },
#[error("MCP server kept paginating tools/list past {limit} pages")]
TooManyToolPages { limit: usize },
#[error("MCP server advertised more than {limit} tools")]
TooManyTools { limit: usize },
#[error(
"MCP server negotiated a protocol revision this client does not implement; \
it supports {supported}"
)]
UnsupportedProtocolVersion { supported: String },
#[error("MCP server returned JSON-RPC error: {0}")]
JsonRpc(JsonRpcError),
#[error("failed to parse the MCP response: {0}")]
ParseError(String),
#[error("MCP server answered the {method} request with a reply for a different request id")]
MismatchedReplyId { method: &'static str },
#[error("the MCP server no longer recognizes this session; it must be established again")]
SessionExpired,
#[error("timed out after {0:?} waiting for the MCP server")]
Timeout(Duration),
#[error("the MCP server closed the reply stream before answering the {method} request")]
StreamClosed { method: &'static str },
#[error(
"the MCP server may have received the '{method}' request but never answered it; \
the call may have executed and must not be retried automatically"
)]
RequestIndeterminate { method: String },
#[error(transparent)]
InvalidServerName(#[from] crate::mcp::bridge::McpServerNameError),
}
#[derive(Debug, Default)]
struct Session {
id: Option<HeaderValue>,
protocol_version: Option<HeaderValue>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ReplyFraming {
Json,
EventStream,
}
pub struct McpStreamableHttpClient {
http: reqwest::Client,
endpoint: Url,
headers: HeaderMap,
limits: McpStreamableHttpLimits,
next_id: AtomicU64,
session: Mutex<Arc<Session>>,
renewal: tokio::sync::Mutex<()>,
server_info: Option<McpServerInfo>,
tools: Vec<McpToolDefinition>,
server_name: String,
}
impl McpStreamableHttpClient {
pub async fn connect(
config: &McpStreamableHttpServerConfig,
) -> Result<Self, McpStreamableHttpError> {
let endpoint = config.validate()?;
let headers = build_headers(config)?;
let http = build_http_client(&config.limits)?;
let mut client = Self {
http,
endpoint,
headers,
limits: config.limits.clone(),
next_id: AtomicU64::new(1),
session: Mutex::new(Arc::default()),
renewal: tokio::sync::Mutex::new(()),
server_info: None,
tools: Vec::new(),
server_name: config.name.clone(),
};
let (result, session) = client.handshake().await?;
client.server_info = Some(result.server_info);
*lock_session(&client.session) = Arc::new(session);
if let Err(error) = client.discover_tools().await {
client.shutdown().await;
return Err(error);
}
Ok(client)
}
pub fn server_name(&self) -> &str {
&self.server_name
}
pub fn endpoint(&self) -> &Url {
&self.endpoint
}
pub fn server_info(&self) -> Option<&McpServerInfo> {
self.server_info.as_ref()
}
pub fn tools(&self) -> &[McpToolDefinition] {
&self.tools
}
pub fn session_id(&self) -> Option<String> {
lock_session(&self.session)
.id
.as_ref()
.and_then(|value| value.to_str().ok())
.map(str::to_string)
}
pub async fn call_tool(
&self,
tool_name: &str,
arguments: Option<JsonValue>,
) -> Result<McpToolCallResult, McpStreamableHttpError> {
let params = McpToolCallParams {
name: tool_name.to_string(),
arguments,
};
self.request("tools/call", Some(params), self.limits.call_tool_timeout)
.await
}
pub async fn shutdown(&self) {
let _renewal = self.renewal.lock().await;
let session = self.session_snapshot();
if session.id.is_none() {
return;
}
self.end_session(&session).await;
*lock_session(&self.session) = Arc::new(Session {
id: None,
protocol_version: session.protocol_version.clone(),
});
}
fn session_snapshot(&self) -> Arc<Session> {
Arc::clone(&lock_session(&self.session))
}
fn end_session_request(&self, session: &Session) -> Option<reqwest::RequestBuilder> {
session.id.as_ref()?;
Some(
self.http
.delete(self.endpoint.clone())
.headers(self.request_headers(session)),
)
}
async fn end_session(&self, session: &Session) {
if let Some(request) = self.end_session_request(session) {
let _ = tokio::time::timeout(self.limits.connect_timeout, request.send()).await;
}
}
fn end_session_detached(&self, session: &Session) {
let Some(request) = self.end_session_request(session) else {
return;
};
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
return;
};
let timeout = self.limits.connect_timeout;
runtime.spawn(async move {
let _ = tokio::time::timeout(timeout, request.send()).await;
});
}
async fn request<P: serde::Serialize, R: DeserializeOwned>(
&self,
method: &'static str,
params: Option<P>,
timeout: Duration,
) -> Result<R, McpStreamableHttpError> {
let params = params
.map(serde_json::to_value)
.transpose()
.map_err(|error| McpStreamableHttpError::ParseError(error.to_string()))?
.filter(|params| !params.is_null());
let session = self.session_snapshot();
let result = match self
.attempt(&session, method, params.clone(), timeout)
.await
{
Err(McpStreamableHttpError::SessionExpired) => {
let replacement = self.reestablish_session(&session).await?;
self.attempt(&replacement, method, params, timeout).await?
}
other => other?,
};
serde_json::from_value(result).map_err(|_| {
McpStreamableHttpError::ParseError("response shape did not match MCP".to_string())
})
}
async fn attempt(
&self,
session: &Session,
method: &'static str,
params: Option<JsonValue>,
timeout: Duration,
) -> Result<JsonValue, McpStreamableHttpError> {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let request = JsonRpcRequest::new(id, method, params);
let operation = async {
let response = self
.post(session, method, &request)
.await
.map_err(|error| classify_failure(method, error))?;
self.read_reply(method, response, id)
.await
.map_err(|error| reply_failure(method, error))
};
match tokio::time::timeout(timeout, operation).await {
Ok(result) => result,
Err(_) => Err(request_timeout(method, timeout)),
}
}
async fn read_reply(
&self,
method: &'static str,
response: reqwest::Response,
id: u64,
) -> Result<JsonValue, McpStreamableHttpError> {
if response.status() == reqwest::StatusCode::ACCEPTED {
drain_bounded(response).await;
return Err(McpStreamableHttpError::StreamClosed { method });
}
match reply_framing(&response)? {
ReplyFraming::Json => {
let body = self.read_bounded_body(response).await?;
let reply = serde_json::from_slice::<JsonRpcResponse>(&body).map_err(|_| {
McpStreamableHttpError::ParseError("reply was not JSON-RPC".to_string())
})?;
outcome(method, reply, id)
}
ReplyFraming::EventStream => self.read_streamed_reply(method, response, id).await,
}
}
async fn notify(
&self,
session: &Session,
method: &str,
timeout: Duration,
) -> Result<(), McpStreamableHttpError> {
let notification = serde_json::json!({"jsonrpc": "2.0", "method": method});
let operation = async {
let response = self.post(session, "POST", ¬ification).await?;
drain_bounded(response).await;
Ok(())
};
tokio::time::timeout(timeout, operation)
.await
.map_err(|_| McpStreamableHttpError::Timeout(timeout))?
}
async fn post<T: serde::Serialize>(
&self,
session: &Session,
method: &'static str,
message: &T,
) -> Result<reqwest::Response, McpStreamableHttpError> {
let response = self
.http
.post(self.endpoint.clone())
.headers(self.request_headers(session))
.header(reqwest::header::ACCEPT, ACCEPT_BOTH)
.json(message)
.send()
.await
.map_err(transport_error)?;
let status = response.status();
if status.is_redirection() {
return Err(McpStreamableHttpError::RedirectRefused);
}
if !status.is_success() {
drain_bounded(response).await;
if status == reqwest::StatusCode::NOT_FOUND && session.id.is_some() {
return Err(McpStreamableHttpError::SessionExpired);
}
return Err(McpStreamableHttpError::HttpStatus { method, status });
}
Ok(response)
}
fn request_headers(&self, session: &Session) -> HeaderMap {
let mut headers = self.headers.clone();
if let Some(id) = &session.id {
headers.insert(HeaderName::from_static(SESSION_HEADER), id.clone());
}
if let Some(version) = &session.protocol_version {
headers.insert(HeaderName::from_static(PROTOCOL_HEADER), version.clone());
}
headers
}
fn session_id_header(headers: &HeaderMap) -> Option<HeaderValue> {
headers
.get(SESSION_HEADER)
.filter(|value| !value.is_empty() && value.len() <= MAX_SESSION_ID_BYTES)
.filter(|value| {
value
.as_bytes()
.iter()
.all(|byte| (0x21..=0x7e).contains(byte))
})
.map(|value| {
let mut value = value.clone();
value.set_sensitive(true);
value
})
}
fn protocol_version_header(reported: &str) -> Result<HeaderValue, McpStreamableHttpError> {
let negotiated = SUPPORTED_PROTOCOL_VERSIONS
.into_iter()
.find(|supported| *supported == reported)
.ok_or_else(|| McpStreamableHttpError::UnsupportedProtocolVersion {
supported: SUPPORTED_PROTOCOL_VERSIONS.join(", "),
})?;
Ok(HeaderValue::from_static(negotiated))
}
async fn read_bounded_body(
&self,
response: reqwest::Response,
) -> Result<Vec<u8>, McpStreamableHttpError> {
let limit = self.limits.max_response_bytes;
let mut body = response.bytes_stream();
let mut buffer = Vec::new();
loop {
let next = tokio::time::timeout(self.limits.stream_idle_timeout, body.next()).await;
let chunk = match next {
Ok(Some(Ok(chunk))) => chunk,
Ok(Some(Err(_))) => {
return Err(McpStreamableHttpError::Transport(
"the reply body could not be read".to_string(),
));
}
Ok(None) => break,
Err(_) => {
return Err(McpStreamableHttpError::Timeout(
self.limits.stream_idle_timeout,
));
}
};
if buffer.len().saturating_add(chunk.len()) > limit {
return Err(McpStreamableHttpError::ReplyTooLarge { limit });
}
buffer.extend_from_slice(&chunk);
}
Ok(buffer)
}
async fn read_streamed_reply(
&self,
method: &'static str,
response: reqwest::Response,
id: u64,
) -> Result<JsonValue, McpStreamableHttpError> {
let mut parser = SseParser::new(self.limits.max_event_bytes);
let mut body = response.bytes_stream();
let mut seen = 0_usize;
loop {
let next = tokio::time::timeout(self.limits.stream_idle_timeout, body.next()).await;
let chunk = match next {
Ok(Some(Ok(chunk))) => chunk,
Ok(Some(Err(_))) => return Err(indeterminate(method)),
Ok(None) => break,
Err(_) => return Err(request_timeout(method, self.limits.stream_idle_timeout)),
};
seen = seen.saturating_add(chunk.len());
if seen > self.limits.max_response_bytes {
return Err(McpStreamableHttpError::ReplyTooLarge {
limit: self.limits.max_response_bytes,
});
}
for event in parser.feed(&chunk)? {
if event.event != "message" {
continue;
}
let Ok(reply) = serde_json::from_str::<JsonRpcResponse>(&event.data) else {
continue;
};
if reply.result.is_none() && reply.error.is_none() {
continue;
}
if reply.id != JsonRpcId::Number(id) {
continue;
}
return outcome(method, reply, id);
}
}
Err(McpStreamableHttpError::StreamClosed { method })
}
async fn handshake(&self) -> Result<(McpInitializeResult, Session), McpStreamableHttpError> {
let mut pending = PendingSession {
client: self,
session: Session::default(),
};
match self.initialize_exchange(&mut pending.session).await {
Ok(result) => Ok((result, std::mem::take(&mut pending.session))),
Err(error) => {
self.end_session(&pending.session).await;
pending.session.id = None;
Err(error)
}
}
}
async fn initialize_exchange(
&self,
pending: &mut Session,
) -> Result<McpInitializeResult, McpStreamableHttpError> {
let params = McpInitializeParams {
protocol_version: PROTOCOL_VERSION.to_string(),
capabilities: serde_json::json!({}),
client_info: McpClientInfo {
name: "mentra".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
};
let params = serde_json::to_value(params)
.map_err(|error| McpStreamableHttpError::ParseError(error.to_string()))?;
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let request = JsonRpcRequest::new(id, "initialize", Some(params));
let timeout = self.limits.initialize_timeout;
let raw = tokio::time::timeout(timeout, async {
let response = self
.post(&Session::default(), "initialize", &request)
.await?;
pending.id = Self::session_id_header(response.headers());
self.read_reply("initialize", response, id).await
})
.await
.map_err(|_| McpStreamableHttpError::Timeout(timeout))??;
let result: McpInitializeResult = serde_json::from_value(raw).map_err(|_| {
McpStreamableHttpError::ParseError("response shape did not match MCP".to_string())
})?;
pending.protocol_version = Some(Self::protocol_version_header(&result.protocol_version)?);
self.notify(pending, "notifications/initialized", timeout)
.await?;
Ok(result)
}
async fn reestablish_session(
&self,
rejected: &Arc<Session>,
) -> Result<Arc<Session>, McpStreamableHttpError> {
let _renewal = self.renewal.lock().await;
let current = self.session_snapshot();
if !Arc::ptr_eq(¤t, rejected) {
return Ok(current);
}
let (_, session) = self.handshake().await?;
let session = Arc::new(session);
*lock_session(&self.session) = Arc::clone(&session);
Ok(session)
}
async fn discover_tools(&mut self) -> Result<(), McpStreamableHttpError> {
let mut tools = Vec::new();
let mut cursor: Option<String> = None;
let mut pages = 0_usize;
loop {
let params = McpListToolsParams {
cursor: cursor.clone(),
};
let page: McpListToolsResult = self
.request("tools/list", Some(params), self.limits.list_tools_timeout)
.await?;
tools.extend(page.tools);
pages += 1;
if tools.len() > self.limits.max_tools {
return Err(McpStreamableHttpError::TooManyTools {
limit: self.limits.max_tools,
});
}
match page.next_cursor {
Some(next) if !next.is_empty() => {
if pages >= self.limits.max_tool_pages {
return Err(McpStreamableHttpError::TooManyToolPages {
limit: self.limits.max_tool_pages,
});
}
cursor = Some(next);
}
_ => break,
}
}
self.tools = tools;
Ok(())
}
}
impl Drop for McpStreamableHttpClient {
fn drop(&mut self) {
self.end_session_detached(&self.session_snapshot());
}
}
struct PendingSession<'a> {
client: &'a McpStreamableHttpClient,
session: Session,
}
impl Drop for PendingSession<'_> {
fn drop(&mut self) {
self.client.end_session_detached(&self.session);
}
}
impl std::fmt::Debug for McpStreamableHttpClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("McpStreamableHttpClient")
.field("server_name", &self.server_name)
.field("endpoint", &self.endpoint.as_str())
.field("tools", &self.tools.len())
.finish_non_exhaustive()
}
}
fn outcome(
method: &'static str,
reply: JsonRpcResponse,
id: u64,
) -> Result<JsonValue, McpStreamableHttpError> {
if reply.id != JsonRpcId::Number(id) {
return Err(McpStreamableHttpError::MismatchedReplyId { method });
}
match reply.error {
Some(error) => Err(McpStreamableHttpError::JsonRpc(JsonRpcError {
code: error.code,
message: "server message omitted".to_string(),
data: None,
})),
None => Ok(reply.result.unwrap_or(JsonValue::Null)),
}
}
fn reply_framing(response: &reqwest::Response) -> Result<ReplyFraming, McpStreamableHttpError> {
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.trim_start()
.to_ascii_lowercase();
if content_type.starts_with("application/json") {
return Ok(ReplyFraming::Json);
}
if content_type.starts_with("text/event-stream") {
return Ok(ReplyFraming::EventStream);
}
Err(McpStreamableHttpError::UnexpectedContentType {
content_type: "[server value omitted]".to_string(),
})
}
fn indeterminate(method: &str) -> McpStreamableHttpError {
if method == "tools/call" {
McpStreamableHttpError::RequestIndeterminate {
method: method.to_string(),
}
} else {
McpStreamableHttpError::StreamClosed {
method: "the handshake",
}
}
}
fn reply_failure(method: &str, error: McpStreamableHttpError) -> McpStreamableHttpError {
if method != "tools/call" {
return error;
}
match error {
McpStreamableHttpError::JsonRpc(_)
| McpStreamableHttpError::RequestIndeterminate { .. } => error,
_ => indeterminate(method),
}
}
fn classify_failure(method: &str, error: McpStreamableHttpError) -> McpStreamableHttpError {
if method != "tools/call" {
return error;
}
match &error {
McpStreamableHttpError::HttpStatus { status, .. } if status.is_client_error() => error,
McpStreamableHttpError::SessionExpired
| McpStreamableHttpError::Config(_)
| McpStreamableHttpError::Endpoint(_) => error,
_ => indeterminate(method),
}
}
fn request_timeout(method: &str, timeout: Duration) -> McpStreamableHttpError {
if method == "tools/call" {
indeterminate(method)
} else {
McpStreamableHttpError::Timeout(timeout)
}
}
fn lock_session(session: &Mutex<Arc<Session>>) -> std::sync::MutexGuard<'_, Arc<Session>> {
session
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn build_http_client(
limits: &McpStreamableHttpLimits,
) -> Result<reqwest::Client, McpStreamableHttpError> {
reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.retry(reqwest::retry::never())
.connect_timeout(limits.connect_timeout)
.build()
.map_err(|error| McpStreamableHttpError::Transport(error.to_string()))
}
fn build_headers(
config: &McpStreamableHttpServerConfig,
) -> Result<HeaderMap, McpStreamableHttpError> {
let mut headers = HeaderMap::new();
for (name, value) in &config.headers {
let name = HeaderName::try_from(name.as_str()).map_err(|_| {
McpStreamableHttpConfigError::InvalidHeaderName {
name: name.to_string(),
}
})?;
let mut value = HeaderValue::try_from(value.expose_secret()).map_err(|_| {
McpStreamableHttpConfigError::InvalidHeaderValue {
name: name.to_string(),
}
})?;
value.set_sensitive(true);
headers.insert(name, value);
}
Ok(headers)
}
async fn drain_bounded(response: reqwest::Response) {
let mut body = response.bytes_stream();
let mut seen = 0_usize;
while let Some(Ok(chunk)) = body.next().await {
seen += chunk.len();
if seen >= MAX_DIAGNOSTIC_BODY_BYTES {
break;
}
}
}
fn transport_error(error: reqwest::Error) -> McpStreamableHttpError {
let reason = if error.is_timeout() {
"timed out"
} else if error.is_connect() {
"could not connect"
} else if error.is_request() {
"the request could not be sent"
} else {
"the connection failed"
};
McpStreamableHttpError::Transport(reason.to_string())
}