#![allow(clippy::disallowed_methods)]
use std::io::{Read, Write};
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::time::Duration;
mod common;
use common::apr_binary;
const SESSION_TIMEOUT: Duration = Duration::from_secs(30);
const INITIALIZE: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"falsifier","version":"1"}}}"#;
const TOOLS_CALL_VERSION: &str = r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"apr.version","arguments":{}}}"#;
fn drive_stdio(input: &[u8]) -> (Option<i32>, Vec<serde_json::Value>, String) {
let mut child = Command::new(apr_binary())
.arg("mcp")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn `apr mcp`");
let mut stdin = child.stdin.take().expect("piped stdin");
stdin.write_all(input).expect("write stdin");
stdin.flush().expect("flush stdin");
drop(stdin);
let mut stdout = child.stdout.take().expect("piped stdout");
let (tx, rx) = mpsc::channel::<String>();
let reader = std::thread::spawn(move || {
let mut buf = String::new();
let _ = stdout.read_to_string(&mut buf);
let _ = tx.send(buf);
});
let out = match rx.recv_timeout(SESSION_TIMEOUT) {
Ok(s) => s,
Err(e) => {
let _ = child.kill();
panic!("`apr mcp` did not close stdout within {SESSION_TIMEOUT:?}: {e}");
}
};
let _ = reader.join();
let status = child.wait().expect("wait for `apr mcp`");
let mut stderr = String::new();
if let Some(mut e) = child.stderr.take() {
let _ = e.read_to_string(&mut stderr);
}
let responses = out
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| {
serde_json::from_str::<serde_json::Value>(l)
.unwrap_or_else(|e| panic!("`apr mcp` emitted a non-JSON stdout line {l:?}: {e}"))
})
.collect();
(status.code(), responses, stderr)
}
fn find_id(responses: &[serde_json::Value], id: i64) -> Option<&serde_json::Value> {
responses.iter().find(|r| r["id"] == serde_json::json!(id))
}
#[test]
fn falsify_mcp_010_tools_call_is_answered_before_eof_exit() {
let input = format!("{INITIALIZE}\n{TOOLS_CALL_VERSION}\n");
for attempt in 1..=3 {
let (code, responses, stderr) = drive_stdio(input.as_bytes());
assert_eq!(
code,
Some(0),
"attempt {attempt}: clean exit; stderr: {stderr}"
);
let call = find_id(&responses, 2).unwrap_or_else(|| {
panic!(
"attempt {attempt}: tools/call response (id=2) was DROPPED on stdin EOF. \
Got {} response(s): {:?}",
responses.len(),
responses
)
});
assert!(
call.get("error").is_none(),
"attempt {attempt}: tools/call must succeed, got {call:?}"
);
let text = call["result"]["content"][0]["text"]
.as_str()
.unwrap_or_else(|| panic!("attempt {attempt}: missing content text in {call:?}"));
let payload: serde_json::Value =
serde_json::from_str(text).expect("apr.version payload is JSON");
assert_eq!(
payload["server"], "aprender-mcp",
"attempt {attempt}: tool payload must be the real apr.version result"
);
assert!(
find_id(&responses, 1).is_some(),
"attempt {attempt}: initialize must still be answered"
);
}
}
#[test]
fn falsify_mcp_010_every_pipelined_tools_call_is_answered() {
let mut input = String::from(INITIALIZE);
input.push('\n');
for id in 2..=6 {
input.push_str(&format!(
r#"{{"jsonrpc":"2.0","id":{id},"method":"tools/call","params":{{"name":"apr.version","arguments":{{}}}}}}"#
));
input.push('\n');
}
let (code, responses, stderr) = drive_stdio(input.as_bytes());
assert_eq!(code, Some(0), "clean exit; stderr: {stderr}");
for id in 1..=6 {
assert!(
find_id(&responses, id).is_some(),
"id={id} unanswered; got {} of 6: {responses:?}",
responses.len()
);
}
}
#[test]
fn falsify_mcp_011_invalid_utf8_line_does_not_kill_the_session() {
let mut input: Vec<u8> = Vec::new();
input.extend_from_slice(br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#);
input.push(b'\n');
input.push(0xFF); input.push(b'\n');
input.extend_from_slice(br#"{"jsonrpc":"2.0","id":2,"method":"ping"}"#);
input.push(b'\n');
let (code, responses, stderr) = drive_stdio(&input);
assert_eq!(
code,
Some(0),
"one bad byte must not kill the server; stderr: {stderr}"
);
assert!(
find_id(&responses, 1).is_some(),
"request before the bad byte must be answered: {responses:?}"
);
let after = find_id(&responses, 2)
.unwrap_or_else(|| panic!("request AFTER the bad byte was lost; got {responses:?}"));
assert!(
after.get("error").is_none(),
"request after the bad byte must be served normally, got {after:?}"
);
let parse_err = responses
.iter()
.find(|r| r["error"]["code"] == serde_json::json!(-32700))
.unwrap_or_else(|| panic!("the bad line itself must be reported: {responses:?}"));
assert!(
parse_err["error"]["message"]
.as_str()
.unwrap_or_default()
.contains("UTF-8"),
"the -32700 must name the cause, got {parse_err:?}"
);
}
#[test]
fn falsify_mcp_011_leading_invalid_utf8_still_serves_the_first_request() {
let mut input: Vec<u8> = vec![0x80, b'\n'];
input.extend_from_slice(br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#);
input.push(b'\n');
let (code, responses, stderr) = drive_stdio(&input);
assert_eq!(code, Some(0), "clean exit; stderr: {stderr}");
assert!(
find_id(&responses, 1).is_some(),
"the request after a leading bad byte must be answered: {responses:?}"
);
}
#[test]
fn falsify_mcp_011_invalid_utf8_inside_a_request_is_rejected_not_repaired() {
let mut input: Vec<u8> = Vec::new();
input.extend_from_slice(
br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"apr.validate","arguments":{"model_path":"/tmp/"#,
);
input.extend_from_slice(&[0xC3, 0x28]); input.extend_from_slice(br#".gguf"}}}"#);
input.push(b'\n');
input.extend_from_slice(br#"{"jsonrpc":"2.0","id":2,"method":"ping"}"#);
input.push(b'\n');
let (code, responses, stderr) = drive_stdio(&input);
assert_eq!(code, Some(0), "clean exit; stderr: {stderr}");
assert!(
responses
.iter()
.any(|r| r["error"]["code"] == serde_json::json!(-32700)),
"the malformed line must be reported as a parse error: {responses:?}"
);
assert!(
find_id(&responses, 2).is_some(),
"the following request must still be served: {responses:?}"
);
}
#[test]
fn falsify_mcp_stdio_protocol_surface_matches_jsonrpc_and_mcp() {
let input = concat!(
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}"#,
"\n",
r#"{"jsonrpc":"2.0","id":2,"method":"ping"}"#,
"\n",
r#"{"id":3,"method":"tools/list"}"#,
"\n",
r#"{"jsonrpc":"2.0","id":4}"#,
"\n",
r#"[{"jsonrpc":"2.0","id":5,"method":"tools/list"}]"#,
"\n",
r#"{not json"#,
"\n",
r#"{"jsonrpc":"2.0","id":7,"method":"tools/list"}"#,
"\n",
);
let (code, responses, stderr) = drive_stdio(input.as_bytes());
assert_eq!(code, Some(0), "clean exit; stderr: {stderr}");
let init = find_id(&responses, 1).expect("initialize answered");
assert!(
init.get("error").is_none(),
"a newer protocolVersion must not abort the handshake: {init:?}"
);
assert_eq!(init["result"]["protocolVersion"], "2024-11-05");
let pong = find_id(&responses, 2).expect("ping answered");
assert_eq!(
pong["result"],
serde_json::json!({}),
"ping must pong: {pong:?}"
);
for id in [3, 4] {
let resp = find_id(&responses, id).unwrap_or_else(|| {
panic!("id={id} must be echoed on an Invalid Request: {responses:?}")
});
assert_eq!(
resp["error"]["code"],
serde_json::json!(-32600),
"id={id} must be Invalid Request, not Parse error: {resp:?}"
);
}
let batch = responses
.iter()
.find(|r| {
r["error"]["message"]
.as_str()
.is_some_and(|m| m.contains("batch"))
})
.unwrap_or_else(|| panic!("batch array must be diagnosed as such: {responses:?}"));
assert_eq!(batch["error"]["code"], serde_json::json!(-32600));
assert!(
responses
.iter()
.any(|r| r["error"]["code"] == serde_json::json!(-32700)),
"`{{not json` must remain a Parse error: {responses:?}"
);
assert!(
find_id(&responses, 7).is_some(),
"the server must keep serving after every malformed line: {responses:?}"
);
}