use std::future::Future;
use std::pin::Pin;
use bytes::Bytes;
use serde_json::Value;
use tracing::{debug, trace};
use turbomcp_protocol::{
InitializeRequest, InitializeResult, MessageId,
jsonrpc::{
JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, JsonRpcResponsePayload,
JsonRpcVersion,
},
};
use turbomcp_transport::{
ChildProcessConfig, ChildProcessTransport, Transport, TransportMessage,
core::TransportMessageMetadata,
};
use uuid::Uuid;
use crate::error::{ProxyError, ProxyResult};
use super::McpBackend;
pub struct StdioBackend {
transport: ChildProcessTransport,
next_id: std::sync::atomic::AtomicU64,
}
impl StdioBackend {
pub async fn new(command: impl Into<String>, args: Vec<String>) -> ProxyResult<Self> {
let config = ChildProcessConfig {
command: command.into(),
args,
working_directory: None,
environment: None,
..Default::default()
};
let transport = ChildProcessTransport::new(config);
transport
.connect()
.await
.map_err(|e| ProxyError::backend(format!("Failed to connect to subprocess: {e}")))?;
Ok(Self {
transport,
next_id: std::sync::atomic::AtomicU64::new(1),
})
}
pub async fn with_working_dir(
command: impl Into<String>,
args: Vec<String>,
working_dir: String,
) -> ProxyResult<Self> {
let config = ChildProcessConfig {
command: command.into(),
args,
working_directory: Some(working_dir),
environment: None,
..Default::default()
};
let transport = ChildProcessTransport::new(config);
transport
.connect()
.await
.map_err(|e| ProxyError::backend(format!("Failed to connect to subprocess: {e}")))?;
Ok(Self {
transport,
next_id: std::sync::atomic::AtomicU64::new(1),
})
}
fn next_message_id(&self) -> u64 {
self.next_id
.fetch_add(1, std::sync::atomic::Ordering::SeqCst)
}
async fn send_request(&self, method: &str, params: Value) -> ProxyResult<Value> {
let id = self.next_message_id();
let request = JsonRpcRequest {
jsonrpc: JsonRpcVersion,
#[allow(clippy::cast_possible_wrap)]
id: MessageId::Number(id as i64),
method: method.to_string(),
params: Some(params),
};
trace!(method = %method, id = %id, "Sending introspection request");
let request_json = serde_json::to_vec(&request)
.map_err(|e| ProxyError::backend(format!("Failed to serialize request: {e}")))?;
self.send_raw(request_json).await?;
let expected = Value::from(id);
loop {
let message = self
.transport
.receive()
.await
.map_err(|e| ProxyError::backend(format!("Failed to receive response: {e}")))?
.ok_or_else(|| {
ProxyError::backend("No response received (transport closed)".to_string())
})?;
let message: Value = serde_json::from_slice(&message.payload)
.map_err(|e| ProxyError::backend(format!("Failed to parse message: {e}")))?;
trace!(message = %message, "Received introspection message");
if message.get("method").is_some() {
self.answer_server_message(&message).await?;
continue;
}
if message.get("id") != Some(&expected) {
debug!(message = %message, "Ignoring response to an unknown request");
continue;
}
let response: JsonRpcResponse = serde_json::from_value(message)
.map_err(|e| ProxyError::backend(format!("Failed to parse response: {e}")))?;
return match response.payload {
JsonRpcResponsePayload::Success { result } => Ok(result),
JsonRpcResponsePayload::Error { error } => {
let mut err =
turbomcp_protocol::Error::from_rpc_code(error.code, error.message);
if let Some(data) = error.data {
err = err.with_data(data);
}
Err(err.into())
}
};
}
}
async fn answer_server_message(&self, message: &Value) -> ProxyResult<()> {
let Some(id) = message.get("id") else {
return Ok(());
};
let reply = if message.get("method").and_then(Value::as_str) == Some("ping") {
serde_json::json!({ "jsonrpc": "2.0", "id": id, "result": {} })
} else {
serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"error": { "code": -32601, "message": "Method not found" }
})
};
let reply = serde_json::to_vec(&reply)
.map_err(|e| ProxyError::backend(format!("Failed to serialize reply: {e}")))?;
self.send_raw(reply).await
}
async fn send_raw(&self, payload: Vec<u8>) -> ProxyResult<()> {
let message = TransportMessage {
id: turbomcp_protocol::MessageId::String(Uuid::new_v4().to_string()),
payload: Bytes::from(payload),
metadata: TransportMessageMetadata::default(),
};
self.transport
.send(message)
.await
.map_err(|e| ProxyError::backend(format!("Failed to send message: {e}")))
}
}
impl McpBackend for StdioBackend {
fn initialize(
&mut self,
request: InitializeRequest,
) -> Pin<Box<dyn Future<Output = ProxyResult<InitializeResult>> + Send + '_>> {
Box::pin(async move {
debug!("Initializing STDIO backend via turbomcp-transport");
let params = serde_json::to_value(&request).map_err(|e| {
ProxyError::backend(format!("Failed to serialize initialize request: {e}"))
})?;
let result = self.send_request("initialize", params).await?;
let init_result: InitializeResult = serde_json::from_value(result).map_err(|e| {
ProxyError::backend(format!("Failed to deserialize initialize result: {e}"))
})?;
debug!(
server_name = %init_result.server_info.name,
server_version = %init_result.server_info.version,
protocol_version = %init_result.protocol_version,
"Server initialized successfully"
);
self.send_notification("notifications/initialized", serde_json::json!({}))
.await?;
Ok(init_result)
})
}
fn call_method<'a>(
&'a mut self,
method: &'a str,
params: Value,
) -> Pin<Box<dyn Future<Output = ProxyResult<Value>> + Send + 'a>> {
Box::pin(async move { self.send_request(method, params).await })
}
fn send_notification<'a>(
&'a mut self,
method: &'a str,
params: Value,
) -> Pin<Box<dyn Future<Output = ProxyResult<()>> + Send + 'a>> {
Box::pin(async move {
let notification = JsonRpcNotification {
jsonrpc: JsonRpcVersion,
method: method.to_string(),
params: Some(params),
};
let notification_json = serde_json::to_vec(¬ification).map_err(|e| {
ProxyError::backend(format!("Failed to serialize notification: {e}"))
})?;
trace!(method = %method, "Sending notification");
self.send_raw(notification_json).await
})
}
fn shutdown(&mut self) -> Pin<Box<dyn Future<Output = ProxyResult<()>> + Send + '_>> {
Box::pin(async move {
debug!("Shutting down STDIO backend");
Ok(())
})
}
fn description(&self) -> String {
"STDIO backend via turbomcp-transport".to_string()
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
const TEST_SUBPROCESS: &str = "/bin/cat";
#[tokio::test]
async fn test_stdio_backend_creation() {
let backend = StdioBackend::new(TEST_SUBPROCESS, Vec::new()).await;
assert!(backend.is_ok(), "backend should spawn: {:?}", backend.err());
}
#[tokio::test]
async fn test_stdio_backend_with_working_dir() {
let backend =
StdioBackend::with_working_dir(TEST_SUBPROCESS, Vec::new(), "/tmp".to_string()).await;
assert!(backend.is_ok(), "backend should spawn: {:?}", backend.err());
}
#[tokio::test]
async fn responses_are_matched_by_id_not_arrival_order() {
use crate::introspection::McpIntrospector;
let script = r#"
log='{"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","data":"warming up"}}'
read -r _init
printf '%s\n' "$log" '{"jsonrpc":"2.0","id":"srv-1","method":"ping"}'
printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"chatty","version":"1.0.0"}}}'
read -r pong
read -r _initialized
read -r _list
case "$pong" in
*'"srv-1"'*'"result"'*|*'"result"'*'"srv-1"'*)
printf '%s\n' "$log" '{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"echo","inputSchema":{"type":"object"}}]}}' ;;
*)
printf '%s\n' '{"jsonrpc":"2.0","id":2,"error":{"code":-32603,"message":"ping went unanswered"}}' ;;
esac
read -r _
"#;
let mut backend = StdioBackend::new("sh", vec!["-c".to_string(), script.to_string()])
.await
.expect("scripted server spawns");
let spec = McpIntrospector::new()
.introspect(&mut backend)
.await
.expect("interleaved notifications and pings do not derail introspection");
assert_eq!(spec.server_info.name, "chatty");
assert_eq!(spec.tools.len(), 1);
assert_eq!(spec.tools[0].name, "echo");
}
}