use super::types::*;
use async_trait::async_trait;
use futures::StreamExt;
use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::Mutex;
use tracing::{debug, warn};
#[async_trait]
pub trait McpTransport: Send + Sync {
async fn send(&self, request: JsonRpcRequest) -> Result<JsonRpcResponse, McpError>;
async fn close(&self) -> Result<(), McpError>;
}
pub struct StdioTransport {
stdin: Arc<Mutex<tokio::process::ChildStdin>>,
stdout: Arc<Mutex<BufReader<tokio::process::ChildStdout>>>,
child: Arc<Mutex<Child>>,
}
impl StdioTransport {
pub async fn new(
command: &str,
args: &[&str],
env: Option<HashMap<String, String>>,
) -> Result<Self, McpError> {
let mut cmd = Command::new(command);
cmd.args(args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
if let Some(env_vars) = env {
for (k, v) in env_vars {
cmd.env(k, v);
}
}
let mut child = cmd
.spawn()
.map_err(|e| McpError::Transport(format!("Failed to spawn '{}': {}", command, e)))?;
let stdin = child
.stdin
.take()
.ok_or_else(|| McpError::Transport("Failed to capture stdin".into()))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| McpError::Transport("Failed to capture stdout".into()))?;
Ok(Self {
stdin: Arc::new(Mutex::new(stdin)),
stdout: Arc::new(Mutex::new(BufReader::new(stdout))),
child: Arc::new(Mutex::new(child)),
})
}
}
#[async_trait]
impl McpTransport for StdioTransport {
async fn send(&self, request: JsonRpcRequest) -> Result<JsonRpcResponse, McpError> {
let mut line = serde_json::to_string(&request)?;
line.push('\n');
{
let mut stdin = self.stdin.lock().await;
stdin
.write_all(line.as_bytes())
.await
.map_err(|e| McpError::Transport(format!("Write error: {}", e)))?;
stdin
.flush()
.await
.map_err(|e| McpError::Transport(format!("Flush error: {}", e)))?;
}
let mut response_line = String::new();
{
let mut stdout = self.stdout.lock().await;
let bytes_read = stdout
.read_line(&mut response_line)
.await
.map_err(|e| McpError::Transport(format!("Read error: {}", e)))?;
if bytes_read == 0 {
return Err(McpError::ConnectionClosed);
}
}
let response: JsonRpcResponse = serde_json::from_str(response_line.trim())?;
Ok(response)
}
async fn close(&self) -> Result<(), McpError> {
let mut child = self.child.lock().await;
let _ = child.kill().await;
Ok(())
}
}
pub struct HttpTransport {
client: reqwest::Client,
base_url: String,
session_id: Mutex<Option<String>>,
}
impl HttpTransport {
const READ_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
pub fn new(url: &str) -> Result<Self, McpError> {
let client = reqwest::Client::builder()
.read_timeout(Self::READ_IDLE_TIMEOUT)
.build()
.map_err(|e| McpError::Transport(format!("Failed to build HTTP client: {e}")))?;
Ok(Self {
client,
base_url: url.trim_end_matches('/').to_string(),
session_id: Mutex::new(None),
})
}
fn response_for(payload: &str, request_id: u64) -> Option<JsonRpcResponse> {
let value: serde_json::Value = serde_json::from_str(payload).ok()?;
if value.get("method").is_some() {
return None;
}
if value.get("result").is_none() && value.get("error").is_none() {
return None;
}
let response: JsonRpcResponse = serde_json::from_value(value).ok()?;
if response.id.is_some_and(|id| id != request_id) {
return None;
}
Some(response)
}
fn event_payload(event: &str) -> Option<String> {
let data: Vec<&str> = event
.lines()
.filter_map(|line| {
line.strip_prefix("data:")
.map(|d| d.trim_start_matches(' '))
})
.collect();
if data.is_empty() {
return None;
}
let payload = data.join("\n");
let payload = payload.trim().to_string();
(!payload.is_empty()).then_some(payload)
}
fn note_skipped(payload: &str, method: &str, skipped: &mut BTreeMap<String, usize>) {
let what = match serde_json::from_str::<serde_json::Value>(payload) {
Ok(value) => match value.get("method").and_then(|m| m.as_str()) {
Some(m) => m.to_string(),
None => match value.get("id").and_then(|i| i.as_u64()) {
Some(id) => format!("response for id {id}"),
None => "unrecognized JSON-RPC frame".to_string(),
},
},
Err(e) => {
warn!(
"SSE frame on '{method}' is not valid JSON ({} bytes): {e}; \
a truncated frame may be the server's real answer",
payload.len()
);
format!("malformed non-JSON frame ({} bytes)", payload.len())
}
};
debug!("skipping SSE frame that is not the response to '{method}': {what}");
*skipped.entry(what).or_default() += 1;
}
fn describe_skipped(skipped: &BTreeMap<String, usize>) -> String {
if skipped.is_empty() {
return String::new();
}
let total: usize = skipped.values().sum();
let listed: Vec<String> = skipped
.iter()
.take(10)
.map(|(what, n)| {
if *n > 1 {
format!("{what} x{n}")
} else {
what.clone()
}
})
.collect();
let more = skipped.len().saturating_sub(listed.len());
let tail = if more > 0 {
format!(", and {more} other kind(s)")
} else {
String::new()
};
format!(" (skipped {total} frame(s): {}{tail})", listed.join(", "))
}
fn decode(bytes: &[u8], method: &str) -> Result<String, McpError> {
std::str::from_utf8(bytes).map(str::to_owned).map_err(|e| {
McpError::Transport(format!(
"invalid UTF-8 in the response body on '{method}' at byte {}: {e}",
e.valid_up_to()
))
})
}
async fn read_response(
resp: reqwest::Response,
request_id: u64,
method: &str,
status: reqwest::StatusCode,
) -> Result<JsonRpcResponse, McpError> {
if let Some(charset) = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.and_then(|ct| {
ct.split(';').skip(1).find_map(|p| {
p.trim()
.strip_prefix("charset=")
.map(|c| c.trim_matches('"').to_ascii_lowercase())
})
})
{
if !matches!(charset.as_str(), "utf-8" | "utf8" | "us-ascii" | "ascii") {
return Err(McpError::Transport(format!(
"unsupported charset '{charset}' on '{method}': MCP bodies are UTF-8 \
(both application/json and text/event-stream mandate it)"
)));
}
}
let mut buf: Vec<u8> = Vec::new();
let mut scanned = 0usize;
let mut searched = 0usize;
let mut skipped: BTreeMap<String, usize> = BTreeMap::new();
let mut pending_cr = false;
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| {
McpError::Transport(format!(
"Response read error on '{method}' after {} byte(s){}: {e}",
buf.len(),
Self::describe_skipped(&skipped)
))
})?;
for &b in chunk.iter() {
if b == b'\r' {
buf.push(b'\n');
pending_cr = true;
} else {
if pending_cr && b == b'\n' {
pending_cr = false;
continue;
}
pending_cr = false;
buf.push(b);
}
}
loop {
let from = scanned.max(searched);
let Some(pos) = buf[from..].windows(2).position(|w| w == b"\n\n") else {
searched = buf.len().saturating_sub(1);
break;
};
let end = from + pos;
let event = Self::decode(&buf[scanned..end], method)?;
scanned = end + 2;
searched = scanned;
let Some(payload) = Self::event_payload(&event) else {
continue;
};
if let Some(response) = Self::response_for(&payload, request_id) {
return Ok(response);
}
Self::note_skipped(&payload, method, &mut skipped);
}
}
let body = Self::decode(&buf, method)?;
let body = body.strip_prefix('\u{feff}').unwrap_or(&body).to_string();
if body.trim().is_empty() {
if status == reqwest::StatusCode::ACCEPTED || status == reqwest::StatusCode::NO_CONTENT
{
return Ok(JsonRpcResponse {
jsonrpc: "2.0".into(),
id: Some(request_id),
result: None,
error: None,
});
}
return Err(McpError::Transport(format!(
"HTTP {status} with an empty body on '{method}' (expected a JSON-RPC response; \
an empty 2xx usually means a proxy or gateway answered instead of the MCP server)"
)));
}
if let Some(response) = Self::response_for(&body, request_id) {
return Ok(response);
}
if scanned < buf.len() {
let tail = Self::decode(&buf[scanned..], method)?;
if let Some(payload) = Self::event_payload(&tail) {
if let Some(response) = Self::response_for(&payload, request_id) {
return Ok(response);
}
Self::note_skipped(&payload, method, &mut skipped);
}
}
Err(McpError::Transport(format!(
"HTTP {status}: no JSON-RPC response for '{method}' (id {request_id}) in the body{}: {}",
Self::describe_skipped(&skipped),
body.chars().take(200).collect::<String>()
)))
}
}
#[async_trait]
impl McpTransport for HttpTransport {
async fn send(&self, request: JsonRpcRequest) -> Result<JsonRpcResponse, McpError> {
let request_id = request.id;
let method = request.method.clone();
let mut builder = self
.client
.post(&self.base_url)
.header("Accept", "application/json, text/event-stream")
.json(&request);
if let Some(session) = self.session_id.lock().await.as_ref() {
builder = builder.header("Mcp-Session-Id", session);
}
let resp = builder
.send()
.await
.map_err(|e| McpError::Transport(format!("HTTP error: {}", e)))?;
let status = resp.status();
if !status.is_success() {
if status == reqwest::StatusCode::NOT_FOUND {
if let Some(dead) = self.session_id.lock().await.take() {
warn!(
"MCP session {dead} was rejected (HTTP 404); reconnect to start a new one"
);
return Err(McpError::Transport(format!(
"MCP session expired (HTTP 404 on '{method}'); reconnect to start a new session"
)));
}
}
let body = resp.text().await.unwrap_or_default();
let detail = body.trim();
return Err(McpError::Transport(if detail.is_empty() {
format!("HTTP {status} from server on '{method}'")
} else {
format!(
"HTTP {status} from server on '{method}': {}",
detail.chars().take(200).collect::<String>()
)
}));
}
match resp.headers().get("mcp-session-id").map(|v| v.to_str()) {
Some(Ok(session)) => *self.session_id.lock().await = Some(session.to_owned()),
Some(Err(e)) => warn!("ignoring unreadable Mcp-Session-Id header: {e}"),
None => {}
}
Self::read_response(resp, request_id, &method, status).await
}
async fn close(&self) -> Result<(), McpError> {
let session = self.session_id.lock().await.take();
if let Some(session) = session {
match self
.client
.delete(&self.base_url)
.header("Mcp-Session-Id", &session)
.send()
.await
{
Ok(resp) if resp.status().is_success() => {}
Ok(resp) => debug!(
"MCP session teardown rejected with HTTP {}; it is optional, continuing",
resp.status()
),
Err(e) => warn!(
"MCP session {session} teardown did not reach {}: {e}; \
the session may leak server-side",
self.base_url
),
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_stdio_transport_with_cat() {
let transport = StdioTransport::new("cat", &[], None).await.unwrap();
let request = JsonRpcRequest::new("test/echo", Some(serde_json::json!({"hello": "world"})));
let request_id = request.id;
let mut line = serde_json::to_string(&request).unwrap();
line.push('\n');
{
let mut stdin = transport.stdin.lock().await;
stdin.write_all(line.as_bytes()).await.unwrap();
stdin.flush().await.unwrap();
}
let mut response_line = String::new();
{
let mut stdout = transport.stdout.lock().await;
stdout.read_line(&mut response_line).await.unwrap();
}
let echoed: JsonRpcRequest = serde_json::from_str(response_line.trim()).unwrap();
assert_eq!(echoed.id, request_id);
assert_eq!(echoed.method, "test/echo");
transport.close().await.unwrap();
}
#[test]
fn test_http_transport_creation() {
let transport = HttpTransport::new("http://localhost:8080/mcp").unwrap();
assert_eq!(transport.base_url, "http://localhost:8080/mcp");
let transport = HttpTransport::new("http://localhost:8080/mcp/").unwrap();
assert_eq!(transport.base_url, "http://localhost:8080/mcp");
}
}