diff --git a/README.es.md b/README.es.md index d349283..257e793 100644 --- a/README.es.md +++ b/README.es.md @@ -93,6 +93,11 @@ plugin de Claude Code para que un agente lo instale y lo maneje por vos: Agrega un skill `becwright` y un comando `/becwright`. Ver [`integrations/claude-code/`](integrations/claude-code/). +Para resultados estructurados, `becwright check --json` imprime un resumen +legible por máquina, y `becwright mcp` (instalá el extra `mcp`: `pipx install +"becwright[mcp]"`) levanta un servidor MCP que expone `check` y `list_checks` a +cualquier agente. Ver [`documentation/mcp.md`](documentation/mcp.md). + Una regla en `.bec/rules.yaml`: ```yaml diff --git a/README.md b/README.md index 7301b55..434a09e 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,11 @@ Claude Code plugin so an agent can install and drive it for you: It adds a `becwright` skill and a `/becwright` command. See [`integrations/claude-code/`](integrations/claude-code/). +For structured results, `becwright check --json` prints a machine-readable +summary, and `becwright mcp` (install the `mcp` extra: `pipx install +"becwright[mcp]"`) runs an MCP server exposing `check` and `list_checks` to any +agent. See [`documentation/mcp.md`](documentation/mcp.md). + A rule in `.bec/rules.yaml`: ```yaml diff --git a/documentation/mcp.es.md b/documentation/mcp.es.md new file mode 100644 index 0000000..7d13e4f --- /dev/null +++ b/documentation/mcp.es.md @@ -0,0 +1,68 @@ +# Servidor MCP y salida JSON + +Para agentes de IA, becwright expone sus resultados en formato legible por +máquina de dos maneras. + +## `becwright check --json` + +Igual que `becwright check`, pero imprime un resumen JSON en vez de texto con +color, consumible sin parsear. El código de salida no cambia (1 si falló una +regla blocking, si no 0). + +```json +{ + "rule_count": 3, + "checked_files": 1, + "blocked": true, + "results": [ + { + "id": "no-debugger-js", + "severity": "blocking", + "passed": false, + "intent": "Do not leave 'debugger;' in JavaScript/TypeScript code.", + "why_it_matters": "A forgotten 'debugger' halts execution ...", + "output": "app.js:1\n > function f(){ debugger; }" + } + ] +} +``` + +No necesita dependencias extra y funciona también desde el binario autónomo. + +## Servidor MCP + +`becwright mcp` levanta un servidor [Model Context +Protocol](https://modelcontextprotocol.io) sobre stdio, así cualquier agente +compatible con MCP (Claude, Cursor, Windsurf, …) obtiene becwright como tools +estructuradas. + +Requiere el extra opcional `mcp`: + +```bash +pipx install "becwright[mcp]" # o: pip install "becwright[mcp]" +``` + +### Tools + +| Tool | Argumentos | Devuelve | +|---|---|---| +| `check` | `all_files` (bool), `path` (dir del repo, opcional) | el mismo resumen que `check --json` | +| `list_checks` | — | los checks built-in como `{name, description}` | + +### Configuración del cliente + +Apuntá la config MCP de tu agente al comando: + +```json +{ + "mcpServers": { + "becwright": { + "command": "becwright", + "args": ["mcp"] + } + } +} +``` + +El servidor MCP viaja solo con el paquete de Python (no está en el binario de +npm). Para uso de CLI/hook sin Python, seguí usando la instalación por npm. diff --git a/documentation/mcp.md b/documentation/mcp.md new file mode 100644 index 0000000..cfb331f --- /dev/null +++ b/documentation/mcp.md @@ -0,0 +1,66 @@ +# MCP server & JSON output + +For AI agents, becwright exposes its results in machine-readable form two ways. + +## `becwright check --json` + +Same as `becwright check`, but prints a JSON summary instead of colored text and +is consumable without parsing. Exit code is unchanged (1 if a blocking rule +failed, else 0). + +```json +{ + "rule_count": 3, + "checked_files": 1, + "blocked": true, + "results": [ + { + "id": "no-debugger-js", + "severity": "blocking", + "passed": false, + "intent": "Do not leave 'debugger;' in JavaScript/TypeScript code.", + "why_it_matters": "A forgotten 'debugger' halts execution ...", + "output": "app.js:1\n > function f(){ debugger; }" + } + ] +} +``` + +This needs no extra dependency and works from the standalone binary too. + +## MCP server + +`becwright mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io) +server over stdio, so any MCP-capable agent (Claude, Cursor, Windsurf, …) gets +becwright as structured tools. + +It requires the optional `mcp` extra: + +```bash +pipx install "becwright[mcp]" # or: pip install "becwright[mcp]" +``` + +### Tools + +| Tool | Arguments | Returns | +|---|---|---| +| `check` | `all_files` (bool), `path` (optional repo dir) | the same summary as `check --json` | +| `list_checks` | — | the built-in checks as `{name, description}` | + +### Client configuration + +Point your agent's MCP config at the command: + +```json +{ + "mcpServers": { + "becwright": { + "command": "becwright", + "args": ["mcp"] + } + } +} +``` + +The MCP server ships only with the Python package (it is not in the npm binary). +For CLI/hook usage without Python, keep using the npm install. diff --git a/integrations/claude-code/skills/becwright/SKILL.md b/integrations/claude-code/skills/becwright/SKILL.md index 37f292d..d845b19 100644 --- a/integrations/claude-code/skills/becwright/SKILL.md +++ b/integrations/claude-code/skills/becwright/SKILL.md @@ -49,6 +49,10 @@ commit runs the checks; a blocking rule that fails stops the commit. | `becwright import ` | Add a BEC from the catalog (shows code, asks before installing) | | `becwright export ` | Export a rule as a portable `.bec.yaml` | +For structured results you can parse, use `becwright check --json`. An MCP server +is also available (`becwright mcp`, needs the `mcp` extra) exposing `check` and +`list_checks` as tools. + Catalog of ready-to-use BECs: https://github.com/DataDave-Dev/becwright/tree/main/becs diff --git a/pyproject.toml b/pyproject.toml index 60f564e..ff58eaa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,9 +28,11 @@ classifiers = [ becwright = "becwright.cli:main" [project.optional-dependencies] -dev = ["pytest>=8", "pytest-cov>=5"] +dev = ["pytest>=8", "pytest-cov>=5", "mcp>=1.2"] # Used to freeze the standalone binary distributed via npm (see packaging/). build = ["pyinstaller>=6"] +# MCP server for AI agents: `becwright mcp` (see src/becwright/mcp_server.py). +mcp = ["mcp>=1.2"] [tool.setuptools.packages.find] where = ["src"] diff --git a/src/becwright/cli.py b/src/becwright/cli.py index 7ee292b..70d92d1 100644 --- a/src/becwright/cli.py +++ b/src/becwright/cli.py @@ -7,8 +7,8 @@ import urllib.request from pathlib import Path -from . import __version__, bundle, git -from .engine import Result, evaluate +from . import __version__, bundle, git, report +from .engine import Result from .rules import load_rules RED = "\033[91m"; GREEN = "\033[92m"; YELLOW = "\033[93m" @@ -37,18 +37,21 @@ def _print_result(result: Result) -> None: def _cmd_check(args: argparse.Namespace) -> int: root = git.repo_root() - rules = load_rules(root / ".bec" / "rules.yaml") + rules, files, result = report.gather(root, all_files=args.all) + + if args.json: + import json + print(json.dumps(report.payload(rules, files, result), indent=2)) + return 1 if (result and result.had_blocking) else 0 + if not rules: print(f"{YELLOW}No .bec/rules.yaml with rules. Nothing to check.{RESET}") return 0 - - files = git.files_to_check(root, all_files=args.all) if not files: print(f"{DIM}No files to check.{RESET}") return 0 print(f"{BOLD}BEC -- {len(files)} file(s) against {len(rules)} rule(s){RESET}\n") - result = evaluate(rules, files, root) _print_result(result) if result.had_blocking: @@ -98,6 +101,18 @@ def _cmd_run(args: argparse.Namespace) -> int: return import_module(f"becwright.checks.{args.module}").main() +def _cmd_mcp(_: argparse.Namespace) -> int: + try: + from .mcp_server import serve + except ImportError: + print(f"{RED}The MCP server needs the 'mcp' extra.{RESET}", file=sys.stderr) + print(f'{DIM} pipx install "becwright[mcp]" (or: pip install "becwright[mcp]"){RESET}', + file=sys.stderr) + return 2 + serve() + return 0 + + def _cmd_list(_: argparse.Namespace) -> int: print(f"{BOLD}Built-in checks{RESET} {DIM}(use as: becwright run ){RESET}") for name in _builtin_check_names(): @@ -283,6 +298,7 @@ def _build_parser() -> argparse.ArgumentParser: p_check = sub.add_parser("check", help="check the code against the rules") p_check.add_argument("--all", action="store_true", help="check the whole repo, not just staging") + p_check.add_argument("--json", action="store_true", help="output results as JSON") p_check.set_defaults(func=_cmd_check) p_init = sub.add_parser("init", help="scaffold a starter .bec/rules.yaml and install the hook") @@ -295,6 +311,7 @@ def _build_parser() -> argparse.ArgumentParser: p_run.set_defaults(func=_cmd_run) sub.add_parser("list", help="list the built-in checks").set_defaults(func=_cmd_list) + sub.add_parser("mcp", help="run the MCP server for AI agents (needs the 'mcp' extra)").set_defaults(func=_cmd_mcp) sub.add_parser("install", help="install the pre-commit hook").set_defaults(func=_cmd_install) sub.add_parser("uninstall", help="remove the pre-commit hook").set_defaults(func=_cmd_uninstall) diff --git a/src/becwright/git.py b/src/becwright/git.py index 137c295..fa82f95 100644 --- a/src/becwright/git.py +++ b/src/becwright/git.py @@ -27,10 +27,10 @@ class NotAGitRepo(RuntimeError): pass -def repo_root() -> Path: +def repo_root(cwd: Path | None = None) -> Path: res = subprocess.run( ["git", "rev-parse", "--show-toplevel"], - capture_output=True, text=True, + cwd=cwd, capture_output=True, text=True, ) if res.returncode != 0: raise NotAGitRepo("You are not inside a git repository.") diff --git a/src/becwright/mcp_server.py b/src/becwright/mcp_server.py new file mode 100644 index 0000000..a8ecc51 --- /dev/null +++ b/src/becwright/mcp_server.py @@ -0,0 +1,45 @@ +"""MCP server exposing becwright to AI agents as structured tools. + +Only imported when running `becwright mcp`, so the `mcp` dependency stays optional +(install the `mcp` extra). The tools are thin wrappers over the same logic the CLI +uses, returning JSON-serializable results instead of human text. +""" +from __future__ import annotations + +from pathlib import Path + +from mcp.server.fastmcp import FastMCP + +from . import git, report +from .cli import _CHECK_DESCRIPTIONS, _builtin_check_names + +mcp = FastMCP("becwright") + + +@mcp.tool() +def check(all_files: bool = False, path: str | None = None) -> dict: + """Run becwright's rules and return structured results. + + Args: + all_files: check the whole repo instead of only staged files. + path: a directory inside the target git repo (defaults to the cwd). + + Returns a summary with rule_count, checked_files, a `blocked` flag and a + per-rule list of {id, severity, passed, intent, why_it_matters, output}. + """ + root = git.repo_root(Path(path) if path else None) + rules, files, result = report.gather(root, all_files=all_files) + return report.payload(rules, files, result) + + +@mcp.tool() +def list_checks() -> list[dict]: + """List becwright's built-in checks as {name, description}.""" + return [ + {"name": name, "description": _CHECK_DESCRIPTIONS.get(name, "")} + for name in _builtin_check_names() + ] + + +def serve() -> None: + mcp.run() diff --git a/src/becwright/report.py b/src/becwright/report.py new file mode 100644 index 0000000..fb2f282 --- /dev/null +++ b/src/becwright/report.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from pathlib import Path + +from . import git +from .engine import Result, evaluate +from .rules import Rule, load_rules + + +def gather(root: Path, *, all_files: bool) -> tuple[list[Rule], list[str], Result | None]: + """Load rules, find the files to check and evaluate them. The result is None + when there is nothing to check (no rules or no files).""" + rules = load_rules(root / ".bec" / "rules.yaml") + files = git.files_to_check(root, all_files=all_files) + if not rules or not files: + return rules, files, None + return rules, files, evaluate(rules, files, root) + + +def payload(rules: list[Rule], files: list[str], result: Result | None) -> dict: + """Build a JSON-serializable summary shared by `check --json` and the MCP server.""" + results = [] + if result is not None: + for r in result.per_rule: + results.append({ + "id": r.rule.id, + "severity": r.rule.severity, + "passed": r.passed, + "intent": r.rule.intent or None, + "why_it_matters": r.rule.why_it_matters or None, + "output": r.output or None, + }) + return { + "rule_count": len(rules), + "checked_files": len(files), + "blocked": result.had_blocking if result else False, + "results": results, + } diff --git a/tests/test_cli_and_git.py b/tests/test_cli_and_git.py index aef681d..741f016 100644 --- a/tests/test_cli_and_git.py +++ b/tests/test_cli_and_git.py @@ -165,3 +165,11 @@ def test_run_unknown_check_returns_2(monkeypatch): import io monkeypatch.setattr("sys.stdin", io.StringIO("")) assert cli.main(["run", "nope_check"]) == 2 + + +# --- the mcp subcommand --- + +def test_mcp_subcommand_without_extra(monkeypatch): + # Simulate the 'mcp' extra not being installed: force the import to fail. + monkeypatch.setitem(sys.modules, "becwright.mcp_server", None) + assert cli.main(["mcp"]) == 2 diff --git a/tests/test_mcp.py b/tests/test_mcp.py new file mode 100644 index 0000000..7ce93e9 --- /dev/null +++ b/tests/test_mcp.py @@ -0,0 +1,55 @@ +import asyncio +import subprocess +from pathlib import Path + +import pytest + +pytest.importorskip("mcp") + +from becwright import mcp_server # noqa: E402 + +_SRC = Path(__file__).resolve().parents[1] / "src" + + +def _git(root, *args): + subprocess.run(["git", *args], cwd=root, check=True, capture_output=True, text=True) + + +def _repo_with_rule(path): + _git(path, "init") + _git(path, "config", "user.email", "t@t.t") + _git(path, "config", "user.name", "t") + check = f'PYTHONPATH="{_SRC}" python -m becwright.checks.forbid --pattern "breakpoint"' + (path / ".bec").mkdir(parents=True) + (path / ".bec" / "rules.yaml").write_text( + "rules:\n - id: no-bp\n paths: ['**/*.py']\n" + f" check: '{check}'\n severity: blocking\n", encoding="utf-8") + return path + + +def test_tools_registered(): + tools = asyncio.run(mcp_server.mcp.list_tools()) + assert {t.name for t in tools} == {"check", "list_checks"} + + +def test_list_checks_tool_returns_all_builtins(): + names = [c["name"] for c in mcp_server.list_checks()] + assert "forbid" in names and "hardcoded_secrets" in names + assert names == sorted(names) + + +def test_check_tool_reports_block(tmp_path): + _repo_with_rule(tmp_path) + (tmp_path / "a.py").write_text("breakpoint()\n", encoding="utf-8") + _git(tmp_path, "add", "a.py") + out = mcp_server.check(all_files=True, path=str(tmp_path)) + assert out["blocked"] is True + assert out["results"][0]["id"] == "no-bp" and out["results"][0]["passed"] is False + + +def test_check_tool_clean_repo(tmp_path): + _repo_with_rule(tmp_path) + (tmp_path / "ok.py").write_text("x = 1\n", encoding="utf-8") + _git(tmp_path, "add", "ok.py") + out = mcp_server.check(all_files=True, path=str(tmp_path)) + assert out["blocked"] is False diff --git a/tests/test_report_and_json.py b/tests/test_report_and_json.py new file mode 100644 index 0000000..a9a2bc3 --- /dev/null +++ b/tests/test_report_and_json.py @@ -0,0 +1,86 @@ +import json +import subprocess +from pathlib import Path + +from becwright import cli, report +from becwright.rules import Rule +from becwright.engine import evaluate + +_SRC = Path(__file__).resolve().parents[1] / "src" + + +def _git(root, *args): + subprocess.run(["git", *args], cwd=root, check=True, capture_output=True, text=True) + + +def _init_repo(path): + _git(path, "init") + _git(path, "config", "user.email", "t@t.t") + _git(path, "config", "user.name", "t") + return path + + +def _rule_yaml(tmp_path): + check = f'PYTHONPATH="{_SRC}" python -m becwright.checks.forbid --pattern "\\bdebugger\\b"' + (tmp_path / ".bec").mkdir(parents=True, exist_ok=True) + (tmp_path / ".bec" / "rules.yaml").write_text( + "rules:\n - id: no-dbg\n intent: no debugger\n why_it_matters: it halts\n" + f" paths: ['**/*.js']\n check: '{check}'\n severity: blocking\n", + encoding="utf-8") + + +# --- report.gather --- + +def test_gather_none_when_no_rules(tmp_path): + _init_repo(tmp_path) + rules, files, result = report.gather(tmp_path, all_files=True) + assert rules == [] and result is None + + +def test_gather_none_when_no_files(tmp_path): + _init_repo(tmp_path) + _rule_yaml(tmp_path) + rules, files, result = report.gather(tmp_path, all_files=True) + assert rules and files == [] and result is None + + +# --- report.payload --- + +def test_payload_reports_blocking_violation(): + rule = Rule(id="no-dbg", paths=("**/*.js",), check="false", + intent="no debugger", why_it_matters="it halts", severity="blocking") + from becwright.engine import Result, RuleResult + result = Result(per_rule=[RuleResult(rule=rule, passed=False, output="app.js:1")]) + out = report.payload([rule], ["app.js"], result) + assert out["blocked"] is True + assert out["checked_files"] == 1 and out["rule_count"] == 1 + entry = out["results"][0] + assert entry == {"id": "no-dbg", "severity": "blocking", "passed": False, + "intent": "no debugger", "why_it_matters": "it halts", "output": "app.js:1"} + + +def test_payload_empty_when_no_result(): + out = report.payload([], [], None) + assert out == {"rule_count": 0, "checked_files": 0, "blocked": False, "results": []} + + +# --- cli: check --json --- + +def test_check_json_blocks_and_is_valid_json(tmp_path, monkeypatch, capsys): + _init_repo(tmp_path) + _rule_yaml(tmp_path) + (tmp_path / "app.js").write_text(" debugger;\n", encoding="utf-8") + _git(tmp_path, "add", "app.js") + monkeypatch.chdir(tmp_path) + rc = cli.main(["check", "--all", "--json"]) + data = json.loads(capsys.readouterr().out) + assert rc == 1 and data["blocked"] is True + assert data["results"][0]["id"] == "no-dbg" + + +def test_check_json_clean_repo(tmp_path, monkeypatch, capsys): + _init_repo(tmp_path) + monkeypatch.chdir(tmp_path) + rc = cli.main(["check", "--all", "--json"]) + data = json.loads(capsys.readouterr().out) + assert rc == 0 and data["blocked"] is False and data["results"] == []