#[cfg(test)]
mod tests;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
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, Clone, 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<Session>,
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(Session::default()),
server_info: None,
tools: Vec::new(),
server_name: config.name.clone(),
};
if let Err(error) = client.initialize().await {
client.shutdown().await;
return Err(error);
}
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) {
if lock_session(&self.session).id.is_none() {
return;
}
let _ = tokio::time::timeout(
self.limits.connect_timeout,
self.http
.delete(self.endpoint.clone())
.headers(self.request_headers())
.send(),
)
.await;
let mut session = lock_session(&self.session);
*session = Session {
id: None,
protocol_version: session.protocol_version.clone(),
};
}
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 result = match self.attempt(method, params.clone(), timeout).await {
Err(McpStreamableHttpError::SessionExpired) => {
self.reestablish_session().await?;
self.attempt(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,
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);
match tokio::time::timeout(timeout, self.exchange(method, &request, id)).await {
Ok(result) => result,
Err(_) => Err(request_timeout(method, timeout)),
}
}
async fn exchange(
&self,
method: &'static str,
request: &JsonRpcRequest,
id: u64,
) -> Result<JsonValue, McpStreamableHttpError> {
let response = self
.post(method, request)
.await
.map_err(|error| classify_failure(method, error))?;
if method == "initialize" {
self.adopt_session(response.headers());
}
self.read_reply(method, response, id)
.await
.map_err(|error| reply_failure(method, error))
}
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, method: &str, timeout: Duration) -> Result<(), McpStreamableHttpError> {
let notification = serde_json::json!({"jsonrpc": "2.0", "method": method});
let operation = async {
let response = self.post("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,
method: &'static str,
message: &T,
) -> Result<reqwest::Response, McpStreamableHttpError> {
let response = self
.http
.post(self.endpoint.clone())
.headers(self.request_headers())
.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() {
let had_session = lock_session(&self.session).id.is_some();
drain_bounded(response).await;
if status == reqwest::StatusCode::NOT_FOUND && had_session {
return Err(McpStreamableHttpError::SessionExpired);
}
return Err(McpStreamableHttpError::HttpStatus { method, status });
}
Ok(response)
}
fn request_headers(&self) -> HeaderMap {
let mut headers = self.headers.clone();
let session = lock_session(&self.session).clone();
if let Some(id) = session.id {
headers.insert(HeaderName::from_static(SESSION_HEADER), id);
}
if let Some(version) = session.protocol_version {
headers.insert(HeaderName::from_static(PROTOCOL_HEADER), version);
}
headers
}
fn adopt_session(&self, headers: &reqwest::header::HeaderMap) {
let id = 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
});
let mut session = lock_session(&self.session);
*session = Session {
id,
protocol_version: session.protocol_version.clone(),
};
}
fn adopt_protocol_version(&self, reported: &str) -> Result<(), McpStreamableHttpError> {
let negotiated = SUPPORTED_PROTOCOL_VERSIONS
.into_iter()
.find(|supported| *supported == reported)
.ok_or_else(|| McpStreamableHttpError::UnsupportedProtocolVersion {
supported: SUPPORTED_PROTOCOL_VERSIONS.join(", "),
})?;
let mut session = lock_session(&self.session);
*session = Session {
id: session.id.clone(),
protocol_version: Some(HeaderValue::from_static(negotiated)),
};
Ok(())
}
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 initialize(&mut self) -> Result<(), McpStreamableHttpError> {
let result = self.handshake().await?;
self.server_info = Some(result.server_info);
Ok(())
}
async fn handshake(&self) -> 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 raw = self
.attempt("initialize", Some(params), self.limits.initialize_timeout)
.await?;
let result: McpInitializeResult = serde_json::from_value(raw).map_err(|_| {
McpStreamableHttpError::ParseError("response shape did not match MCP".to_string())
})?;
self.adopt_protocol_version(&result.protocol_version)?;
self.notify("notifications/initialized", self.limits.initialize_timeout)
.await?;
Ok(result)
}
async fn reestablish_session(&self) -> Result<(), McpStreamableHttpError> {
{
let mut session = lock_session(&self.session);
*session = Session::default();
}
self.handshake().await.map(|_| ())
}
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) {
if lock_session(&self.session).id.is_none() {
return;
}
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
return;
};
let request = self
.http
.delete(self.endpoint.clone())
.headers(self.request_headers());
let timeout = self.limits.connect_timeout;
runtime.spawn(async move {
let _ = tokio::time::timeout(timeout, request.send()).await;
});
}
}
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<Session>) -> std::sync::MutexGuard<'_, 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())
}