From 1d8ab38b73195cacc47fd9076090828ab35f0f0f Mon Sep 17 00:00:00 2001 From: Tauan BF <11513929+tauanbinato@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:47:45 -0300 Subject: [PATCH] Serve JevGate to coding agents over MCP jevgate mcp is a Model Context Protocol server on stdin and stdout with three tools: jevgate_check runs a check in the repository as a child process and returns its findings, with an incomplete run as a tool error; jevgate_findings reads the last report; jevgate_rules lists the rules. Tool arguments are passed as --flag=value and paths after --, so none becomes another flag. --- .github/homebrew-formula.sh | 2 +- CHANGELOG.md | 2 + site/generate.py | 2 +- site/src/coding-agents.md | 22 +++ src/command.rs | 6 +- src/main.rs | 1 + src/mcp.rs | 358 ++++++++++++++++++++++++++++++++++++ src/options/commands.rs | 16 +- tests/cli/main.rs | 1 + tests/cli/mcp.rs | 54 ++++++ 10 files changed, 459 insertions(+), 5 deletions(-) create mode 100644 src/mcp.rs create mode 100644 tests/cli/mcp.rs diff --git a/.github/homebrew-formula.sh b/.github/homebrew-formula.sh index 2d0241f..4c3d31a 100755 --- a/.github/homebrew-formula.sh +++ b/.github/homebrew-formula.sh @@ -48,7 +48,7 @@ class Jevgate < Formula generate_completions_from_executable(bin/"jevgate", "completions") man1.mkpath (man1/"jevgate.1").write Utils.safe_popen_read(bin/"jevgate", "man") - %w[auth check baseline rules init serve completions man].each do |command| + %w[auth check baseline rules init serve mcp completions man].each do |command| (man1/"jevgate-#{command}.1").write Utils.safe_popen_read(bin/"jevgate", "man", command) end end diff --git a/CHANGELOG.md b/CHANGELOG.md index db71268..b7998c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Notable changes to JevGate. Versions follow [Semantic Versioning](https://semver ## [Unreleased] +- `jevgate mcp` runs a Model Context Protocol server on stdin and stdout, so coding agents can call JevGate as a tool: `jevgate_check` runs a check and returns its findings (an incomplete run is a tool error), `jevgate_findings` reads the last report, and `jevgate_rules` lists the rules. + ## [0.18.0] - 2026-09-25 - Documentation site: https://tech-byte-frontier.github.io/jevgate/, with guides, troubleshooting, a page on coding agents, and rules, configuration and command-line references generated from the binary. It is published with each release. diff --git a/site/generate.py b/site/generate.py index c3db628..e9677b4 100644 --- a/site/generate.py +++ b/site/generate.py @@ -13,7 +13,7 @@ import sys from pathlib import Path -COMMANDS = ["auth", "check", "baseline", "rules", "init", "completions", "man", "serve"] +COMMANDS = ["auth", "check", "baseline", "rules", "init", "completions", "man", "serve", "mcp"] GROUPS = { "maintainability": "On by default.", "tests": "On by default; judged with `--include-tests` or `include_tests = true`.", diff --git a/site/src/coding-agents.md b/site/src/coding-agents.md index fe2ab2e..fefcddd 100644 --- a/site/src/coding-agents.md +++ b/site/src/coding-agents.md @@ -19,6 +19,28 @@ for a `consider`, fix it or say why the code should stay as it is. | 1 | The gate failed: act on the findings listed | | 2 | The run could not finish (no key, provider rejection, request budget); report it, don't treat it as a pass | +## As an MCP server + +`jevgate mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on stdin and stdout, so an agent can call JevGate as a tool instead of running a shell command. Register it, started in the repository: + +```sh +claude mcp add jevgate -- jevgate mcp # Claude Code +``` + +```json +{"mcpServers": {"jevgate": {"command": "jevgate", "args": ["mcp"]}}} +``` + +The second form is for clients configured with JSON, such as Cursor. The server offers three tools: + +| Tool | What it does | +|---|---| +| `jevgate_check` | Runs `jevgate check` in the repository with `base`, `paths`, `rules`, `include_tests`, `dry_run` or `verbose`, and returns the ranked findings. An incomplete run (exit 2) is a tool error, never a pass | +| `jevgate_findings` | Reads the last report's findings, optionally under one path, without running anything | +| `jevgate_rules` | Lists every rule with the question it asks | + +A check runs as a child process with the repository's `jevgate.toml` and key, so the tool reviews exactly what the command line would. + ## Structured output `--format json` prints the full report: every file, finding, raw answer and probability, and the gate. The same report is always written to `.jevgate/latest.json`, whatever the output format, so an agent can run the check once and read the details after. `jevgate check --help` explains its fields. diff --git a/src/command.rs b/src/command.rs index 729f7bc..341c97e 100644 --- a/src/command.rs +++ b/src/command.rs @@ -3,7 +3,7 @@ use crate::{ auth, baseline, cancellation, catalog, config, config::ConfigContext, - init, manual, + init, manual, mcp, options::{self, JevCommand}, output, revision, server, }; @@ -15,6 +15,7 @@ pub fn run(command: JevCommand) -> Result { JevCommand::Completions { shell } => manual::completions(shell).map(|()| 0), JevCommand::Man { command } => manual::man(command.as_deref()).map(|()| 0), JevCommand::Init { force } => init(force), + JevCommand::Mcp => mcp::run().map(|()| 0), command => configured(command), } } @@ -44,7 +45,8 @@ fn configured(command: JevCommand) -> Result { JevCommand::Auth { .. } | JevCommand::Init { .. } | JevCommand::Completions { .. } - | JevCommand::Man { .. } => { + | JevCommand::Man { .. } + | JevCommand::Mcp => { unreachable!("handled before repository configuration") } JevCommand::Check(mut args) => { diff --git a/src/main.rs b/src/main.rs index 98db43a..1eeb890 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,6 +43,7 @@ mod inventory; mod line_ranges; mod locations; mod manual; +mod mcp; mod options; mod output; mod packages; diff --git a/src/mcp.rs b/src/mcp.rs new file mode 100644 index 0000000..b4aa870 --- /dev/null +++ b/src/mcp.rs @@ -0,0 +1,358 @@ +//! `jevgate mcp`: a Model Context Protocol server on stdin and stdout, so a +//! coding agent can run a check, read the last report's findings and look up +//! the rules as tools. Messages are newline-delimited JSON-RPC 2.0. A check +//! runs as a child process, so nothing it prints reaches the protocol stream. +use crate::{catalog, output, schema::Report}; +use anyhow::{Context, Result}; +use serde_json::{Value, json}; +use std::{ + io::{BufRead, Write}, + path::PathBuf, +}; + +/// Protocol versions this server speaks; the first is offered when the +/// client asks for one it does not know. +const VERSIONS: [&str; 4] = ["2025-06-18", "2025-11-25", "2025-03-26", "2024-11-05"]; +/// Findings returned by one `jevgate_findings` call. +const MAX_FINDINGS: usize = 50; + +const INSTRUCTIONS: &str = "JevGate reviews code by asking TypeSafe Jev small questions about functions, files, tests and docs. \ +Call jevgate_check with `base` (such as origin/main) to review what changed; it uses the repository's jevgate.toml and TYPESAFE_API_KEY, and paid requests only for code the answer cache lacks. \ +Fix each `review` finding; for a `consider`, fix it or explain why the code should stay. Exit code 2 means the run could not finish: report it, never treat it as a pass. \ +jevgate_findings reads the last report without running anything."; + +pub fn run() -> Result<()> { + let root = crate::config::repository_root(&std::env::current_dir()?.canonicalize()?); + let server = Server { + root, + executable: std::env::current_exe().context("Cannot find the jevgate executable")?, + }; + let stdin = std::io::stdin(); + let mut stdout = std::io::stdout().lock(); + for line in stdin.lock().lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + if let Some(reply) = server.handle(&line) { + writeln!(stdout, "{reply}")?; + stdout.flush()?; + } + } + Ok(()) +} + +struct Server { + root: PathBuf, + executable: PathBuf, +} + +impl Server { + /// The reply to one message, or none for a notification. + fn handle(&self, line: &str) -> Option { + let Ok(message) = serde_json::from_str::(line) else { + return Some(error(Value::Null, -32700, "Parse error")); + }; + let id = message.get("id").cloned()?; + let params = message.get("params").cloned().unwrap_or(Value::Null); + Some(match message["method"].as_str() { + Some("initialize") => reply(id, initialize(¶ms)), + Some("ping") => reply(id, json!({})), + Some("tools/list") => reply(id, json!({"tools": tools()})), + Some("tools/call") => reply(id, self.call(¶ms)), + _ => error(id, -32601, "Method not found"), + }) + } + + fn call(&self, params: &Value) -> Value { + let arguments = ¶ms["arguments"]; + let outcome = match params["name"].as_str() { + Some("jevgate_check") => self.check(arguments), + Some("jevgate_findings") => self.findings(arguments), + Some("jevgate_rules") => { + Ok(serde_json::to_string_pretty(&catalog::describe()).unwrap_or_default()) + } + other => Err(anyhow::anyhow!("Unknown tool: {}", other.unwrap_or(""))), + }; + match outcome { + Ok(text) => json!({"content": [{"type": "text", "text": text}], "isError": false}), + Err(error) => { + json!({"content": [{"type": "text", "text": format!("{error:#}")}], "isError": true}) + } + } + } + + /// Run `jevgate check` in the repository and return its agent output + /// with the exit code's meaning. + fn check(&self, arguments: &Value) -> Result { + let output = std::process::Command::new(&self.executable) + .current_dir(&self.root) + .args(check_arguments(arguments)?) + .stdin(std::process::Stdio::null()) + .output() + .context("Cannot start jevgate check")?; + let mut text = String::from_utf8_lossy(&output.stdout) + .trim_end() + .to_string(); + let errors = String::from_utf8_lossy(&output.stderr); + if !errors.trim().is_empty() { + text.push_str(&format!("\n\n{}", errors.trim_end())); + } + let meaning = match output.status.code() { + Some(0) if arguments["dry_run"].as_bool() == Some(true) => "dry run: nothing was sent", + Some(0) => "exit 0: the gate passed", + Some(1) => "exit 1: the gate failed; act on the findings", + Some(2) => anyhow::bail!( + "{}\n\n(exit 2: the run could not finish or the arguments are invalid; this is not a pass)", + text.trim_start() + ), + _ => anyhow::bail!("{}\n\n(the check was interrupted)", text.trim_start()), + }; + Ok(format!("{text}\n\n({meaning})")) + } + + /// Findings of the last report, ranked, optionally for one path prefix. + fn findings(&self, arguments: &Value) -> Result { + let report = crate::storage::read_latest(&self.root) + .context("No report yet; call jevgate_check first")?; + Ok(serde_json::to_string_pretty(&findings( + &report, + arguments["path"].as_str(), + arguments["include_notes"].as_bool().unwrap_or(false), + )) + .unwrap_or_default()) + } +} + +/// `check` arguments from a tool call. Values are passed as `--flag=value` +/// and paths after `--`, so no value is read as another flag. +fn check_arguments(arguments: &Value) -> Result> { + let mut args = vec![ + "check".to_string(), + "--format=agent".into(), + "--color=never".into(), + ]; + if let Some(base) = arguments["base"].as_str() { + args.push(format!("--base={base}")); + } + for rule in strings(&arguments["rules"], "rules")? { + args.push(format!("--rule={rule}")); + } + for (flag, name) in [ + ("--include-tests", "include_tests"), + ("--dry-run", "dry_run"), + ("--verbose", "verbose"), + ] { + if arguments[name].as_bool() == Some(true) { + args.push(flag.into()); + } + } + let paths = strings(&arguments["paths"], "paths")?; + if !paths.is_empty() { + args.push("--".into()); + args.extend(paths); + } + Ok(args) +} + +fn strings(value: &Value, name: &str) -> Result> { + match value { + Value::Null => Ok(Vec::new()), + Value::Array(items) => items + .iter() + .map(|item| { + item.as_str() + .map(str::to_string) + .with_context(|| format!("`{name}` must be a list of strings")) + }) + .collect(), + _ => anyhow::bail!("`{name}` must be a list of strings"), + } +} + +fn findings(report: &Report, prefix: Option<&str>, include_notes: bool) -> Value { + let all: Vec = output::ranked(report) + .into_iter() + .filter(|(path, _)| prefix.is_none_or(|p| path.starts_with(p))) + .filter(|(_, f)| include_notes || f.strength != crate::schema::Strength::Note) + .map(|(path, f)| { + json!({ + "path": path, + "line": f.line, + "rule": f.rule, + "strength": output::label(&f.strength), + "message": f.message, + "action": f.action, + "probability": f.concern_probability, + "baselined": f.baselined, + }) + }) + .collect(); + json!({ + "headline": output::headline(report), + "complete": report.complete, + "gate": report.gate, + "shown": all.len().min(MAX_FINDINGS), + "total": all.len(), + "findings": all.into_iter().take(MAX_FINDINGS).collect::>(), + }) +} + +fn initialize(params: &Value) -> Value { + let asked = params["protocolVersion"].as_str().unwrap_or_default(); + let version = VERSIONS + .iter() + .find(|v| **v == asked) + .unwrap_or(&VERSIONS[0]); + json!({ + "protocolVersion": version, + "capabilities": {"tools": {"listChanged": false}}, + "serverInfo": {"name": "jevgate", "version": env!("CARGO_PKG_VERSION")}, + "instructions": INSTRUCTIONS, + }) +} + +fn tools() -> Value { + json!([ + { + "name": "jevgate_check", + "title": "Review code with JevGate", + "description": "Run `jevgate check` in the repository and return its ranked findings, each with a location, probability and next step. Uses jevgate.toml and TYPESAFE_API_KEY; unchanged code is answered from the cache for free, and dry_run costs nothing. Can take minutes on a large change.", + "inputSchema": { + "type": "object", + "properties": { + "base": {"type": "string", "description": "Review only files changed since this Git revision, such as origin/main"}, + "paths": {"type": "array", "items": {"type": "string"}, "description": "Files or directories to review instead of the discovered source"}, + "rules": {"type": "array", "items": {"type": "string"}, "description": "Rule IDs, keys or groups, such as security; replaces the configured selection"}, + "include_tests": {"type": "boolean", "description": "Also judge tests"}, + "dry_run": {"type": "boolean", "description": "List the files and planned requests without sending anything"}, + "verbose": {"type": "boolean", "description": "Also show optional notes and per-file detail"}, + }, + "additionalProperties": false, + }, + }, + { + "name": "jevgate_findings", + "title": "Read the last JevGate report", + "description": "Return the findings of the last check in this repository (.jevgate/latest.json), ranked, without running anything.", + "inputSchema": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "Only findings in files under this path"}, + "include_notes": {"type": "boolean", "description": "Also return optional notes"}, + }, + "additionalProperties": false, + }, + "annotations": {"readOnlyHint": true}, + }, + { + "name": "jevgate_rules", + "title": "List JevGate's rules", + "description": "Every rule with its ID, group, default, the question it asks and what it looks at.", + "inputSchema": {"type": "object", "properties": {}, "additionalProperties": false}, + "annotations": {"readOnlyHint": true}, + }, + ]) +} + +fn reply(id: Value, result: Value) -> Value { + json!({"jsonrpc": "2.0", "id": id, "result": result}) +} + +fn error(id: Value, code: i64, message: &str) -> Value { + json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}}) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + fn server() -> Server { + Server { + root: Path::new(".").into(), + executable: PathBuf::from("jevgate"), + } + } + + #[test] + fn initialize_lists_tools_and_answers_unknown_methods() { + let server = server(); + let init = server + .handle(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26"}}"#) + .unwrap(); + assert_eq!(init["result"]["protocolVersion"], "2025-03-26"); + assert_eq!(init["result"]["serverInfo"]["name"], "jevgate"); + let newer = server + .handle(r#"{"jsonrpc":"2.0","id":2,"method":"initialize","params":{"protocolVersion":"2099-01-01"}}"#) + .unwrap(); + assert_eq!(newer["result"]["protocolVersion"], VERSIONS[0]); + assert!( + server + .handle(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#) + .is_none() + ); + let list = server + .handle(r#"{"jsonrpc":"2.0","id":"a","method":"tools/list"}"#) + .unwrap(); + let names: Vec<&str> = list["result"]["tools"] + .as_array() + .unwrap() + .iter() + .map(|t| t["name"].as_str().unwrap()) + .collect(); + assert_eq!( + names, + ["jevgate_check", "jevgate_findings", "jevgate_rules"] + ); + let unknown = server + .handle(r#"{"jsonrpc":"2.0","id":3,"method":"resources/list"}"#) + .unwrap(); + assert_eq!(unknown["error"]["code"], -32601); + assert_eq!(server.handle("not json").unwrap()["error"]["code"], -32700); + } + + #[test] + fn tool_arguments_never_become_flags() { + let args = check_arguments(&json!({ + "base": "--config=/etc/passwd", + "rules": ["security"], + "paths": ["--refresh", "src"], + "dry_run": true, + })) + .unwrap(); + assert_eq!( + args, + [ + "check", + "--format=agent", + "--color=never", + "--base=--config=/etc/passwd", + "--rule=security", + "--dry-run", + "--", + "--refresh", + "src" + ] + ); + assert!(check_arguments(&json!({"paths": "src"})).is_err()); + } + + #[test] + fn a_failed_tool_call_is_a_tool_error_not_a_protocol_error() { + let reply = server() + .handle(r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"nope","arguments":{}}}"#) + .unwrap(); + assert_eq!(reply["result"]["isError"], true); + let rules = server() + .handle(r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"jevgate_rules"}}"#) + .unwrap(); + assert_eq!(rules["result"]["isError"], false); + assert!( + rules["result"]["content"][0]["text"] + .as_str() + .unwrap() + .contains("security/injection") + ); + } +} diff --git a/src/options/commands.rs b/src/options/commands.rs index ff939f1..25e1e44 100644 --- a/src/options/commands.rs +++ b/src/options/commands.rs @@ -97,9 +97,17 @@ pub enum JevCommand { /// command, such as `jevgate-check`. #[command(after_long_help = MAN_EXAMPLES)] Man { - /// A command: auth, check, baseline, rules, init, serve or completions + /// A command: auth, check, baseline, rules, init, serve, mcp or completions command: Option, }, + /// Run a Model Context Protocol server on stdin and stdout, for coding agents + /// + /// Offers three tools: `jevgate_check` runs a check in the repository and + /// returns its findings, `jevgate_findings` reads the last report, and + /// `jevgate_rules` lists the rules. Register it with an agent as the + /// command `jevgate mcp`, started in the repository. + #[command(after_long_help = MCP_EXAMPLES)] + Mcp, /// Serve the latest report as read-only JSON on localhost (run alongside `check --watch`) /// /// Answers GET requests from local tools, never from a browser page: @@ -235,6 +243,12 @@ Examples: jevgate completions fish > ~/.config/fish/completions/jevgate.fish jevgate completions powershell >> $PROFILE"; +const MCP_EXAMPLES: &str = "\ +Examples: + claude mcp add jevgate -- jevgate mcp Claude Code, in the repository + {\"mcpServers\": {\"jevgate\": {\"command\": \"jevgate\", \"args\": [\"mcp\"]}}} + Clients configured with JSON, such as Cursor"; + const MAN_EXAMPLES: &str = "\ Examples: jevgate man > ~/.local/share/man/man1/jevgate.1 diff --git a/tests/cli/main.rs b/tests/cli/main.rs index 1de16dd..8f33f22 100644 --- a/tests/cli/main.rs +++ b/tests/cli/main.rs @@ -3,6 +3,7 @@ mod auth; mod changes; mod manual; +mod mcp; mod preview; mod rules; #[path = "../support/temp_dir.rs"] diff --git a/tests/cli/mcp.rs b/tests/cli/mcp.rs new file mode 100644 index 0000000..0a07bdf --- /dev/null +++ b/tests/cli/mcp.rs @@ -0,0 +1,54 @@ +//! The MCP server over stdin and stdout. +use super::*; +use std::io::Write; + +#[test] +fn mcp_answers_each_request_on_its_own_line_and_runs_a_dry_check() { + let project = Project::new(); + std::fs::write( + project.0.join("app.py"), + "def total(rows):\n s = 0\n for r in rows:\n s += r\n s = s * 2\n return s + 1\n", + ) + .unwrap(); + let mut child = project + .command() + .arg("mcp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + let requests = [ + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}"#, + r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#, + r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"jevgate_check","arguments":{"dry_run":true}}}"#, + r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"jevgate_check","arguments":{"base":"no-such-revision"}}}"#, + ]; + let mut stdin = child.stdin.take().unwrap(); + for request in requests { + writeln!(stdin, "{request}").unwrap(); + } + drop(stdin); + let output = child.wait_with_output().unwrap(); + assert!(output.status.success()); + let replies: Vec = String::from_utf8(output.stdout) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(replies.len(), 3, "the notification gets no reply"); + assert_eq!(replies[0]["result"]["protocolVersion"], "2025-06-18"); + let dry = &replies[1]["result"]; + assert_eq!(dry["isError"], false); + let text = dry["content"][0]["text"].as_str().unwrap(); + assert!(text.starts_with("JevGate: dry run ยท 1 files"), "{text}"); + assert!(text.ends_with("(dry run: nothing was sent)")); + let failed = &replies[2]["result"]; + assert_eq!(failed["isError"], true, "exit 2 is never a pass"); + assert!( + failed["content"][0]["text"] + .as_str() + .unwrap() + .contains("no-such-revision") + ); + assert!(!project.0.join(".jevgate/latest.json").exists()); +}