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 { 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 = 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")); }