use anyhow::{Context, Result, anyhow, bail};
use futures::StreamExt;
use reqwest::StatusCode;
use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderName, HeaderValue};
use serde_json::{Value, json};
use std::sync::RwLock;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use tokio::time::{Duration, timeout};
use super::oauth::OAuthSession;
use super::transport::{
JsonRpcError, REQUEST_TIMEOUT_SECS, RequestTimeout, extract_jsonrpc_result, is_response,
parse_response_id,
};
use mermaid_domain::{McpServerConfig, TransportKind};
use mermaid_model::utils::{CredentialStore, HostClass, classify_host, drain_sse_events};
const CONNECT_TIMEOUT_SECS: u64 = 10;
const NOTIFICATION_TIMEOUT_SECS: u64 = 10;
const DELETE_TIMEOUT_SECS: u64 = 5;
const MAX_SSE_RECONNECTS: u32 = 5;
const ERROR_BODY_SNIPPET_BYTES: usize = 200;
const SESSION_HEADER: HeaderName = HeaderName::from_static("mcp-session-id");
const PROTOCOL_VERSION_HEADER: HeaderName = HeaderName::from_static("mcp-protocol-version");
const LAST_EVENT_ID_HEADER: HeaderName = HeaderName::from_static("last-event-id");
const METHOD_HEADER: HeaderName = HeaderName::from_static("mcp-method");
const ACCEPT_POST: &str = "application/json, text/event-stream";
const ACCEPT_SSE: &str = "text/event-stream";
pub(super) struct HttpTransport {
client: reqwest::Client,
url: reqwest::Url,
static_headers: HeaderMap,
env_headers: Vec<(HeaderName, String)>,
session_id: RwLock<Option<HeaderValue>>,
protocol_version: RwLock<Option<HeaderValue>>,
next_id: AtomicU64,
oauth: Option<OAuthSession>,
modern: AtomicBool,
}
impl HttpTransport {
pub fn new(name: &str, config: &McpServerConfig) -> Result<Self> {
Self::with_store(name, config, None)
}
pub(super) fn with_store(
name: &str,
config: &McpServerConfig,
store: Option<std::sync::Arc<dyn CredentialStore>>,
) -> Result<Self> {
if config.transport_kind()? != TransportKind::Http {
bail!("MCP server config is not url-shaped");
}
let url_str = config
.url
.as_deref()
.expect("transport_kind checked url presence");
let url = reqwest::Url::parse(url_str)
.map_err(|e| anyhow!("invalid MCP server url '{url_str}': {e}"))?;
check_ip_literal(&url, config.allow_private_network)?;
let mut static_headers = HeaderMap::new();
for (name, value) in &config.headers {
let n = HeaderName::from_bytes(name.as_bytes())
.map_err(|_| anyhow!("invalid MCP header name '{name}'"))?;
let v = HeaderValue::from_str(value)
.map_err(|_| anyhow!("invalid value for MCP header '{name}'"))?;
static_headers.insert(n, v);
}
let mut env_headers = Vec::new();
for (name, var) in &config.env_headers {
let n = HeaderName::from_bytes(name.as_bytes())
.map_err(|_| anyhow!("invalid MCP header name '{name}'"))?;
env_headers.push((n, var.clone()));
}
let client = vetted_client(config.allow_private_network)?;
let oauth = OAuthSession::for_server(name, config, store, client.clone());
Ok(Self {
client,
url,
static_headers,
env_headers,
session_id: RwLock::new(None),
protocol_version: RwLock::new(None),
next_id: AtomicU64::new(1),
oauth,
modern: AtomicBool::new(false),
})
}
pub async fn send_request(&self, method: &str, params: Value) -> Result<Value> {
self.send_request_with_timeout(method, params, REQUEST_TIMEOUT_SECS)
.await
}
pub async fn send_request_with_timeout(
&self,
method: &str,
params: Value,
response_timeout_secs: u64,
) -> Result<Value> {
self.send_request_with_headers(method, params, response_timeout_secs, &HeaderMap::new())
.await
}
pub async fn send_request_with_headers(
&self,
method: &str,
params: Value,
response_timeout_secs: u64,
extra: &HeaderMap,
) -> Result<Value> {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let request = json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params,
});
timeout(
Duration::from_secs(response_timeout_secs),
self.request_roundtrip(method, id, &request, extra),
)
.await
.map_err(|_| RequestTimeout {
method: method.to_string(),
secs: response_timeout_secs,
})?
}
pub async fn send_notification(&self, method: &str, params: Value) -> Result<()> {
let notification = json!({
"jsonrpc": "2.0",
"method": method,
"params": params,
});
self.post_message(¬ification)
.await
.with_context(|| format!("MCP notification failed (method: {method})"))
}
pub fn set_modern(&self, version: Option<&str>) {
self.modern.store(version.is_some(), Ordering::Release);
*self
.protocol_version
.write()
.expect("mcp protocol_version lock poisoned") =
version.and_then(|v| HeaderValue::from_str(v).ok());
*self
.session_id
.write()
.expect("mcp session_id lock poisoned") = None;
}
fn is_modern(&self) -> bool {
self.modern.load(Ordering::Acquire)
}
pub fn set_protocol_version(&self, version: &str) {
match HeaderValue::from_str(version) {
Ok(v) => {
*self
.protocol_version
.write()
.expect("mcp protocol_version lock poisoned") = Some(v);
},
Err(_) => tracing::warn!(
"MCP: server negotiated protocol version {:?} is not a valid header value; \
omitting MCP-Protocol-Version",
version
),
}
}
pub async fn shutdown(&self) {
if self
.session_id
.read()
.expect("mcp session_id lock poisoned")
.is_none()
{
return;
}
let Ok(headers) = self.request_headers(None) else {
return;
};
let result = timeout(
Duration::from_secs(DELETE_TIMEOUT_SECS),
self.send_authed(
headers,
|h| self.client.delete(self.url.clone()).headers(h),
|| "MCP session DELETE failed".to_string(),
),
)
.await;
match result {
Ok(Ok(resp)) if resp.status() == StatusCode::METHOD_NOT_ALLOWED => {
tracing::debug!("MCP: server does not allow client session termination (405)");
},
Ok(Err(e)) => tracing::debug!("MCP: session DELETE failed: {:#}", e),
Err(_) => tracing::debug!("MCP: session DELETE timed out"),
Ok(Ok(_)) => {},
}
}
async fn request_roundtrip(
&self,
method: &str,
id: u64,
request: &Value,
extra: &HeaderMap,
) -> Result<Value> {
let mut headers = self.request_headers(Some(ACCEPT_POST))?;
for (name, value) in extra {
headers.insert(name, value.clone());
}
if self.is_modern() {
headers.insert(
METHOD_HEADER,
HeaderValue::from_str(method)
.map_err(|_| anyhow!("MCP method '{method}' is not a valid header value"))?,
);
}
let response = self
.send_authed(
headers,
|h| self.client.post(self.url.clone()).headers(h).json(request),
|| format!("MCP HTTP request failed (method: {method})"),
)
.await?;
self.capture_session(response.headers());
let status = response.status();
if status == StatusCode::ACCEPTED {
bail!("MCP server returned 202 Accepted to a request (method: {method})");
}
if !status.is_success() {
return Err(self.status_error(method, status, response).await);
}
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_ascii_lowercase();
if content_type.starts_with("text/event-stream") {
let msg = self.drain_sse_for_response(response, method, id).await?;
extract_jsonrpc_result(msg)
} else if content_type.starts_with("application/json") {
let msg: Value = response
.json()
.await
.with_context(|| format!("MCP response was not valid JSON (method: {method})"))?;
if !is_response(&msg) || msg.get("id").and_then(parse_response_id) != Some(id) {
bail!("MCP JSON response did not answer request {id} (method: {method})");
}
extract_jsonrpc_result(msg)
} else {
bail!(
"MCP server returned unsupported Content-Type '{content_type}' \
(method: {method}; expected application/json or text/event-stream)"
);
}
}
async fn drain_sse_for_response(
&self,
response: reqwest::Response,
method: &str,
id: u64,
) -> Result<Value> {
let mut meta = SseMeta::default();
let mut response = response;
let mut reconnects = 0u32;
loop {
if let Some(msg) = self.drain_one_stream(response, id, &mut meta).await? {
return Ok(msg);
}
let Some(last_id) = meta.last_event_id.clone().filter(|_| !self.is_modern()) else {
bail!("MCP SSE stream ended without a response (method: {method})");
};
reconnects += 1;
if reconnects > MAX_SSE_RECONNECTS {
bail!(
"MCP SSE stream did not deliver a response after {MAX_SSE_RECONNECTS} \
reconnects (method: {method})"
);
}
if let Some(ms) = meta.retry_ms {
tokio::time::sleep(Duration::from_millis(ms)).await;
}
let mut headers = self.request_headers(Some(ACCEPT_SSE))?;
headers.insert(
LAST_EVENT_ID_HEADER,
HeaderValue::from_str(&last_id)
.map_err(|_| anyhow!("MCP SSE event id is not a valid header value"))?,
);
let resumed = self
.send_authed(
headers,
|h| self.client.get(self.url.clone()).headers(h),
|| format!("MCP SSE resume failed (method: {method})"),
)
.await?;
self.capture_session(resumed.headers());
if !resumed.status().is_success() {
let status = resumed.status();
return Err(self.status_error(method, status, resumed).await);
}
response = resumed;
}
}
async fn drain_one_stream(
&self,
response: reqwest::Response,
id: u64,
meta: &mut SseMeta,
) -> Result<Option<Value>> {
meta.start_stream();
let mut stream = response.bytes_stream();
let mut buf: Vec<u8> = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk = match chunk {
Ok(c) => c,
Err(e) => {
if meta.last_event_id.is_some() {
tracing::debug!("MCP: SSE stream broke, will resume: {}", e);
return Ok(None);
}
return Err(anyhow!(e)).context("MCP SSE stream read failed");
},
};
meta.feed(&chunk);
buf.extend_from_slice(&chunk);
for payload in drain_sse_events(&mut buf) {
let Ok(msg) = serde_json::from_str::<Value>(&payload) else {
tracing::warn!("MCP: unparseable SSE event payload");
continue;
};
if is_response(&msg) {
if msg.get("id").and_then(parse_response_id) == Some(id) {
return Ok(Some(msg));
}
continue;
}
if msg.get("method").is_some() {
match msg.get("id") {
Some(rid) if !rid.is_null() => {
let rid = rid.clone();
self.answer_server_request(&msg, &rid).await;
},
_ => tracing::trace!(
method = msg.get("method").and_then(|m| m.as_str()).unwrap_or(""),
"MCP: ignoring server notification on SSE stream"
),
}
}
}
}
Ok(None)
}
async fn answer_server_request(&self, msg: &Value, rid: &Value) {
let method = msg.get("method").and_then(|m| m.as_str()).unwrap_or("");
let reply = if method == "ping" {
json!({ "jsonrpc": "2.0", "id": rid, "result": {} })
} else {
json!({
"jsonrpc": "2.0",
"id": rid,
"error": { "code": -32601, "message": "method not supported" },
})
};
if let Err(e) = self.post_message(&reply).await {
tracing::debug!(
"MCP: failed to answer server-initiated request '{}': {}",
method,
e
);
}
}
async fn post_message(&self, message: &Value) -> Result<()> {
let fut = async {
let response = self
.send_authed(
self.request_headers(Some(ACCEPT_POST))?,
|h| self.client.post(self.url.clone()).headers(h).json(message),
|| "MCP HTTP post failed".to_string(),
)
.await?;
self.capture_session(response.headers());
let status = response.status();
if !status.is_success() {
return Err(self.status_error("(notification)", status, response).await);
}
Ok(())
};
timeout(Duration::from_secs(NOTIFICATION_TIMEOUT_SECS), fut)
.await
.map_err(|_| anyhow!("MCP notification timed out after {NOTIFICATION_TIMEOUT_SECS}s"))?
}
async fn send_authed(
&self,
headers: HeaderMap,
build: impl Fn(HeaderMap) -> reqwest::RequestBuilder,
what: impl Fn() -> String,
) -> Result<reqwest::Response> {
let Some(oauth) = &self.oauth else {
return build(headers).send().await.with_context(what);
};
let with_bearer = |bearer: Option<&HeaderValue>| {
let mut h = headers.clone();
if let Some(b) = bearer {
h.insert(AUTHORIZATION, b.clone());
}
h
};
let sent = oauth.bearer().await;
let response = build(with_bearer(sent.as_ref()))
.send()
.await
.with_context(&what)?;
match response.status() {
StatusCode::UNAUTHORIZED => {},
StatusCode::FORBIDDEN => {
if let Some(scope) = super::oauth::insufficient_scope(response.headers()) {
let scope = Some(scope).filter(|s| !s.is_empty());
if let Some(s) = &scope {
oauth.note_insufficient_scope(s).await;
}
return Err(oauth.sign_in_required(scope));
}
return Ok(response);
},
_ => return Ok(response),
}
let Some(fresh) = oauth.after_unauthorized(sent.as_ref()).await else {
return Err(oauth.sign_in_required(None));
};
let retry = build(with_bearer(Some(&fresh)))
.send()
.await
.with_context(&what)?;
if retry.status() == StatusCode::UNAUTHORIZED {
return Err(oauth.sign_in_required(None));
}
Ok(retry)
}
fn request_headers(&self, accept: Option<&'static str>) -> Result<HeaderMap> {
let mut headers = self.static_headers.clone();
for (name, var) in &self.env_headers {
if let Ok(value) = std::env::var(var) {
let v = HeaderValue::from_str(&value).map_err(|_| {
anyhow!("invalid value in env var '{var}' for MCP header '{name}'")
})?;
headers.insert(name.clone(), v);
}
}
if let Some(accept) = accept {
headers.insert(ACCEPT, HeaderValue::from_static(accept));
}
if let Some(sid) = self
.session_id
.read()
.expect("mcp session_id lock poisoned")
.clone()
{
headers.insert(SESSION_HEADER, sid);
}
if let Some(pv) = self
.protocol_version
.read()
.expect("mcp protocol_version lock poisoned")
.clone()
{
headers.insert(PROTOCOL_VERSION_HEADER, pv);
}
Ok(headers)
}
fn capture_session(&self, headers: &HeaderMap) {
if self.is_modern() {
return;
}
if let Some(v) = headers.get(&SESSION_HEADER) {
*self
.session_id
.write()
.expect("mcp session_id lock poisoned") = Some(v.clone());
}
}
async fn status_error(
&self,
method: &str,
status: StatusCode,
response: reqwest::Response,
) -> anyhow::Error {
if status == StatusCode::NOT_FOUND
&& self
.session_id
.read()
.expect("mcp session_id lock poisoned")
.is_some()
{
return anyhow!(
"MCP session expired (HTTP 404); a new session must be initialized — \
restart the server connection (method: {method})"
);
}
let body = response.text().await.unwrap_or_default();
let snippet = mermaid_model::utils::redact_secrets(&body);
let end = snippet.floor_char_boundary(ERROR_BODY_SNIPPET_BYTES.min(snippet.len()));
let rpc = serde_json::from_str::<Value>(&body)
.ok()
.and_then(|v| v.get("error").map(JsonRpcError::from_error_member));
HttpStatusError {
status: status.as_u16(),
rpc,
text: format!(
"MCP server returned HTTP {} (method: {}): {}",
status,
method,
&snippet[..end]
),
}
.into()
}
}
#[derive(Debug)]
pub(super) struct HttpStatusError {
pub status: u16,
pub rpc: Option<JsonRpcError>,
text: String,
}
impl std::fmt::Display for HttpStatusError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.text)
}
}
impl std::error::Error for HttpStatusError {}
#[derive(Default)]
struct SseMeta {
line_buf: Vec<u8>,
pending_event_id: Option<String>,
last_event_id: Option<String>,
retry_ms: Option<u64>,
}
impl SseMeta {
fn start_stream(&mut self) {
self.line_buf.clear();
self.pending_event_id = None;
}
fn feed(&mut self, chunk: &[u8]) {
self.line_buf.extend_from_slice(chunk);
while let Some(pos) = self.line_buf.iter().position(|&b| b == b'\n') {
let line: Vec<u8> = self.line_buf.drain(..=pos).collect();
let text = String::from_utf8_lossy(&line);
let text = text.trim_end_matches(['\n', '\r']);
if text.is_empty() {
if let Some(id) = self.pending_event_id.take() {
self.last_event_id = Some(id);
}
} else if let Some(v) = text.strip_prefix("id:") {
let v = v.strip_prefix(' ').unwrap_or(v);
self.pending_event_id = Some(v.to_string());
} else if let Some(v) = text.strip_prefix("retry:")
&& let Ok(ms) = v.trim().parse::<u64>()
{
self.retry_ms = Some(ms);
}
}
}
}
pub(super) fn check_ip_literal(url: &reqwest::Url, allow_private: bool) -> Result<()> {
let host = url.host_str().unwrap_or_default();
let blocked = match classify_host(host) {
HostClass::Loopback | HostClass::Public => false,
_ => !allow_private,
};
if blocked {
bail!(
"refusing to connect MCP server '{host}': it is a private/internal \
address (set allow_private_network = true for this server to permit it)"
);
}
Ok(())
}
pub(super) fn vetted_client(allow_private: bool) -> Result<reqwest::Client> {
reqwest::Client::builder()
.user_agent(format!("mermaid/{}", env!("CARGO_PKG_VERSION")))
.connect_timeout(Duration::from_secs(CONNECT_TIMEOUT_SECS))
.redirect(reqwest::redirect::Policy::none())
.dns_resolver(std::sync::Arc::new(McpVettingResolver { allow_private }))
.build()
.context("failed to build MCP HTTP client")
}
struct McpVettingResolver {
allow_private: bool,
}
impl reqwest::dns::Resolve for McpVettingResolver {
fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
let allow_private = self.allow_private;
Box::pin(async move {
let host = name.as_str().to_string();
let addrs: Vec<std::net::SocketAddr> =
tokio::net::lookup_host((host.as_str(), 0)).await?.collect();
for addr in &addrs {
let class = classify_host(&addr.ip().to_string());
let blocked = match class {
HostClass::Loopback | HostClass::Public => false,
_ => !allow_private,
};
if blocked {
return Err(format!(
"refusing to connect MCP server '{host}': it resolves to a \
private/internal address (set allow_private_network = true \
for this server to permit it)"
)
.into());
}
}
Ok(Box::new(addrs.into_iter()) as reqwest::dns::Addrs)
})
}
}
#[cfg(test)]
pub(super) mod test_fixture {
use super::*;
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::Mutex;
pub enum Reply {
Raw(String),
Hang,
}
pub struct Fixture {
pub url: String,
requests: Arc<Mutex<Vec<String>>>,
}
impl Fixture {
pub async fn requests(&self) -> Vec<String> {
self.requests.lock().await.clone()
}
pub fn config(&self) -> McpServerConfig {
McpServerConfig {
url: Some(self.url.clone()),
..Default::default()
}
}
pub fn transport(&self) -> HttpTransport {
HttpTransport::with_store(
"fx",
&self.config(),
Some(Arc::new(super::super::oauth::MemStore::default())),
)
.expect("transport")
}
}
pub async fn fixture(replies: Vec<Reply>) -> Fixture {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let addr = listener.local_addr().expect("addr");
let requests = Arc::new(Mutex::new(Vec::new()));
let recorded = Arc::clone(&requests);
tokio::spawn(async move {
for reply in replies {
let Ok((mut sock, _)) = listener.accept().await else {
return;
};
let raw = read_http_request(&mut sock).await;
recorded.lock().await.push(raw);
match reply {
Reply::Raw(bytes) => {
let _ = sock.write_all(bytes.as_bytes()).await;
let _ = sock.shutdown().await;
},
Reply::Hang => {
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
},
}
}
});
Fixture {
url: format!("http://127.0.0.1:{}/mcp", addr.port()),
requests,
}
}
async fn read_http_request(sock: &mut tokio::net::TcpStream) -> String {
let mut buf: Vec<u8> = Vec::new();
let mut tmp = [0u8; 4096];
loop {
if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
let head = String::from_utf8_lossy(&buf[..pos]).to_ascii_lowercase();
let content_length = head
.lines()
.find_map(|l| l.strip_prefix("content-length:"))
.and_then(|v| v.trim().parse::<usize>().ok())
.unwrap_or(0);
let total = pos + 4 + content_length;
while buf.len() < total {
match sock.read(&mut tmp).await {
Ok(0) | Err(_) => break,
Ok(n) => buf.extend_from_slice(&tmp[..n]),
}
}
return String::from_utf8_lossy(&buf[..total.min(buf.len())]).into_owned();
}
match sock.read(&mut tmp).await {
Ok(0) | Err(_) => return String::from_utf8_lossy(&buf).into_owned(),
Ok(n) => buf.extend_from_slice(&tmp[..n]),
}
}
}
pub fn json_reply_with_headers(body: &str, extra_headers: &str) -> Reply {
Reply::Raw(format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n{extra_headers}Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
))
}
pub fn json_reply(body: &str) -> Reply {
json_reply_with_headers(body, "")
}
pub fn sse_reply(events: &str) -> Reply {
Reply::Raw(format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{events}",
events.len()
))
}
pub fn status_reply(code: u16, reason: &str) -> Reply {
Reply::Raw(format!(
"HTTP/1.1 {code} {reason}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
))
}
pub fn rpc_response(id: u64, result: &str) -> String {
format!(r#"{{"jsonrpc":"2.0","id":{id},"result":{result}}}"#)
}
}
#[cfg(test)]
mod tests {
use super::super::client::McpClient;
use super::test_fixture::*;
use super::*;
#[tokio::test]
async fn plain_json_response_round_trips() {
let fx = fixture(vec![json_reply(&rpc_response(1, r#"{"ok":true}"#))]).await;
let t = fx.transport();
let result = t.send_request("ping", json!({})).await.expect("result");
assert_eq!(result["ok"], true);
let reqs = fx.requests().await;
assert_eq!(reqs.len(), 1);
assert!(reqs[0].starts_with("POST /mcp"), "{}", reqs[0]);
assert!(
reqs[0]
.to_ascii_lowercase()
.contains("accept: application/json, text/event-stream"),
"{}",
reqs[0]
);
assert!(reqs[0].contains(r#""method":"ping""#), "{}", reqs[0]);
}
#[tokio::test]
async fn sse_response_drains_notifications_until_our_id() {
let events = format!(
"data: {}\n\ndata: {}\n\n",
r#"{"jsonrpc":"2.0","method":"notifications/message","params":{}}"#,
rpc_response(1, r#"{"via":"sse"}"#)
);
let fx = fixture(vec![sse_reply(&events)]).await;
let t = fx.transport();
let result = t
.send_request("tools/list", json!({}))
.await
.expect("result");
assert_eq!(result["via"], "sse");
}
#[tokio::test]
async fn session_id_is_captured_and_echoed_including_delete() {
let fx = fixture(vec![
json_reply_with_headers(&rpc_response(1, "{}"), "Mcp-Session-Id: sess-123\r\n"),
json_reply(&rpc_response(2, "{}")),
status_reply(200, "OK"), ])
.await;
let t = fx.transport();
t.send_request("initialize", json!({})).await.expect("init");
t.send_request("tools/list", json!({})).await.expect("list");
t.shutdown().await;
let reqs = fx.requests().await;
assert_eq!(reqs.len(), 3);
assert!(
!reqs[0].to_ascii_lowercase().contains("mcp-session-id"),
"no session id before the server assigns one: {}",
reqs[0]
);
assert!(
reqs[1]
.to_ascii_lowercase()
.contains("mcp-session-id: sess-123"),
"{}",
reqs[1]
);
assert!(reqs[2].starts_with("DELETE /mcp"), "{}", reqs[2]);
assert!(
reqs[2]
.to_ascii_lowercase()
.contains("mcp-session-id: sess-123"),
"{}",
reqs[2]
);
}
#[tokio::test]
async fn shutdown_without_session_sends_nothing() {
let fx = fixture(vec![]).await;
let t = fx.transport();
t.shutdown().await;
assert!(fx.requests().await.is_empty());
}
#[tokio::test]
async fn delete_405_is_tolerated() {
let fx = fixture(vec![
json_reply_with_headers(&rpc_response(1, "{}"), "MCP-Session-Id: s1\r\n"),
status_reply(405, "Method Not Allowed"),
])
.await;
let t = fx.transport();
t.send_request("initialize", json!({})).await.expect("init");
t.shutdown().await;
assert_eq!(fx.requests().await.len(), 2);
}
#[tokio::test]
async fn http_404_with_active_session_reports_expiry() {
let fx = fixture(vec![
json_reply_with_headers(&rpc_response(1, "{}"), "MCP-Session-Id: s1\r\n"),
status_reply(404, "Not Found"),
])
.await;
let t = fx.transport();
t.send_request("initialize", json!({})).await.expect("init");
let err = t
.send_request("tools/list", json!({}))
.await
.expect_err("expired");
assert!(err.to_string().contains("session expired"), "{err}");
}
#[tokio::test]
async fn static_and_env_headers_reach_the_wire() {
const VAR: &str = "MERMAID_TEST_MCP_HTTP_ENV_HEADER_A";
unsafe { std::env::set_var(VAR, "from-env") };
let fx = fixture(vec![json_reply(&rpc_response(1, "{}"))]).await;
let mut config = fx.config();
config
.headers
.insert("X-Static-Token".to_string(), "static-secret".to_string());
config
.env_headers
.insert("X-Env-Token".to_string(), VAR.to_string());
config.env_headers.insert(
"X-Missing".to_string(),
"MERMAID_TEST_MCP_HTTP_ENV_HEADER_MISSING".to_string(),
);
let t = HttpTransport::with_store(
"t",
&config,
Some(std::sync::Arc::new(super::super::oauth::MemStore::default())),
)
.expect("transport");
t.send_request("ping", json!({})).await.expect("result");
let req = fx.requests().await.remove(0).to_ascii_lowercase();
assert!(req.contains("x-static-token: static-secret"), "{req}");
assert!(req.contains("x-env-token: from-env"), "{req}");
assert!(!req.contains("x-missing"), "{req}");
}
#[tokio::test]
async fn invalid_header_name_errors_at_construction_without_value() {
let mut config = McpServerConfig {
url: Some("https://example.com/mcp".to_string()),
..Default::default()
};
config
.headers
.insert("bad header".to_string(), "secret-value".to_string());
let err = HttpTransport::new("t", &config)
.map(|_| ())
.expect_err("bad name");
assert!(err.to_string().contains("bad header"), "{err}");
assert!(!err.to_string().contains("secret-value"), "{err}");
}
#[tokio::test]
async fn notification_accepts_202_and_rejects_500() {
let fx = fixture(vec![
status_reply(202, "Accepted"),
Reply::Raw(
"HTTP/1.1 500 Internal Server Error\r\nContent-Type: text/plain\r\nContent-Length: 4\r\nConnection: close\r\n\r\noops"
.to_string(),
),
])
.await;
let t = fx.transport();
t.send_notification("notifications/initialized", json!({}))
.await
.expect("202 ok");
let err = t
.send_notification("notifications/initialized", json!({}))
.await
.expect_err("500 err");
let rendered = format!("{err:#}");
assert!(rendered.contains("500"), "{rendered}");
assert!(
rendered.contains("oops"),
"body snippet expected: {rendered}"
);
}
#[tokio::test]
async fn http_202_on_a_request_is_a_protocol_error() {
let fx = fixture(vec![status_reply(202, "Accepted")]).await;
let t = fx.transport();
let err = t
.send_request("tools/list", json!({}))
.await
.expect_err("202 on request");
assert!(err.to_string().contains("202"), "{err}");
}
#[tokio::test]
async fn sse_eof_without_response_and_without_ids_errors() {
let events =
"data: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/message\",\"params\":{}}\n\n";
let fx = fixture(vec![sse_reply(events)]).await;
let t = fx.transport();
let err = t
.send_request("tools/list", json!({}))
.await
.expect_err("eof");
assert!(
err.to_string().contains("ended without a response"),
"{err}"
);
}
#[tokio::test]
async fn sse_disconnect_with_event_id_resumes_via_get_last_event_id() {
let priming = "id: ev-7\nretry: 10\ndata:\n\n";
let resumed = format!(
"id: ev-8\ndata: {}\n\n",
rpc_response(1, r#"{"resumed":true}"#)
);
let fx = fixture(vec![sse_reply(priming), sse_reply(&resumed)]).await;
let t = fx.transport();
let result = t
.send_request("tools/call", json!({}))
.await
.expect("resumed");
assert_eq!(result["resumed"], true);
let reqs = fx.requests().await;
assert_eq!(reqs.len(), 2);
assert!(reqs[1].starts_with("GET /mcp"), "{}", reqs[1]);
let get = reqs[1].to_ascii_lowercase();
assert!(get.contains("last-event-id: ev-7"), "{}", reqs[1]);
assert!(get.contains("accept: text/event-stream"), "{}", reqs[1]);
}
#[tokio::test]
async fn server_request_on_sse_stream_gets_error_reply_posted_back() {
let events = format!(
"data: {}\n\ndata: {}\n\n",
r#"{"jsonrpc":"2.0","id":99,"method":"sampling/createMessage","params":{}}"#,
rpc_response(1, "{}")
);
let fx = fixture(vec![sse_reply(&events), status_reply(202, "Accepted")]).await;
let t = fx.transport();
t.send_request("tools/list", json!({}))
.await
.expect("result");
let reqs = fx.requests().await;
assert_eq!(reqs.len(), 2, "the -32601 reply must be POSTed back");
assert!(reqs[1].contains("-32601"), "{}", reqs[1]);
assert!(reqs[1].contains(r#""id":99"#), "{}", reqs[1]);
}
#[tokio::test]
async fn server_ping_on_sse_stream_gets_result_not_32601() {
let events = format!(
"data: {}\n\ndata: {}\n\n",
r#"{"jsonrpc":"2.0","id":42,"method":"ping"}"#,
rpc_response(1, "{}")
);
let fx = fixture(vec![sse_reply(&events), status_reply(202, "Accepted")]).await;
let t = fx.transport();
t.send_request("tools/list", json!({}))
.await
.expect("result");
let reqs = fx.requests().await;
assert_eq!(reqs.len(), 2);
assert!(reqs[1].contains(r#""result":{}"#), "{}", reqs[1]);
assert!(!reqs[1].contains("-32601"), "{}", reqs[1]);
}
#[tokio::test]
async fn injected_short_timeout_is_enforced() {
let fx = fixture(vec![Reply::Hang]).await;
let t = fx.transport();
let start = std::time::Instant::now();
let err = t
.send_request_with_timeout("tools/list", json!({}), 1)
.await
.expect_err("must time out");
assert!(err.to_string().contains("timed out"), "{err}");
assert!(start.elapsed() < std::time::Duration::from_secs(5));
}
fn legacy_no_session() -> Reply {
Reply::Raw({
let body = r#"{"jsonrpc":"2.0","id":null,"error":{"code":-32000,"message":"Bad Request: No valid session ID provided"}}"#;
format!(
"HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
})
}
fn rpc_error_reply(status: u16, code: i64, data: &str) -> Reply {
let body = format!(
r#"{{"jsonrpc":"2.0","id":1,"error":{{"code":{code},"message":"m","data":{data}}}}}"#
);
Reply::Raw(format!(
"HTTP/1.1 {status} Bad Request\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
))
}
const DISCOVER_MODERN: &str = r#"{"resultType":"complete","supportedVersions":["2026-07-28"],"capabilities":{"tools":{}},"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"modern-fx","version":"2.0"}}}"#;
#[tokio::test]
async fn legacy_server_falls_back_to_initialize_with_session_headers() {
let init_result = r#"{"protocolVersion":"2025-11-25","capabilities":{},"serverInfo":{"name":"fx","version":"1.0"}}"#;
let fx = fixture(vec![
legacy_no_session(),
json_reply_with_headers(&rpc_response(2, init_result), "MCP-Session-Id: s9\r\n"),
status_reply(202, "Accepted"), json_reply(&rpc_response(3, r#"{"tools":[]}"#)),
])
.await;
let mut client = McpClient::new(fx.transport().into());
let info = client.initialize().await.expect("initialize");
assert_eq!(info.name, "fx");
assert_eq!(client.era(), super::super::client::Era::Legacy);
client.list_tools().await.expect("list");
let reqs = fx.requests().await;
assert_eq!(reqs.len(), 4);
let probe = reqs[0].to_ascii_lowercase();
assert!(
probe.contains("mcp-protocol-version: 2026-07-28"),
"{probe}"
);
assert!(probe.contains("mcp-method: server/discover"), "{probe}");
assert!(
reqs[0].contains(r#""io.modelcontextprotocol/protocolVersion":"2026-07-28""#),
"{}",
reqs[0]
);
let init = reqs[1].to_ascii_lowercase();
assert!(!init.contains("mcp-protocol-version"), "{init}");
assert!(!init.contains("mcp-method"), "{init}");
for req in &reqs[2..] {
let low = req.to_ascii_lowercase();
assert!(low.contains("mcp-protocol-version: 2025-11-25"), "{req}");
assert!(low.contains("mcp-session-id: s9"), "{req}");
assert!(!req.contains("io.modelcontextprotocol/"), "{req}");
}
}
#[tokio::test]
async fn modern_server_skips_the_handshake_and_mirrors_headers() {
let tools = r#"{"resultType":"complete","tools":[
{"name":"execute_sql","inputSchema":{"type":"object","properties":{
"region":{"type":"string","x-mcp-header":"Region"},
"query":{"type":"string"}}}},
{"name":"broken","inputSchema":{"type":"object","properties":{
"n":{"type":"number","x-mcp-header":"N"}}}}
]}"#;
let call = r#"{"resultType":"complete","content":[{"type":"text","text":"ok"}]}"#;
let fx = fixture(vec![
json_reply_with_headers(&rpc_response(1, DISCOVER_MODERN), "MCP-Session-Id: s9\r\n"),
json_reply(&rpc_response(2, tools)),
json_reply(&rpc_response(3, call)),
])
.await;
let mut client = McpClient::new(fx.transport().into());
let info = client.initialize().await.expect("initialize");
assert_eq!(info.name, "modern-fx");
assert_eq!(client.era(), super::super::client::Era::Modern);
let listed = client.list_tools().await.expect("list");
let names: Vec<&str> = listed.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, ["execute_sql"], "the invalid tool is left out");
let result = client
.call_tool(
"execute_sql",
&json!({"region": "us-west1", "query": "SELECT 1"}),
)
.await
.expect("call");
assert!(!result.is_error);
let reqs = fx.requests().await;
assert_eq!(reqs.len(), 3, "no initialize, no notification");
for req in &reqs {
let low = req.to_ascii_lowercase();
assert!(low.contains("mcp-protocol-version: 2026-07-28"), "{req}");
assert!(!low.contains("mcp-session-id"), "{req}");
assert!(
req.contains(r#""io.modelcontextprotocol/protocolVersion":"2026-07-28""#),
"{req}"
);
assert!(
req.contains(r#""io.modelcontextprotocol/clientCapabilities":{}"#),
"{req}"
);
}
let call = reqs[2].to_ascii_lowercase();
assert!(call.contains("mcp-method: tools/call"), "{call}");
assert!(call.contains("mcp-name: execute_sql"), "{call}");
assert!(call.contains("mcp-param-region: us-west1"), "{call}");
}
#[tokio::test]
async fn unsupported_version_with_a_legacy_entry_uses_the_handshake() {
let init_result =
r#"{"protocolVersion":"2025-11-25","capabilities":{},"serverInfo":{"name":"fx"}}"#;
let fx = fixture(vec![
rpc_error_reply(
400,
-32022,
r#"{"supported":["2025-11-25"],"requested":"2026-07-28"}"#,
),
json_reply(&rpc_response(2, init_result)),
status_reply(202, "Accepted"),
])
.await;
let mut client = McpClient::new(fx.transport().into());
client.initialize().await.expect("initialize");
assert_eq!(client.era(), super::super::client::Era::Legacy);
}
#[tokio::test]
async fn unsupported_version_with_no_common_version_is_an_error() {
let fx = fixture(vec![rpc_error_reply(
400,
-32022,
r#"{"supported":["2027-03-01"],"requested":"2026-07-28"}"#,
)])
.await;
let mut client = McpClient::new(fx.transport().into());
let err = client.initialize().await.expect_err("no common version");
assert!(err.to_string().contains("2027-03-01"), "{err}");
assert_eq!(fx.requests().await.len(), 1, "no fallback to initialize");
}
#[tokio::test]
async fn sign_in_needed_on_the_probe_is_not_a_legacy_server() {
let fx = fixture(vec![Reply::Raw(
"HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Bearer\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
.to_string(),
)])
.await;
let mut client = McpClient::new(fx.transport().into());
let err = client.initialize().await.expect_err("sign-in needed");
assert!(super::super::oauth::is_auth_required(&err), "{err:#}");
assert_eq!(fx.requests().await.len(), 1, "no fallback to initialize");
}
#[tokio::test]
async fn input_required_with_state_only_is_retried_with_that_state() {
let fx = fixture(vec![
json_reply(&rpc_response(1, DISCOVER_MODERN)),
json_reply(&rpc_response(
2,
r#"{"resultType":"input_required","requestState":"opaque-1"}"#,
)),
json_reply(&rpc_response(
3,
r#"{"resultType":"complete","content":[{"type":"text","text":"done"}]}"#,
)),
])
.await;
let mut client = McpClient::new(fx.transport().into());
client.initialize().await.expect("initialize");
let result = client.call_tool("t", &json!({})).await.expect("call");
assert!(
matches!(&result.content[0], super::super::client::ContentBlock::Text(t) if t == "done")
);
let reqs = fx.requests().await;
assert!(!reqs[1].contains("requestState"), "{}", reqs[1]);
assert!(
reqs[2].contains(r#""requestState":"opaque-1""#),
"{}",
reqs[2]
);
}
#[tokio::test]
async fn input_required_that_asks_questions_is_an_error() {
let fx = fixture(vec![
json_reply(&rpc_response(1, DISCOVER_MODERN)),
json_reply(&rpc_response(
2,
r#"{"resultType":"input_required","inputRequests":{"q":{"method":"elicitation/create","params":{}}}}"#,
)),
])
.await;
let mut client = McpClient::new(fx.transport().into());
client.initialize().await.expect("initialize");
let err = client.call_tool("t", &json!({})).await.expect_err("asks");
assert!(err.to_string().contains("elicitation/create"), "{err}");
}
#[tokio::test]
async fn header_mismatch_reloads_the_tool_list_and_retries() {
let old_tools = r#"{"tools":[{"name":"t","inputSchema":{"type":"object"}}]}"#;
let new_tools = r#"{"tools":[{"name":"t","inputSchema":{"type":"object","properties":{
"id":{"type":"string","x-mcp-header":"Id"}}}}]}"#;
let fx = fixture(vec![
json_reply(&rpc_response(1, DISCOVER_MODERN)),
json_reply(&rpc_response(2, old_tools)),
rpc_error_reply(400, -32020, "null"),
json_reply(&rpc_response(4, new_tools)),
json_reply(&rpc_response(5, r#"{"content":[]}"#)),
])
.await;
let mut client = McpClient::new(fx.transport().into());
client.initialize().await.expect("initialize");
client.list_tools().await.expect("list");
client
.call_tool("t", &json!({"id": "abc"}))
.await
.expect("retried call");
let reqs = fx.requests().await;
assert_eq!(reqs.len(), 5);
assert!(!reqs[2].to_ascii_lowercase().contains("mcp-param-id"));
assert!(reqs[4].to_ascii_lowercase().contains("mcp-param-id: abc"));
}
#[tokio::test]
async fn resolver_allows_loopback_blocks_private_honors_flag() {
use reqwest::dns::Resolve;
use std::str::FromStr;
let name = |s: &str| reqwest::dns::Name::from_str(s).expect("name");
let default = McpVettingResolver {
allow_private: false,
};
assert!(default.resolve(name("localhost")).await.is_ok());
assert!(default.resolve(name("192.168.1.5")).await.is_err());
let opted_in = McpVettingResolver {
allow_private: true,
};
assert!(opted_in.resolve(name("192.168.1.5")).await.is_ok());
}
#[test]
fn sse_meta_tracks_id_and_retry_across_chunk_splits() {
let mut meta = SseMeta::default();
meta.feed(b"id: ev");
assert_eq!(meta.last_event_id, None);
meta.feed(b"-1\nretry: 250\ndata: x\n\n");
assert_eq!(meta.last_event_id.as_deref(), Some("ev-1"));
assert_eq!(meta.retry_ms, Some(250));
meta.feed(b"id: ev-2\r\ndata: y\r\n\r\n");
assert_eq!(meta.last_event_id.as_deref(), Some("ev-2"));
}
#[test]
fn sse_meta_commits_id_only_at_event_boundary() {
let mut meta = SseMeta::default();
meta.feed(b"id: ev-1\ndata: x\n\n");
assert_eq!(meta.last_event_id.as_deref(), Some("ev-1"));
meta.feed(b"id: ev-2\ndata: {\"jsonr");
assert_eq!(meta.last_event_id.as_deref(), Some("ev-1"));
meta.start_stream();
meta.feed(b"id: ev-2\ndata: y\n\n");
assert_eq!(meta.last_event_id.as_deref(), Some("ev-2"));
}
#[test]
fn new_vets_ip_literal_hosts() {
let private = McpServerConfig {
url: Some("https://192.168.1.5/mcp".to_string()),
..Default::default()
};
let err = match HttpTransport::new("t", &private) {
Ok(_) => panic!("private literal must be rejected"),
Err(e) => e,
};
assert!(err.to_string().contains("allow_private_network"), "{err}");
let metadata = McpServerConfig {
url: Some("https://[::ffff:169.254.169.254]/mcp".to_string()),
..Default::default()
};
assert!(HttpTransport::new("t", &metadata).is_err());
let opted_in = McpServerConfig {
url: Some("https://192.168.1.5/mcp".to_string()),
allow_private_network: true,
..Default::default()
};
assert!(HttpTransport::new("t", &opted_in).is_ok());
let loopback = McpServerConfig {
url: Some("http://127.0.0.1:9099/mcp".to_string()),
..Default::default()
};
assert!(HttpTransport::new("t", &loopback).is_ok());
}
#[test]
fn new_rejects_config_without_url() {
let config = McpServerConfig {
command: "npx".to_string(),
..Default::default()
};
assert!(HttpTransport::new("t", &config).is_err());
}
}