Metadata-Version: 2.4
Name: rails-mcp
Version: 0.1.0
Summary: 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.
Requires-Python: >=3.12
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: fastmcp==3.4.2
Requires-Dist: pyyaml>=6.0
Provides-Extra: test
Requires-Dist: pytest==9.0.3; extra == "test"
Dynamic: license-file

# 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) or sponsor ongoing maintenance via
[GitHub Sponsors](https://github.com/sponsors/jaimenbell).

<!-- MCP registry ownership marker -->
mcp-name: io.github.jaimenbell/rails-mcp
