use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use serde_json::Value;
const RPC_TIMEOUT: Duration = Duration::from_secs(30);
fn koda_bin() -> String {
if let Ok(path) = std::env::var("CARGO_BIN_EXE_koda") {
return path;
}
let mut path = std::env::current_exe().unwrap();
path.pop(); path.pop(); path.push("koda");
path.to_string_lossy().to_string()
}
struct ServerHarness {
child: Option<Child>,
stdin: Option<ChildStdin>,
rx: mpsc::Receiver<std::io::Result<String>>,
reader_handle: Option<thread::JoinHandle<()>>,
stderr_path: std::path::PathBuf,
}
impl ServerHarness {
fn spawn(project_dir: &tempfile::TempDir, config_dir: &tempfile::TempDir) -> Self {
let stderr_path = config_dir.path().join("koda-stderr.log");
let stderr_file = std::fs::File::create(&stderr_path).expect("create stderr log");
let mut child = Command::new(koda_bin())
.arg("--project-root")
.arg(project_dir.path())
.args(["server", "--stdio"])
.env("XDG_CONFIG_HOME", config_dir.path())
.env("RUST_LOG", "koda_core=debug,koda_cli=debug")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::from(stderr_file))
.spawn()
.expect("Failed to start koda server");
let stdin = child.stdin.take().unwrap();
let stdout = child.stdout.take().unwrap();
let (tx, rx) = mpsc::channel();
let reader_handle = thread::spawn(move || {
let mut buf = BufReader::new(stdout);
loop {
let mut line = String::new();
match buf.read_line(&mut line) {
Ok(0) => break, Ok(_) => {
if tx.send(Ok(line)).is_err() {
break;
}
}
Err(e) => {
let _ = tx.send(Err(e));
break;
}
}
}
});
Self {
child: Some(child),
stdin: Some(stdin),
rx,
reader_handle: Some(reader_handle),
stderr_path,
}
}
fn dump_stderr(&self) -> String {
std::fs::read_to_string(&self.stderr_path).unwrap_or_else(|e| format!("<read err: {e}>"))
}
fn send_and_recv(&mut self, label: &str, msg: &Value) -> Value {
let stdin = self.stdin.as_mut().expect("stdin gone");
let line = serde_json::to_string(msg).unwrap() + "\n";
stdin.write_all(line.as_bytes()).unwrap();
stdin.flush().unwrap();
let response = match self.rx.recv_timeout(RPC_TIMEOUT) {
Ok(Ok(line)) => line,
Ok(Err(e)) => panic!("[{label}] read error from server: {e}"),
Err(mpsc::RecvTimeoutError::Timeout) => {
let status = self
.child
.as_mut()
.and_then(|c| c.try_wait().ok().flatten());
let stderr = self.dump_stderr();
panic!(
"[{label}] timed out after {RPC_TIMEOUT:?} waiting for server \
response (process exited: {status:?}).\nSent: {}\n=== koda stderr ===\n{stderr}",
serde_json::to_string_pretty(msg).unwrap()
);
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
let status = self
.child
.as_mut()
.and_then(|c| c.try_wait().ok().flatten());
let stderr = self.dump_stderr();
panic!(
"[{label}] Server EOF before responding (process exited: \
{status:?}).\nSent: {}\n=== koda stderr ===\n{stderr}",
serde_json::to_string_pretty(msg).unwrap()
);
}
};
if response.trim().is_empty() {
panic!(
"[{label}] Server returned empty response.\nSent: {}",
serde_json::to_string_pretty(msg).unwrap()
);
}
serde_json::from_str(response.trim())
.unwrap_or_else(|e| panic!("[{label}] invalid JSON: {e}\nraw: {response:?}"))
}
fn send_notification(&mut self, msg: &Value) {
let stdin = self.stdin.as_mut().expect("stdin gone");
let line = serde_json::to_string(msg).unwrap() + "\n";
stdin.write_all(line.as_bytes()).unwrap();
stdin.flush().unwrap();
}
fn send_raw_line(&mut self, line: &str) {
let stdin = self.stdin.as_mut().expect("stdin gone");
stdin.write_all(line.as_bytes()).unwrap();
if !line.ends_with('\n') {
stdin.write_all(b"\n").unwrap();
}
stdin.flush().unwrap();
}
fn shutdown(&mut self) {
drop(self.stdin.take());
if let Some(mut child) = self.child.take() {
let _ = child.kill();
let _ = child.wait();
}
if let Some(h) = self.reader_handle.take() {
let _ = h.join();
}
}
}
impl Drop for ServerHarness {
fn drop(&mut self) {
if let Some(child) = self.child.as_mut() {
let _ = child.kill();
let _ = child.wait();
}
}
}
fn initialize_msg(id: u64) -> Value {
serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"method": "initialize",
"params": {
"protocolVersion": "0.1",
"clientCapabilities": {}
}
})
}
fn new_session_msg(id: u64, project_dir: &tempfile::TempDir) -> Value {
serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"method": "session/new",
"params": {
"cwd": project_dir.path().to_string_lossy(),
"mcpServers": []
}
})
}
#[test]
fn test_server_protocol_e2e() {
let project_dir = tempfile::TempDir::new().unwrap();
let config_dir = tempfile::TempDir::new().unwrap();
let mut srv = ServerHarness::spawn(&project_dir, &config_dir);
step_initialize(&mut srv, 1);
step_unknown_method_returns_method_not_found(&mut srv, 2);
step_prompt_without_active_session_returns_error(&mut srv, 3);
step_cancel_notification_without_active_session_is_noop(&mut srv);
step_authenticate_succeeds(&mut srv, 4);
let session_id_a = step_new_session(&mut srv, 5, &project_dir);
let session_id_b = step_new_session_replaces_active(&mut srv, 6, &project_dir, &session_id_a);
step_cancel_notification_for_active_session(&mut srv, &session_id_b);
step_malformed_json_line_returns_parse_error(&mut srv);
step_post_recovery_initialize(&mut srv, 99);
srv.shutdown();
}
fn step_initialize(srv: &mut ServerHarness, id: u64) {
let resp = srv.send_and_recv("initialize", &initialize_msg(id));
assert_eq!(resp["jsonrpc"], "2.0");
assert_eq!(resp["id"], id);
assert!(
resp["result"].is_object(),
"initialize: expected result object"
);
let agent_info = &resp["result"]["agentInfo"];
assert_eq!(
agent_info["name"], "koda",
"initialize: agent name should be 'koda'"
);
assert_eq!(
agent_info["version"],
env!("CARGO_PKG_VERSION"),
"initialize: should report compiled version"
);
}
fn step_unknown_method_returns_method_not_found(srv: &mut ServerHarness, id: u64) {
let req = serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"method": "nonsense/method",
"params": {}
});
let resp = srv.send_and_recv("unknown_method", &req);
assert_eq!(resp["id"], id, "unknown_method: id should round-trip");
assert_eq!(
resp["error"]["code"], -32601,
"unknown_method: expected -32601 (Method not found), got: {resp:?}"
);
let err_msg = resp["error"]["message"].as_str().unwrap_or("");
assert!(
err_msg.contains("nonsense/method"),
"unknown_method: error message should name the offending method, got: {err_msg:?}"
);
}
fn step_prompt_without_active_session_returns_error(srv: &mut ServerHarness, id: u64) {
let req = serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"method": "session/prompt",
"params": {
"sessionId": "nonexistent",
"prompt": [{"type": "text", "text": "hello"}]
}
});
let resp = srv.send_and_recv("prompt_without_session", &req);
assert_eq!(resp["id"], id);
assert_eq!(
resp["error"]["code"], -32000,
"prompt_without_session: expected -32000, got: {resp:?}"
);
let err_msg = resp["error"]["message"].as_str().unwrap_or("");
assert!(
err_msg.contains("session/new"),
"prompt_without_session: error should suggest calling session/new, got: {err_msg:?}"
);
}
fn step_cancel_notification_without_active_session_is_noop(srv: &mut ServerHarness) {
srv.send_notification(&serde_json::json!({
"jsonrpc": "2.0",
"method": "session/cancel",
"params": { "sessionId": "nonexistent" }
}));
}
fn step_authenticate_succeeds(srv: &mut ServerHarness, id: u64) {
let req = serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"method": "authenticate",
"params": { "methodId": "local" }
});
let resp = srv.send_and_recv("authenticate", &req);
assert_eq!(resp["id"], id);
assert!(
resp["result"].is_object(),
"authenticate: expected result object even though it's a no-op, got: {resp:?}"
);
}
fn step_new_session(srv: &mut ServerHarness, id: u64, project_dir: &tempfile::TempDir) -> String {
let resp = srv.send_and_recv("session/new", &new_session_msg(id, project_dir));
assert_eq!(resp["id"], id);
assert!(
resp["result"].is_object(),
"session/new: expected result object"
);
let session_id = resp["result"]["sessionId"]
.as_str()
.expect("session/new: sessionId should be a string")
.to_string();
assert!(
!session_id.is_empty(),
"session/new: sessionId should not be empty"
);
session_id
}
fn step_new_session_replaces_active(
srv: &mut ServerHarness,
id: u64,
project_dir: &tempfile::TempDir,
previous_session_id: &str,
) -> String {
let session_id = step_new_session(srv, id, project_dir);
assert_ne!(
session_id, previous_session_id,
"session/new called twice should return distinct sessionIds"
);
session_id
}
fn step_cancel_notification_for_active_session(srv: &mut ServerHarness, session_id: &str) {
srv.send_notification(&serde_json::json!({
"jsonrpc": "2.0",
"method": "session/cancel",
"params": { "sessionId": session_id }
}));
}
fn step_malformed_json_line_returns_parse_error(srv: &mut ServerHarness) {
srv.send_raw_line("not even close to JSON {{{{");
let response = match srv.rx.recv_timeout(RPC_TIMEOUT) {
Ok(Ok(line)) => line,
other => panic!("malformed_json: expected a parse-error response line, got: {other:?}"),
};
let resp: Value = serde_json::from_str(response.trim()).unwrap_or_else(|e| {
panic!("malformed_json: invalid JSON in response: {e}\nraw: {response:?}")
});
assert_eq!(
resp["error"]["code"], -32700,
"malformed_json: expected -32700 (Parse error), got: {resp:?}"
);
assert!(
resp["id"].is_null(),
"malformed_json: parse-error response must use null id (the offending line had no parseable id), got: {:?}",
resp["id"]
);
}
fn step_post_recovery_initialize(srv: &mut ServerHarness, id: u64) {
let resp = srv.send_and_recv("post_recovery_initialize", &initialize_msg(id));
assert_eq!(resp["id"], id);
assert!(
resp["result"].is_object(),
"post_recovery_initialize: server should still respond after malformed input + cancels, got: {resp:?}"
);
}