#![allow(missing_docs)]
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use serde_json::{Value, json};
fn fixture_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixture_vault")
}
struct Server {
child: Child,
stdin: Option<ChildStdin>,
stdout: BufReader<ChildStdout>,
next_id: i64,
}
impl Server {
fn start(root: &Path) -> Self {
let mut child = Command::new(env!("CARGO_BIN_EXE_vissue-mcp"))
.env("VISSUE_ROOT", root)
.env("VISSUE_PREFIX", "Software")
.env("VISSUE_AGENT", "mcp-stdio-test")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn vissue-mcp");
let stdin = child.stdin.take().expect("stdin");
let stdout = BufReader::new(child.stdout.take().expect("stdout"));
Self {
child,
stdin: Some(stdin),
stdout,
next_id: 0,
}
}
fn notify(&mut self, method: &str, params: Value) {
let msg = json!({"jsonrpc": "2.0", "method": method, "params": params});
self.write(&msg);
}
fn write(&mut self, msg: &Value) {
let stdin = self.stdin.as_mut().expect("stdin is still open");
writeln!(stdin, "{msg}").expect("write");
stdin.flush().expect("flush");
}
fn close_and_wait(&mut self) -> std::process::ExitStatus {
drop(self.stdin.take());
self.child.wait().expect("wait")
}
fn request(&mut self, method: &str, params: Value) -> Value {
self.next_id += 1;
let id = self.next_id;
let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
self.write(&msg);
loop {
let mut line = String::new();
let read = self.stdout.read_line(&mut line).expect("read");
assert!(read > 0, "{method}: the server closed the pipe");
let value: Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(err) => panic!("{method}: not JSON: {err}: {line:?}"),
};
if value.get("id").and_then(Value::as_i64) == Some(id) {
return value;
}
}
}
fn handshake(&mut self) -> Value {
let init = self.request(
"initialize",
json!({
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "vissue-stdio-test", "version": "0"},
}),
);
self.notify("notifications/initialized", json!({}));
init
}
fn call_text(&mut self, name: &str, arguments: Value) -> Result<String, Value> {
let reply = self.request("tools/call", json!({"name": name, "arguments": arguments}));
if let Some(err) = reply.get("error") {
return Err(err.clone());
}
let result = &reply["result"];
if result["isError"].as_bool().unwrap_or(false) {
return Err(result.clone());
}
let text = result["content"][0]["text"]
.as_str()
.unwrap_or_default()
.to_string();
Ok(text)
}
}
impl Drop for Server {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
#[test]
fn the_server_completes_a_handshake_and_names_itself() {
let mut server = Server::start(&fixture_root());
let init = server.handshake();
assert!(init.get("error").is_none(), "{init}");
let info = &init["result"]["serverInfo"];
assert_eq!(info["name"], "vissue", "{init}");
assert_eq!(info["version"], env!("CARGO_PKG_VERSION"), "{init}");
assert!(
init["result"]["capabilities"].get("tools").is_some(),
"a server with no tools capability offers an agent nothing: {init}"
);
}
#[test]
fn every_tool_is_listed_with_a_schema_an_agent_can_fill_in() {
let mut server = Server::start(&fixture_root());
server.handshake();
let listed = server.request("tools/list", json!({}));
let tools = listed["result"]["tools"]
.as_array()
.unwrap_or_else(|| panic!("no tools array: {listed}"));
assert!(tools.len() > 20, "only {} tools listed", tools.len());
for tool in tools {
let name = tool["name"].as_str().unwrap_or_default();
assert!(name.starts_with("vissue_"), "odd tool name: {name}");
assert!(
tool["description"].as_str().is_some_and(|d| !d.is_empty()),
"{name} has no description for an agent to read"
);
assert_eq!(
tool["inputSchema"]["type"], "object",
"{name} has no object input schema: {tool}"
);
}
let source =
std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("src/server.rs"))
.expect("server.rs");
let implemented = source
.lines()
.filter_map(|l| l.trim().strip_prefix("async fn "))
.filter_map(|rest| rest.split('(').next())
.filter(|n| n.starts_with("vissue_"))
.count();
assert_eq!(
tools.len(),
implemented,
"the wire lists {} tools, the crate defines {implemented}",
tools.len()
);
}
#[test]
fn a_read_tool_answers_over_the_pipe() {
let mut server = Server::start(&fixture_root());
server.handshake();
let ready = server.call_text("vissue_ready", json!({})).expect("ready");
assert!(!ready.trim().is_empty(), "ready answered nothing");
let shown = server
.call_text("vissue_show", json!({"issue_id": "atlas-2c3d"}))
.expect("show");
let detail: Value = serde_json::from_str(&shown).expect("show returns JSON");
assert_eq!(detail["id"], "atlas-2c3d", "{detail}");
assert!(
detail["body"].as_str().is_some_and(|b| !b.is_empty()),
"the detail an agent receives carries no body: {detail}"
);
let org = server
.call_text("vissue_org", json!({"issue_id": "atlas-2c3d"}))
.expect("org");
assert!(org.starts_with("* "), "{org}");
assert!(org.contains(":ID:"), "{org}");
}
#[test]
fn a_write_tool_changes_the_tracker_over_the_pipe() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(dir.path().join("Software")).expect("projects dir");
let mut server = Server::start(dir.path());
server.handshake();
let made = server
.call_text(
"vissue_create",
json!({"project": "atlas", "title": "Made over stdio"}),
)
.expect("create");
let id = made.split_whitespace().next().expect("an id").to_string();
assert!(id.starts_with("atlas-"), "{made}");
server
.call_text(
"vissue_append",
json!({"issue_id": id, "text": "## Done\n\n* took the pipe\n"}),
)
.expect("append");
let org = server
.call_text("vissue_org", json!({"issue_id": id}))
.expect("org");
assert!(org.contains("took the pipe"), "{org}");
let check = server.call_text("vissue_check", json!({})).expect("check");
assert!(check.contains("0 error(s)"), "{check}");
}
#[test]
fn failures_come_back_as_errors_rather_than_a_dropped_pipe() {
let mut server = Server::start(&fixture_root());
server.handshake();
let unknown_id = server
.call_text("vissue_show", json!({"issue_id": "atlas-zzzz"}))
.expect_err("an unknown id is an error");
assert!(
format!("{unknown_id}").contains("atlas-zzzz"),
"{unknown_id}"
);
let unknown_tool = server.call_text("vissue_nope", json!({}));
assert!(unknown_tool.is_err(), "an unknown tool answered anyway");
let bad_args = server.call_text("vissue_show", json!({}));
assert!(bad_args.is_err(), "a call with no id was accepted");
let ready = server.call_text("vissue_ready", json!({})).expect("ready");
assert!(!ready.trim().is_empty());
}
#[test]
fn closing_the_pipe_ends_the_server() {
let mut server = Server::start(&fixture_root());
server.handshake();
server.call_text("vissue_ready", json!({})).expect("ready");
let status = server.close_and_wait();
assert!(status.success(), "exited with {status}");
}