brick/tests/mcp_cli.rs
trvon d292b9f3f2
feat: add brick_callers and brick_uncalled caller-status queries
Agents asking "is this function used?" get resolved, unknown, or
unreferenced per function instead of a bare zero in-degree. Unknown lists
why callers may exist: same-name unresolved call sites (excluding foreign
receivers and macros), name references (callbacks, address-taken, macro
bodies), virtual/trait-impl/external-base/outside-class members, framework
attributes, implicit protocol invocations, entry points, test code,
incomplete parses, and, unless assume_closed_world is set, exported or
external-linkage functions. Unreferenced is a review candidate with an
explicit heuristic/compiler evidence basis, never a dead-code proof.

The queries are read-only over validated schema-4 models and return
complete canonical nodes and edges plus metrics.resolution coverage.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 15:12:13 -06:00

273 lines
10 KiB
Rust

use brick::scan;
use serde_json::{Value, json};
use std::fs;
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
fn run_mcp(model: &Path, requests: &[Value]) -> (std::process::ExitStatus, String, String) {
let mut child = Command::new(env!("CARGO_BIN_EXE_brick"))
.args(["mcp", model.to_str().unwrap()])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
{
let stdin = child.stdin.as_mut().unwrap();
for request in requests {
writeln!(stdin, "{}", serde_json::to_string(request).unwrap()).unwrap();
}
}
let output = child.wait_with_output().unwrap();
(
output.status,
String::from_utf8(output.stdout).unwrap(),
String::from_utf8(output.stderr).unwrap(),
)
}
fn responses(stdout: &str) -> Vec<Value> {
stdout
.lines()
.filter(|line| !line.is_empty())
.map(|line| serde_json::from_str(line).unwrap())
.collect()
}
fn call(id: usize, name: &str, arguments: Value) -> Value {
json!({
"jsonrpc": "2.0",
"id": id,
"method": "tools/call",
"params": {"name": name, "arguments": arguments}
})
}
#[test]
fn mcp_serves_versioned_impact_cycles_bridges_and_complete_evidence() {
let directory = tempfile::tempdir().unwrap();
let project = directory.path().join("project");
fs::create_dir_all(&project).unwrap();
fs::write(
project.join("lib.rs"),
"fn a() { b(); }\nfn b() { a(); c(); }\nfn c() {}\n",
)
.unwrap();
let model = scan::scan(&project).unwrap();
let c_id = model
.nodes
.iter()
.find(|node| node.name == "c")
.unwrap()
.id
.clone();
let bridge_id = model
.edges
.iter()
.find(|edge| {
let source = model
.nodes
.iter()
.find(|node| node.id == edge.source)
.unwrap();
let target = model
.nodes
.iter()
.find(|node| node.id == edge.target)
.unwrap();
source.name == "b" && target.name == "c"
})
.unwrap()
.id
.clone();
let graph = directory.path().join("graph.json");
fs::write(&graph, serde_json::to_vec_pretty(&model).unwrap()).unwrap();
let requests = vec![
json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}),
json!({"jsonrpc":"2.0","method":"notifications/initialized"}),
json!({"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}),
call(
3,
"brick_impact",
json!({"node_id": c_id, "direction":"dependents", "max_depth":2}),
),
call(4, "brick_cycles", json!({})),
call(5, "brick_bridges", json!({})),
call(6, "brick_evidence", json!({"entity_id": bridge_id})),
];
let (status, stdout, stderr) = run_mcp(&graph, &requests);
assert!(status.success(), "{stderr}");
let messages = responses(&stdout);
assert_eq!(messages.len(), 6);
assert_eq!(messages[0]["result"]["protocolVersion"], "2025-06-18");
assert_eq!(messages[1]["result"]["tools"].as_array().unwrap().len(), 6);
let impact = &messages[2]["result"]["structuredContent"];
assert_eq!(impact["query_schema_version"], 1);
assert_eq!(impact["building_schema_version"], 4);
assert_eq!(impact["impacted_nodes"].as_array().unwrap().len(), 2);
let cycles = &messages[3]["result"]["structuredContent"];
assert_eq!(cycles["cycles"].as_array().unwrap().len(), 1);
assert_eq!(cycles["cycles"][0]["nodes"].as_array().unwrap().len(), 2);
let bridges = &messages[4]["result"]["structuredContent"];
assert!(
bridges["bridges"]
.as_array()
.unwrap()
.iter()
.any(|entry| entry["edge"]["id"] == bridge_id)
);
let evidence = &messages[5]["result"]["structuredContent"];
assert_eq!(evidence["entity_kind"], "edge");
assert_eq!(evidence["edge"]["id"], bridge_id);
assert_eq!(evidence["edge"]["evidence"][0]["lane"], "heuristic");
assert_eq!(
evidence["edge"]["evidence"][0]["backend"],
"tree-sitter-rust:0.24.2:brick-source-v1:heuristic-resolver-v2"
);
assert!(evidence["edge"]["evidence"][0].get("confidence").is_some());
assert!(evidence["edge"]["evidence"][0].get("provenance").is_some());
}
#[test]
fn mcp_enforces_initialization_lifecycle_and_required_params() {
let directory = tempfile::tempdir().unwrap();
fs::write(directory.path().join("lib.rs"), "fn lone() {}\n").unwrap();
let graph = directory.path().join("graph.json");
fs::write(
&graph,
serde_json::to_vec_pretty(&scan::scan(directory.path()).unwrap()).unwrap(),
)
.unwrap();
let requests = vec![
json!([]),
json!({"jsonrpc":"2.0"}),
json!({"jsonrpc":"2.0","id":{},"method":"ping"}),
json!({"jsonrpc":"2.0","id":1,"method":"ping","params":[]}),
json!({"jsonrpc":"2.0","id":"ping","method":"ping","params":{}}),
json!({"jsonrpc":"2.0","method":"notifications/initialized","params":[]}),
json!({"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}),
json!({"jsonrpc":"2.0","id":3,"method":"initialize","params":{}}),
json!({"jsonrpc":"2.0","id":4,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}),
json!({"jsonrpc":"2.0","method":"notifications/initialized"}),
json!({"jsonrpc":"2.0","id":5,"method":"tools/list","params":{}}),
];
let (status, stdout, stderr) = run_mcp(&graph, &requests);
assert!(status.success(), "{stderr}");
let messages = responses(&stdout);
assert_eq!(messages.len(), 9);
for message in &messages[0..4] {
assert_eq!(message["error"]["code"], -32600);
}
assert_eq!(messages[0]["id"], Value::Null);
assert_eq!(messages[1]["id"], Value::Null);
assert_eq!(messages[2]["id"], Value::Null);
assert_eq!(messages[3]["id"], 1);
assert_eq!(messages[4]["id"], "ping");
assert_eq!(messages[4]["result"], json!({}));
assert_eq!(messages[5]["error"]["code"], -32002);
assert_eq!(messages[6]["error"]["code"], -32602);
assert_eq!(messages[7]["result"]["protocolVersion"], "2025-06-18");
assert_eq!(messages[8]["result"]["tools"].as_array().unwrap().len(), 6);
}
#[test]
fn mcp_returns_clean_negatives_and_rejects_incompatible_models() {
let directory = tempfile::tempdir().unwrap();
fs::write(directory.path().join("lib.rs"), "fn lone() {}\n").unwrap();
let model = scan::scan(directory.path()).unwrap();
let lone_id = model
.nodes
.iter()
.find(|node| node.name == "lone")
.unwrap()
.id
.clone();
let graph = directory.path().join("graph.json");
fs::write(&graph, serde_json::to_vec_pretty(&model).unwrap()).unwrap();
let requests = vec![
json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}),
json!({"jsonrpc":"2.0","method":"notifications/initialized"}),
call(2, "brick_impact", json!({"node_id": lone_id})),
call(3, "brick_cycles", json!({})),
call(4, "brick_bridges", json!({})),
call(5, "brick_evidence", json!({"entity_id":"edge:missing"})),
call(6, "brick_cycles", json!({"unknown": true})),
call(7, "brick_unknown", json!({})),
];
let (status, stdout, stderr) = run_mcp(&graph, &requests);
assert!(status.success(), "{stderr}");
let messages = responses(&stdout);
assert!(
messages[1]["result"]["structuredContent"]["impacted_nodes"]
.as_array()
.unwrap()
.is_empty()
);
assert!(
messages[2]["result"]["structuredContent"]["cycles"]
.as_array()
.unwrap()
.is_empty()
);
assert!(
messages[3]["result"]["structuredContent"]["bridges"]
.as_array()
.unwrap()
.is_empty()
);
assert_eq!(messages[4]["result"]["isError"], true);
assert!(
messages[4]["result"]["content"][0]["text"]
.as_str()
.unwrap()
.contains("unknown Brick entity")
);
assert_eq!(messages[5]["error"]["code"], -32602);
assert_eq!(messages[6]["error"]["code"], -32602);
let no_requests: Vec<Value> = Vec::new();
let mut incompatible: Value = serde_json::to_value(&model).unwrap();
incompatible
.as_object_mut()
.unwrap()
.insert("schema_version".into(), json!(3));
let old_graph = directory.path().join("old.json");
fs::write(
&old_graph,
serde_json::to_vec_pretty(&incompatible).unwrap(),
)
.unwrap();
let (status, _, stderr) = run_mcp(&old_graph, &no_requests);
assert!(!status.success());
assert!(stderr.contains("incompatible"));
let mut no_evidence = serde_json::to_value(model).unwrap();
no_evidence["edges"][0]["evidence"] = json!([]);
let no_evidence_graph = directory.path().join("no-evidence.json");
fs::write(
&no_evidence_graph,
serde_json::to_vec_pretty(&no_evidence).unwrap(),
)
.unwrap();
let (status, _, stderr) = run_mcp(&no_evidence_graph, &no_requests);
assert!(!status.success());
assert!(stderr.contains("has no evidence"));
let mut invalid_scope = serde_json::to_value(scan::scan(directory.path()).unwrap()).unwrap();
invalid_scope["scope"]["path"] = json!("../src");
let invalid_scope_graph = directory.path().join("invalid-scope.json");
fs::write(
&invalid_scope_graph,
serde_json::to_vec_pretty(&invalid_scope).unwrap(),
)
.unwrap();
let (status, _, stderr) = run_mcp(&invalid_scope_graph, &no_requests);
assert!(!status.success());
assert!(stderr.contains("invalid scan scope"));
}