use std::sync::LazyLock;
use crate::engine::postprocess::{Source, postprocess_report};
use crate::engine::profiles;
use crate::engine::rules;
use crate::engine::scanner::FileEntry;
use crate::engine::security_scanner;
use super::protocol::{Tool, ToolResult};
static MCP_RUNTIME: LazyLock<tokio::runtime::Runtime> =
LazyLock::new(|| tokio::runtime::Runtime::new().expect("Failed to create MCP tokio runtime"));
pub fn list_tools() -> Vec<Tool> {
vec![
Tool {
name: "cora.list_rules".to_string(),
description: "List all active review rules, quality profiles, and security patterns for this project.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
Tool {
name: "cora.check_snippet".to_string(),
description: "Check a code snippet against cora's deterministic rules (secrets, security patterns). No LLM call.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"code": { "type": "string", "description": "Code snippet to check" },
"language": { "type": "string", "description": "Language of the snippet (e.g., 'rs', 'py', 'go')" }
},
"required": ["code"]
}),
},
Tool {
name: "cora.get_quality_gate".to_string(),
description: "Get the current quality gate configuration and thresholds.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
Tool {
name: "cora.get_config".to_string(),
description: "Get the effective cora configuration for this project (without secrets).".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"section": { "type": "string", "description": "Config section to get (e.g., 'quality_gate', 'provider', 'rules')" }
},
"required": []
}),
},
Tool {
name: "cora.list_profiles".to_string(),
description: "List all available quality profiles (built-in and custom).".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
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": []
}),
},
Tool {
name: "cora.review_diff".to_string(),
description: "Review a git diff using cora's full pipeline (deterministic rules + LLM). Returns issues, quality gate status, and severity breakdown. Note: makes an LLM API call and requires API key.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"diff": { "type": "string", "description": "Git diff text to review" },
"min_severity": { "type": "string", "description": "Minimum severity to report: info, minor, major, critical (default: info)" }
},
"required": ["diff"]
}),
},
Tool {
name: "cora.get_debt".to_string(),
description: "Get tech debt report from review history. Returns quality score, finding counts, severity breakdown, and trend.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
Tool {
name: "cora.get_project_info".to_string(),
description: "Get project context: repository name, current branch, cora version, and whether a symbol index exists.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
Tool {
name: "cora.get_memory".to_string(),
description: "Recall project memories from Uteke (if installed). Returns relevant memories from previous reviews and code patterns.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "Recall query (e.g., 'auth patterns', 'review history')" }
},
"required": ["query"]
}),
},
Tool {
name: "cora.brain_search".to_string(),
description: "Hybrid code search: FTS5 + vector embeddings + graph proximity → RRF fusion. Better than plain text search for semantic code queries.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "Search query (code concept, symbol name, or description)" },
"limit": { "type": "integer", "description": "Max results (default: 20)" }
},
"required": ["query"]
}),
},
Tool {
name: "cora.install".to_string(),
description: "Detect installed AI coding agents and configure Cora as an MCP server. List detected agents or install configuration.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"list": { "type": "boolean", "description": "List detected agents without installing" },
"agents": { "type": "string", "description": "Specific agents to install (comma-separated)" },
"dry_run": { "type": "boolean", "description": "Show what would be changed without writing" },
"confirm": { "type": "boolean", "description": "Required to actually write agent config files (unless list or dry_run is set)" }
},
"required": []
}),
},
Tool {
name: "cora.dead_code".to_string(),
description: "Find potentially dead code — functions/methods with no callers in the codebase. Public API surface (pub/export) is skipped by default.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"include_tests": { "type": "boolean", "description": "Include test functions in results" },
"include_pub_api": { "type": "boolean", "description": "Include public API surface (pub/export items); skipped by default since they are consumed externally" },
"min_lines": { "type": "integer", "description": "Minimum lines of code to report" }
},
"required": []
}),
},
Tool {
name: "cora.query".to_string(),
description: "Query the code graph with simple patterns (e.g. 'main -> *', '* -> authenticate', 'MyStruct'). Returns matching symbols and edges.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "Graph query pattern (e.g. 'main -> *', '* -> main')" },
"limit": { "type": "integer", "description": "Max results (default: 50)" }
},
"required": ["query"]
}),
},
]
}
pub fn handle_tool_call(name: &str, params: &serde_json::Value) -> ToolResult {
match name {
"cora.list_rules" => handle_list_rules(),
"cora.check_snippet" => handle_check_snippet(params),
"cora.get_quality_gate" => handle_get_quality_gate(),
"cora.get_config" => handle_get_config(params),
"cora.list_profiles" => handle_list_profiles(),
"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(),
"cora.review_diff" => handle_review_diff(params),
"cora.get_debt" => handle_get_debt(),
"cora.get_project_info" => handle_get_project_info(),
"cora.get_memory" => handle_get_memory(params),
"cora.brain_search" => handle_brain_search(params),
"cora.install" => handle_install(params),
"cora.dead_code" => handle_dead_code(params),
"cora.query" => handle_query(params),
_ => ToolResult::error(format!("Unknown tool: {name}")),
}
}
fn handle_list_rules() -> ToolResult {
let mut sections = Vec::new();
sections.push("## Rule Engine".to_string());
let builtin_rules = rules::builtin::builtin_rules();
for rule in &builtin_rules {
sections.push(format!(
"- **{}**: {} (severity: {:?})",
rule.id, rule.message, rule.severity
));
}
sections.push("\n## Secret Patterns".to_string());
sections.push(format!(
"{} built-in secret detection patterns (AWS, GitHub, OpenAI, Anthropic, etc.)",
"12"
));
sections.push("\n## Security Patterns".to_string());
sections.push("11 static security patterns:".to_string());
for pattern in security_scanner::PATTERNS {
sections.push(format!(
"- **{}**: {} ({:?})",
pattern.id, pattern.name, pattern.severity
));
}
ToolResult::text(sections.join("\n"))
}
fn handle_check_snippet(params: &serde_json::Value) -> ToolResult {
let code = match params.get("code").and_then(|v| v.as_str()) {
Some(c) => c,
None => return ToolResult::error("Missing required parameter: code"),
};
let lang = params
.get("language")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let config = match load_project_config() {
Ok(c) => c,
Err(e) => return ToolResult::error(format!("Failed to load config: {e}")),
};
check_snippet_with(code, lang, &config)
}
fn check_snippet_with(
code: &str,
lang: &str,
config: &crate::config::schema::Config,
) -> ToolResult {
let ext: String = lang
.trim_start_matches('.')
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
let entry = FileEntry {
path: format!("snippet.{ext}"),
content: code.to_string(),
lines: code.lines().count(),
};
let report = postprocess_report(
Vec::new(),
&Source::Files(std::slice::from_ref(&entry)),
config,
);
if report.issues.is_empty() {
return ToolResult::text("✅ No issues found in snippet by deterministic scanners.");
}
let mut lines = vec![format!("Found {} issue(s):\n", report.issues.len())];
for f in &report.issues {
lines.push(format!(
"- **{}** ({}): {} — {}",
f.rule_id.as_deref().unwrap_or(""),
f.severity.label(),
f.title,
f.body
));
}
if report.dropped > 0 {
lines.push(format!(
"\n{} more deterministic finding(s) not shown (rules.max_findings = {}; raise it or set 0/null to show all)",
report.dropped, config.rules_config.max_findings
));
}
ToolResult::text(lines.join("\n"))
}
fn handle_get_quality_gate() -> ToolResult {
match load_project_config() {
Ok(config) => {
let qg = &config.quality_gate;
let output = format!(
"## Quality Gate\n- **Enabled**: {}\n- **Thresholds**:\n - max_critical: {}\n - max_major: {}\n - max_minor: {}\n - max_security: {}\n- **Categories**: {}",
qg.enabled,
qg.thresholds.max_critical,
qg.thresholds.max_major,
qg.thresholds.max_minor,
qg.thresholds.max_security,
qg.categories.len(),
);
ToolResult::text(output)
}
Err(e) => ToolResult::error(format!("Failed to load config: {e}")),
}
}
fn handle_get_config(params: &serde_json::Value) -> ToolResult {
let section = params.get("section").and_then(|v| v.as_str());
match load_project_config() {
Ok(config) => {
let output = match section {
Some("provider") => {
format!(
"Provider: {} ({})",
config.provider.provider, config.provider.model
)
}
Some("quality_gate") => format!(
"Enabled: {}, max_critical: {}, max_security: {}",
config.quality_gate.enabled,
config.quality_gate.thresholds.max_critical,
config.quality_gate.thresholds.max_security,
),
Some("rules") => format!("{} custom rules configured", config.rules.len()),
Some("focus") => format!("Focus areas: {}", config.focus.join(", ")),
_ => {
serde_json::to_string_pretty(&serde_json::json!({
"provider": config.provider.provider,
"model": config.provider.model,
"focus": config.focus,
"quality_gate_enabled": config.quality_gate.enabled,
"rules_count": config.rules.len(),
}))
.unwrap_or_else(|_| "Failed to serialize config".to_string())
}
};
ToolResult::text(output)
}
Err(e) => ToolResult::error(format!("Failed to load config: {e}")),
}
}
fn handle_list_profiles() -> ToolResult {
let mut lines = vec!["## Available Quality Profiles\n".to_string()];
for name in profiles::BUILTIN_PROFILES {
if let Some(p) = profiles::load_builtin(name) {
lines.push(format!(
"### {}\n{}\nFocus areas: {}\n",
p.name,
p.description,
p.focus_areas
.iter()
.map(|a| a.id.clone())
.collect::<Vec<_>>()
.join(", "),
));
}
}
ToolResult::text(lines.join("\n"))
}
fn open_index_db() -> anyhow::Result<(rusqlite::Connection, i64)> {
use crate::engine::index_bridge::{IndexBridge, NoIndexError};
match IndexBridge::open_strict_cwd() {
Ok(bridge) => {
let (conn, project_id, _root) = bridge.into_strict_parts()?;
Ok((conn, project_id))
}
Err(e) if e.downcast_ref::<NoIndexError>().is_some() => {
anyhow::bail!("No symbol index found. Run 'cora index' first to build the index.")
}
Err(e) => Err(e),
}
}
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, project_id) = match open_index_db() {
Ok((c, pid)) => (c, pid),
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 = clamped_u64(params, "limit", 50, MAX_LIMIT) as usize;
let query = crate::index::SymbolQuery {
text: Some(query_text.to_string()),
kind,
file_prefix,
language,
limit,
};
match crate::index::search(&conn, project_id, &query) {
Ok(results) => {
if results.is_empty() {
return ToolResult::text(format!("No symbols found matching '{query_text}'."));
}
let json: Vec<serde_json::Value> = 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 = clamped_u64(params, "limit", 50, MAX_LIMIT) as usize;
let (conn, project_id) = match open_index_db() {
Ok((c, pid)) => (c, pid),
Err(e) => return ToolResult::error(e.to_string()),
};
match crate::index::graph::find_callers(&conn, project_id, symbol, limit) {
Ok(callers) => {
if callers.is_empty() {
return ToolResult::text(format!("No callers found for '{symbol}'."));
}
let json: Vec<serde_json::Value> = 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 = clamped_u64(params, "depth", 3, MAX_DEPTH) as u32;
let (conn, project_id) = match open_index_db() {
Ok((c, pid)) => (c, pid),
Err(e) => return ToolResult::error(e.to_string()),
};
match crate::index::graph::impact_analysis(&conn, project_id, symbol, depth) {
Ok(impact) => {
if impact.is_empty() {
return ToolResult::text(format!("No impact found for '{symbol}'."));
}
let json: Vec<serde_json::Value> = 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}")),
}
}
const MAX_LIMIT: u64 = 500;
const MAX_DEPTH: u64 = 10;
const MAX_MIN_LINES: u64 = 100_000;
const MAX_DIFF_BYTES: usize = 1024 * 1024;
fn clamped_u64(params: &serde_json::Value, key: &str, default: u64, max: u64) -> u64 {
params
.get(key)
.and_then(|v| v.as_u64())
.unwrap_or(default)
.min(max)
}
fn handle_find_affected_tests(params: &serde_json::Value) -> ToolResult {
let files: Vec<String> = 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");
}
if let Err(e) = crate::index::queries::validate_changed_files(&files) {
return ToolResult::error(e.to_string());
}
let (conn, project_id) = match open_index_db() {
Ok((c, pid)) => (c, pid),
Err(e) => return ToolResult::error(e.to_string()),
};
let sorted = match crate::index::queries::find_affected_tests(
&conn,
project_id,
&files,
&crate::index::queries::AffectedOptions::default(),
) {
Ok(s) => s,
Err(e) => return ToolResult::error(format!("DB error: {e}")),
};
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, project_id) = match open_index_db() {
Ok((c, pid)) => (c, pid),
Err(e) => return ToolResult::error(e.to_string()),
};
match crate::index::index_stats(&conn, project_id) {
Ok(stats) => {
let mut 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,
});
if let Some(hint) = project_root_mismatch_hint(&conn, project_id, stats.total_symbols) {
json["hint"] = serde_json::json!(hint);
}
ToolResult::text(serde_json::to_string_pretty(&json).unwrap_or_default())
}
Err(e) => ToolResult::error(format!("Failed to get stats: {e}")),
}
}
fn project_root_mismatch_hint(
conn: &rusqlite::Connection,
project_id: i64,
total_symbols: usize,
) -> Option<String> {
if total_symbols > 0 {
return None;
}
let mut stmt = conn
.prepare(
"SELECT p.root_path, COUNT(s.id)
FROM projects p
JOIN symbols s ON s.project_id = p.id
WHERE p.id != ?1
GROUP BY p.id
ORDER BY COUNT(s.id) DESC
LIMIT 3",
)
.ok()?;
let rows: Vec<(String, i64)> = stmt
.query_map([project_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})
.ok()?
.filter_map(|r| r.ok())
.collect();
if rows.is_empty() {
return None;
}
let list: Vec<String> = rows
.iter()
.map(|(root, n)| format!("{root} ({n} symbols)"))
.collect();
Some(format!(
"This project root has 0 indexed symbols, but the global index holds data for \
other roots: {}. Likely a project-root mismatch between where 'cora index' ran \
and where this session resolved the root. Run 'cora index' at your project root.",
list.join(", ")
))
}
fn handle_review_diff(params: &serde_json::Value) -> ToolResult {
let diff = match params.get("diff").and_then(|v| v.as_str()) {
Some(d) => d,
None => return ToolResult::error("Missing required parameter: diff"),
};
if diff.trim().is_empty() {
return ToolResult::error("Diff is empty");
}
if diff.len() > MAX_DIFF_BYTES {
return ToolResult::error(format!(
"Diff is too large ({} bytes); maximum is {MAX_DIFF_BYTES} bytes",
diff.len()
));
}
let config = match load_project_config() {
Ok(c) => c,
Err(e) => return ToolResult::error(format!("Failed to load config: {e}")),
};
let llm_config = match crate::config::loader::build_llm_config(&config, None) {
Ok(c) => c,
Err(e) => {
return ToolResult::error(format!(
"Failed to build LLM config: {e}. Is API key set? Use 'cora auth login'."
));
}
};
let result = MCP_RUNTIME.block_on(crate::engine::review::review_diff_with_cache(
&config,
&llm_config,
diff,
false, true, true, None, ));
match result {
Ok(response) => {
let json = serde_json::json!({
"summary": response.summary,
"total_issues": response.issues.len(),
"should_block": response.should_block,
"issues": response.issues.iter().map(|i| serde_json::json!({
"title": i.title,
"severity": i.severity.label(),
"file": i.file,
"line": i.line,
"type": i.issue_type,
"body": i.body,
})).collect::<Vec<_>>(),
"gate": if config.quality_gate.enabled {
let gate = crate::engine::quality_gate::evaluate(&response.issues, &config.quality_gate);
serde_json::json!({
"status": format!("{:?}", gate.status),
"total_findings": gate.total_findings,
})
} else {
serde_json::json!({"enabled": false})
},
});
ToolResult::text(serde_json::to_string_pretty(&json).unwrap_or_default())
}
Err(e) => ToolResult::error(format!("Review failed: {e}")),
}
}
fn handle_get_debt() -> ToolResult {
let config = load_project_config().unwrap_or_default();
let snapshots = crate::engine::debt_tracker::load_snapshots(config.debt.history_dir.as_deref());
if snapshots.is_empty() {
return ToolResult::text(
"No debt snapshots found. Run 'cora review' or 'cora commit' to generate history.",
);
}
let report = crate::engine::debt_tracker::aggregate(&snapshots);
let json = serde_json::json!({
"quality_score": report.quality_score_avg,
"quality_score_change": report.quality_score_change,
"trend": report.trend,
"total_reviews": report.reviews_analyzed,
"total_findings": report.total_findings,
"change_from_previous": report.change_from_previous,
"findings": report.findings,
"categories": report.categories.iter().map(|c| serde_json::json!({
"name": c.name,
"count": c.count,
"change": c.change,
"trend": c.trend,
})).collect::<Vec<_>>(),
"period_start": report.period_start.map(|t| t.to_rfc3339()),
"period_end": report.period_end.map(|t| t.to_rfc3339()),
});
ToolResult::text(serde_json::to_string_pretty(&json).unwrap_or_default())
}
fn handle_get_project_info() -> ToolResult {
let cwd = match std::env::current_dir() {
Ok(c) => c,
Err(e) => return ToolResult::error(format!("Failed to get cwd: {e}")),
};
let repo_name = cwd
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
let branch = std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
.ok()
.and_then(|o| {
if o.status.success() {
String::from_utf8(o.stdout)
.ok()
.map(|s| s.trim().to_string())
} else {
None
}
})
.unwrap_or_else(|| "unknown".to_string());
let index_exists = crate::data_dir::graph_db_path().exists();
let json = serde_json::json!({
"repository": repo_name,
"branch": branch,
"cora_version": env!("CARGO_PKG_VERSION"),
"index_exists": index_exists,
"working_dir": cwd.to_string_lossy(),
});
ToolResult::text(serde_json::to_string_pretty(&json).unwrap_or_default())
}
fn handle_get_memory(params: &serde_json::Value) -> ToolResult {
let query = match params.get("query").and_then(|v| v.as_str()) {
Some(q) => q,
None => return ToolResult::error("Missing required parameter: query"),
};
if which::which("uteke").is_err() {
return ToolResult::text(
"Uteke is not installed. Memory features require 'uteke' CLI. Install from https://github.com/codecoradev/uteke",
);
}
let project = std::process::Command::new("git")
.args(["config", "--get", "remote.origin.url"])
.output()
.ok()
.and_then(|o| {
String::from_utf8(o.stdout).ok().and_then(|s| {
s.trim()
.rsplit('/')
.next()
.map(|s| s.trim_end_matches(".git").to_string())
})
})
.unwrap_or_else(|| "unknown".to_string());
let mut backend = crate::engine::memory::MemoryBackend::default();
backend.detect();
if !backend.is_available() {
return ToolResult::text(
"Uteke detected but not accessible. Run 'uteke doctor' to diagnose.",
);
}
let memories = backend.recall_context(&project);
if memories.is_empty() {
return ToolResult::text(format!(
"No memories found for '{query}' in namespace 'cora'."
));
}
let query_lower = query.to_lowercase();
let filtered: Vec<&String> = memories
.iter()
.filter(|m| m.to_lowercase().contains(&query_lower))
.collect();
let results = if filtered.is_empty() {
memories.iter().take(5).collect()
} else {
filtered
};
let json: Vec<serde_json::Value> = results
.iter()
.enumerate()
.map(|(i, m)| {
serde_json::json!({
"index": i + 1,
"content": m,
})
})
.collect();
ToolResult::text(serde_json::to_string_pretty(&json).unwrap_or_default())
}
fn handle_brain_search(params: &serde_json::Value) -> ToolResult {
let query = match params.get("query").and_then(|v| v.as_str()) {
Some(q) => q,
None => return ToolResult::error("Missing required parameter: query"),
};
let limit = clamped_u64(params, "limit", 20, MAX_LIMIT) as usize;
let (conn, project_id) = match open_index_db() {
Ok((c, pid)) => (c, pid),
Err(e) => return ToolResult::error(e.to_string()),
};
crate::index::session::configure_for_search(crate::index::session::ConfigSource::ProjectOnly);
match crate::index::brain::brain_search(&conn, project_id, query, limit) {
Ok(results) => {
if results.is_empty() {
return ToolResult::text(format!("No results found for '{query}'."));
}
let json: Vec<serde_json::Value> = results
.iter()
.map(|r| {
serde_json::json!({
"name": r.name,
"kind": r.kind,
"file": r.file,
"line": r.line,
"signature": r.signature,
"score": r.score,
"signals": r.signals,
})
})
.collect();
ToolResult::text(serde_json::to_string_pretty(&json).unwrap_or_default())
}
Err(e) => ToolResult::error(format!("Brain search failed: {e}")),
}
}
fn handle_install(params: &serde_json::Value) -> ToolResult {
let list = params
.get("list")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let agents = params
.get("agents")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let dry_run = params
.get("dry_run")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let confirm = params
.get("confirm")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if let Some(list_str) = &agents {
let known = crate::commands::install::known_agent_names();
for name in list_str.split(',').map(str::trim) {
if !known.contains(&name) {
return ToolResult::error(format!(
"Unknown agent '{name}'. Known agents: {}",
known.join(", ")
));
}
}
}
if !list && !dry_run && !confirm {
return ToolResult::error(
"Refusing to modify agent configs without explicit confirmation. \
Re-run with `dry_run: true` to preview, or `confirm: true` to write.",
);
}
let opts = crate::commands::install::InstallOptions {
list,
agents,
dry_run,
force: false,
yes: true, remove: false,
validate: false,
};
match crate::commands::install::execute_install(&opts) {
Ok(msg) => ToolResult::text(msg),
Err(e) => ToolResult::error(format!("Install failed: {e:#}")),
}
}
fn handle_dead_code(params: &serde_json::Value) -> ToolResult {
let (conn, project_id) = match open_index_db() {
Ok((c, pid)) => (c, pid),
Err(e) => return ToolResult::error(e.to_string()),
};
let include_tests = params
.get("include_tests")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let include_pub_api = params
.get("include_pub_api")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let min_lines = params
.get("min_lines")
.and_then(|v| v.as_u64())
.map(|v| v.min(MAX_MIN_LINES) as u32);
let config = load_project_config().unwrap_or_default();
let flags = crate::index::queries::DeadCodeFlags {
include_tests,
include_pub_api,
min_lines,
};
match crate::index::queries::find_dead_code(&conn, project_id, &config, flags) {
Ok(results) => {
if results.is_empty() {
return ToolResult::text("No dead code found.");
}
let json: Vec<serde_json::Value> = results
.iter()
.map(|r| {
serde_json::json!({
"name": r.name,
"kind": r.kind,
"file": r.file,
"line": r.line,
"reason": r.reason,
})
})
.collect();
ToolResult::text(serde_json::to_string_pretty(&json).unwrap_or_default())
}
Err(e) => ToolResult::error(format!("Dead code detection failed: {e}")),
}
}
fn handle_query(params: &serde_json::Value) -> ToolResult {
let query = match params.get("query").and_then(|v| v.as_str()) {
Some(q) => q,
None => return ToolResult::error("Missing required parameter: query"),
};
let limit = clamped_u64(params, "limit", 50, MAX_LIMIT) as usize;
match crate::commands::query::execute_query_cli(query, true, limit) {
Ok(output) => ToolResult::text(output),
Err(e) => ToolResult::error(format!("Query failed: {e:#}")),
}
}
fn load_project_config() -> anyhow::Result<crate::config::schema::Config> {
let mut config = crate::config::schema::Config::default();
let cwd = std::env::current_dir()?;
if let Some((_, cora)) = crate::config::loader::find_cora_file(&cwd)? {
cora.merge_into(&mut config)?;
}
Ok(config)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn list_tools_returns_tools() {
let tools = list_tools();
assert!(tools.len() >= 5);
assert!(tools.iter().any(|t| t.name == "cora.list_rules"));
assert!(tools.iter().any(|t| t.name == "cora.check_snippet"));
}
#[test]
fn handle_unknown_tool() {
let result = handle_tool_call("cora.unknown", &serde_json::json!({}));
assert!(result.is_error);
}
#[test]
fn handle_list_rules() {
let result = handle_tool_call("cora.list_rules", &serde_json::json!({}));
assert!(!result.is_error);
assert!(result.content[0].text.contains("Rule Engine"));
assert!(result.content[0].text.contains("Security Patterns"));
}
#[test]
fn handle_check_snippet_clean() {
let result = handle_tool_call(
"cora.check_snippet",
&serde_json::json!({"code": "let x = 1;", "language": "rs"}),
);
assert!(!result.is_error);
assert!(result.content[0].text.contains("No issues"));
}
#[test]
fn handle_check_snippet_secret() {
let result = handle_tool_call(
"cora.check_snippet",
&serde_json::json!({"code": "key = 'AKIAIOSFODNN7EXAMPLE'", "language": "py"}),
);
assert!(!result.is_error);
assert!(result.content[0].text.contains("issue"));
}
fn snippet_text(code: &str, lang: &str, config: &crate::config::schema::Config) -> String {
let r = check_snippet_with(code, lang, config);
assert!(!r.is_error);
r.content[0].text.clone()
}
const AWS: &str = "key = 'AKIAIOSFODNN7EXAMPLE'";
#[test]
fn snippet_reports_secret_with_rule_id() {
let cfg = crate::config::schema::Config::default();
let text = snippet_text(AWS, "py", &cfg);
assert!(text.contains("Found 1 issue(s)"), "{text}");
assert!(text.contains("secrets/aws-access-key"), "{text}");
}
#[test]
fn snippet_honors_ignore_rules() {
let mut cfg = crate::config::schema::Config::default();
cfg.ignore.rules = vec!["secrets/aws-access-key".to_string()];
let text = snippet_text(AWS, "py", &cfg);
assert!(text.contains("No issues"), "{text}");
}
#[test]
fn snippet_honors_inline_cora_ignore() {
let cfg = crate::config::schema::Config::default();
let same_line = format!("{AWS} # cora-ignore: secrets/aws-access-key");
assert!(snippet_text(&same_line, "py", &cfg).contains("No issues"));
let next_line = format!("# cora-ignore: secrets/aws-access-key\n{AWS}");
assert!(snippet_text(&next_line, "py", &cfg).contains("No issues"));
let other = format!("{AWS} # cora-ignore: crypto/md5-password");
assert!(snippet_text(&other, "py", &cfg).contains("Found 1 issue(s)"));
}
#[test]
fn snippet_applies_false_positive_filters() {
let cfg = crate::config::schema::Config::default();
for line in [
"password = os.environ[\"DB_PASSWORD\"]",
"const apiKey: string = process.env.API_KEY;",
"password: ${DB_PASSWORD}",
] {
assert!(
snippet_text(line, "py", &cfg).contains("No issues"),
"{line}"
);
}
let hit = snippet_text("password = \"hunter2hunter2\"", "py", &cfg);
assert!(hit.contains("Found"), "{hit}");
}
#[test]
fn snippet_path_is_derived_from_language() {
let cfg = crate::config::schema::Config::default();
let text = snippet_text("let p = \"x\";\nlet h = md5(password);", "rs", &cfg);
assert!(text.contains("snippet.rs"), "{text}");
let text = snippet_text("let h = md5(password);", "../../etc/x", &cfg);
assert!(!text.contains("../"), "{text}");
}
#[test]
fn snippet_respects_max_findings_and_reports_dropped() {
let mut cfg = crate::config::schema::Config::default();
cfg.rules_config.max_findings = 1;
let code = "a = 'AKIAIOSFODNN7EXAMPLE'\nb = 'AKIAIOSFODNN7EXAMPLF'";
let text = snippet_text(code, "py", &cfg);
assert!(text.contains("Found 1 issue(s)"), "{text}");
assert!(
text.contains("1 more deterministic finding(s) not shown"),
"{text}"
);
cfg.rules_config.max_findings = 0; let text = snippet_text(code, "py", &cfg);
assert!(text.contains("Found 2 issue(s)"), "{text}");
assert!(!text.contains("not shown"), "{text}");
}
#[test]
fn handle_check_snippet_missing_code() {
let result = handle_tool_call("cora.check_snippet", &serde_json::json!({}));
assert!(result.is_error);
}
#[test]
fn handle_list_profiles() {
let result = handle_tool_call("cora.list_profiles", &serde_json::json!({}));
assert!(!result.is_error);
assert!(result.content[0].text.contains("security-first"));
assert!(result.content[0].text.contains("rust-strict"));
}
#[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() {
let result = handle_tool_call("cora.index_status", &serde_json::json!({}));
assert!(result.is_error || result.content[0].text.contains("total_symbols"));
}
#[test]
fn project_root_mismatch_hint_on_zero_symbol_project() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::index::schema::run_migrations(&conn).unwrap();
let indexed_pid =
crate::index::schema::get_or_create_project(&conn, "/workspace/uteke").unwrap();
conn.execute(
"INSERT INTO symbols (name, kind, file, line, signature, language, project_id)
VALUES ('alpha', 'function', 'lib.rs', 1, '', 'rust', ?1)",
[indexed_pid],
)
.unwrap();
let empty_pid =
crate::index::schema::get_or_create_project(&conn, "/workspace/uteke/crates/app")
.unwrap();
let hint = project_root_mismatch_hint(&conn, empty_pid, 0);
assert!(
hint.is_some(),
"zero-symbol project beside an indexed one must hint"
);
let hint = hint.unwrap();
assert!(
hint.contains("/workspace/uteke"),
"hint should name the root that actually holds data: {hint}"
);
assert!(
hint.contains("(1 symbols)"),
"hint should include counts: {hint}"
);
assert!(project_root_mismatch_hint(&conn, empty_pid, 5).is_none());
assert!(project_root_mismatch_hint(&conn, indexed_pid, 1).is_none());
}
#[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);
}
#[test]
fn list_tools_includes_phase2() {
let tools = list_tools();
assert!(tools.iter().any(|t| t.name == "cora.review_diff"));
assert!(tools.iter().any(|t| t.name == "cora.get_debt"));
}
#[test]
fn handle_review_diff_missing_diff() {
let result = handle_tool_call("cora.review_diff", &serde_json::json!({}));
assert!(result.is_error);
}
#[test]
fn handle_review_diff_empty_diff() {
let result = handle_tool_call("cora.review_diff", &serde_json::json!({"diff": ""}));
assert!(result.is_error);
}
#[test]
fn handle_get_debt_returns_data_or_error() {
let result = handle_tool_call("cora.get_debt", &serde_json::json!({}));
let _ = result;
}
#[test]
fn list_tools_includes_phase3() {
let tools = list_tools();
assert!(tools.iter().any(|t| t.name == "cora.get_project_info"));
assert!(tools.iter().any(|t| t.name == "cora.get_memory"));
}
#[test]
fn handle_get_project_info() {
let result = handle_tool_call("cora.get_project_info", &serde_json::json!({}));
assert!(!result.is_error);
assert!(result.content[0].text.contains("repository"));
assert!(result.content[0].text.contains("cora_version"));
}
#[test]
fn handle_get_memory_missing_query() {
let result = handle_tool_call("cora.get_memory", &serde_json::json!({}));
assert!(result.is_error);
}
#[test]
fn find_affected_tests_rejects_too_many_files() {
let files: Vec<String> = (0..=crate::index::queries::MAX_AFFECTED_FILES)
.map(|i| format!("f{i}.rs"))
.collect();
let result = handle_tool_call(
"cora.find_affected_tests",
&serde_json::json!({ "files": files }),
);
assert!(result.is_error);
assert!(result.content[0].text.contains("maximum"));
}
#[test]
fn numeric_params_are_clamped() {
let p = serde_json::json!({"limit": 1_000_000, "depth": 99});
assert_eq!(clamped_u64(&p, "limit", 50, MAX_LIMIT), MAX_LIMIT);
assert_eq!(clamped_u64(&p, "depth", 3, MAX_DEPTH), MAX_DEPTH);
assert_eq!(clamped_u64(&p, "missing", 7, MAX_LIMIT), 7);
}
#[test]
fn review_diff_rejects_oversized_diff() {
let diff = "x".repeat(MAX_DIFF_BYTES + 1);
let result = handle_tool_call("cora.review_diff", &serde_json::json!({ "diff": diff }));
assert!(result.is_error);
assert!(result.content[0].text.contains("too large"));
}
#[test]
fn install_requires_confirm_to_write() {
let result = handle_tool_call("cora.install", &serde_json::json!({}));
assert!(result.is_error);
assert!(result.content[0].text.contains("confirm"));
}
#[test]
fn install_rejects_unknown_agents() {
let result = handle_tool_call(
"cora.install",
&serde_json::json!({"agents": "cursor,../../etc", "confirm": true}),
);
assert!(result.is_error);
assert!(result.content[0].text.contains("Unknown agent"));
}
#[test]
fn get_debt_schema_advertises_no_unused_params() {
let tools = list_tools();
let t = tools.iter().find(|t| t.name == "cora.get_debt").unwrap();
assert!(t.input_schema["properties"].as_object().unwrap().is_empty());
}
#[test]
fn total_tool_count() {
let tools = list_tools();
assert_eq!(tools.len(), 18);
}
}