Self-hosted, caller-configured default-deny action registry + append-only spend ledger + human sign-off audit trail, exposed as MCP tools. classify() is unconditional GATED -- that invariant is never configurable. Governance/safety flagship: stop an agent from doing something irreversible without a human noticing.
Project description
rails-mcp
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_typestrings 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_actionreturningGATEDdoes 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_intentrecords 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)
pip install rails-mcp
Add to your Claude Desktop/Code MCP config:
{
"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:
{
"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:
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.pyandrails_mcp/routes.pynever import or callappend_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) intests/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_signofftool exists, checked intests/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.jsoninvokes) imports onlyrails_mcp.server, neverrails_mcp.cli-- so even the process that serves MCP tools has no code path to thesignsubcommand.
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
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):
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
.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) -- theserve/signsubcommands, including thatsignis 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, andmain()end-to-end against match/drift/missing fixtures.
Install / connect
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
.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 -- MIT-licensed, backed by Microsoft, broader/heavier policy-enforcement scope covering the OWASP Agentic Top 10. The "big-name, well-resourced" adjacent entrant.
- Marchward -- 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.pyspecifically (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.jsondeny rules, no graduation gates. You wireclassify_action/is_action_registeredinto 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_budgettells you the number; nothing here intercepts a call before it happens. - Ledger rotation/capping.
record_spend_intentappends 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. For production MCP integrations, agent-governance rails, or agent-reliability work, see jaimenbell.dev or sponsor ongoing maintenance via GitHub Sponsors.
mcp-name: io.github.jaimenbell/rails-mcp
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file rails_mcp-0.1.0.tar.gz.
File metadata
- Download URL: rails_mcp-0.1.0.tar.gz
- Upload date:
- Size: 33.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.10
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
54b35c0ef0dbee99a1637599703427557deca3d2db7f598bf054383e415eae74
|
|
| MD5 |
cbf1953f070674df153dda915c82a38f
|
|
| BLAKE2b-256 |
de7aebe9f40eddfdf9b496950e35eddebb9d901f1d7645cd5e3ad12bd2fd3570
|
File details
Details for the file rails_mcp-0.1.0-py3-none-any.whl.
File metadata
- Download URL: rails_mcp-0.1.0-py3-none-any.whl
- Upload date:
- Size: 22.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.10
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3efe48650b64bd3a102cd8c7dfd71d3e938a3e5816fb427b5b2cb3bffa90cb8d
|
|
| MD5 |
4a64341a40fd7a53b67d40c53d726ce5
|
|
| BLAKE2b-256 |
fcf73dc031bbcaf944b7621f84bcb1140cbf1ca35e7561768631155012ffff95
|