use std::io::{BufRead, BufReader, Write};
use std::process::{ChildStdin, ChildStdout, Command, Stdio};
use std::time::Duration;
use assert_cmd::cargo::CommandCargoExt;
use serde_json::{json, Value};
const EXPECTED_TOOL_NAMES: &[&str] = &[
"cordance_advise_by_rule",
"cordance_advise_findings",
"cordance_advise_run",
"cordance_check_drift",
"cordance_context_list_sources",
"cordance_context_source_info",
"cordance_context_summary",
"cordance_cortex_receipt",
"cordance_doctrine_lookup",
"cordance_doctrine_topics",
"cordance_evidence_lookup",
"cordance_harness_target",
"cordance_pack_dry_run",
];
const FORBIDDEN_TOOL_NAMES: &[&str] = &[
"cordance_doctrine_read",
"cordance_cortex_push",
"cordance_scan",
];
struct Server {
child: std::process::Child,
stdin: ChildStdin,
stdout: BufReader<ChildStdout>,
}
impl Server {
fn spawn(workdir: &std::path::Path) -> Self {
Self::spawn_with_cwd(workdir, workdir)
}
fn spawn_with_cwd(target_dir: &std::path::Path, server_cwd: &std::path::Path) -> Self {
let mut cmd = Command::cargo_bin("cordance").expect("locate cordance binary");
cmd.arg("--target").arg(target_dir);
if target_dir != server_cwd {
cmd.arg("--allow-outside-cwd");
}
cmd.arg("serve")
.current_dir(server_cwd)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env("CORDANCE_LOG", "warn");
let mut child = cmd.spawn().expect("spawn cordance serve");
let stdin = child.stdin.take().expect("take stdin");
let stdout = BufReader::new(child.stdout.take().expect("take stdout"));
Self {
child,
stdin,
stdout,
}
}
fn send(&mut self, payload: &Value) {
let s = serde_json::to_string(payload).expect("encode request");
writeln!(self.stdin, "{s}").expect("write request");
self.stdin.flush().expect("flush request");
}
fn send_notification(&mut self, method: &str) {
self.send(&json!({ "jsonrpc": "2.0", "method": method }));
}
fn recv(&mut self) -> Value {
let mut line = String::new();
let n = self.stdout.read_line(&mut line).expect("read response");
assert!(n > 0, "server closed stdout unexpectedly");
serde_json::from_str::<Value>(line.trim()).unwrap_or_else(|e| {
panic!("server emitted non-JSON line: {line:?} ({e})");
})
}
fn close(mut self) {
drop(self.stdin); let _ = self.child.wait_timeout(Duration::from_secs(5));
let _ = self.child.kill();
let _ = self.child.wait();
}
}
trait ChildExt {
fn wait_timeout(&mut self, dur: Duration) -> std::io::Result<Option<std::process::ExitStatus>>;
}
impl ChildExt for std::process::Child {
fn wait_timeout(&mut self, dur: Duration) -> std::io::Result<Option<std::process::ExitStatus>> {
let deadline = std::time::Instant::now() + dur;
loop {
if let Some(status) = self.try_wait()? {
return Ok(Some(status));
}
if std::time::Instant::now() >= deadline {
return Ok(None);
}
std::thread::sleep(Duration::from_millis(50));
}
}
}
fn initialize_request(id: i64) -> Value {
json!({
"jsonrpc": "2.0",
"id": id,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": { "name": "cordance-test-client", "version": "0.0.1" }
}
})
}
fn list_tools_request(id: i64) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "method": "tools/list" })
}
fn call_tool_request(id: i64, name: &str, arguments: &Value) -> Value {
json!({
"jsonrpc": "2.0",
"id": id,
"method": "tools/call",
"params": { "name": name, "arguments": arguments }
})
}
#[test]
fn server_initializes_with_correct_protocol_version_and_capabilities() {
let dir = tempfile::tempdir().expect("tempdir");
let mut s = Server::spawn(dir.path());
s.send(&initialize_request(1));
let resp = s.recv();
assert_eq!(resp["jsonrpc"], "2.0", "response must be JSON-RPC 2.0");
assert_eq!(resp["id"], 1, "id must echo");
assert!(resp.get("error").is_none(), "initialize errored: {resp}");
let result = resp.get("result").expect("initialize result");
assert_eq!(
result["protocolVersion"], "2025-06-18",
"Cordance pins to MCP 2025-06-18"
);
assert_eq!(
result["serverInfo"]["name"], "cordance",
"serverInfo.name must be 'cordance'"
);
assert!(
result["capabilities"]["tools"].is_object(),
"tools capability MUST be advertised"
);
s.close();
}
#[test]
fn tools_list_returns_exactly_thirteen_named_tools() {
let dir = tempfile::tempdir().expect("tempdir");
let mut s = Server::spawn(dir.path());
s.send(&initialize_request(1));
let _init = s.recv();
s.send_notification("notifications/initialized");
s.send(&list_tools_request(2));
let resp = s.recv();
assert!(resp.get("error").is_none(), "tools/list errored: {resp}");
let tools = resp["result"]["tools"]
.as_array()
.expect("tools must be an array")
.iter()
.map(|t| {
t["name"]
.as_str()
.expect("tool must have a name")
.to_string()
})
.collect::<Vec<_>>();
let mut sorted = tools.clone();
sorted.sort();
assert_eq!(
sorted.len(),
EXPECTED_TOOL_NAMES.len(),
"expected exactly {} tools, got {}: {:?}",
EXPECTED_TOOL_NAMES.len(),
sorted.len(),
sorted,
);
for (got, want) in sorted.iter().zip(EXPECTED_TOOL_NAMES.iter()) {
assert_eq!(got, want, "tool list drifted: {sorted:?}");
}
for forbidden in FORBIDDEN_TOOL_NAMES {
assert!(
!tools.iter().any(|n| n == forbidden),
"forbidden tool {forbidden} is registered"
);
}
for tool in resp["result"]["tools"].as_array().expect("array") {
assert!(tool["name"].is_string(), "tool.name must be string");
assert!(
tool["description"].is_string(),
"tool.description required ({:?})",
tool["name"]
);
assert!(
tool["inputSchema"].is_object(),
"tool.inputSchema required ({:?})",
tool["name"]
);
}
s.close();
}
#[test]
fn unknown_tool_returns_invalid_params_error() {
let dir = tempfile::tempdir().expect("tempdir");
let mut s = Server::spawn(dir.path());
s.send(&initialize_request(1));
let _init = s.recv();
s.send_notification("notifications/initialized");
s.send(&call_tool_request(
2,
"cordance_nonexistent_tool",
&json!({}),
));
let resp = s.recv();
let err = resp
.get("error")
.expect("calling an unknown tool MUST yield a JSON-RPC error");
assert_eq!(
err["code"], -32_602,
"unknown tool MUST be INVALID_PARAMS, got {err}"
);
s.close();
}
#[test]
fn context_summary_returns_metadata_without_file_content() {
let dir = tempfile::tempdir().expect("tempdir");
let mut s = Server::spawn(dir.path());
s.send(&initialize_request(1));
let _init = s.recv();
s.send_notification("notifications/initialized");
s.send(&call_tool_request(
2,
"cordance_context_summary",
&json!({}),
));
let resp = s.recv();
assert!(
resp.get("error").is_none(),
"context_summary errored: {resp}"
);
let result = &resp["result"];
let structured = result["structuredContent"].clone();
assert!(
structured.is_object(),
"structuredContent must be an object for a Json<T> tool"
);
assert_eq!(
structured["schema"], "cordance-context-summary.v1",
"summary schema literal must be pinned",
);
assert!(structured["project_name"].is_string());
assert!(structured["source_count"].is_number());
let body_str = serde_json::to_string(&structured).expect("serialise summary");
assert!(
!body_str.contains("```rust"),
"summary leaked code-fenced content: {body_str}"
);
s.close();
}
#[test]
fn cortex_receipt_authority_boundary_is_locked_to_candidate_only() {
let dir = tempfile::tempdir().expect("tempdir");
let mut s = Server::spawn(dir.path());
s.send(&initialize_request(1));
let _init = s.recv();
s.send_notification("notifications/initialized");
s.send(&call_tool_request(
2,
"cordance_cortex_receipt",
&json!({ "dry_run": true }),
));
let resp = s.recv();
assert!(
resp.get("error").is_none(),
"cortex_receipt errored: {resp}"
);
let receipt = resp["result"]["structuredContent"]["receipt"].clone();
let boundary = receipt["authority_boundary"].clone();
assert_eq!(
boundary["candidate_only"], true,
"candidate_only must always be true"
);
for flag in [
"cortex_truth_allowed",
"cortex_admission_allowed",
"durable_promotion_allowed",
"memory_promotion_allowed",
"doctrine_promotion_allowed",
"trusted_history_allowed",
"release_acceptance_allowed",
"runtime_authority_allowed",
] {
assert_eq!(
boundary[flag], false,
"authority_boundary.{flag} must be false (Cordance never grants Cortex authority)"
);
}
assert!(
resp["result"]["structuredContent"]["written_path"].is_null(),
"dry_run=true must not write a file"
);
s.close();
}
#[test]
fn path_traversal_via_tool_argument_is_rejected() {
let dir = tempfile::tempdir().expect("tempdir");
let mut s = Server::spawn(dir.path());
s.send(&initialize_request(1));
let _init = s.recv();
s.send_notification("notifications/initialized");
s.send(&call_tool_request(
2,
"cordance_context_summary",
&json!({ "target": "../.." }),
));
let resp = s.recv();
let err = resp.get("error").expect("traversal must error");
assert_eq!(
err["code"], -32_602,
"path traversal MUST be INVALID_PARAMS"
);
s.close();
}
#[test]
fn mcp_target_allow_list_ignores_target_cordance_toml() {
let parent = tempfile::tempdir().expect("parent tempdir");
let target_dir = parent.path().join("hostile-target");
let server_cwd = parent.path().join("operator-cwd");
let sibling = parent.path().join("offlimits-sibling");
std::fs::create_dir_all(&target_dir).expect("mkdir target");
std::fs::create_dir_all(&server_cwd).expect("mkdir server cwd");
std::fs::create_dir_all(&sibling).expect("mkdir sibling");
let toml = format!("[mcp]\nallowed_roots = [{:?}]\n", sibling.to_string_lossy(),);
std::fs::write(target_dir.join("cordance.toml"), toml).expect("write hostile cordance.toml");
let mut s = Server::spawn_with_cwd(&target_dir, &server_cwd);
s.send(&initialize_request(1));
let _init = s.recv();
s.send_notification("notifications/initialized");
let sibling_str = sibling.to_str().expect("sibling path utf8");
s.send(&call_tool_request(
2,
"cordance_context_summary",
&json!({ "target": sibling_str }),
));
let resp = s.recv();
let err = resp
.get("error")
.expect("target-cordance.toml widening must error");
assert_eq!(
err["code"], -32_602,
"hostile widening MUST be INVALID_PARAMS, got {err}"
);
s.close();
}
#[test]
fn mcp_validation_error_omits_paths() {
let dir = tempfile::tempdir().expect("tempdir");
let mut s = Server::spawn(dir.path());
s.send(&initialize_request(1));
let _init = s.recv();
s.send_notification("notifications/initialized");
let parent_str = dir
.path()
.parent()
.expect("tempdir parent")
.to_str()
.expect("parent utf8");
let marker = "leak-marker-aBcDeF123456";
let raw_with_marker = format!("{parent_str}/{marker}");
s.send(&call_tool_request(
2,
"cordance_context_summary",
&json!({ "target": raw_with_marker }),
));
let resp = s.recv();
let err = resp.get("error").expect("invalid target must error");
let msg = err["message"]
.as_str()
.expect("error.message must be a string");
assert!(
!msg.contains(marker),
"wire error leaked input marker {marker}: {msg:?}"
);
assert!(
!msg.contains(parent_str),
"wire error leaked parent path: {msg:?}"
);
assert!(
!msg.contains(dir.path().to_str().expect("dir utf8")),
"wire error leaked cwd path: {msg:?}"
);
s.close();
}