use schemars::JsonSchema;
use serde::Deserialize;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::time::{Duration, timeout};
use tower_mcp::extract::{Context, Json};
use tower_mcp::protocol::{ElicitAction, ElicitFormParams, ElicitFormSchema};
use tower_mcp::transport::stdio::BidirectionalStdioTransport;
use tower_mcp::{CallToolResult, McpRouter, StdioTransport, ToolBuilder};
use tower_mcp_types::testing::assert_jsonrpc_error_response;
fn router() -> McpRouter {
McpRouter::new().server_info("stdio-loop-test", "0.0.0")
}
async fn read_n_frames<R>(mut reader: BufReader<R>, expected: usize) -> Vec<serde_json::Value>
where
R: tokio::io::AsyncRead + Unpin,
{
let mut out = Vec::with_capacity(expected);
while out.len() < expected {
let mut line = String::new();
let n = reader
.read_line(&mut line)
.await
.expect("read from server output");
if n == 0 {
break; }
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let v: serde_json::Value = serde_json::from_str(trimmed)
.unwrap_or_else(|e| panic!("invalid JSON on output: {e}: {trimmed}"));
out.push(v);
}
out
}
#[tokio::test]
async fn stdio_transport_parse_error_wire_shape() {
let mut transport = StdioTransport::new(router());
let (server_stdin_writer, server_stdin) = tokio::io::duplex(4096);
let (server_stdout, server_stdout_reader) = tokio::io::duplex(4096);
let handle = tokio::spawn(async move {
transport
.run_with_streams(server_stdin, server_stdout)
.await
});
let mut stdin_writer = server_stdin_writer;
stdin_writer
.write_all(b"not valid json{{{\n")
.await
.unwrap();
stdin_writer.flush().await.unwrap();
drop(stdin_writer);
let reader = BufReader::new(server_stdout_reader);
let frames = read_n_frames(reader, 1).await;
handle
.await
.expect("transport task join")
.expect("run_with_streams ok");
assert_eq!(
frames.len(),
1,
"expected one parse-error frame, got: {frames:?}"
);
let frame = &frames[0];
assert_jsonrpc_error_response(frame);
assert!(
frame["id"].is_null(),
"parse error id must be null, got: {frame}"
);
assert_eq!(frame["error"]["code"].as_i64().unwrap(), -32700);
}
#[tokio::test]
async fn stdio_transport_loop_continues_after_parse_error() {
let mut transport = StdioTransport::new(router());
let (server_stdin_writer, server_stdin) = tokio::io::duplex(4096);
let (server_stdout, server_stdout_reader) = tokio::io::duplex(4096);
let handle = tokio::spawn(async move {
transport
.run_with_streams(server_stdin, server_stdout)
.await
});
let mut stdin_writer = server_stdin_writer;
stdin_writer.write_all(b"this is not json\n").await.unwrap();
stdin_writer
.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":42,\"method\":\"ping\"}\n")
.await
.unwrap();
stdin_writer.flush().await.unwrap();
drop(stdin_writer);
let reader = BufReader::new(server_stdout_reader);
let frames = read_n_frames(reader, 2).await;
handle
.await
.expect("transport task join")
.expect("run_with_streams ok");
assert_eq!(
frames.len(),
2,
"expected parse-error frame + ping response, got: {frames:?}"
);
assert_jsonrpc_error_response(&frames[0]);
assert!(frames[0]["id"].is_null());
assert_eq!(frames[0]["error"]["code"].as_i64().unwrap(), -32700);
assert_eq!(frames[1]["jsonrpc"], "2.0");
assert_eq!(frames[1]["id"], 42);
assert!(
frames[1].get("result").is_some(),
"ping must return a successful result frame, got: {}",
frames[1]
);
}
#[tokio::test]
async fn stdio_transport_eof_returns_ok() {
let mut transport = StdioTransport::new(router());
let (server_stdin_writer, server_stdin) = tokio::io::duplex(4096);
let (server_stdout, _server_stdout_reader) = tokio::io::duplex(4096);
let handle = tokio::spawn(async move {
transport
.run_with_streams(server_stdin, server_stdout)
.await
});
drop(server_stdin_writer);
let result = handle.await.expect("transport task join");
assert!(
result.is_ok(),
"run_with_streams must return Ok on EOF, got: {result:?}"
);
}
#[tokio::test]
async fn bidi_transport_parse_error_wire_shape() {
let mut transport = BidirectionalStdioTransport::new(router());
let (server_stdin_writer, server_stdin) = tokio::io::duplex(4096);
let (server_stdout, server_stdout_reader) = tokio::io::duplex(4096);
let handle = tokio::spawn(async move {
transport
.run_with_streams(server_stdin, server_stdout)
.await
});
let mut stdin_writer = server_stdin_writer;
stdin_writer
.write_all(b"not valid json{{{\n")
.await
.unwrap();
stdin_writer.flush().await.unwrap();
drop(stdin_writer);
let reader = BufReader::new(server_stdout_reader);
let frames = read_n_frames(reader, 1).await;
handle
.await
.expect("transport task join")
.expect("run_with_streams ok");
assert_eq!(
frames.len(),
1,
"expected one parse-error frame, got: {frames:?}"
);
assert_jsonrpc_error_response(&frames[0]);
assert!(frames[0]["id"].is_null());
assert_eq!(frames[0]["error"]["code"].as_i64().unwrap(), -32700);
}
#[tokio::test]
async fn bidi_transport_loop_continues_after_parse_error() {
let mut transport = BidirectionalStdioTransport::new(router());
let (server_stdin_writer, server_stdin) = tokio::io::duplex(4096);
let (server_stdout, server_stdout_reader) = tokio::io::duplex(4096);
let handle = tokio::spawn(async move {
transport
.run_with_streams(server_stdin, server_stdout)
.await
});
let mut stdin_writer = server_stdin_writer;
stdin_writer.write_all(b"this is not json\n").await.unwrap();
stdin_writer
.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"ping\"}\n")
.await
.unwrap();
stdin_writer.flush().await.unwrap();
drop(stdin_writer);
let reader = BufReader::new(server_stdout_reader);
let frames = read_n_frames(reader, 2).await;
handle
.await
.expect("transport task join")
.expect("run_with_streams ok");
assert_eq!(
frames.len(),
2,
"expected parse-error frame + ping response, got: {frames:?}"
);
assert_jsonrpc_error_response(&frames[0]);
assert!(frames[0]["id"].is_null());
assert_eq!(frames[0]["error"]["code"].as_i64().unwrap(), -32700);
assert_eq!(frames[1]["jsonrpc"], "2.0");
assert_eq!(frames[1]["id"], 7);
assert!(
frames[1].get("result").is_some(),
"ping must return a successful result frame, got: {}",
frames[1]
);
}
#[derive(Debug, Deserialize, JsonSchema)]
struct NoArgs {}
async fn read_frame<R>(reader: &mut BufReader<R>) -> serde_json::Value
where
R: tokio::io::AsyncRead + Unpin,
{
loop {
let mut line = String::new();
let n = reader
.read_line(&mut line)
.await
.expect("read from server output");
if n == 0 {
panic!("EOF before a frame was read");
}
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
return serde_json::from_str(trimmed)
.unwrap_or_else(|e| panic!("invalid JSON on output: {e}: {trimmed}"));
}
}
#[tokio::test]
async fn bidi_transport_wires_client_requester_into_context() {
let check = ToolBuilder::new("check_elicit")
.description("Report whether elicitation is available")
.extractor_handler((), |ctx: Context, Json(_): Json<NoArgs>| async move {
Ok(CallToolResult::text(ctx.can_elicit().to_string()))
})
.build();
let router = McpRouter::new()
.server_info("bidi-elicit-test", "0.0.0")
.tool(check);
let mut transport = BidirectionalStdioTransport::new(router);
let (server_stdin_writer, server_stdin) = tokio::io::duplex(8192);
let (server_stdout, server_stdout_reader) = tokio::io::duplex(8192);
let handle = tokio::spawn(async move {
transport
.run_with_streams(server_stdin, server_stdout)
.await
});
let mut w = server_stdin_writer;
w.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{\"elicitation\":{}},\"clientInfo\":{\"name\":\"t\",\"version\":\"0\"}}}\n").await.unwrap();
w.write_all(b"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n")
.await
.unwrap();
w.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"check_elicit\",\"arguments\":{}}}\n").await.unwrap();
w.flush().await.unwrap();
let reader = BufReader::new(server_stdout_reader);
let frames = read_n_frames(reader, 2).await;
drop(w);
let _ = handle.await;
let call = &frames[1];
assert_eq!(call["id"], 2, "expected tools/call response, got: {call}");
let text = call["result"]["content"][0]["text"].as_str().unwrap_or("");
assert_eq!(
text, "true",
"can_elicit() must be true once the requester is wired, got: {call}"
);
}
#[tokio::test]
async fn bidi_transport_elicitation_round_trip() {
let confirm = ToolBuilder::new("confirm")
.description("Confirm an action via elicitation")
.extractor_handler((), |ctx: Context, Json(_): Json<NoArgs>| async move {
let params = ElicitFormParams {
message: "Confirm?".to_string(),
requested_schema: ElicitFormSchema::new().boolean_field(
"confirmed",
Some("Confirm"),
true,
),
mode: None,
meta: None,
};
match ctx.elicit_form(params).await {
Ok(result) => {
let accepted = matches!(result.action, ElicitAction::Accept);
Ok(CallToolResult::text(format!("confirmed={accepted}")))
}
Err(e) => Ok(CallToolResult::error(format!("elicit failed: {e}"))),
}
})
.build();
let router = McpRouter::new()
.server_info("bidi-elicit-test", "0.0.0")
.tool(confirm);
let mut transport = BidirectionalStdioTransport::new(router);
let (server_stdin_writer, server_stdin) = tokio::io::duplex(8192);
let (server_stdout, server_stdout_reader) = tokio::io::duplex(8192);
let handle = tokio::spawn(async move {
transport
.run_with_streams(server_stdin, server_stdout)
.await
});
let driver = async move {
let mut w = server_stdin_writer;
let mut reader = BufReader::new(server_stdout_reader);
w.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{\"elicitation\":{}},\"clientInfo\":{\"name\":\"t\",\"version\":\"0\"}}}\n").await.unwrap();
w.flush().await.unwrap();
let init = read_frame(&mut reader).await;
assert_eq!(init["id"], 1, "expected initialize response, got: {init}");
w.write_all(b"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n")
.await
.unwrap();
w.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"confirm\",\"arguments\":{}}}\n").await.unwrap();
w.flush().await.unwrap();
loop {
let frame = read_frame(&mut reader).await;
if frame.get("method").and_then(|m| m.as_str()) == Some("elicitation/create") {
let response = serde_json::json!({
"jsonrpc": "2.0",
"id": frame["id"],
"result": { "action": "accept", "content": { "confirmed": true } }
});
w.write_all(format!("{response}\n").as_bytes())
.await
.unwrap();
w.flush().await.unwrap();
continue;
}
if frame["id"] == serde_json::json!(2) {
assert!(
frame.get("result").is_some(),
"tools/call must succeed after elicitation, got: {frame}"
);
let text = frame["result"]["content"][0]["text"].as_str().unwrap_or("");
assert_eq!(
text, "confirmed=true",
"elicitation round-trip should return the client's accept, got: {frame}"
);
break;
}
}
drop(w);
};
timeout(Duration::from_secs(5), driver)
.await
.expect("elicitation round-trip over bidirectional stdio timed out (deadlock)");
let _ = timeout(Duration::from_secs(2), handle).await;
}