# rails-mcp [![PyPI](https://img.shields.io/pypi/v/rails-mcp)](https://pypi.org/project/rails-mcp/) [![MCP Registry](https://img.shields.io/badge/MCP%20Registry-io.github.jaimenbell%2Frails--mcp-blue)](https://registry.modelcontextprotocol.io) [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![Tests](https://img.shields.io/badge/tests-106%20passing-brightgreen)](#testing) [![CI](https://github.com/jaimenbell/rails-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/jaimenbell/rails-mcp/actions/workflows/ci.yml) A self-hosted, caller-configured **default-deny action registry** + append-only **spend ledger** + CLI-only **sign-off audit trail**, exposed as MCP tools. Different category from this author's other six MCP servers (mcp-factory, rag-mcp, bus-mcp, desktop-mcp, github-mcp, discord-mcp) -- those are devtools ("connect an agent to X"); this one is **governance and safety**: "stop an agent from doing something irreversible without a human noticing." Ported and generalized from a live internal registry (`shared/rails/`, 31 tests, running against a real multi-bot fleet since 2026-07-06) -- this pattern shipped internally before it shipped publicly. ## What this is / is not **Is:** - A schema + pure logic for classifying `action_type` strings as unconditionally **GATED** (default-deny), registered or not. - A place to **register** what you know about an action-type's current enforcement (`enforcement_layer`, `enforcement_pointer`, `ceremony`) -- informational, never permissive. - An append-only **spend-intent ledger** + rolling-window budget check. - An append-only **human sign-off ledger**, recording who blessed the registry's current hash and when. **Is NOT:** - **Not an enforcer.** `classify_action` returning `GATED` does not block anything by itself. You still wire it into your own PreToolUse hook, permission deny-list, or CI gate -- rails-mcp gives you the schema and the audit trail, not the interceptor. `record_spend_intent` records intent to spend; it never calls a vendor, a paid API, or a broker, and nothing here stops an over-budget spend from happening. - **Not pre-loaded with any action-type data.** Every adopter supplies their own registry (a YAML/JSON config file, or a plain dict). No fleet's specific action-types ship with this package. - **Not multi-tenant.** The sign-off ledger assumes one human operator string per registry; fine for v1, a known limitation for later. ## The one invariant that is never configurable `classify_action(action_type)` **always** returns `"GATED"` -- registered or not, whatever config was loaded, no argument or config field can change it. This is the whole product. A config-driven fail-open knob would defeat the entire pitch, so `classify()` (`rails_mcp/registry.py`) takes only an `action_type` argument: there is no parameter through which a caller could ever make it return anything permissive. `is_action_registered` answers a separate, purely informational question -- "do I know something about this action-type's enforcement?" -- and never feeds back into the GATED verdict. ## Quickstart (60 seconds) ```bash pip install rails-mcp ``` Add to your Claude Desktop/Code MCP config: ```json { "mcpServers": { "rails-mcp": { "command": "rails-mcp" } } } ``` No console script on PATH? Fall back to `"command": "python", "args": ["-m", "rails_mcp"]`. By default the registry loads **empty** (honest-empty, not fail-open -- `classify_action` is still unconditionally `GATED` for everything). Point it at your own action-type config: ```json { "mcpServers": { "rails-mcp": { "command": "rails-mcp", "env": { "RAILS_MCP_CONFIG_PATH": "C:\\path\\to\\rails.config.yaml" } } } } ``` See `examples/rails.config.example.yaml` (or `.example.json`) for the config shape. ## Tools All six are **read-mostly** -- none of them can write to the sign-off ledger. | Tool | Purpose | |---|---| | `classify_action(action_type)` | Default-deny verdict: always `"GATED"`. Implemented in `rails_mcp/registry.py::classify`, tested in `tests/test_registry.py` + `tests/test_server.py`. | | `is_action_registered(action_type)` | Whether the loaded registry has an entry, plus `enforcement_layer`/`enforcement_pointer`/`ceremony` when present. `rails_mcp/routes.py::is_action_registered`, tested in `tests/test_routes.py`. | | `get_rails_hash()` | 12-hex sha256 digest of the loaded registry + entry count -- the value a human sign-off records. `rails_mcp/registry.py::rails_hash`, tested in `tests/test_registry.py`. | | `get_signoff_state()` | Current active human sign-off, or `null`. Read-only. `rails_mcp/registry.py::load_signoff_state`, tested in `tests/test_registry.py` + `tests/test_routes.py`. | | `record_spend_intent(amount_usd, vendor, purpose, actor)` | Append one spend-intent record. Never calls a vendor or paid API. `rails_mcp/spend_ledger.py::record_spend_intent`, tested in `tests/test_spend_ledger.py`. | | `evaluate_budget(limit_usd, window_days=30.0)` | Rolling-window spend total vs. limit. Never raises. `rails_mcp/spend_ledger.py::evaluate_budget`, tested in `tests/test_spend_ledger.py`. | ## The CLI-only sign-off boundary -- and why it exists `append_signoff` -- the function that records a human blessing the registry's current hash -- is **deliberately not an MCP tool**, and never will be. It is exposed only as a CLI command a human runs by hand: ```bash rails-mcp sign --operator "jaime" --note "reviewed 2026-07-16 config" ``` **Why:** the boundary exists to prevent an agent holding only this server's MCP tool connection from self-approving an irreversible action. If `append_signoff` were reachable as an MCP tool, any agent holding this server's connection could sign its own registry -- silently defeating the one thing the boundary exists to enforce. This mirrors the internal design rule the original `shared/rails/` implementation was built around: **the lane that builds the auditor never signs the registry it ships.** An auditor that can also sign isn't an auditor. **What this boundary does *not* prove:** the sign-off ledger has no cryptographic tamper-evidence and no binding to a real human identity -- its integrity rests entirely on filesystem ACLs and the self-hosted deployment model, not on cryptography. An agent (or anyone) with shell or file-write access to the ledger's path can run `rails-mcp sign` itself, or hand-append a forged `{"type": "signoff", ...}` JSONL line straight into the file -- the ledger has no way to tell that apart from a real CLI invocation. The MCP-only boundary stops the narrower case of an agent that has *only* this server's MCP tool connection; it is not proof that a human reviewed anything, and shouldn't be read as one. This boundary is enforced structurally, not just by convention: - `rails_mcp/server.py` and `rails_mcp/routes.py` never import or call `append_signoff`, anywhere -- proven by an AST-based check (not a naive string grep, which would false-positive on this very explanation appearing in their docstrings) in `tests/test_server.py::test_append_signoff_unreachable_via_any_mcp_tool`. - The registered MCP tool set is exactly the 6 read-mostly tools above -- no `sign`/`append_signoff`/`revoke_signoff` tool exists, checked in `tests/test_server.py::test_all_six_rails_tools_registered`. - A behavioral test drives every registered tool and confirms the sign-off ledger file is never created (`test_no_registered_tool_can_create_a_signoff_record`). - `run_server.py` (the entrypoint `~/.claude.json` invokes) imports only `rails_mcp.server`, never `rails_mcp.cli` -- so even the process that serves MCP tools has no code path to the `sign` subcommand. ## Env vars | Var | Default | Purpose | |---|---|---| | `RAILS_MCP_CONFIG_PATH` | unset | Path to your `rails.config.{yaml,yml,json}`. Unset = honest-empty registry (nothing registered, `classify_action` still unconditionally `GATED`). | | `RAILS_MCP_SIGNOFF_LEDGER_PATH` | `./rails_data/signoff.jsonl` | Where the append-only sign-off ledger lives. | | `RAILS_MCP_SPEND_LEDGER_PATH` | `./rails_data/spend.jsonl` | Where the append-only spend-intent ledger lives. | ## Config file shape ```yaml actions: deploy_prod: enforcement_layer: "L1" enforcement_pointer: "CI gate requires a passing e2e suite + a manual approve step" ceremony: "operator hand" ``` Or the more compact 3-element form (matches the internal registry's native shape): ```yaml actions: deploy_prod: ["L1", "CI gate requires a passing e2e suite + a manual approve step", "operator hand"] ``` JSON works identically (`{"actions": {"deploy_prod": [...]}}`). See `examples/` for full examples of both. `enforcement_layer` should be **honest, not aspirational** -- `"prose"` (no structural rail exists yet, just a doc) is a legitimate, correct value. Rounding a `"prose"` entry up to `"L1"` because it feels better defeats the entire point of an honest registry. ## Testing ```bash .venv/Scripts/python.exe -m pytest -q ``` CI (`.github/workflows/ci.yml`) runs this suite on every push/PR and fails the build if the Tests badge above drifts from what the suite actually reports -- see `scripts/check_readme_counts.py`. 106 tests, all hermetic (every ledger/config path goes through `tmp_path` + an autouse env-isolation fixture in `tests/conftest.py`; nothing touches a real `./rails_data/`). No network, no live-smoke gate needed -- this server has no external API to fake. - `tests/test_registry.py` (24) -- the ported + generalized registry logic: default-deny property tests, immutability, hash determinism, sign-off ledger fold/append/load, structural no-shell-out proof. - `tests/test_spend_ledger.py` (14) -- ported near-verbatim from the internal suite: append/load roundtrips, budget window math, naive- datetime honest-degrade, structural no-effector proof. - `tests/test_config.py` (18) -- new: env-var resolution, YAML/JSON loading in both entry shapes, honest-empty-when-unconfigured, loud failure on an explicit missing path. - `tests/test_routes.py` (14) -- the MCP tool surface's business logic, exercised directly. - `tests/test_server.py` (17) -- tool registration, passthrough correctness, and the CLI-only sign-off structural + behavioral proof. - `tests/test_cli.py` (8) -- the `serve`/`sign` subcommands, including that `sign` is genuinely append-only and prints a human-readable confirmation. - `tests/test_check_readme_counts.py` (11) -- this CI gate's own TDD suite: parse-claimed, parse-actual, compare, and `main()` end-to-end against match/drift/missing fixtures. ## Install / connect ```bash python -m venv .venv .venv/Scripts/python.exe -m pip install -e ".[test]" ``` Registered in `~/.claude.json` under `mcpServers.rails-mcp` as a stdio server invoking `run_server.py` by absolute path (no `cwd` needed -- the entrypoint adds its own directory to `sys.path`), OR via the `rails-mcp` console script once installed from PyPI. ## Handshake check ```bash .venv/Scripts/python.exe scripts/list_tools.py ``` Prints the six registered tool names with no transport started. ## Competitive picture (fact-checked 2026-07-16) The closest prior art is **not** a hosted dead-man's-switch product -- that's a different problem ("is the operator still alive and watching"). The closer comparisons, once actually verified: - **[Microsoft Agent Governance Toolkit](https://github.com/microsoft/agent-governance-toolkit)** -- MIT-licensed, backed by Microsoft, broader/heavier policy-enforcement scope covering the OWASP Agentic Top 10. The "big-name, well-resourced" adjacent entrant. - **[Marchward](https://marchward.ai/)** -- the closest feature-for-feature match: server-side credential injection, spend caps, human-approval gates for irreversible actions, tamper-evident logging, Apache-2.0 open-source proxy. - **AgentLedger** -- AGPL-3.0, overlaps `spend_ledger.py` specifically (budgets, approvals, audit trail). `rails-mcp`'s narrower bet: a small, inspectable, self-hosted registry+ledger+audit-trail schema with one hard invariant (default-deny classification can never be configured away) and one hard boundary (sign-off is CLI-only, never MCP-reachable) -- not a full policy-engine product. ## Out of scope - **Actual enforcement.** No git hooks, no `settings.json` deny rules, no graduation gates. You wire `classify_action`/`is_action_registered` into your own interceptor. - **A coverage auditor** that checks whether your specific enforcement mechanism (a hook, a CI gate) actually does what your registry claims. That is real, separate engineering (this author's internal `coverage_audit.py`) and is not part of this package. - **Multi-tenant / multi-operator sign-off.** One operator string per ledger for v1. - **Blocking an over-budget spend.** `evaluate_budget` tells you the number; nothing here intercepts a call before it happens. - **Ledger rotation/capping.** `record_spend_intent` appends forever -- there's no rotation, size cap, or archival built in. A known limitation, not yet a problem at v1 scale. ## Commercial support Maintained by [Jaimen Bell](https://jaimenbell.dev). For production MCP integrations, agent-governance rails, or agent-reliability work, see [jaimenbell.dev](https://jaimenbell.dev). Building your own MCP server? The [MCP Starter Kit](https://jaimenbell.gumroad.com/l/adnojp) has templates, a build playbook, and packaging war-stories from shipping this one. mcp-name: io.github.jaimenbell/rails-mcp