use tokio::io::BufReader;
use tracing::debug;
use turbomcp_server::transport::{LineReader, LineTransportRunner, LineWriter};
use turbomcp_server::{McpHandler, RequestContext, ServerConfig};
use crate::error::{ProxyError, ProxyResult};
use crate::proxy::ProxyService;
pub struct StdioFrontend {
service: ProxyService,
config: ServerConfig,
}
impl StdioFrontend {
#[must_use]
pub fn new(service: ProxyService, config: ServerConfig) -> Self {
Self { service, config }
}
pub async fn run(self) -> ProxyResult<()> {
debug!("Starting STDIO frontend");
turbomcp_server::transport::stdio::run_with_config(&self.service, &self.config)
.await
.map_err(|e| ProxyError::backend(format!("STDIO frontend error: {e}")))
}
pub async fn serve<R, W>(self, reader: R, writer: W) -> ProxyResult<()>
where
R: tokio::io::AsyncRead + Unpin + Send + 'static,
BufReader<R>: LineReader,
W: LineWriter,
{
self.service
.on_initialize()
.await
.map_err(ProxyError::from)?;
let runner = LineTransportRunner::with_config(self.service.clone(), self.config);
let result = runner
.run(BufReader::new(reader), writer, RequestContext::stdio)
.await
.map_err(|e| ProxyError::backend(format!("STDIO frontend error: {e}")));
self.service.on_shutdown().await.map_err(ProxyError::from)?;
result
}
}
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use super::*;
use crate::proxy::BackendConnector;
struct Wire {
to_proxy: tokio::io::DuplexStream,
from_proxy: tokio::io::Lines<BufReader<tokio::io::DuplexStream>>,
}
impl Wire {
async fn over(backend: BackendConnector) -> Self {
let spec = backend.introspect().await.expect("introspection");
let frontend = StdioFrontend::new(
ProxyService::new(backend, spec),
ServerConfig::builder().build(),
);
let (to_proxy, proxy_in) = tokio::io::duplex(64 * 1024);
let (proxy_out, from_proxy) = tokio::io::duplex(64 * 1024);
tokio::spawn(frontend.serve(proxy_in, proxy_out));
Self {
to_proxy,
from_proxy: BufReader::new(from_proxy).lines(),
}
}
async fn send(&mut self, message: Value) {
let mut line = serde_json::to_vec(&message).expect("serializes");
line.push(b'\n');
self.to_proxy.write_all(&line).await.expect("proxy reads");
}
async fn recv(&mut self) -> Value {
let line = tokio::time::timeout(
std::time::Duration::from_secs(5),
self.from_proxy.next_line(),
)
.await
.expect("the proxy answers")
.expect("readable")
.expect("a line");
serde_json::from_str(&line).expect("the proxy writes JSON")
}
async fn initialize(&mut self, version: &str) -> Value {
self.send(json!({
"jsonrpc": "2.0",
"id": 0,
"method": "initialize",
"params": {
"protocolVersion": version,
"capabilities": {},
"clientInfo": { "name": "wire-test", "version": "1.0.0" }
}
}))
.await;
let response = self.recv().await;
self.send(json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }))
.await;
response
}
}
fn catalogue_backend() -> BackendConnector {
BackendConnector::from_static_data_for_test(
vec![turbomcp_protocol::types::Tool {
name: "echo".to_string(),
..Default::default()
}],
vec![],
vec![],
vec![],
)
}
#[tokio::test]
async fn initialize_is_a_real_handshake() {
for version in ["2025-11-25", "2025-06-18"] {
let mut wire = Wire::over(catalogue_backend()).await;
let response = wire.initialize(version).await;
assert_eq!(response["id"], 0);
let result = &response["result"];
assert_eq!(result["protocolVersion"], version);
assert_eq!(result["serverInfo"]["name"], "test-backend-proxy");
assert_eq!(result["capabilities"]["tools"], json!({}));
}
}
#[tokio::test]
async fn ping_is_answered_and_notifications_are_not() {
let mut wire = Wire::over(catalogue_backend()).await;
wire.initialize("2025-11-25").await;
wire.send(json!({
"jsonrpc": "2.0",
"method": "notifications/cancelled",
"params": { "requestId": 99 }
}))
.await;
wire.send(json!({ "jsonrpc": "2.0", "id": 1, "method": "ping" }))
.await;
assert_eq!(
wire.recv().await,
json!({ "jsonrpc": "2.0", "id": 1, "result": {} })
);
}
#[tokio::test]
async fn upstream_errors_are_forwarded_with_their_data() {
let elicitations = json!({
"elicitations": [{
"mode": "url",
"elicitationId": "e-1",
"url": "https://example.com/connect",
"message": "Connect your account"
}]
});
let upstream = turbomcp_protocol::Error::from_rpc_code(-32042, "Authorization required")
.with_data(elicitations.clone());
let mut wire = Wire::over(BackendConnector::failing_tool_calls_for_test(
"connect", upstream,
))
.await;
wire.initialize("2025-11-25").await;
wire.send(json!({
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": { "name": "connect", "arguments": {} }
}))
.await;
let response = wire.recv().await;
assert_eq!(response["id"], 7);
assert_eq!(response["error"]["code"], -32042);
assert_eq!(response["error"]["data"], elicitations);
}
#[tokio::test]
async fn an_unknown_method_is_method_not_found() {
let mut wire = Wire::over(catalogue_backend()).await;
wire.initialize("2025-11-25").await;
wire.send(json!({ "jsonrpc": "2.0", "id": 3, "method": "no/such/method" }))
.await;
let response = wire.recv().await;
assert_eq!(response["error"]["code"], -32601);
}
}