use super::types::*;
#[cfg(feature = "native")]
use async_trait::async_trait;
use futures::StreamExt;
use std::collections::BTreeMap;
#[cfg(feature = "native")]
use std::collections::HashMap;
#[cfg(feature = "native")]
use std::sync::Arc;
#[cfg(feature = "native")]
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
#[cfg(feature = "native")]
use tokio::process::{Child, Command};
use tokio::sync::Mutex;
use tracing::{debug, warn};
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
pub trait McpTransport: crate::rt::MaybeSend + crate::rt::MaybeSync {
async fn send(&self, request: JsonRpcRequest) -> Result<JsonRpcResponse, McpError>;
async fn notify(&self, notification: JsonRpcNotification) -> Result<(), McpError> {
let method = notification.method.clone();
if let Err(e) = self
.send(JsonRpcRequest::new(
notification.method,
notification.params,
))
.await
{
warn!("MCP notification '{method}' through send() failed: {e}");
}
Ok(())
}
async fn close(&self) -> Result<(), McpError>;
}
#[cfg(feature = "native")]
#[cfg_attr(docsrs, doc(cfg(feature = "native")))]
pub struct StdioTransport {
stdin: Arc<Mutex<StdinWriter>>,
stdout: Arc<Mutex<BufReader<tokio::process::ChildStdout>>>,
child: Arc<Mutex<Child>>,
stderr_tail: Arc<std::sync::Mutex<StderrTail>>,
stderr_drain: Mutex<Option<tokio::task::JoinHandle<()>>>,
command: String,
}
#[cfg(feature = "native")]
struct StdinWriter {
stdin: tokio::process::ChildStdin,
interrupted: bool,
}
#[cfg(feature = "native")]
#[derive(Default)]
struct StderrTail {
lines: std::collections::VecDeque<String>,
bytes: usize,
}
#[cfg(feature = "native")]
impl StderrTail {
const MAX_BYTES: usize = 4096;
fn push(&mut self, line: String) {
self.bytes += line.len();
self.lines.push_back(line);
while self.bytes > Self::MAX_BYTES && self.lines.len() > 1 {
if let Some(old) = self.lines.pop_front() {
self.bytes -= old.len();
}
}
}
fn render(&self) -> String {
self.lines.iter().cloned().collect::<Vec<_>>().join("\n")
}
}
#[cfg(feature = "native")]
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())
.kill_on_drop(true);
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()))?;
let stderr_tail = Arc::new(std::sync::Mutex::new(StderrTail::default()));
let stderr_drain = child.stderr.take().map(|stderr| {
tokio::spawn(drain_stderr(
stderr,
command.to_string(),
stderr_tail.clone(),
))
});
Ok(Self {
stdin: Arc::new(Mutex::new(StdinWriter {
stdin,
interrupted: false,
})),
stdout: Arc::new(Mutex::new(BufReader::new(stdout))),
child: Arc::new(Mutex::new(child)),
stderr_tail,
stderr_drain: Mutex::new(stderr_drain),
command: command.to_string(),
})
}
async fn write_message(&self, json: &str) -> Result<(), McpError> {
let mut writer = self.stdin.lock().await;
let message = if writer.interrupted {
format!("\n{json}\n")
} else {
format!("{json}\n")
};
writer.interrupted = true; writer
.stdin
.write_all(message.as_bytes())
.await
.map_err(|e| McpError::Transport(format!("Write error: {}", e)))?;
writer
.stdin
.flush()
.await
.map_err(|e| McpError::Transport(format!("Flush error: {}", e)))?;
writer.interrupted = false;
Ok(())
}
async fn closed_error(&self, skipped: &str) -> McpError {
let drain = self.stderr_drain.lock().await.take();
if let Some(drain) = drain {
let _ = tokio::time::timeout(std::time::Duration::from_millis(500), drain).await;
}
let status = {
let mut child = self.child.lock().await;
match tokio::time::timeout(std::time::Duration::from_millis(500), child.wait()).await {
Ok(Ok(status)) => format!("exited with {status}"),
_ => "closed its output".to_string(),
}
};
let tail = self
.stderr_tail
.lock()
.map(|t| t.render())
.unwrap_or_default();
let tail = if tail.trim().is_empty() {
String::new()
} else {
format!("; last stderr:\n{tail}")
};
McpError::Transport(format!(
"Connection closed: MCP server '{}' {status}{skipped}{tail}",
self.command
))
}
}
#[cfg(feature = "native")]
async fn drain_stderr(
stderr: tokio::process::ChildStderr,
server: String,
tail: Arc<std::sync::Mutex<StderrTail>>,
) {
use tokio::io::AsyncReadExt;
let mut reader = BufReader::new(stderr);
let mut buf = Vec::new();
loop {
buf.clear();
match (&mut reader).take(8192).read_until(b'\n', &mut buf).await {
Ok(0) => return,
Ok(_) => {
let line = String::from_utf8_lossy(&buf).trim_end().to_string();
if line.is_empty() {
continue;
}
debug!(target: "yoagent::mcp::stderr", server = %server, "{line}");
if let Ok(mut tail) = tail.lock() {
tail.push(line);
}
}
Err(e) => {
warn!("MCP server '{server}': reading its stderr failed ({e}); it may block if it keeps writing");
return;
}
}
}
}
#[cfg(feature = "native")]
fn server_request_reply(method: &str, id: serde_json::Value) -> serde_json::Value {
if method == "ping" {
serde_json::json!({"jsonrpc": "2.0", "id": id, "result": {}})
} else {
warn!("MCP server asked for '{method}', which this client does not support; declining");
serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"error": {"code": -32601, "message": format!("method not supported by this client: {method}")}
})
}
}
#[cfg(feature = "native")]
fn response_id(value: &serde_json::Value) -> Option<u64> {
match value.get("id")? {
serde_json::Value::Number(n) => n.as_u64(),
serde_json::Value::String(s) => s.parse().ok(),
_ => None,
}
}
#[cfg(feature = "native")]
#[async_trait]
impl McpTransport for StdioTransport {
async fn send(&self, request: JsonRpcRequest) -> Result<JsonRpcResponse, McpError> {
let request_id = request.id;
let method = request.method.clone();
self.write_message(&serde_json::to_string(&request)?)
.await?;
let mut stdout = self.stdout.lock().await;
let mut non_json = 0usize;
let mut last_non_json = String::new();
loop {
let mut line = String::new();
let bytes_read = stdout
.read_line(&mut line)
.await
.map_err(|e| McpError::Transport(format!("Read error: {}", e)))?;
if bytes_read == 0 {
let skipped = if non_json > 0 {
format!(
" ({non_json} non-JSON line(s) on stdout skipped; the last began: {last_non_json:?})"
)
} else {
String::new()
};
return Err(self.closed_error(&skipped).await);
}
let line = line.trim();
if line.is_empty() {
continue;
}
let value: serde_json::Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(e) => {
non_json += 1;
last_non_json = line.chars().take(80).collect();
warn!(
"MCP server '{}': skipping a non-JSON line on stdout during '{method}' ({} bytes, {e}): {last_non_json:?}",
self.command,
line.len()
);
continue;
}
};
if let Some(server_method) = value.get("method").and_then(|m| m.as_str()) {
match value.get("id").cloned() {
Some(id) => {
let reply = server_request_reply(server_method, id);
self.write_message(&reply.to_string()).await?;
}
None => debug!("MCP stdio: server notification '{server_method}'"),
}
continue;
}
match response_id(&value) {
Some(id) if id == request_id => return Ok(serde_json::from_value(value)?),
None if value.get("error").is_some() => {
warn!(
"MCP server '{}': an error without an id arrived during '{method}'; reporting it for this call",
self.command
);
return Ok(serde_json::from_value(value)?);
}
other => debug!("MCP stdio: dropping a response for id {other:?} on '{method}'"),
}
}
}
async fn notify(&self, notification: JsonRpcNotification) -> Result<(), McpError> {
self.write_message(&serde_json::to_string(¬ification)?)
.await
}
async fn close(&self) -> Result<(), McpError> {
let mut child = self.child.lock().await;
if let Err(e) = child.kill().await {
debug!("MCP server '{}': kill on close: {e}", self.command);
}
Ok(())
}
}
pub struct HttpTransport {
client: reqwest::Client,
base_url: String,
headers: reqwest::header::HeaderMap,
session_id: Mutex<Option<String>>,
}
impl HttpTransport {
#[cfg(not(target_arch = "wasm32"))]
const READ_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
pub fn new(url: &str) -> Result<Self, McpError> {
let builder = reqwest::Client::builder();
#[cfg(not(target_arch = "wasm32"))]
let builder = builder.read_timeout(Self::READ_IDLE_TIMEOUT);
let client = builder
.build()
.map_err(|e| McpError::Transport(format!("Failed to build HTTP client: {e}")))?;
Ok(Self {
client,
base_url: url.trim_end_matches('/').to_string(),
headers: reqwest::header::HeaderMap::new(),
session_id: Mutex::new(None),
})
}
const RESERVED_HEADERS: [&'static str; 7] = [
"accept",
"content-type",
"mcp-session-id",
"content-length",
"transfer-encoding",
"connection",
"host",
];
pub fn with_header(mut self, name: &str, value: impl AsRef<str>) -> Result<Self, McpError> {
let header = reqwest::header::HeaderName::from_bytes(name.as_bytes())
.map_err(|e| McpError::Transport(format!("invalid header name {name:?}: {e}")))?;
if Self::RESERVED_HEADERS.contains(&header.as_str()) {
return Err(McpError::Transport(format!(
"header {name:?} is set by the MCP transport or HTTP client and cannot be overridden"
)));
}
let invalid = || McpError::Transport(format!("invalid value for header {name:?}"));
let mut value =
reqwest::header::HeaderValue::from_str(value.as_ref()).map_err(|_| invalid())?;
if value.to_str().is_err() {
return Err(invalid());
}
value.set_sensitive(true);
self.headers.insert(header, value);
Ok(self)
}
fn request(&self, method: reqwest::Method) -> reqwest::RequestBuilder {
self.client
.request(method, &self.base_url)
.headers(self.headers.clone())
}
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>()
)))
}
}
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait::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
.request(reqwest::Method::POST)
.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 notify(&self, notification: JsonRpcNotification) -> Result<(), McpError> {
let method = notification.method.clone();
let mut builder = self
.request(reqwest::Method::POST)
.header("Accept", "application/json, text/event-stream")
.json(¬ification);
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 on '{method}': {e}")))?;
let status = resp.status();
if status.is_success() {
Ok(())
} else {
let body = resp.text().await.unwrap_or_default();
let detail = body.trim();
Err(McpError::Transport(if detail.is_empty() {
format!("HTTP {status} from server on notification '{method}'")
} else {
format!(
"HTTP {status} from server on notification '{method}': {}",
detail.chars().take(200).collect::<String>()
)
}))
}
}
async fn close(&self) -> Result<(), McpError> {
let session = self.session_id.lock().await.take();
if let Some(session) = session {
match self
.request(reqwest::Method::DELETE)
.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::*;
#[test]
fn caller_header_values_are_sensitive() {
let transport = HttpTransport::new("http://127.0.0.1:9/mcp")
.unwrap()
.with_header("x-api-key", "s3cret")
.unwrap();
assert!(transport.headers["x-api-key"].is_sensitive());
assert!(!format!("{:?}", transport.headers).contains("s3cret"));
}
#[cfg(feature = "native")]
#[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 writer = transport.stdin.lock().await;
writer.stdin.write_all(line.as_bytes()).await.unwrap();
writer.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");
}
}