use std::io::{BufRead, BufReader, Write};
use std::process::{Command, Stdio};
use codelore_lib::test_support::{delivery_repo, tiny_repo};
use serde_json::{Value, json};
const LLM_ENV_VARS: &[&str] = &[
"CODELORE_LLM_PROVIDER",
"CODELORE_LLM_BASE_URL",
"CODELORE_LLM_API_KEY",
"CODELORE_LLM_MODEL",
"ANTHROPIC_API_KEY",
];
fn ndjson_line(msg: &Value) -> Vec<u8> {
let mut bytes = serde_json::to_vec(msg).unwrap();
bytes.push(b'\n');
bytes
}
fn read_ndjson(reader: &mut BufReader<impl std::io::Read>) -> Value {
let mut line = String::new();
reader.read_line(&mut line).expect("read JSON-RPC line");
serde_json::from_str(line.trim()).expect("parse JSON-RPC line")
}
fn spawn_mcp(
repo_path: &str,
) -> (
std::process::Child,
std::process::ChildStdin,
BufReader<std::process::ChildStdout>,
) {
spawn_mcp_with_args(repo_path, &[])
}
fn spawn_mcp_with_args(
repo_path: &str,
extra_args: &[&str],
) -> (
std::process::Child,
std::process::ChildStdin,
BufReader<std::process::ChildStdout>,
) {
let bin = assert_cmd::cargo::cargo_bin("codelore");
let mut builder = Command::new(&bin);
builder
.args(["mcp", "--repo", repo_path])
.args(extra_args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null());
for var in LLM_ENV_VARS {
builder.env_remove(var);
}
let mut child = builder.spawn().expect("spawn codelore mcp");
let mut stdin = child.stdin.take().expect("stdin");
let stdout = child.stdout.take().expect("stdout");
let mut reader = BufReader::new(stdout);
let init_req = json!({
"jsonrpc": "2.0", "id": 0, "method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": { "name": "test-client", "version": "0.0.1" }
}
});
stdin.write_all(&ndjson_line(&init_req)).unwrap();
stdin.flush().unwrap();
let init_resp = read_ndjson(&mut reader);
let instructions = init_resp["result"]["instructions"]
.as_str()
.expect("initialize response carries an instructions string");
assert!(
instructions.contains("No network"),
"instructions must state the local-only positioning, got: {instructions}"
);
assert_eq!(
init_resp["result"]["protocolVersion"].as_str(),
Some("2024-11-05"),
"negotiated MCP protocol revision changed; update every client \
expectation deliberately rather than adopting the new value here"
);
let notif = json!({ "jsonrpc": "2.0", "method": "notifications/initialized" });
stdin.write_all(&ndjson_line(¬if)).unwrap();
stdin.flush().unwrap();
(child, stdin, reader)
}
fn call_tool(
stdin: &mut std::process::ChildStdin,
reader: &mut BufReader<std::process::ChildStdout>,
id: u64,
name: &str,
arguments: &Value,
) -> Value {
let req = json!({
"jsonrpc": "2.0", "id": id, "method": "tools/call",
"params": { "name": name, "arguments": arguments }
});
stdin.write_all(&ndjson_line(&req)).unwrap();
stdin.flush().unwrap();
read_ndjson(reader)
}
fn assert_tool_ok(resp: &Value, tool_name: &str) -> Value {
assert_eq!(resp["jsonrpc"], "2.0", "{tool_name}: bad jsonrpc field");
assert!(
!resp["result"]["isError"].as_bool().unwrap_or(false),
"{tool_name} returned MCP error: {resp}"
);
let content = resp["result"]["content"].as_array().expect("content array");
assert!(!content.is_empty(), "{tool_name}: content array is empty");
let text = content[0]["text"].as_str().expect("text field");
serde_json::from_str(text)
.unwrap_or_else(|e| panic!("{tool_name}: content text is not valid JSON ({e}): {text}"))
}
fn assert_tool_ok_text(resp: &Value, tool_name: &str) -> String {
assert_eq!(resp["jsonrpc"], "2.0", "{tool_name}: bad jsonrpc field");
assert!(
!resp["result"]["isError"].as_bool().unwrap_or(false),
"{tool_name} returned MCP error: {resp}"
);
let content = resp["result"]["content"].as_array().expect("content array");
assert!(!content.is_empty(), "{tool_name}: content array is empty");
content[0]["text"].as_str().expect("text field").to_string()
}
fn assert_rpc_error_code(resp: &Value, expected_code: i64, ctx: &str) {
let code = resp["error"]["code"].as_i64().unwrap_or_else(|| {
panic!("{ctx}: expected a JSON-RPC error object with a numeric code, got: {resp}")
});
assert_eq!(
code, expected_code,
"{ctx}: wrong JSON-RPC error code: {resp}"
);
}
#[test]
fn mcp_tools_list_and_repo_overview() {
let repo = tiny_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let list_req = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} });
stdin.write_all(&ndjson_line(&list_req)).unwrap();
stdin.flush().unwrap();
let list_resp = read_ndjson(&mut reader);
assert_eq!(list_resp["id"], 1);
let tools = list_resp["result"]["tools"]
.as_array()
.expect("tools array");
assert_eq!(
tools.len(),
11,
"expected exactly 11 tools, got {}: {:?}",
tools.len(),
tools
.iter()
.filter_map(|t| t["name"].as_str())
.collect::<Vec<_>>()
);
let tool_names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
for expected in &[
"repo_overview",
"hotspots",
"code_health",
"delta_health",
"refactoring_targets",
"function_xray",
"check_gates",
"finding_hotspot_overlap",
"explain_file",
"change_context",
"gate_changes",
] {
assert!(
tool_names.contains(expected),
"{expected} missing from tools/list: {tool_names:?}"
);
}
for tool in tools {
assert!(
tool["inputSchema"].is_object(),
"tool {:?} missing inputSchema: {tool}",
tool["name"]
);
let ann = &tool["annotations"];
assert!(
ann["readOnlyHint"].is_boolean(),
"tool {:?} missing annotations.readOnlyHint: {tool}",
tool["name"]
);
assert!(
ann["openWorldHint"].is_boolean(),
"tool {:?} missing annotations.openWorldHint: {tool}",
tool["name"]
);
if let Some(desc) = tool["inputSchema"]["properties"]["limit"]["description"].as_str() {
assert!(
!desc.contains("default: all"),
"tool {:?} advertises an uncapped `limit` default, but every \
capped handler resolves an absent limit to 50: {desc:?}",
tool["name"]
);
assert!(
desc.contains("50"),
"tool {:?} `limit` description must name the default the handler \
applies (50) so an agent knows the list is capped: {desc:?}",
tool["name"]
);
}
}
let hint = |name: &str, field: &str| -> Option<bool> {
tools.iter().find(|t| t["name"] == name)?["annotations"][field].as_bool()
};
assert_eq!(
hint("delta_health", "readOnlyHint"),
Some(false),
"delta_health writes git worktrees and must not claim to be read-only"
);
assert_eq!(
hint("explain_file", "openWorldHint"),
Some(true),
"explain_file can call an external LLM endpoint and must declare an open world"
);
let resp = call_tool(&mut stdin, &mut reader, 2, "repo_overview", &json!({}));
let parsed = assert_tool_ok(&resp, "repo_overview");
assert!(
parsed["summary"].is_array(),
"expected `summary` array in repo_overview response: {parsed}"
);
assert!(
parsed["options"].is_object(),
"expected `options` object in repo_overview response: {parsed}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_code_health_returns_scored_rows() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(&mut stdin, &mut reader, 1, "code_health", &json!({}));
let parsed = assert_tool_ok(&resp, "code_health");
let rows = parsed.as_array().expect("code_health: expected JSON array");
assert!(
!rows.is_empty(),
"code_health returned no rows for delivery_repo"
);
let first = &rows[0];
assert!(
first["band"].is_string(),
"row missing `band` field: {first}"
);
assert!(
first["score"].is_number(),
"row missing numeric `score` field: {first}"
);
let mut any_corpus = false;
for row in rows {
if let Some(cp) = row.get("corpus_percentile") {
let p = cp
.as_f64()
.unwrap_or_else(|| panic!("corpus_percentile must be a number: {row}"));
assert!(
(0.0..=1.0).contains(&p),
"corpus_percentile must be in 0..=1: {row}"
);
any_corpus = true;
if let Some(beyond) = row.get("beyond_corpus") {
assert!(
beyond.is_boolean(),
"beyond_corpus must be a bool when present: {row}"
);
}
} else {
assert!(
row.get("beyond_corpus").is_none(),
"beyond_corpus must be absent when corpus_percentile is: {row}"
);
}
}
assert!(
any_corpus,
"the embedded world corpus covers Rust, so at least one delivery_repo row \
must carry corpus_percentile"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_hotspots_returns_capped_rows() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(
&mut stdin,
&mut reader,
1,
"hotspots",
&json!({ "limit": 1 }),
);
let parsed = assert_tool_ok(&resp, "hotspots");
let entries = parsed.as_array().expect("hotspots: expected JSON array");
assert!(
!entries.is_empty(),
"hotspots returned no rows for delivery_repo"
);
let summary = entries.last().filter(|v| v.get("omitted").is_some());
let rows: Vec<_> = entries[..entries.len() - usize::from(summary.is_some())].to_vec();
assert!(
rows.len() <= 1,
"limit=1 must cap hotspots rows at one, got {}: {parsed}",
rows.len()
);
if let Some(s) = summary {
let omitted = s["omitted"].as_u64().expect("omitted must be a number");
let total = s["total"].as_u64().expect("total must be a number");
assert!(omitted > 0, "a summary object implies rows were cut: {s}");
assert_eq!(
total,
rows.len() as u64 + omitted,
"total must equal shown + omitted: {s}"
);
}
let first = &rows[0];
assert!(
first["path"].is_string(),
"row missing `path` field: {first}"
);
assert!(
first["revisions"].is_number(),
"row missing numeric `revisions` field: {first}"
);
assert!(
first["hotspot_score"].is_number(),
"row missing numeric `hotspot_score` field: {first}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_refactoring_targets_returns_array() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(
&mut stdin,
&mut reader,
1,
"refactoring_targets",
&json!({ "limit": 5 }),
);
let parsed = assert_tool_ok(&resp, "refactoring_targets");
assert!(
parsed.is_array(),
"refactoring_targets: expected JSON array, got: {parsed}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_function_xray_returns_rows_for_valid_path() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(
&mut stdin,
&mut reader,
1,
"function_xray",
&json!({ "path": "src/core.rs" }),
);
let parsed = assert_tool_ok(&resp, "function_xray");
assert!(
parsed.is_array(),
"function_xray: expected JSON array, got: {parsed}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_check_gates_returns_verdict() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let thresholds_path = repo.dir.path().join(".codelore-thresholds.toml");
std::fs::write(&thresholds_path, "[gates]\ncode_health_min = 0.0\n").unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(&mut stdin, &mut reader, 1, "check_gates", &json!({}));
let parsed = assert_tool_ok(&resp, "check_gates");
assert!(
parsed["verdict"].is_string(),
"check_gates: expected `verdict` string: {parsed}"
);
assert!(
parsed["violation_count"].is_number(),
"check_gates: expected `violation_count` number: {parsed}"
);
assert!(
parsed["violations"].is_array(),
"check_gates: expected `violations` array: {parsed}"
);
assert_eq!(
parsed["verdict"], "pass",
"check_gates: expected pass verdict with permissive threshold, got: {parsed}"
);
let skipped: Vec<&str> = parsed["skipped_gates"]
.as_array()
.expect("check_gates: skipped_gates array")
.iter()
.filter_map(|v| v["gate"].as_str())
.collect();
assert_eq!(
skipped,
vec!["fail_on_degraded"],
"check_gates: only the default-on degraded semantics may be skipped when \
every explicitly set gate is evaluated: {parsed}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_check_gates_discloses_skipped_gates() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
std::fs::write(
repo.dir.path().join(".codelore-thresholds.toml"),
"[gates]\ncode_health_min = 0.0\nmax_findings_in_hot_files = 100\nhotspot_anchored_max = 9.0\nfail_on_degraded = false\n",
)
.unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(&mut stdin, &mut reader, 1, "check_gates", &json!({}));
let parsed = assert_tool_ok(&resp, "check_gates");
let skipped: Vec<&str> = parsed["skipped_gates"]
.as_array()
.expect("check_gates: skipped_gates array")
.iter()
.filter_map(|v| v["gate"].as_str())
.collect();
assert!(
skipped.contains(&"max_findings_in_hot_files"),
"a configured check-only gate must be disclosed under skipped_gates: {parsed}"
);
assert!(
skipped.contains(&"hotspot_anchored_max"),
"the corpus-dependent anchored gate is check-only here and must be disclosed: {parsed}"
);
assert!(
!skipped.contains(&"fail_on_degraded"),
"explicitly disabled degraded semantics must not be disclosed as skipped: {parsed}"
);
assert!(
parsed["skipped_gates"]
.as_array()
.expect("skipped_gates array")
.iter()
.all(|v| v["reason"].as_str().is_some_and(|r| !r.is_empty())),
"each skipped gate must carry a reason: {parsed}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_check_gates_caps_violations_without_changing_the_count() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
std::fs::write(
repo.dir.path().join(".codelore-thresholds.toml"),
"[gates]\ncode_health_min = 100.0\ncorpus_percentile_max = 0.0\n",
)
.unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let full = assert_tool_ok(
&call_tool(&mut stdin, &mut reader, 1, "check_gates", &json!({})),
"check_gates",
);
let full_count = full["violation_count"].as_u64().expect("violation_count");
let full_rows = full["violations"].as_array().expect("violations").len();
assert!(
full_count >= 2,
"fixture must produce at least two violations or this test cannot \
exercise the cap, got {full_count}: {full}"
);
let capped = assert_tool_ok(
&call_tool(
&mut stdin,
&mut reader,
2,
"check_gates",
&json!({ "limit": 1 }),
),
"check_gates",
);
let capped_rows = capped["violations"].as_array().expect("violations").len();
assert_eq!(
capped_rows, 1,
"limit=1 must return exactly one violation row, got {capped_rows}: {capped}"
);
assert!(
full_rows > capped_rows,
"the uncapped call must return more rows than limit=1, else the cap is \
untested: {full_rows} vs {capped_rows}"
);
assert_eq!(
capped["violation_count"].as_u64(),
Some(full_count),
"violation_count must be the true total at any limit: {capped}"
);
assert_eq!(
capped["verdict"], full["verdict"],
"the verdict must not depend on how many rows were returned: {capped}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_check_gates_evaluates_corpus_percentile_max_for_real() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
std::fs::write(
repo.dir.path().join(".codelore-thresholds.toml"),
"[gates]\ncorpus_percentile_max = 0.0\n",
)
.unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(&mut stdin, &mut reader, 1, "check_gates", &json!({}));
let parsed = assert_tool_ok(&resp, "check_gates");
assert_eq!(
parsed["verdict"], "fail",
"a 0.0 ceiling must fail against delivery_repo's real corpus percentiles: {parsed}"
);
let violations = parsed["violations"]
.as_array()
.expect("check_gates: violations array");
assert!(
violations
.iter()
.any(|v| v["gate"] == "corpus_percentile_max"),
"corpus_percentile_max must produce real violations, not a skip: {parsed}"
);
let skipped: Vec<&str> = parsed["skipped_gates"]
.as_array()
.expect("check_gates: skipped_gates array")
.iter()
.filter_map(|v| v["gate"].as_str())
.collect();
assert!(
!skipped.contains(&"corpus_percentile_max"),
"corpus_percentile_max must not be disclosed as skipped when it was actually evaluated: {parsed}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_function_xray_errors_on_unknown_path() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(
&mut stdin,
&mut reader,
1,
"function_xray",
&json!({ "path": "src/does_not_exist.rs" }),
);
assert_eq!(resp["jsonrpc"], "2.0");
assert_rpc_error_code(&resp, -32602, "function_xray unknown path");
assert!(
resp.to_string().contains("src/does_not_exist.rs"),
"the error must name the unknown path: {resp}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_code_health_errors_on_unknown_path() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(
&mut stdin,
&mut reader,
1,
"code_health",
&json!({ "path": "src/does_not_exist.rs" }),
);
assert_eq!(resp["jsonrpc"], "2.0");
assert_rpc_error_code(&resp, -32602, "code_health unknown path");
assert!(
resp.to_string().contains("src/does_not_exist.rs"),
"the error must name the unknown path: {resp}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_explain_file_errors_on_unknown_path() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(
&mut stdin,
&mut reader,
1,
"explain_file",
&json!({ "path": "src/does_not_exist.rs" }),
);
assert_eq!(resp["jsonrpc"], "2.0");
assert_rpc_error_code(&resp, -32602, "explain_file unknown path");
assert!(
resp.to_string().contains("src/does_not_exist.rs"),
"the error must name the unknown path: {resp}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_code_health_limit_is_honored() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(
&mut stdin,
&mut reader,
1,
"code_health",
&json!({ "limit": 1 }),
);
let parsed = assert_tool_ok(&resp, "code_health");
let arr = parsed
.as_array()
.expect("code_health returns an array when listing");
let real_rows = arr.iter().filter(|v| v.get("path").is_some()).count();
assert!(
real_rows <= 1,
"limit=1 must cap real rows at one: {parsed}"
);
assert!(
arr.iter().all(|v| v.get("omitted").is_none()),
"an untruncated list carries no omitted summary object: {parsed}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_delta_health_description_discloses_diff_subset() {
let repo = tiny_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let list_req = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} });
stdin.write_all(&ndjson_line(&list_req)).unwrap();
stdin.flush().unwrap();
let list_resp = read_ndjson(&mut reader);
let tools = list_resp["result"]["tools"]
.as_array()
.expect("tools array");
let delta = tools
.iter()
.find(|t| t["name"] == "delta_health")
.expect("delta_health tool present");
let desc = delta["description"].as_str().unwrap_or_default();
assert!(
desc.contains("codelore diff"),
"delta_health must disclose it is a subset of `codelore diff`: {desc}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_delta_health_rejects_bad_rev() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(
&mut stdin,
&mut reader,
1,
"delta_health",
&json!({ "base": "nonexistent-branch-xyz", "head": "HEAD" }),
);
assert_eq!(resp["jsonrpc"], "2.0");
let is_rpc_error = resp["error"].is_object();
let is_tool_error = resp["result"]["isError"].as_bool().unwrap_or(false);
assert!(
is_rpc_error || is_tool_error,
"delta_health with bad rev should return an error, got: {resp}"
);
assert_rpc_error_code(&resp, -32602, "delta_health bad rev");
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_delta_health_returns_section_for_valid_revs() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(
&mut stdin,
&mut reader,
1,
"delta_health",
&json!({ "base": "HEAD~1", "head": "HEAD" }),
);
let parsed = assert_tool_ok(&resp, "delta_health");
assert!(
parsed["verdict"].is_string(),
"delta_health: expected `verdict` string: {parsed}"
);
assert!(
parsed["counts"].is_object(),
"delta_health: expected `counts` object: {parsed}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_delta_health_sees_a_whole_file_deletion() {
let dir = tempfile::tempdir().expect("tempdir");
let p = dir.path();
let git = |args: &[&str]| {
let ok = std::process::Command::new("git")
.arg("-C")
.arg(p)
.args(args)
.status()
.expect("spawn git")
.success();
assert!(ok, "git {args:?} failed");
};
git(&["init", "-b", "main", "--quiet"]);
git(&["config", "user.email", "t@example.com"]);
git(&["config", "user.name", "T"]);
let mut doomed = String::new();
for i in 0..40 {
use std::fmt::Write as _;
let _ = writeln!(doomed, " if x == {i} {{ return {i}; }}");
}
std::fs::write(
p.join("doomed.rs"),
format!(
"pub fn doomed(x: i32) -> i32 {{
{doomed} 0
}}
"
),
)
.expect("write");
std::fs::write(
p.join("keep.rs"),
"pub fn keep() -> i32 { 1 }
",
)
.expect("write");
git(&["add", "."]);
git(&["commit", "-m", "base: two files", "--quiet"]);
std::fs::remove_file(p.join("doomed.rs")).expect("remove");
git(&["add", "-A"]);
git(&["commit", "-m", "head: delete the complex file", "--quiet"]);
let (mut child, mut stdin, mut reader) = spawn_mcp(p.to_str().unwrap());
let resp = call_tool(
&mut stdin,
&mut reader,
1,
"delta_health",
&json!({ "base": "HEAD~1", "head": "HEAD" }),
);
let parsed = assert_tool_ok(&resp, "delta_health");
let removed = parsed["counts"]["removed"].as_u64().unwrap_or(0);
assert!(
removed >= 1,
"deleting a file must register as a removal, got counts {}: {parsed}",
parsed["counts"]
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_finding_hotspot_overlap_returns_note_when_sidecar_absent() {
let repo = tiny_repo::build();
let repo_path_buf = repo.dir.path().to_path_buf();
let repo_path = repo_path_buf.to_str().unwrap();
let cache_root = codelore_lib::cli_api::cache::default_cache_root();
let sidecar_path = codelore_lib::cli_api::cache::repo_cache_dir(&cache_root, &repo_path_buf)
.join("external-findings.duckdb-ext");
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(
&mut stdin,
&mut reader,
1,
"finding_hotspot_overlap",
&json!({}),
);
let parsed = assert_tool_ok(&resp, "finding_hotspot_overlap");
assert!(
parsed["findings"].is_array(),
"finding_hotspot_overlap: expected `findings` array in note response: {parsed}"
);
assert_eq!(
parsed["findings"].as_array().unwrap().len(),
0,
"finding_hotspot_overlap: note response `findings` must be empty: {parsed}"
);
assert!(
parsed["note"].is_string(),
"finding_hotspot_overlap: expected `note` string in response: {parsed}"
);
assert!(
parsed["note"].as_str().unwrap().contains("ingest-sarif"),
"finding_hotspot_overlap: note must mention ingest-sarif: {parsed}"
);
drop(stdin);
let _ = child.wait();
assert!(
!sidecar_path.exists(),
"MCP read must not create the sidecar: {}",
sidecar_path.display()
);
}
#[test]
fn mcp_explain_file_returns_fact_sheet_and_narrative_error_without_llm() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let ch = call_tool(&mut stdin, &mut reader, 1, "code_health", &json!({}));
let ch_rows = assert_tool_ok(&ch, "code_health");
let target = ch_rows
.as_array()
.and_then(|rows| rows.first())
.and_then(|row| row["path"].as_str())
.expect("code_health yields at least one file for delivery_repo")
.to_string();
let resp = call_tool(
&mut stdin,
&mut reader,
2,
"explain_file",
&json!({ "path": target }),
);
let parsed = assert_tool_ok(&resp, "explain_file");
let sections = parsed["fact_sheet"]
.as_array()
.expect("explain_file always returns a fact_sheet array");
assert!(
!sections.is_empty(),
"the fact sheet carries at least the mandatory code-health section: {parsed}"
);
assert!(
sections
.iter()
.any(|s| s["section"] == "code-health" && s["facts"].is_object()),
"the fact sheet includes a structured code-health section: {parsed}"
);
assert!(
parsed["narrative_error"].is_string(),
"without an LLM configured, explain_file sets narrative_error: {parsed}"
);
assert!(
parsed.get("narrative").is_none(),
"no narrative may be present when the LLM is unavailable: {parsed}"
);
drop(stdin);
let _ = child.wait();
}
fn write_foreign_defect_artifact(dir: &std::path::Path) -> std::path::PathBuf {
use codelore_lib::defect_calibration::{
DEFECT_FORMAT_VERSION, DefectArtifact, MiningStats, OracleConfig, TuningDecision,
ValidationMetrics, save, validate::default_weights,
};
let artifact = DefectArtifact {
format_version: DEFECT_FORMAT_VERSION,
repo_identity: "0".repeat(64),
head_at_mining: "0".repeat(40),
vintage: "defects-2026-07-17".to_string(),
generated_at: "2026-07-17T00:00:00Z".to_string(),
oracle: OracleConfig::default(),
mining: MiningStats::default(),
validation: ValidationMetrics {
band_table: vec![("red".to_string(), 5, 1.0)],
auc_default: None,
precision_at_10: None,
precision_at_red: None,
implicated_files: 3,
linked_defects: 5,
sample_dates: vec!["2026-01-01".to_string()],
excluded_no_data: 0,
},
weights: default_weights(),
tuning: TuningDecision::DefaultsKept {
reason: "insufficient evidence for weight tuning".to_string(),
auc_validation_default: None,
auc_validation_tuned: None,
},
};
let path = dir.join("defects.calib.json");
save(&artifact, &path).expect("save artifact");
path
}
#[test]
fn mcp_explain_file_defect_calibration_adds_defect_evidence_section() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let artifact_dir = tempfile::tempdir().expect("artifact dir");
let artifact_path = write_foreign_defect_artifact(artifact_dir.path());
let (mut child, mut stdin, mut reader) = spawn_mcp_with_args(
repo_path,
&[
"--defect-calibration",
artifact_path.to_str().unwrap(),
"--allow-foreign-calibration",
],
);
let ch = call_tool(&mut stdin, &mut reader, 1, "code_health", &json!({}));
let ch_rows = assert_tool_ok(&ch, "code_health");
let target = ch_rows
.as_array()
.and_then(|rows| rows.first())
.and_then(|row| row["path"].as_str())
.expect("code_health yields at least one file for delivery_repo")
.to_string();
let resp = call_tool(
&mut stdin,
&mut reader,
2,
"explain_file",
&json!({ "path": target }),
);
let parsed = assert_tool_ok(&resp, "explain_file");
let sections = parsed["fact_sheet"]
.as_array()
.expect("explain_file always returns a fact_sheet array");
assert!(
sections.iter().any(|s| s["section"] == "defect-evidence"),
"the fact sheet must carry a defect-evidence section when the server \
was started with --defect-calibration: {parsed}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_refuses_to_start_on_foreign_artifact_without_override() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let artifact_dir = tempfile::tempdir().expect("artifact dir");
let artifact_path = write_foreign_defect_artifact(artifact_dir.path());
let bin = assert_cmd::cargo::cargo_bin("codelore");
let mut builder = Command::new(&bin);
builder
.args([
"mcp",
"--repo",
repo_path,
"--defect-calibration",
artifact_path.to_str().unwrap(),
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for var in LLM_ENV_VARS {
builder.env_remove(var);
}
let output = builder.output().expect("spawn codelore mcp");
assert_eq!(
output.status.code(),
Some(4),
"expected exit code 4 for foreign artifact without override, got: {:?}",
output.status.code()
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("mined from a different repository"),
"stderr must mention 'mined from a different repository', got: {stderr}"
);
assert!(
stderr.contains("--allow-foreign-calibration"),
"stderr must mention '--allow-foreign-calibration', got: {stderr}"
);
}
#[test]
fn check_gates_honors_calibration_section() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let artifact_dir = tempfile::tempdir().expect("artifact dir");
let artifact_path = write_foreign_defect_artifact(artifact_dir.path());
std::fs::write(
repo.dir.path().join(".codelore-thresholds.toml"),
format!(
"[gates]\ncode_health_min = 0.0\n\n[calibration]\ndefect_artifact = '{}'\n",
artifact_path.display()
),
)
.unwrap();
let (mut child, mut stdin, mut reader) =
spawn_mcp_with_args(repo_path, &["--allow-foreign-calibration"]);
std::fs::remove_file(&artifact_path).expect("delete artifact after startup");
let resp = call_tool(&mut stdin, &mut reader, 1, "check_gates", &json!({}));
let is_error =
resp["error"].is_object() || resp["result"]["isError"].as_bool().unwrap_or(false);
assert!(
is_error,
"check_gates must fail when the configured calibration artifact cannot be loaded: {resp}"
);
assert!(
resp.to_string().contains("defects.calib.json"),
"the error must name the missing artifact: {resp}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_change_context_returns_briefing_for_known_path() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let ch = call_tool(&mut stdin, &mut reader, 1, "code_health", &json!({}));
let ch_rows = assert_tool_ok(&ch, "code_health");
let target = ch_rows
.as_array()
.and_then(|rows| rows.first())
.and_then(|row| row["path"].as_str())
.expect("code_health yields at least one file for delivery_repo")
.to_string();
let resp = call_tool(
&mut stdin,
&mut reader,
2,
"change_context",
&json!({ "paths": [target.clone()] }),
);
let text = assert_tool_ok_text(&resp, "change_context");
assert!(
text.contains(&target),
"briefing must name the requested path: {text}"
);
assert!(
text.contains("health "),
"briefing must carry a health line: {text}"
);
assert!(
text.contains("owner:"),
"briefing must carry an owner line: {text}"
);
assert!(
!text.contains("undefined"),
"briefing must not contain the literal 'undefined': {text}"
);
assert!(
!text.contains('{') && !text.contains('}'),
"briefing is plain text, not JSON: {text}"
);
drop(stdin);
let _ = child.wait();
}
fn head_sha(repo_path: &str) -> String {
let out = Command::new("git")
.args(["-C", repo_path, "rev-parse", "HEAD"])
.output()
.expect("git rev-parse HEAD");
assert!(out.status.success(), "git rev-parse HEAD failed");
String::from_utf8(out.stdout)
.expect("utf8")
.trim()
.to_string()
}
#[test]
fn mcp_change_context_reflects_mid_session_merge_state() {
const NOTE: &str = "merge/rebase in progress";
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let merge_head = repo.dir.path().join(".git").join("MERGE_HEAD");
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let ch = call_tool(&mut stdin, &mut reader, 1, "code_health", &json!({}));
let ch_rows = assert_tool_ok(&ch, "code_health");
let target = ch_rows
.as_array()
.and_then(|rows| rows.first())
.and_then(|row| row["path"].as_str())
.expect("code_health yields at least one file")
.to_string();
let params = json!({ "paths": [target] });
let clean = assert_tool_ok_text(
&call_tool(&mut stdin, &mut reader, 2, "change_context", ¶ms),
"change_context",
);
assert!(
!clean.contains(NOTE),
"clean tree must carry no merge note: {clean}"
);
std::fs::write(&merge_head, format!("{}\n", head_sha(repo_path))).expect("write MERGE_HEAD");
let merging = assert_tool_ok_text(
&call_tool(&mut stdin, &mut reader, 3, "change_context", ¶ms),
"change_context",
);
assert!(
merging.contains(NOTE),
"a merge started at unchanged HEAD must surface the note, not a memoized-stale briefing: {merging}"
);
std::fs::remove_file(&merge_head).expect("remove MERGE_HEAD");
let aborted = assert_tool_ok_text(
&call_tool(&mut stdin, &mut reader, 4, "change_context", ¶ms),
"change_context",
);
assert!(
!aborted.contains(NOTE),
"an aborted merge at unchanged HEAD must drop the note: {aborted}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_change_context_rejects_empty_and_oversized_path_lists() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let empty = call_tool(
&mut stdin,
&mut reader,
1,
"change_context",
&json!({ "paths": [] }),
);
assert_eq!(empty["jsonrpc"], "2.0");
let empty_is_error =
empty["error"].is_object() || empty["result"]["isError"].as_bool().unwrap_or(false);
assert!(
empty_is_error,
"an empty path list must be an error, got: {empty}"
);
assert!(
empty.to_string().contains("20"),
"the empty-list error must name the 20-path limit: {empty}"
);
assert_rpc_error_code(&empty, -32602, "change_context empty paths");
let too_many: Vec<String> = (0..21).map(|i| format!("src/file{i}.rs")).collect();
let oversized = call_tool(
&mut stdin,
&mut reader,
2,
"change_context",
&json!({ "paths": too_many }),
);
assert_eq!(oversized["jsonrpc"], "2.0");
let oversized_is_error =
oversized["error"].is_object() || oversized["result"]["isError"].as_bool().unwrap_or(false);
assert!(
oversized_is_error,
"an oversized path list must be an error, got: {oversized}"
);
assert!(
oversized.to_string().contains("20"),
"the oversized-list error must name the 20-path limit: {oversized}"
);
assert_rpc_error_code(&oversized, -32602, "change_context oversized paths");
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_change_context_stays_within_token_budget() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let ch = call_tool(&mut stdin, &mut reader, 1, "code_health", &json!({}));
let ch_rows = assert_tool_ok(&ch, "code_health");
let rows = ch_rows
.as_array()
.expect("code_health returns an array for delivery_repo");
let first = rows
.first()
.and_then(|row| row["path"].as_str())
.expect("code_health yields at least one file for delivery_repo")
.to_string();
let second = rows
.get(1)
.and_then(|row| row["path"].as_str())
.unwrap_or(&first)
.to_string();
let resp = call_tool(
&mut stdin,
&mut reader,
2,
"change_context",
&json!({ "paths": [first, second] }),
);
let text = assert_tool_ok_text(&resp, "change_context");
let tokens = text.split_whitespace().count();
assert!(
tokens <= 300,
"budget is 150 tokens/path; got {tokens} for 2 paths: {text}"
);
drop(stdin);
let _ = child.wait();
}
const GATE_MONSTER_FN: &str = r"
fn monster(x: i32) -> i32 {
let mut acc = 0;
for a in 0..x {
if a % 2 == 0 && a % 3 == 0 || a % 5 == 0 {
for b in 0..a {
if b > 1 {
match b % 4 {
0 => { if b > 10 { acc += 1; } else { acc += 2; } }
1 => { while acc < 100 { acc += 1; if acc % 7 == 0 { break; } } }
2 => { for c in 0..b { if c > 3 && c < 9 || c == 5 { acc += c; } } }
_ => { if a > b { acc -= 1; } else { acc += 1; } }
}
}
}
}
}
acc
}
";
fn worsen_file(repo_root: &std::path::Path, rel_path: &str) {
let path = repo_root.join(rel_path);
let mut content = std::fs::read_to_string(&path).expect("read fixture file");
content.push_str(GATE_MONSTER_FN);
std::fs::write(&path, content).expect("write fixture file");
}
fn commit_cohort_fillers(repo_root: &std::path::Path, n: usize) {
for i in 0..n {
std::fs::write(
repo_root.join(format!("src/cohort_filler_{i}.rs")),
format!("pub fn cohort_filler_{i}() -> i32 {{ {i} }}\n"),
)
.expect("write cohort filler");
}
let git = |args: &[&str]| {
let out = Command::new("git")
.args(args)
.current_dir(repo_root)
.output()
.expect("run git");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
};
git(&["add", "."]);
git(&[
"-c",
"user.email=cohort@example.com",
"-c",
"user.name=Cohort",
"commit",
"--quiet",
"-m",
"cohort fillers",
]);
}
#[test]
fn gate_changes_reports_clean_tree() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(&mut stdin, &mut reader, 1, "gate_changes", &json!({}));
let text = assert_tool_ok_text(&resp, "gate_changes");
assert!(
text.contains("no working-tree changes"),
"a fresh clone has nothing to gate: {text}"
);
assert!(
text.starts_with("PASS"),
"a clean tree is an explicit pass, not a skipped evaluation: {text}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn gate_changes_flags_working_tree_edit() {
let repo = delivery_repo::build();
commit_cohort_fillers(repo.dir.path(), 9);
let repo_path = repo.dir.path().to_str().unwrap();
std::fs::write(
repo.dir.path().join(".codelore-thresholds.toml"),
"[diff]\ndelta_code_health_min_per_file = 0.0\n",
)
.unwrap();
worsen_file(repo.dir.path(), "src/core.rs");
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(&mut stdin, &mut reader, 1, "gate_changes", &json!({}));
let text = assert_tool_ok_text(&resp, "gate_changes");
assert!(
text.starts_with("FAIL — "),
"line 1 must be the FAIL verdict: {text}"
);
assert!(
text.contains(" - delta_code_health_min_per_file: src/core.rs — actual "),
"the violation row must use check's exact form and name the file: {text}"
);
assert!(
text.contains("[health-drop] src/core.rs:"),
"the health-drop finding must name the file: {text}"
);
assert!(
text.contains(" → "),
"the delta table must render baseline → projected: {text}"
);
assert!(
text.contains("\u{2192} fix src/core.rs first"),
"the FAIL action line must name the worst-delta file to fix first: {text}"
);
assert!(
text.contains("drives the delta_code_health_min_per_file violation"),
"the FAIL action line must name the driving gate: {text}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn gate_changes_token_budget_holds() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
worsen_file(repo.dir.path(), "src/core.rs");
worsen_file(repo.dir.path(), "src/stable.rs");
std::fs::write(
repo.dir.path().join("src/fresh.rs"),
"pub fn fresh() -> u32 { 1 }\n",
)
.unwrap();
let add = Command::new("git")
.args(["-C", repo_path, "add", "src/fresh.rs"])
.output()
.expect("git add");
assert!(add.status.success(), "git add failed: {add:?}");
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(&mut stdin, &mut reader, 1, "gate_changes", &json!({}));
let text = assert_tool_ok_text(&resp, "gate_changes");
assert!(
text.starts_with("no thresholds configured — advisory only"),
"without thresholds the verdict line discloses advisory-only: {text}"
);
let findings = text.lines().filter(|l| l.starts_with('[')).count();
assert!(
findings >= 3,
"two worsened files plus a staged new file must produce at least \
three findings, got {findings}: {text}"
);
let tokens = text.split_whitespace().count();
assert!(
tokens <= 80 + 40 * findings,
"budget is 80 + 40·findings tokens; got {tokens} for {findings} findings: {text}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn gate_changes_findings_render_capped_with_more_tail() {
const ADDED: usize = 13;
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
for i in 0..ADDED {
std::fs::write(
repo.dir.path().join(format!("src/gate_extra_{i}.rs")),
format!("pub fn extra_{i}() -> u32 {{ {i} }}\n"),
)
.unwrap();
}
let add = Command::new("git")
.args(["-C", repo_path, "add", "-A"])
.output()
.expect("git add");
assert!(add.status.success(), "git add failed: {add:?}");
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(&mut stdin, &mut reader, 1, "gate_changes", &json!({}));
let text = assert_tool_ok_text(&resp, "gate_changes");
let finding_lines = text.lines().filter(|l| l.starts_with("[new-file]")).count();
assert_eq!(
finding_lines, 10,
"the findings render must cap at 10 rows: {text}"
);
assert!(
text.contains(&format!("(+{} more findings)", ADDED - 10)),
"a '(+n more findings)' tail must disclose the hidden rows: {text}"
);
drop(stdin);
let _ = child.wait();
}
fn commit_all(repo_root: &std::path::Path, message: &str) {
let status = Command::new("git")
.arg("-C")
.arg(repo_root)
.args(["-c", "user.email=codelore-test@example.com"])
.args(["-c", "user.name=CodeLore Test"])
.args(["commit", "-aqm", message])
.status()
.expect("spawn git commit");
assert!(status.success(), "git commit must succeed");
}
#[test]
fn mcp_committed_state_read_repeat_is_byte_identical() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let first = assert_tool_ok_text(
&call_tool(&mut stdin, &mut reader, 1, "code_health", &json!({})),
"code_health",
);
let second = assert_tool_ok_text(
&call_tool(&mut stdin, &mut reader, 2, "code_health", &json!({})),
"code_health",
);
assert_eq!(
first, second,
"a repeated committed-state read must return the memoized payload verbatim"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_committed_state_read_refreshes_after_new_commit() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let before = assert_tool_ok_text(
&call_tool(&mut stdin, &mut reader, 1, "code_health", &json!({})),
"code_health",
);
worsen_file(repo.dir.path(), "src/core.rs");
commit_all(repo.dir.path(), "worsen core.rs");
let after = assert_tool_ok_text(
&call_tool(&mut stdin, &mut reader, 2, "code_health", &json!({})),
"code_health",
);
assert_ne!(
before, after,
"a read after a new commit must be recomputed for the new HEAD, not served \
from the pre-commit memo"
);
let after_again = assert_tool_ok_text(
&call_tool(&mut stdin, &mut reader, 3, "code_health", &json!({})),
"code_health",
);
assert_eq!(
after, after_again,
"the post-commit result must be memoized under the new HEAD"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_gate_changes_is_not_memoized_across_worktree_edit() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let clean = assert_tool_ok_text(
&call_tool(&mut stdin, &mut reader, 1, "gate_changes", &json!({})),
"gate_changes",
);
assert!(
clean.contains("no working-tree changes"),
"a clean tree reports nothing to gate: {clean}"
);
worsen_file(repo.dir.path(), "src/core.rs");
let dirty = assert_tool_ok_text(
&call_tool(&mut stdin, &mut reader, 2, "gate_changes", &json!({})),
"gate_changes",
);
assert_ne!(
clean, dirty,
"an uncommitted edit must change gate_changes output — it is never memoized"
);
assert!(
!dirty.contains("no working-tree changes"),
"the second call must observe the uncommitted edit: {dirty}"
);
assert!(
dirty.contains("src/core.rs"),
"the worsened file must surface in the second call: {dirty}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_check_gates_declares_output_schema_and_returns_structured_content() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
std::fs::write(
repo.dir.path().join(".codelore-thresholds.toml"),
"[gates]\ncode_health_min = 100.0\n",
)
.unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let list_req = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} });
stdin.write_all(&ndjson_line(&list_req)).unwrap();
stdin.flush().unwrap();
let listed = read_ndjson(&mut reader);
let tools = listed["result"]["tools"].as_array().expect("tools array");
let cg = tools
.iter()
.find(|t| t["name"] == "check_gates")
.expect("check_gates present");
let schema = &cg["outputSchema"];
assert!(
schema.is_object(),
"check_gates must declare an outputSchema: {cg}"
);
let props = schema["properties"]
.as_object()
.expect("outputSchema.properties");
for key in ["verdict", "violation_count", "violations", "skipped_gates"] {
assert!(
props.contains_key(key),
"outputSchema must describe `{key}`: {schema}"
);
}
assert_eq!(
schema["properties"]["violations"]["type"], "array",
"`violations` must be typed as an array, not left opaque: {schema}"
);
let resp = call_tool(&mut stdin, &mut reader, 2, "check_gates", &json!({}));
let structured = &resp["result"]["structuredContent"];
assert!(
structured.is_object(),
"declaring an outputSchema obliges the tool to return structuredContent: {resp}"
);
assert_eq!(
structured["verdict"], "fail",
"a 100.0 floor must fail, and the verdict must arrive as structured data: {structured}"
);
let text = resp["result"]["content"][0]["text"]
.as_str()
.expect("text content block");
let reparsed: serde_json::Value = serde_json::from_str(text).expect("text block is JSON");
assert_eq!(
&reparsed, structured,
"the text block and structuredContent must carry the same document"
);
drop(stdin);
let _ = child.wait();
}