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