diff --git a/src/engine/language_analyzer.rs b/src/engine/language_analyzer.rs index b49c8fdb..477fc490 100644 --- a/src/engine/language_analyzer.rs +++ b/src/engine/language_analyzer.rs @@ -5,10 +5,30 @@ use crate::engine::diff_parser; -/// Build language-specific context string from detected languages in the diff. +/// Build language-specific context from already-parsed diff chunks. /// -/// Detects which languages are present and injects relevant rules/patterns -/// into the LLM prompt. +/// Uses pre-parsed chunks to avoid redundant parsing. +pub fn build_language_context_from_chunks(chunks: &[diff_parser::FileChunk]) -> String { + let languages = detect_languages(chunks); + + if languages.is_empty() { + return String::new(); + } + + let mut parts = Vec::new(); + parts.push("LANGUAGE-SPECIFIC REVIEW GUIDANCE:".to_string()); + + for lang in &languages { + if let Some(rules) = get_language_rules(lang) { + parts.push(rules.to_string()); + } + } + + parts.join("\n\n") +} + +/// Build language-specific context string from raw diff text. +#[allow(dead_code)] pub fn build_language_context(diff_text: &str) -> String { let chunks = diff_parser::parse_diff(diff_text); let languages = detect_languages(&chunks); diff --git a/src/engine/review.rs b/src/engine/review.rs index 818354b5..34ded77b 100644 --- a/src/engine/review.rs +++ b/src/engine/review.rs @@ -192,8 +192,9 @@ async fn review_diff_inner( combined_context }; - // Inject language-specific context - let lang_context = crate::engine::language_analyzer::build_language_context(diff); + // Inject language-specific context (reuses parsed diff_chunks) + let lang_context = + crate::engine::language_analyzer::build_language_context_from_chunks(&diff_chunks); let final_context = if !lang_context.is_empty() { match final_context { Some(ctx) => Some(format!("{lang_context}\n\n{ctx}")), diff --git a/src/engine/security_scanner.rs b/src/engine/security_scanner.rs index fb742f06..3bf5fa93 100644 --- a/src/engine/security_scanner.rs +++ b/src/engine/security_scanner.rs @@ -14,14 +14,14 @@ use crate::engine::rules::types::RuleFinding; // ─── Security patterns ─── -struct SecurityPattern { - id: &'static str, - name: &'static str, - regex: &'static str, - severity: Severity, +pub struct SecurityPattern { + pub id: &'static str, + pub name: &'static str, + pub regex: &'static str, + pub severity: Severity, } -static PATTERNS: &[SecurityPattern] = &[ +pub static PATTERNS: &[SecurityPattern] = &[ // ── Weak crypto ── SecurityPattern { id: "crypto/md5-password", diff --git a/src/main.rs b/src/main.rs index 7ae23d85..2d03432a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ mod error; mod formatters; mod git; mod hook; +mod mcp; mod progress; use commands::{ @@ -245,6 +246,9 @@ enum Command { /// Shell name: bash, zsh, fish, or powershell shell: String, }, + + /// Start MCP server (Model Context Protocol for AI agents) + Mcp, } #[derive(Subcommand, Debug)] @@ -501,6 +505,10 @@ async fn main() -> Result<()> { completion::execute_completion(&shell)?; 0 } + Command::Mcp => { + mcp::server::run_server()?; + 0 + } }; std::process::exit(exit_code); diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs new file mode 100644 index 00000000..1badd652 --- /dev/null +++ b/src/mcp/mod.rs @@ -0,0 +1,28 @@ +//! MCP (Model Context Protocol) server — exposes cora rules and config to AI coding agents. +//! +//! Allows Claude Code, Cursor, Copilot, Windsurf, etc. to query cora's quality +//! rules, project config, and review history in real-time. +//! +//! Transport: stdio (stdin/stdout JSON-RPC) + +pub mod protocol; +pub mod server; +pub mod tools; + +use serde::{Deserialize, Serialize}; + +/// MCP server configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[allow(dead_code)] +pub struct McpConfig { + /// Enable MCP server. + #[serde(default)] + pub enabled: bool, +} + +#[allow(clippy::derivable_impls)] +impl Default for McpConfig { + fn default() -> Self { + Self { enabled: false } + } +} diff --git a/src/mcp/protocol.rs b/src/mcp/protocol.rs new file mode 100644 index 00000000..3790813c --- /dev/null +++ b/src/mcp/protocol.rs @@ -0,0 +1,154 @@ +//! MCP protocol types — JSON-RPC 2.0 messages for Model Context Protocol. + +use serde::{Deserialize, Serialize}; + +/// JSON-RPC 2.0 request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcRequest { + pub jsonrpc: String, + pub id: Option, + pub method: String, + #[serde(default)] + pub params: serde_json::Value, +} + +/// JSON-RPC 2.0 response. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcResponse { + pub jsonrpc: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// JSON-RPC error object. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcError { + pub code: i64, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +/// Request ID — can be a number or string. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum RequestId { + Number(i64), + String(String), +} + +// ─── MCP-specific types ─── + +/// MCP tool definition. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Tool { + pub name: String, + pub description: String, + #[serde(rename = "inputSchema")] + pub input_schema: serde_json::Value, +} + +/// MCP tool call result. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolResult { + pub content: Vec, + #[serde(default)] + pub is_error: bool, +} + +/// Content block in a tool result. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContentBlock { + #[serde(rename = "type")] + pub content_type: String, + pub text: String, +} + +impl ToolResult { + /// Create a text result. + pub fn text(text: impl Into) -> Self { + Self { + content: vec![ContentBlock { + content_type: "text".to_string(), + text: text.into(), + }], + is_error: false, + } + } + + /// Create an error result. + pub fn error(text: impl Into) -> Self { + Self { + content: vec![ContentBlock { + content_type: "text".to_string(), + text: text.into(), + }], + is_error: true, + } + } +} + +/// Server info for initialization. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerInfo { + pub name: String, + pub version: String, +} + +/// Server capabilities. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerCapabilities { + pub tools: serde_json::Value, +} + +/// Initialize result. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InitializeResult { + #[serde(rename = "protocolVersion")] + pub protocol_version: String, + pub capabilities: ServerCapabilities, + #[serde(rename = "serverInfo")] + pub server_info: ServerInfo, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_request() { + let req: JsonRpcRequest = + serde_json::from_str(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#) + .unwrap(); + assert_eq!(req.method, "tools/list"); + } + + #[test] + fn serialize_response() { + let resp = JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: Some(RequestId::Number(1)), + result: Some(serde_json::json!({"ok": true})), + error: None, + }; + let json = serde_json::to_string(&resp).unwrap(); + assert!(json.contains("\"result\"")); + } + + #[test] + fn tool_result_text() { + let result = ToolResult::text("hello"); + assert!(!result.is_error); + assert_eq!(result.content[0].text, "hello"); + } + + #[test] + fn tool_result_error() { + let result = ToolResult::error("fail"); + assert!(result.is_error); + } +} diff --git a/src/mcp/server.rs b/src/mcp/server.rs new file mode 100644 index 00000000..16874471 --- /dev/null +++ b/src/mcp/server.rs @@ -0,0 +1,242 @@ +//! MCP server — JSON-RPC 2.0 server over stdio transport. +//! +//! Reads JSON-RPC requests from stdin, dispatches to tool handlers, +//! writes responses to stdout. + +use std::io::{self, BufRead, Write}; + +use tracing::{debug, error, info}; + +#[allow(unused_imports)] +use super::protocol::{ + InitializeResult, JsonRpcError, JsonRpcRequest, JsonRpcResponse, RequestId, ServerCapabilities, + ServerInfo, +}; +use super::tools; + +const PROTOCOL_VERSION: &str = "2024-11-05"; +const SERVER_NAME: &str = "cora-mcp"; +const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Run the MCP server, reading from stdin and writing to stdout. +pub fn run_server() -> anyhow::Result<()> { + info!("Starting cora MCP server on stdio"); + + let stdin = io::stdin(); + let stdout = io::stdout(); + let mut stdout_lock = stdout.lock(); + + for line in stdin.lock().lines() { + let line = line?; + let trimmed = line.trim(); + + if trimmed.is_empty() { + continue; + } + + debug!(input = trimmed, "received request"); + + let request: JsonRpcRequest = match serde_json::from_str(trimmed) { + Ok(req) => req, + Err(e) => { + error!(error = %e, "failed to parse request"); + let err_resp = JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: None, + result: None, + error: Some(JsonRpcError { + code: -32700, + message: format!("Parse error: {e}"), + data: None, + }), + }; + write_response(&mut stdout_lock, &err_resp)?; + continue; + } + }; + + let response = handle_request(&request); + write_response(&mut stdout_lock, &response)?; + stdout_lock.flush()?; + + // Exit on shutdown notification + if request.method == "notifications/cancelled" || request.method == "shutdown" { + info!("Shutting down MCP server"); + break; + } + } + + Ok(()) +} + +fn handle_request(request: &JsonRpcRequest) -> JsonRpcResponse { + match request.method.as_str() { + "initialize" => handle_initialize(request), + "initialized" => { + // Notification — no response needed, but we send empty for JSON-RPC + debug!("client initialized"); + JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: request.id.clone(), + result: Some(serde_json::json!({})), + error: None, + } + } + "tools/list" => handle_tools_list(request), + "tools/call" => handle_tools_call(request), + "ping" => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: request.id.clone(), + result: Some(serde_json::json!({})), + error: None, + }, + _ => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: request.id.clone(), + result: None, + error: Some(JsonRpcError { + code: -32601, + message: format!("Method not found: {}", request.method), + data: None, + }), + }, + } +} + +fn handle_initialize(request: &JsonRpcRequest) -> JsonRpcResponse { + let result = InitializeResult { + protocol_version: PROTOCOL_VERSION.to_string(), + capabilities: ServerCapabilities { + tools: serde_json::json!({}), + }, + server_info: ServerInfo { + name: SERVER_NAME.to_string(), + version: SERVER_VERSION.to_string(), + }, + }; + + JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: request.id.clone(), + result: Some(serde_json::to_value(result).unwrap_or_default()), + error: None, + } +} + +fn handle_tools_list(request: &JsonRpcRequest) -> JsonRpcResponse { + let tools = tools::list_tools(); + JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: request.id.clone(), + result: Some(serde_json::json!({ "tools": tools })), + error: None, + } +} + +fn handle_tools_call(request: &JsonRpcRequest) -> JsonRpcResponse { + let tool_name = request + .params + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let args = request + .params + .get("arguments") + .cloned() + .unwrap_or(serde_json::json!({})); + + let result = tools::handle_tool_call(tool_name, &args); + + JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: request.id.clone(), + result: Some(serde_json::to_value(result).unwrap_or_default()), + error: None, + } +} + +fn write_response(stdout: &mut io::StdoutLock, response: &JsonRpcResponse) -> anyhow::Result<()> { + let json = serde_json::to_string(response)?; + debug!(output = %json, "sending response"); + writeln!(stdout, "{json}")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn handle_initialize_response() { + let req = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: Some(RequestId::Number(1)), + method: "initialize".to_string(), + params: serde_json::json!({}), + }; + let resp = handle_request(&req); + assert!(resp.result.is_some()); + assert!(resp.error.is_none()); + let result = resp.result.unwrap(); + assert_eq!(result["protocolVersion"], PROTOCOL_VERSION); + assert_eq!(result["serverInfo"]["name"], SERVER_NAME); + } + + #[test] + fn handle_tools_list_response() { + let req = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: Some(RequestId::Number(2)), + method: "tools/list".to_string(), + params: serde_json::json!({}), + }; + let resp = handle_request(&req); + assert!(resp.result.is_some()); + let result = resp.result.unwrap(); + let tools = result["tools"].as_array().unwrap(); + assert!(!tools.is_empty()); + } + + #[test] + fn handle_tools_call_list_rules() { + let req = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: Some(RequestId::Number(3)), + method: "tools/call".to_string(), + params: serde_json::json!({ + "name": "cora.list_rules", + "arguments": {} + }), + }; + let resp = handle_request(&req); + assert!(resp.result.is_some()); + assert!(resp.error.is_none()); + } + + #[test] + fn handle_unknown_method() { + let req = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: Some(RequestId::Number(99)), + method: "unknown/method".to_string(), + params: serde_json::json!({}), + }; + let resp = handle_request(&req); + assert!(resp.error.is_some()); + assert_eq!(resp.error.unwrap().code, -32601); + } + + #[test] + fn handle_ping() { + let req = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: Some(RequestId::Number(4)), + method: "ping".to_string(), + params: serde_json::json!({}), + }; + let resp = handle_request(&req); + assert!(resp.result.is_some()); + assert!(resp.error.is_none()); + } +} diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs new file mode 100644 index 00000000..b0414cef --- /dev/null +++ b/src/mcp/tools.rs @@ -0,0 +1,314 @@ +//! MCP tool handlers — implement the actual tool logic. +//! +//! Each handler takes JSON params and returns a ToolResult. + +use crate::engine::diff_parser; +use crate::engine::profiles; +use crate::engine::rules; +use crate::engine::secrets_scanner; +use crate::engine::security_scanner; + +use super::protocol::{Tool, ToolResult}; + +/// List all available MCP tools. +pub fn list_tools() -> Vec { + 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": [] + }), + }, + ] +} + +/// Dispatch a tool call to the appropriate handler. +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(), + _ => ToolResult::error(format!("Unknown tool: {name}")), + } +} + +fn handle_list_rules() -> ToolResult { + let mut sections = Vec::new(); + + // Built-in rule engine rules + 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 + )); + } + + // Secret patterns + sections.push("\n## Secret Patterns".to_string()); + sections.push(format!( + "{} built-in secret detection patterns (AWS, GitHub, OpenAI, Anthropic, etc.)", + "12" + )); + + // Security patterns + 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"); + + // Create a fake diff chunk from the snippet + let fake_diff = format!( + "diff --git a/snippet.{lang} b/snippet.{lang}\n--- a/snippet.{lang}\n+++ b/snippet.{lang}\n@@ -0,0 +1,{count} @@\n{added}", + count = code.lines().count(), + added = code + .lines() + .map(|l| format!("+{l}")) + .collect::>() + .join("\n"), + ); + + let chunks = diff_parser::parse_diff(&fake_diff); + + let mut findings = Vec::new(); + + // Run secrets scanner + let secrets = secrets_scanner::scan_secrets(&chunks, 10); + findings.extend(secrets); + + // Run security scanner + let security = security_scanner::scan_security(&chunks, 10); + findings.extend(security); + + if findings.is_empty() { + ToolResult::text("✅ No issues found in snippet by deterministic scanners.") + } else { + let mut lines = vec![format!("Found {} issue(s):\n", findings.len())]; + for f in &findings { + lines.push(format!( + "- **{}** ({}): {} — {}", + f.rule_id, + f.severity.label(), + f.title, + f.body + )); + } + 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(", ")), + _ => { + // Return safe overview (no secrets) + 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::>() + .join(", "), + )); + } + } + + ToolResult::text(lines.join("\n")) +} + +/// Load project config safely (no API keys exposed). +fn load_project_config() -> anyhow::Result { + 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")); + } + + #[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")); + } +}