use crate::cli::toml_config::TomlConfig;
use crate::presets;
use crate::scan;
use serde_json::json;
use std::io::{self, BufRead, Write};
use std::path::{Path, PathBuf};
pub fn run_mcp_server(config_path: &Path) {
let stdin = io::stdin();
let mut stdout = io::stdout();
for line in stdin.lock().lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
};
if line.trim().is_empty() {
continue;
}
let request: serde_json::Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(e) => {
let error_response = json!({
"jsonrpc": "2.0",
"id": null,
"error": { "code": -32700, "message": format!("Parse error: {}", e) }
});
let _ = writeln!(stdout, "{}", error_response);
let _ = stdout.flush();
continue;
}
};
let id = request.get("id").cloned();
let method = request.get("method").and_then(|m| m.as_str()).unwrap_or("");
let params = request.get("params").cloned().unwrap_or(json!({}));
let response = match method {
"initialize" => handle_initialize(id.clone()),
"tools/list" => handle_tools_list(id.clone()),
"tools/call" => handle_tools_call(id.clone(), ¶ms, config_path),
"notifications/initialized" | "notifications/cancelled" => continue,
_ => json!({
"jsonrpc": "2.0",
"id": id,
"error": { "code": -32601, "message": format!("Unknown method: {}", method) }
}),
};
let _ = writeln!(stdout, "{}", response);
let _ = stdout.flush();
}
}
fn handle_initialize(id: Option<serde_json::Value>) -> serde_json::Value {
json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {}
},
"serverInfo": {
"name": "guardrails",
"version": env!("CARGO_PKG_VERSION")
}
}
})
}
fn handle_tools_list(id: Option<serde_json::Value>) -> serde_json::Value {
json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"tools": [
{
"name": "guardrails_scan",
"description": "Scan files for rule violations. Returns structured violations with fix suggestions.",
"inputSchema": {
"type": "object",
"properties": {
"paths": {
"type": "array",
"items": { "type": "string" },
"description": "File or directory paths to scan"
},
"content": {
"type": "string",
"description": "Inline file content to scan (alternative to paths)"
},
"filename": {
"type": "string",
"description": "Virtual filename for glob matching when using content"
}
}
}
},
{
"name": "guardrails_list_rules",
"description": "List all configured rules and their descriptions.",
"inputSchema": {
"type": "object",
"properties": {}
}
}
]
}
})
}
fn handle_tools_call(
id: Option<serde_json::Value>,
params: &serde_json::Value,
config_path: &Path,
) -> serde_json::Value {
let tool_name = params
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("");
let arguments = params.get("arguments").cloned().unwrap_or(json!({}));
match tool_name {
"guardrails_scan" => handle_scan(&id, &arguments, config_path),
"guardrails_list_rules" => handle_list_rules(&id, config_path),
_ => json!({
"jsonrpc": "2.0",
"id": id,
"error": { "code": -32602, "message": format!("Unknown tool: {}", tool_name) }
}),
}
}
fn handle_scan(
id: &Option<serde_json::Value>,
arguments: &serde_json::Value,
config_path: &Path,
) -> serde_json::Value {
if let Some(content) = arguments.get("content").and_then(|c| c.as_str()) {
let filename = arguments
.get("filename")
.and_then(|f| f.as_str())
.unwrap_or("stdin.tsx");
match scan::run_scan_stdin(config_path, content, filename) {
Ok(result) => {
let violations = format_violations_json(&result);
json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"content": [{ "type": "text", "text": violations.to_string() }]
}
})
}
Err(e) => json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"content": [{ "type": "text", "text": format!("Error: {}", e) }],
"isError": true
}
}),
}
} else {
let paths: Vec<PathBuf> = arguments
.get("paths")
.and_then(|p| p.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(PathBuf::from))
.collect()
})
.unwrap_or_else(|| vec![PathBuf::from(".")]);
match scan::run_scan(config_path, &paths) {
Ok(result) => {
let violations = format_violations_json(&result);
json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"content": [{ "type": "text", "text": violations.to_string() }]
}
})
}
Err(e) => json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"content": [{ "type": "text", "text": format!("Error: {}", e) }],
"isError": true
}
}),
}
}
}
fn handle_list_rules(
id: &Option<serde_json::Value>,
config_path: &Path,
) -> serde_json::Value {
let config_text = match std::fs::read_to_string(config_path) {
Ok(c) => c,
Err(e) => {
return json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"content": [{ "type": "text", "text": format!("Error reading config: {}", e) }],
"isError": true
}
});
}
};
let toml_config: TomlConfig = match toml::from_str(&config_text) {
Ok(c) => c,
Err(e) => {
return json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"content": [{ "type": "text", "text": format!("Error parsing config: {}", e) }],
"isError": true
}
});
}
};
let resolved = match presets::resolve_rules(&toml_config.guardrails.extends, &toml_config.rule) {
Ok(r) => r,
Err(e) => {
return json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"content": [{ "type": "text", "text": format!("Error resolving rules: {}", e) }],
"isError": true
}
});
}
};
let rules: Vec<serde_json::Value> = resolved
.iter()
.map(|r| {
json!({
"id": r.id,
"type": r.rule_type,
"severity": r.severity,
"glob": r.glob,
"message": r.message,
})
})
.collect();
let text = serde_json::to_string_pretty(&json!({ "rules": rules })).unwrap();
json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"content": [{ "type": "text", "text": text }]
}
})
}
fn format_violations_json(result: &scan::ScanResult) -> serde_json::Value {
use crate::config::Severity;
let violations: Vec<serde_json::Value> = result
.violations
.iter()
.map(|v| {
let mut obj = json!({
"rule_id": v.rule_id,
"severity": match v.severity {
Severity::Error => "error",
Severity::Warning => "warning",
},
"file": v.file.display().to_string(),
"line": v.line,
"column": v.column,
"message": v.message,
"suggest": v.suggest,
});
if let Some(ref fix) = v.fix {
obj["fix"] = json!({ "old": fix.old, "new": fix.new });
}
obj
})
.collect();
json!({
"violations": violations,
"summary": {
"total": result.violations.len(),
"errors": result.violations.iter().filter(|v| v.severity == Severity::Error).count(),
"warnings": result.violations.iter().filter(|v| v.severity == Severity::Warning).count(),
"files_scanned": result.files_scanned,
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Severity;
use crate::rules::Violation;
use std::collections::HashMap;
use std::path::PathBuf;
#[test]
fn initialize_returns_protocol_version() {
let resp = handle_initialize(Some(json!(1)));
assert_eq!(resp["jsonrpc"], "2.0");
assert_eq!(resp["id"], 1);
assert_eq!(resp["result"]["protocolVersion"], "2024-11-05");
assert_eq!(resp["result"]["serverInfo"]["name"], "guardrails");
}
#[test]
fn tools_list_returns_both_tools() {
let resp = handle_tools_list(Some(json!(2)));
assert_eq!(resp["jsonrpc"], "2.0");
let tools = resp["result"]["tools"].as_array().unwrap();
assert_eq!(tools.len(), 2);
assert_eq!(tools[0]["name"], "guardrails_scan");
assert_eq!(tools[1]["name"], "guardrails_list_rules");
}
#[test]
fn format_violations_empty() {
let result = scan::ScanResult {
violations: vec![],
files_scanned: 3,
rules_loaded: 2,
ratchet_counts: HashMap::new(),
changed_files_count: None,
base_ref: None,
};
let json = format_violations_json(&result);
assert_eq!(json["summary"]["total"], 0);
assert_eq!(json["summary"]["files_scanned"], 3);
assert!(json["violations"].as_array().unwrap().is_empty());
}
#[test]
fn format_violations_with_fix() {
let result = scan::ScanResult {
violations: vec![Violation {
rule_id: "test-rule".into(),
severity: Severity::Error,
file: PathBuf::from("test.tsx"),
line: Some(5),
column: Some(10),
message: "bad class".into(),
suggest: Some("use good class".into()),
source_line: None,
fix: Some(crate::rules::Fix {
old: "bg-red-500".into(),
new: "bg-destructive".into(),
}),
}],
files_scanned: 1,
rules_loaded: 1,
ratchet_counts: HashMap::new(),
changed_files_count: None,
base_ref: None,
};
let json = format_violations_json(&result);
assert_eq!(json["summary"]["total"], 1);
assert_eq!(json["summary"]["errors"], 1);
let v = &json["violations"][0];
assert_eq!(v["rule_id"], "test-rule");
assert_eq!(v["fix"]["old"], "bg-red-500");
assert_eq!(v["fix"]["new"], "bg-destructive");
}
#[test]
fn format_violations_counts_severities() {
let result = scan::ScanResult {
violations: vec![
Violation {
rule_id: "r1".into(),
severity: Severity::Error,
file: PathBuf::from("a.ts"),
line: Some(1),
column: None,
message: "err".into(),
suggest: None,
source_line: None,
fix: None,
},
Violation {
rule_id: "r2".into(),
severity: Severity::Warning,
file: PathBuf::from("b.ts"),
line: Some(2),
column: None,
message: "warn".into(),
suggest: None,
source_line: None,
fix: None,
},
],
files_scanned: 2,
rules_loaded: 2,
ratchet_counts: HashMap::new(),
changed_files_count: None,
base_ref: None,
};
let json = format_violations_json(&result);
assert_eq!(json["summary"]["errors"], 1);
assert_eq!(json["summary"]["warnings"], 1);
assert_eq!(json["summary"]["total"], 2);
}
#[test]
fn unknown_tool_returns_error() {
let resp = handle_tools_call(
Some(json!(3)),
&json!({ "name": "nonexistent_tool", "arguments": {} }),
std::path::Path::new("guardrails.toml"),
);
assert!(resp["error"].is_object());
assert_eq!(resp["error"]["code"], -32602);
}
}