use crate::server::Server;
use rustlavel_core::{Error, Result};
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
pub async fn serve_stdio(server: Arc<Server>) -> Result<()> {
serve(server, tokio::io::stdin(), tokio::io::stdout()).await
}
pub async fn serve<R, W>(server: Arc<Server>, reader: R, writer: W) -> Result<()>
where
R: AsyncRead + Unpin + Send,
W: AsyncWrite + Unpin + Send,
{
let mut lines = BufReader::new(reader).lines();
let mut writer = writer;
while let Some(line) = lines.next_line().await.map_err(Error::Io)? {
if line.trim().is_empty() {
continue;
}
if let Some(answer) = server.handle_text(&line).await {
write_frame(&mut writer, &answer).await?;
}
}
Ok(())
}
async fn write_frame<W>(writer: &mut W, message: &str) -> Result<()>
where
W: AsyncWrite + Unpin + Send,
{
writer.write_all(message.as_bytes()).await.map_err(Error::Io)?;
writer.write_all(b"\n").await.map_err(Error::Io)?;
writer.flush().await.map_err(Error::Io)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::PROTOCOL_VERSION;
use crate::schema::Schema;
use crate::tool::Tool;
use rustlavel_core::Json;
use tokio::io::AsyncBufReadExt;
fn server() -> Arc<Server> {
Arc::new(Server::new("stdio-test", "0.1.0").tool(Tool::new(
"shout",
"Upper-case a word",
Schema::object().string("word", "The word to shout"),
|args: Json| async move {
Ok(Json::from(
args.get("word").and_then(Json::as_str).unwrap_or("").to_uppercase(),
))
},
)))
}
async fn exchange(input: &str) -> Vec<Json> {
let mut output = Vec::new();
serve(server(), input.as_bytes(), &mut output).await.unwrap();
let mut lines = BufReader::new(output.as_slice()).lines();
let mut messages = Vec::new();
while let Some(line) = lines.next_line().await.unwrap() {
messages.push(Json::parse(&line).unwrap());
}
messages
}
#[tokio::test]
async fn a_full_session_runs_over_newline_delimited_json() {
let messages = exchange(concat!(
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}"#,
"\n",
r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
"\n",
r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"shout","arguments":{"word":"hello"}}}"#,
"\n",
))
.await;
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].get("id").unwrap().as_i64(), Some(1));
assert_eq!(
messages[0].get("result.protocolVersion").unwrap().as_str(),
Some(PROTOCOL_VERSION)
);
assert_eq!(messages[1].get("id").unwrap().as_i64(), Some(2));
assert_eq!(messages[1].get("result.content.0.text").unwrap().as_str(), Some("HELLO"));
}
#[tokio::test]
async fn blank_lines_between_frames_are_ignored() {
let messages = exchange("\n\n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}\n\n").await;
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].get("result").unwrap().to_string(), "{}");
}
#[tokio::test]
async fn a_garbled_line_is_answered_and_the_session_continues() {
let messages = exchange(concat!(
"not json at all\n",
r#"{"jsonrpc":"2.0","id":9,"method":"tools/list"}"#,
"\n"
))
.await;
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].get("error.code").unwrap().as_i64(), Some(-32700));
assert_eq!(messages[1].get("id").unwrap().as_i64(), Some(9));
}
#[tokio::test]
async fn each_message_is_written_on_exactly_one_line() {
let mut output = Vec::new();
serve(
server(),
&b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}\n"[..],
&mut output,
)
.await
.unwrap();
let text = String::from_utf8(output).unwrap();
assert_eq!(text.matches('\n').count(), 1);
assert!(text.ends_with('\n'));
}
#[tokio::test]
async fn the_transport_survives_a_panicking_tool_over_the_wire() {
let server = Arc::new(Server::new("stdio-test", "0.1.0").tool(Tool::new(
"explodes",
"Panics",
Schema::object(),
|_: Json| async { panic!("boom") },
)));
let mut output = Vec::new();
serve(
Arc::clone(&server),
concat!(
r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"explodes"}}"#,
"\n",
r#"{"jsonrpc":"2.0","id":2,"method":"ping"}"#,
"\n"
)
.as_bytes(),
&mut output,
)
.await
.unwrap();
let text = String::from_utf8(output).unwrap();
let mut lines = text.lines();
let first = Json::parse(lines.next().unwrap()).unwrap();
let second = Json::parse(lines.next().unwrap()).unwrap();
assert_eq!(first.get("result.isError").unwrap().as_bool(), Some(true));
assert_eq!(second.get("id").unwrap().as_i64(), Some(2));
}
#[tokio::test]
async fn the_transport_serves_a_live_duplex_pipe() {
let (mut client, server_side) = tokio::io::duplex(8 * 1024);
let (server_read, server_write) = tokio::io::split(server_side);
let task = tokio::spawn(serve(server(), server_read, server_write));
let request = format!("{}\n", r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#);
tokio::io::AsyncWriteExt::write_all(&mut client, request.as_bytes()).await.unwrap();
let mut reader = BufReader::new(&mut client);
let mut line = String::new();
reader.read_line(&mut line).await.unwrap();
let answer = Json::parse(&line).unwrap();
assert_eq!(answer.get("result.tools.0.name").unwrap().as_str(), Some("shout"));
drop(client);
task.await.unwrap().unwrap();
}
#[tokio::test]
async fn a_dispatcher_error_still_carries_the_request_id() {
let messages = exchange(concat!(
r#"{"jsonrpc":"2.0","id":"x","method":"tools/call","params":{"name":"shout","arguments":{"word":7}}}"#,
"\n"
))
.await;
assert_eq!(messages[0].get("id").unwrap().as_str(), Some("x"));
assert_eq!(messages[0].get("error.code").unwrap().as_i64(), Some(-32602));
}
#[tokio::test]
async fn a_batch_arriving_on_one_line_is_answered_on_one_line() {
let messages = exchange(concat!(
r#"[{"jsonrpc":"2.0","id":1,"method":"ping"},{"jsonrpc":"2.0","id":2,"method":"ping"}]"#,
"\n"
))
.await;
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].as_array().unwrap().len(), 2);
}
#[tokio::test]
async fn an_unknown_method_over_the_transport_is_method_not_found() {
let messages =
exchange("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/teleport\"}\n").await;
assert_eq!(messages[0].get("error.code").unwrap().as_i64(), Some(-32601));
}
}