use std::path::Path;
use std::sync::Mutex;
use std::time::Duration;
use async_trait::async_trait;
use serde_json::Value;
use crate::error::{Error, Result};
use crate::lsp::jsonrpc::{EnvPolicy, JsonRpcClient, MCP_ENV_ALLOWLIST, await_completion};
pub const DEFAULT_MCP_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_HTTP_BODY: usize = 10 * 1024 * 1024;
pub const MCP_PROTOCOL_VERSION: &str = "2025-06-18";
fn tool_err(code: &str, message: impl Into<String>) -> Error {
Error::tool("mcp", format!("[{code}] {}", message.into()))
}
#[async_trait]
pub trait McpTransport: Send + Sync {
async fn request(&self, method: &str, params: Value, timeout: Duration) -> Result<Value>;
async fn notify(&self, method: &str, params: Value) -> Result<()>;
fn is_alive(&self) -> bool;
async fn close(&self);
fn diagnostics_tail(&self) -> String;
}
pub struct StdioTransport {
rpc: JsonRpcClient,
lane: std::sync::Arc<asupersync::sync::Mutex<()>>,
}
impl StdioTransport {
pub fn spawn(
command: &str,
args: &[String],
env: &[(String, String)],
cwd: &Path,
) -> Result<Self> {
let rpc = JsonRpcClient::spawn_with_policy(
command,
args,
env,
cwd,
&EnvPolicy::Allowlist(MCP_ENV_ALLOWLIST),
"mcp",
)?;
Ok(Self {
rpc,
lane: std::sync::Arc::new(asupersync::sync::Mutex::new(())),
})
}
}
#[async_trait]
impl McpTransport for StdioTransport {
async fn request(&self, method: &str, params: Value, timeout: Duration) -> Result<Value> {
let cx = crate::agent_cx::AgentCx::for_current_or_request();
let _lane =
asupersync::sync::OwnedMutexGuard::lock(std::sync::Arc::clone(&self.lane), cx.cx())
.await
.map_err(|_| tool_err("MCP_CANCELLED", "cancelled by ambient context"))?;
let (id, rx) = self
.rpc
.request(method, params)
.map_err(|err| tool_err(&err.mcp_code(), err.message()))?;
let outcome = await_completion(rx, timeout, || self.rpc.cancel_request(id)).await;
match outcome {
Ok(Ok(value)) => Ok(value),
Ok(Err(err)) => Err(tool_err(&err.mcp_code(), err.message())),
Err(crate::lsp::jsonrpc::CompletionWaitError::Timeout) => Err(tool_err(
"MCP_TIMEOUT",
format!("request timed out after {} ms", timeout.as_millis()),
)),
Err(crate::lsp::jsonrpc::CompletionWaitError::Cancelled) => {
Err(tool_err("MCP_CANCELLED", "cancelled by ambient context"))
}
Err(crate::lsp::jsonrpc::CompletionWaitError::Closed) => Err(tool_err(
"MCP_TRANSPORT_CLOSED",
"completion channel dropped (server died)",
)),
}
}
async fn notify(&self, method: &str, params: Value) -> Result<()> {
self.rpc
.notify(method, params)
.map_err(|err| tool_err(&err.mcp_code(), err.message()))
}
fn is_alive(&self) -> bool {
self.rpc.is_alive() && !self.rpc.child_exited()
}
async fn close(&self) {
self.rpc.shutdown();
}
fn diagnostics_tail(&self) -> String {
self.rpc.stderr_tail()
}
}
fn require_header_safe(what: &str, value: &str) -> Result<()> {
if value.contains(['\r', '\n']) {
return Err(tool_err(
"MCP_HEADER_UNSAFE",
format!("{what} contains CR/LF; refusing to send it as a header"),
));
}
Ok(())
}
pub struct HttpTransport {
client: crate::http::client::Client,
url: String,
headers: Vec<(String, String)>,
session_id: Mutex<Option<String>>,
alive: std::sync::atomic::AtomicBool,
lane: std::sync::Arc<asupersync::sync::Mutex<()>>,
}
impl HttpTransport {
pub fn new(url: &str, headers: Vec<(String, String)>) -> Result<Self> {
for (name, value) in &headers {
require_header_safe("header name", name)?;
require_header_safe(&format!("header {name:?} value"), value)?;
}
Ok(Self {
client: crate::http::client::Client::new(),
url: url.to_string(),
headers,
session_id: Mutex::new(None),
alive: std::sync::atomic::AtomicBool::new(true),
lane: std::sync::Arc::new(asupersync::sync::Mutex::new(())),
})
}
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
mutex
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
async fn round_trip(&self, frame: &Value, timeout: Duration) -> Result<Value> {
let mut request = self
.client
.post(&self.url)
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream");
for (name, value) in &self.headers {
request = request.header(name.clone(), value.clone());
}
let assigned_session = { Self::lock(&self.session_id).clone() };
if let Some(session) = assigned_session {
request = request.header("Mcp-Session-Id", session);
}
let response = request
.json(frame)
.map_err(|err| tool_err("MCP_TRANSPORT_IO", format!("encode: {err}")))?
.timeout(timeout)
.send()
.await
.map_err(|err| tool_err("MCP_TRANSPORT_IO", format!("send: {err}")))?;
let status = response.status();
if let Some(assigned) = response
.headers()
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case("mcp-session-id"))
.map(|(_, value)| value.clone())
.filter(|value| !value.contains(['\r', '\n']))
{
*Self::lock(&self.session_id) = Some(assigned);
}
let content_type = response
.headers()
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case("content-type"))
.map(|(_, value)| value.clone())
.unwrap_or_default();
if status == 202 {
return Ok(Value::Null);
}
if !(200..300).contains(&status) {
let body = response.text_limited(4096).await.unwrap_or_default();
return Err(tool_err(
"MCP_HTTP_STATUS",
format!("HTTP {status} from {}: {}", self.url, body.trim()),
));
}
if content_type.contains("text/event-stream") {
let body = response
.text_limited(MAX_HTTP_BODY)
.await
.map_err(|err| tool_err("MCP_TRANSPORT_IO", format!("read SSE body: {err}")))?;
return parse_sse_responses(&body);
}
let body = response
.text_limited(MAX_HTTP_BODY)
.await
.map_err(|err| tool_err("MCP_TRANSPORT_IO", format!("read body: {err}")))?;
let value: Value = serde_json::from_str(&body).map_err(|err| {
tool_err(
"MCP_PROTOCOL",
format!("response is not JSON: {err} (body: {:.200})", body.trim()),
)
})?;
if let Some(error) = value.get("error") {
let message = error
.get("message")
.and_then(Value::as_str)
.unwrap_or("unknown server error");
return Err(tool_err(
"MCP_SERVER_ERROR",
format!("server error: {message}"),
));
}
Ok(value.get("result").cloned().unwrap_or(Value::Null))
}
}
fn parse_sse_responses(body: &str) -> Result<Value> {
let mut parser = crate::sse::SseParser::new();
let events = parser.feed(body);
for event in events {
for line in event.data.lines() {
let Ok(value) = serde_json::from_str::<Value>(line) else {
continue;
};
if value.get("result").is_some() || value.get("error").is_some() {
if let Some(error) = value.get("error") {
let message = error
.get("message")
.and_then(Value::as_str)
.unwrap_or("unknown server error");
return Err(tool_err(
"MCP_SERVER_ERROR",
format!("server error: {message}"),
));
}
return Ok(value.get("result").cloned().unwrap_or(Value::Null));
}
}
}
Err(tool_err(
"MCP_PROTOCOL",
"SSE stream ended without a JSON-RPC response",
))
}
static NEXT_HTTP_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
#[async_trait]
impl McpTransport for HttpTransport {
async fn request(&self, method: &str, params: Value, timeout: Duration) -> Result<Value> {
let cx = crate::agent_cx::AgentCx::for_current_or_request();
let _lane =
asupersync::sync::OwnedMutexGuard::lock(std::sync::Arc::clone(&self.lane), cx.cx())
.await
.map_err(|_| tool_err("MCP_CANCELLED", "cancelled by ambient context"))?;
let id = NEXT_HTTP_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let frame = serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params,
});
self.round_trip(&frame, timeout).await
}
async fn notify(&self, method: &str, params: Value) -> Result<()> {
let frame = serde_json::json!({
"jsonrpc": "2.0",
"method": method,
"params": params,
});
self.round_trip(&frame, DEFAULT_MCP_TIMEOUT)
.await
.map(|_| ())
}
fn is_alive(&self) -> bool {
self.alive.load(std::sync::atomic::Ordering::SeqCst)
}
async fn close(&self) {
self.alive.store(false, std::sync::atomic::Ordering::SeqCst);
Self::lock(&self.session_id).take();
}
fn diagnostics_tail(&self) -> String {
format!("http transport to {}", self.url)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sse_parse_extracts_result() {
let body =
"event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"tools\":[]}}\n\n";
let value = parse_sse_responses(body).expect("result");
assert_eq!(value["tools"], serde_json::json!([]));
}
#[test]
fn sse_parse_surfaces_error() {
let body = "event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"code\":-32601,\"message\":\"no method\"}}\n\n";
let err = parse_sse_responses(body).expect_err("error");
assert!(err.to_string().contains("no method"), "{err}");
}
#[test]
fn sse_parse_skips_non_rpc_events() {
let body = "event: ping\ndata: {}\n\nevent: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":2,\"result\":42}\n\n";
let value = parse_sse_responses(body).expect("result after skip");
assert_eq!(value, serde_json::json!(42));
}
#[test]
fn sse_parse_requires_response() {
let body = "event: message\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\",\"params\":{}}\n\n";
assert!(parse_sse_responses(body).is_err());
}
}