use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use std::sync::mpsc;
use std::time::Duration;
const FRAME_TIMEOUT: Duration = Duration::from_secs(20);
struct McpChild {
child: Child,
stdin: ChildStdin,
lines: mpsc::Receiver<std::io::Result<String>>,
}
impl McpChild {
fn spawn() -> Self {
let mut child = Command::new(env!("CARGO_BIN_EXE_sysknife"))
.arg("mcp-server")
.env("SYSKNIFE_SOCKET", "/nonexistent/sysknife-mcp-test.sock")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn sysknife mcp-server");
let stdin = child.stdin.take().expect("piped stdin");
let stdout: ChildStdout = child.stdout.take().expect("piped stdout");
let (tx, lines) = mpsc::channel();
std::thread::spawn(move || {
for line in BufReader::new(stdout).lines() {
if tx.send(line).is_err() {
break;
}
}
});
Self {
child,
stdin,
lines,
}
}
fn send(&mut self, message: &serde_json::Value) {
writeln!(self.stdin, "{message}").expect("write JSON-RPC frame");
self.stdin.flush().expect("flush JSON-RPC frame");
}
fn recv_response(&mut self) -> serde_json::Value {
loop {
let line = self
.lines
.recv_timeout(FRAME_TIMEOUT)
.expect("server produced a frame before the timeout")
.expect("stdout line is readable");
if line.trim().is_empty() {
continue;
}
let value: serde_json::Value = serde_json::from_str(&line).unwrap_or_else(|e| {
panic!("server wrote a non-JSON line to stdout: {line:?} ({e})")
});
if value.get("id").is_some() {
return value;
}
}
}
}
impl Drop for McpChild {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
#[test]
fn the_mcp_server_completes_a_real_initialize_and_tools_list_over_stdio() {
let mut server = McpChild::spawn();
server.send(&serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": { "name": "sysknife-stdio-test", "version": "0" }
}
}));
let init = server.recv_response();
assert!(
init.get("error").is_none(),
"initialize returned an error: {init}"
);
let server_info = &init["result"]["serverInfo"];
assert_eq!(
server_info["name"], "sysknife",
"server identified itself as {server_info} over the wire"
);
server.send(&serde_json::json!({
"jsonrpc": "2.0",
"method": "notifications/initialized"
}));
server.send(&serde_json::json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {}
}));
let listed = server.recv_response();
assert!(
listed.get("error").is_none(),
"tools/list returned an error: {listed}"
);
let tools = listed["result"]["tools"]
.as_array()
.unwrap_or_else(|| panic!("tools/list has no tools array: {listed}"));
let names: Vec<&str> = tools
.iter()
.filter_map(|tool| tool["name"].as_str())
.collect();
let mut sorted = names.clone();
sorted.sort_unstable();
assert_eq!(
sorted,
vec![
"sysknife_audit_verify",
"sysknife_doctor",
"sysknife_execute",
"sysknife_history",
"sysknife_plan",
],
"advertised tools changed"
);
for tool in tools {
let name = tool["name"].as_str().unwrap_or("<unnamed>");
assert!(
tool["inputSchema"].is_object(),
"{name} has no object inputSchema: {tool}"
);
}
}
#[test]
fn resources_read_answers_complete_and_advertises_no_cache_directive() {
let mut server = McpChild::spawn();
server.send(&serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": { "name": "sysknife-stdio-test", "version": "0" }
}
}));
let init = server.recv_response();
assert!(
init.get("error").is_none(),
"initialize returned an error: {init}"
);
server.send(&serde_json::json!({
"jsonrpc": "2.0",
"method": "notifications/initialized"
}));
server.send(&serde_json::json!({
"jsonrpc": "2.0",
"id": 2,
"method": "resources/list",
"params": {}
}));
let listed = server.recv_response();
assert!(
listed.get("error").is_none(),
"resources/list returned an error: {listed}"
);
let uri = listed["result"]["resources"][0]["uri"]
.as_str()
.unwrap_or_else(|| panic!("resources/list advertised no uri: {listed}"))
.to_string();
server.send(&serde_json::json!({
"jsonrpc": "2.0",
"id": 3,
"method": "resources/read",
"params": { "uri": uri }
}));
let read = server.recv_response();
assert!(
read.get("error").is_none(),
"resources/read returned an error: {read}"
);
let result = read["result"]
.as_object()
.unwrap_or_else(|| panic!("resources/read has no result object: {read}"));
let mut keys: Vec<&str> = result.keys().map(String::as_str).collect();
keys.sort_unstable();
assert_eq!(
keys,
vec!["contents"],
"resources/read must answer with contents alone: {read}"
);
let contents = result["contents"]
.as_array()
.unwrap_or_else(|| panic!("contents is not an array: {read}"));
assert!(
!contents.is_empty(),
"a completed read must carry at least one content item: {read}"
);
}