From bd1d2f15369894031e926a2aeeb8a1b3ab612232 Mon Sep 17 00:00:00 2001 From: Aji Anaz Date: Sun, 14 Jun 2026 14:42:28 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20MCP=20Phase=201=20=E2=80=94=20Code=20In?= =?UTF-8?q?telligence=20tools=20(#284)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 5 MCP tools for code intelligence: - cora.search_symbols: FTS5 search across symbol index Input: query, kind?, file?, language?, limit? Output: JSON array of matching symbols - cora.find_callers: reverse call graph traversal Input: symbol, limit? Output: JSON array of caller locations - cora.find_impact: blast radius analysis Input: symbol, depth? Output: JSON array of affected symbols by depth level - cora.find_affected_tests: test file selection Input: files[] (changed source files) Output: JSON with affected_tests array + count - cora.index_status: index health check Input: none Output: JSON with total_symbols, files, by_kind, by_language All tools: - Read-only (no side effects) - JSON output for agent parsing - Graceful degradation (helpful error if no index) - Fast: <100ms (no LLM calls) - Stateless Runtime verified via MCP protocol: index_status → 534 symbols, 49 files ✅ find_callers open_index → 11 callers ✅ find_impact open_index -d 2 → 21 affected sites ✅ find_affected_tests src/main.rs → 2 test files ✅ 7 new unit tests (parameter validation + tool listing). 559 tests total, clippy clean. --- src/mcp/tools.rs | 374 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 374 insertions(+) diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index b0414ce..2422550 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -13,6 +13,7 @@ use super::protocol::{Tool, ToolResult}; /// List all available MCP tools. pub fn list_tools() -> Vec { vec![ + // ─── Review & Security ─── Tool { name: "cora.list_rules".to_string(), description: "List all active review rules, quality profiles, and security patterns for this project.".to_string(), @@ -34,6 +35,7 @@ pub fn list_tools() -> Vec { "required": ["code"] }), }, + // ─── Config ─── Tool { name: "cora.get_quality_gate".to_string(), description: "Get the current quality gate configuration and thresholds.".to_string(), @@ -63,17 +65,89 @@ pub fn list_tools() -> Vec { "required": [] }), }, + // ─── Code Intelligence ─── + Tool { + name: "cora.search_symbols".to_string(), + description: "Search the symbol index for code intelligence. Returns matching symbols with file location, kind, and signature. Requires `cora index` to be run first.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "query": { "type": "string", "description": "Search query (symbol name or keyword)" }, + "kind": { "type": "string", "description": "Filter by kind: function, struct, enum, trait, method, constant, module" }, + "file": { "type": "string", "description": "Filter by file path prefix" }, + "language": { "type": "string", "description": "Filter by language (rs, py, ts, go, etc.)" }, + "limit": { "type": "integer", "description": "Max results (default 50)", "default": 50 } + }, + "required": ["query"] + }), + }, + Tool { + name: "cora.find_callers".to_string(), + description: "Find all callers of a symbol (who calls this function/method?). Uses reverse call graph traversal.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "symbol": { "type": "string", "description": "Symbol name to find callers for" }, + "limit": { "type": "integer", "description": "Max results (default 50)", "default": 50 } + }, + "required": ["symbol"] + }), + }, + Tool { + name: "cora.find_impact".to_string(), + description: "Analyze the blast radius of changing a symbol. Returns all affected symbols up to the specified depth.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "symbol": { "type": "string", "description": "Symbol name to analyze" }, + "depth": { "type": "integer", "description": "Traversal depth (default 3)", "default": 3 } + }, + "required": ["symbol"] + }), + }, + Tool { + name: "cora.find_affected_tests".to_string(), + description: "Find test files affected by source code changes. Uses call graph + naming conventions.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { "type": "string" }, + "description": "Changed source files" + } + }, + "required": ["files"] + }), + }, + Tool { + name: "cora.index_status".to_string(), + description: "Check if a symbol index exists and get statistics (total symbols, files, languages).".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": {}, + "required": [] + }), + }, ] } /// Dispatch a tool call to the appropriate handler. pub fn handle_tool_call(name: &str, params: &serde_json::Value) -> ToolResult { match name { + // Review & Security "cora.list_rules" => handle_list_rules(), "cora.check_snippet" => handle_check_snippet(params), + // Config "cora.get_quality_gate" => handle_get_quality_gate(), "cora.get_config" => handle_get_config(params), "cora.list_profiles" => handle_list_profiles(), + // Code Intelligence + "cora.search_symbols" => handle_search_symbols(params), + "cora.find_callers" => handle_find_callers(params), + "cora.find_impact" => handle_find_impact(params), + "cora.find_affected_tests" => handle_find_affected_tests(params), + "cora.index_status" => handle_index_status(), _ => ToolResult::error(format!("Unknown tool: {name}")), } } @@ -240,6 +314,253 @@ fn handle_list_profiles() -> ToolResult { ToolResult::text(lines.join("\n")) } +// ─── Code Intelligence Handlers ─── + +/// Open the index database, returning helpful error if not found. +fn open_index_db() -> anyhow::Result { + let cwd = std::env::current_dir()?; + let db_path = crate::index::default_db_path(&cwd); + if !db_path.exists() { + anyhow::bail!("No symbol index found. Run 'cora index' first to build the index."); + } + crate::index::open_index(&db_path) +} + +fn handle_search_symbols(params: &serde_json::Value) -> ToolResult { + let query_text = match params.get("query").and_then(|v| v.as_str()) { + Some(q) => q, + None => return ToolResult::error("Missing required parameter: query"), + }; + + let conn = match open_index_db() { + Ok(c) => c, + Err(e) => return ToolResult::error(e.to_string()), + }; + + let kind = params + .get("kind") + .and_then(|v| v.as_str()) + .map(crate::index::SymbolKind::from_str); + let file_prefix = params + .get("file") + .and_then(|v| v.as_str()) + .map(String::from); + let language = params + .get("language") + .and_then(|v| v.as_str()) + .map(String::from); + let limit = params.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize; + + let query = crate::index::SymbolQuery { + text: Some(query_text.to_string()), + kind, + file_prefix, + language, + limit, + }; + + match crate::index::search(&conn, &query) { + Ok(results) => { + if results.is_empty() { + return ToolResult::text(format!("No symbols found matching '{query_text}'.")); + } + let json: Vec = results + .iter() + .map(|r| { + serde_json::json!({ + "name": r.symbol.name, + "kind": r.symbol.kind.as_str(), + "file": r.symbol.file, + "line": r.symbol.line, + "signature": r.symbol.signature, + "language": r.symbol.language, + "score": r.score, + }) + }) + .collect(); + ToolResult::text(serde_json::to_string_pretty(&json).unwrap_or_default()) + } + Err(e) => ToolResult::error(format!("Search failed: {e}")), + } +} + +fn handle_find_callers(params: &serde_json::Value) -> ToolResult { + let symbol = match params.get("symbol").and_then(|v| v.as_str()) { + Some(s) => s, + None => return ToolResult::error("Missing required parameter: symbol"), + }; + let limit = params.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize; + + let conn = match open_index_db() { + Ok(c) => c, + Err(e) => return ToolResult::error(e.to_string()), + }; + + match crate::index::graph::find_callers(&conn, symbol, limit) { + Ok(callers) => { + if callers.is_empty() { + return ToolResult::text(format!("No callers found for '{symbol}'.")); + } + let json: Vec = callers + .iter() + .map(|c| { + serde_json::json!({ + "caller": c.caller, + "file": c.file, + "line": c.line, + }) + }) + .collect(); + ToolResult::text(serde_json::to_string_pretty(&json).unwrap_or_default()) + } + Err(e) => ToolResult::error(format!("Find callers failed: {e}")), + } +} + +fn handle_find_impact(params: &serde_json::Value) -> ToolResult { + let symbol = match params.get("symbol").and_then(|v| v.as_str()) { + Some(s) => s, + None => return ToolResult::error("Missing required parameter: symbol"), + }; + let depth = params.get("depth").and_then(|v| v.as_u64()).unwrap_or(3) as u32; + + let conn = match open_index_db() { + Ok(c) => c, + Err(e) => return ToolResult::error(e.to_string()), + }; + + match crate::index::graph::impact_analysis(&conn, symbol, depth) { + Ok(impact) => { + if impact.is_empty() { + return ToolResult::text(format!("No impact found for '{symbol}'.")); + } + let json: Vec = impact + .iter() + .map(|n| { + serde_json::json!({ + "symbol": n.symbol, + "file": n.file, + "line": n.line, + "depth": n.depth, + }) + }) + .collect(); + ToolResult::text(serde_json::to_string_pretty(&json).unwrap_or_default()) + } + Err(e) => ToolResult::error(format!("Impact analysis failed: {e}")), + } +} + +fn handle_find_affected_tests(params: &serde_json::Value) -> ToolResult { + let files: Vec = match params.get("files").and_then(|v| v.as_array()) { + Some(arr) => arr + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(), + None => { + return ToolResult::error("Missing required parameter: files (array of file paths)"); + } + }; + if files.is_empty() { + return ToolResult::error("Parameter 'files' must not be empty"); + } + + let conn = match open_index_db() { + Ok(c) => c, + Err(e) => return ToolResult::error(e.to_string()), + }; + + let patterns = ["test", "spec", "_test", "_spec"]; + let mut affected: std::collections::HashSet = std::collections::HashSet::new(); + + for file in &files { + // Strategy 1: call graph + let symbols_in_file: Vec = { + let mut stmt = match conn.prepare("SELECT DISTINCT name FROM symbols WHERE file = ?1") { + Ok(s) => s, + Err(e) => return ToolResult::error(format!("DB error: {e}")), + }; + let rows = match stmt.query_map(rusqlite::params![file], |row| row.get::<_, String>(0)) + { + Ok(r) => r, + Err(e) => return ToolResult::error(format!("DB error: {e}")), + }; + rows.filter_map(|r| r.ok()).collect() + }; + + for sym_name in &symbols_in_file { + if let Ok(callers) = crate::index::graph::find_callers(&conn, sym_name, 100) { + for caller in callers { + if patterns.iter().any(|p| caller.file.contains(*p)) { + affected.insert(caller.file.clone()); + } + } + } + } + + // Strategy 2: naming convention + let stem = file + .rsplit('/') + .next() + .unwrap_or(file) + .rsplit('.') + .next() + .unwrap_or(""); + let test_names = [ + format!("{stem}_test.rs"), + format!("tests/{stem}.rs"), + format!("{stem}_test.go"), + format!("test_{stem}.py"), + format!("{stem}.test.ts"), + format!("{stem}.spec.ts"), + ]; + for tn in &test_names { + if let Ok(mut stmt) = conn.prepare("SELECT DISTINCT path FROM files WHERE path LIKE ?1") + { + let pattern = format!("%{tn}"); + if let Ok(rows) = + stmt.query_map(rusqlite::params![pattern], |row| row.get::<_, String>(0)) + { + for row in rows.map_while(Result::ok) { + affected.insert(row); + } + } + } + } + } + + let mut sorted: Vec = affected.into_iter().collect(); + sorted.sort(); + + let json = serde_json::json!({ + "affected_tests": sorted, + "count": sorted.len(), + }); + ToolResult::text(serde_json::to_string_pretty(&json).unwrap_or_default()) +} + +fn handle_index_status() -> ToolResult { + let conn = match open_index_db() { + Ok(c) => c, + Err(e) => return ToolResult::error(e.to_string()), + }; + + match crate::index::index_stats(&conn) { + Ok(stats) => { + let json = serde_json::json!({ + "exists": true, + "total_symbols": stats.total_symbols, + "total_files": stats.total_files, + "db_size_bytes": stats.db_size_bytes, + "symbols_by_kind": stats.symbols_by_kind, + "symbols_by_language": stats.symbols_by_language, + }); + ToolResult::text(serde_json::to_string_pretty(&json).unwrap_or_default()) + } + Err(e) => ToolResult::error(format!("Failed to get stats: {e}")), + } +} + /// Load project config safely (no API keys exposed). fn load_project_config() -> anyhow::Result { let mut config = crate::config::schema::Config::default(); @@ -311,4 +632,57 @@ mod tests { assert!(result.content[0].text.contains("security-first")); assert!(result.content[0].text.contains("rust-strict")); } + + // ─── Code Intelligence Tool Tests ─── + + #[test] + fn list_tools_includes_code_intel() { + let tools = list_tools(); + assert!(tools.iter().any(|t| t.name == "cora.search_symbols")); + assert!(tools.iter().any(|t| t.name == "cora.find_callers")); + assert!(tools.iter().any(|t| t.name == "cora.find_impact")); + assert!(tools.iter().any(|t| t.name == "cora.find_affected_tests")); + assert!(tools.iter().any(|t| t.name == "cora.index_status")); + } + + #[test] + fn handle_index_status_no_index() { + // From test dir, there should be no index + let result = handle_tool_call("cora.index_status", &serde_json::json!({})); + // May error if no index — that's acceptable + assert!(result.is_error || result.content[0].text.contains("total_symbols")); + } + + #[test] + fn handle_search_symbols_missing_query() { + let result = handle_tool_call("cora.search_symbols", &serde_json::json!({})); + assert!(result.is_error); + } + + #[test] + fn handle_find_callers_missing_symbol() { + let result = handle_tool_call("cora.find_callers", &serde_json::json!({})); + assert!(result.is_error); + } + + #[test] + fn handle_find_impact_missing_symbol() { + let result = handle_tool_call("cora.find_impact", &serde_json::json!({})); + assert!(result.is_error); + } + + #[test] + fn handle_find_affected_tests_missing_files() { + let result = handle_tool_call("cora.find_affected_tests", &serde_json::json!({})); + assert!(result.is_error); + } + + #[test] + fn handle_find_affected_tests_empty_files() { + let result = handle_tool_call( + "cora.find_affected_tests", + &serde_json::json!({"files": []}), + ); + assert!(result.is_error); + } }