# Authentication isn't authorization: guarding Rails MCP tools with Reeve

> Source: <https://victorvelazquez.dev/blog/authentication-is-not-authorization-guarding-rails-mcp-tools-with-reeve/>
> Published: 2026-10-15 00:00:00+00:00

# Authentication isn't authorization: guarding Rails MCP tools with Reeve

**TL;DR:** [Reeve](https://github.com/vicmaster/reeve) is a Ruby gem that wraps MCP tool calls in your Rails app with per-record authorization, fail-closed behavior, and an audit ledger. We run it in production on an internal lead-scoring CRM at MagmaLabs, reusing the app’s existing Pundit policies. This post walks through that integration across six releases: what’s guarded, the bug that made a “deny” lie, why aggregates need provenance, and the tamper-evident ledger in 0.6.0. It also covers what *isn’t* guarded yet, because that’s most of the endpoint.

Here’s an MCP tool that’s authenticated and still wrong:

``` python
def call(query:)
  Invoice.where("number LIKE ?", "%#{query}%")
end
```

The request came in with a valid bearer token. Your controller checked it. Everything looks fine. But the token only answered one question: *who got through the door?* It didn’t answer the ones that matter in a multi-user app:

- Which human is this agent acting for?
- Which organizations and records may that human see?
- Does this tool apply the same policy as the web UI?
- If the call gets denied halfway through, did it already change anything?
- Afterwards, can anyone tell what the agent touched, and trust that record?

In the [graph engineering post](https://victorvelazquez.dev/blog/graph-engineering-minus-the-buzzword/) I promised Reeve would get its own introduction. This is it, told through the real integration rather than the README, because the integration is where the interesting problems showed up.

## What Reeve is, and isn’t

Reeve sits around a tool invocation *after* the caller has authenticated. It answers two narrow questions:

1. What may this authenticated agent touch on behalf of this human principal?
2. What did the invocation attempt, what came back, and which rule decided it?

Every guarded call goes through one funnel:

``` php
resolve principal
  -> look up guard
  -> authorize
  -> execute tool
  -> scope returned records / check derived-data provenance
  -> record audit entry
  -> return or deny
```

The collaborators fail closed. No principal, no guard, no scoper, or no recorder means a denial, never “carry on quietly.”

Just as important is what it doesn’t do. It doesn’t authenticate the connection, replace Devise or OAuth, prevent prompt injection, rate-limit, or control token costs. It can’t roll back an email that already went out. And it can’t guard a tool call that never passes through it. That last one comes back later.

(The name: a reeve was an official acting with delegated authority on someone else’s behalf. “Sheriff” comes from “shire-reeve.” An agent acting under a human’s authority seemed like a good fit.)

## The host app

The CRM is a Rails 8.1 app on PostgreSQL and Heroku. Before Reeve, it already had most of the pieces:

- Devise for the web app, plus API tokens tied to a user and an organization (stored as SHA-256 digests).
- Organization memberships, roles, and Pundit policies with policy scopes.
- A **custom** JSON-RPC MCP server at`POST /mcp` . No MCP framework.
- Twenty registered tools, from `search_leads` to`send_email` .

That custom server matters. If Reeve only worked with one MCP framework, it wouldn’t have worked here. The whole bridge in the server is this:

``` python
def reeve_invoke(tool:, arguments: {})
  Reeve.invoke(
    tool: tool,
    arguments: arguments,
    agent: @agent,
    metadata: @metadata
  )
end
```

One call. That’s the stable integration boundary, whatever your server looks like.

## Fail-closed, on purpose

The initializer is where you make the decisions that actually define your security posture:

```
Reeve.configure do |config|
  config.principal_resolver = lambda do |context|
    authorization = context.metadata.dig(:headers, "Authorization").to_s
    token = authorization.sub(/\ABearer\s+/, "")

    if token.present?
      api_token = ApiToken.authenticate(token)
      Current.user = api_token&.user
      Current.organization = api_token&.organization
    end

    Current.user
  end

  config.unguarded_tools = :deny
  config.redact_arguments = Reeve::Configuration::DEFAULT_REDACTED_ARGUMENTS
  config.audit_failure_mode = :fail
  config.max_recorded_ids = 1000
  config.ledger_key = Rails.application.key_generator
    .generate_key("reeve audit ledger", 32)
end
```

Two of these deserve a sentence each:

- **`unguarded_tools = :deny`** : a tool that reaches Reeve without declaring a guard is denied. Not logged-and-allowed. Denied.
- **`audit_failure_mode = :fail`** : if the audit row can’t be written, the call fails. Reeve writes synchronously from an`ensure` block, so the calls most worth auditing (denials and errors) can’t silently lose their trace while another error is in flight. That’s a real availability trade-off.`:warn` exists as an explicit degraded mode, and choosing between them is a product decision, not a library default you should accept blindly.

## Reuse the policies you already have

We did not build a second authorization system for MCP. The CRM’s `LeadPolicy` already decided who sees what in the web UI, so the MCP tools use it too:

```
class SearchLeadsReeveTool < ReadReeveTool
  include Reeve::Guard

  def self.tool_name = 'search_leads'

  guard_with LeadPolicy, action: :index

  def call(stage:, **filters)
    relation = base_relation(stage).current_records
    relation = apply_filters(relation, **filters)
    relation = ordered_relation(stage, relation)
    relation = scoped(relation)
    records, @meta = paginate(relation, **filters)
    records
  end
end
```

`base_relation` already narrows to `Current.organization`. `scoped(relation)` then applies the principal’s Pundit scope on top. Doubling up is deliberate defense in depth.

Reeve authorizes *before* execution and scopes *after*. A search tool has no record yet when it starts, so `index?` answers “may you search at all?” Then the policy scope answers “which of these may you see?” A collection gets narrowed; a single out-of-scope record gets denied rather than leaked.

One subtle thing in `get_lead`: it fetches through the scope, `scoped(base_relation(stage)).find_by(id: id)`. An unscoped `find_by(id:)` followed by a check can leak the difference between “exists but isn’t yours” and “doesn’t exist.” Fetching through the scope makes both look absent.

Real apps also break library assumptions. The CRM’s `LeadPolicy` inherits from a `LeadBasePolicy`, which is completely ordinary Pundit. Reeve 0.1 only looked for `Scope` in the policy’s own namespace, so it refused this policy at declaration time with a `ConfigurationError`. The first integration hit that, and 0.2 fixed it the next day by walking the policy’s ancestry.

## Aggregates need provenance, not post-processing

This one isn’t obvious until you hit it. Here’s the pipeline overview tool:

```
def call
  leads     = scoped(Current.organization.leads)
  pql       = scoped(Current.organization.pre_qualified_leads)
  mql       = scoped(Current.organization.marketing_qualified_leads)
  sql       = scoped(Current.organization.sales_qualified_leads)
  prospects = scoped(Current.organization.prospects)

  { pipeline: Pipeline::Overview.call([leads, pql, mql, sql, prospects]) }
end
```

You can remove unauthorized rows from a list after the fact. You can’t subtract them from a count. Once a tool returns `{ total: 412 }`, nobody can tell which rows went into it.

So Reeve treats `scoped(...)` as provenance. A derived result is allowed only when it was computed from scoped relations. Safe by construction, not by cleanup.

## A deny that follows a commit is a lie

This is the bug I’m least proud of and most glad we found.

Consider a write tool:

``` python
def call(id:, notes:)
  lead = Lead.find(id)
  lead.update!(notes: notes)
  lead
end
```

Pre-execution, the policy allows `update` at the class level. The tool runs and commits. Post-execution scoping discovers the lead belongs to another organization, so Reeve returns a denial and writes an audit row that says `deny`.

The database says otherwise. The write happened. The audit log is now making a false statement, which is worse than having no audit log.

Reeve 0.4 runs the tool body inside a nested transaction and rolls back on denial or error:

```
ActiveRecord::Base.transaction(requires_new: true) do
  # execute and scope the result
  raise ActiveRecord::Rollback if scope_denied?
end
```

`requires_new: true` becomes a savepoint if the host is already in a transaction, so Reeve undoes the tool’s work without undoing the caller’s. The CRM has an integration test for exactly this case:

```
it "rolls back writes from denied invocations" do
  hidden_lead = create(:lead, organization: other_organization, notes: "private")

  expect do
    Reeve.invoke(
      tool: ReeveLeadUpdateTool,
      principal: user,
      agent: { id: "rspec" },
      arguments: { id: hidden_lead.id, notes: "rewritten" }
    )
  end.to raise_error(Reeve::DeniedError)

  expect(hidden_lead.reload.notes).to eq("private")
end
```

That changed what “deny” means. It’s not a response status anymore. It’s a statement about database state.

### Transactions don’t unsend email

A rollback can undo a row. It can’t retract a webhook or unsend an email. For those, you authorize *before* the effect:

``` python
def call(id:)
  lead = authorize!(Lead.find(id))
  ExternalSideEffect.call(lead)
  lead
end
```

The CRM tests that an out-of-scope record never reaches the side effect. To be precise about it: that’s a test tool proving the gem’s behavior. The production `send_email` tool isn’t routed through Reeve yet.

## What one audit row tells you

Every guarded invocation writes one row, whether it was allowed, denied, or errored. Here’s what the CRM’s spec asserts on a search:

```
expect(audit_entry).to have_attributes(
  agent_id: "rspec",
  principal_type: "User",
  principal_id: user.id.to_s,
  tool_name: "reeve_lead_search_tool",
  outcome: "allow",
  rule: "LeadPolicy#index?",
  record_type: "Lead",
  record_count: 1,
  contract_version: 3
)

expect(audit_entry.arguments).to eq("token" => "[REDACTED]")
```

The agent, the human, the tool, the exact rule that decided, and which records came back (up to 1,000 IDs, with the true count and a `truncated` flag beyond that).

The remote request spec adds my favourite assertion:

``` js
expect(audit_entry.metadata).to eq(
  "headers" => { "Authorization" => "[REDACTED]" }
)
```

Transport metadata is useful for forensics and dangerous to store, because headers carry credentials. Redaction is recursive and keeps the *field name* while dropping the *value*, so you know a token was there without collecting tokens in your audit table.

One caveat on durability: the default recorder writes inside a savepoint, so if a larger host transaction wraps the invocation and rolls back, the audit row goes with it. Reeve 0.3 added an `IsolatedRecorder` that writes on its own connection. We confirmed it works on our PostgreSQL, but haven’t turned it on, because rows that survive surrounding rollbacks are a behavior change worth its own decision.

Each row also stamps a `contract_version`. A `NULL` can mean different things before and after a behavior change, and stamping every row removes that guesswork. We even dropped the column’s database default in a later migration so the recorder has to stamp each row deliberately.

## 0.6.0: tamper-evident, not tamper-proof

Before 0.6.0, the ledger was append-only at the library level. A raw `UPDATE` or `DELETE` in the database would change history, and nothing would notice.

0.6.0 chains each new row to its predecessor with HMAC-SHA256:

```
digest[n] = HMAC-SHA256(secret_key, canonical(entry[n]) + digest[n-1])
```

The hard part wasn’t the HMAC. It was **canonicalization**. The digest gets computed once before insert and again from whatever PostgreSQL, MySQL, or SQLite hands back later. JSON key order, time zones, and numeric formatting can’t be allowed to cause false alarms. So keys are sorted recursively, numbers use an exact decimal form, times become integer microseconds, and the string `"1"` stays distinct from the number `1`.

Verification is one command:

```
bin/rails reeve:verify_ledger   # exit 0 intact, 1 broken, 2 can't verify
```

Assuming the key hasn’t leaked, the chain detects edited rows, deleted rows before the head, reordered rows, rows inserted outside Reeve, and a deleted chain-state row.

Here’s what it *can’t* catch, and I think this list matters more:

- A rewrite by someone who holds the signing key.
- Deleting the newest rows *and* rewinding the chain head (you’d need an external checkpoint for that).
- Deleting the whole ledger. That reports `no_chain` , not “intact,” but it can’t tell you when it happened.
- Anything in rows written before chaining began.

On that last point: the upgrade left existing rows untouched and reports them as uncovered. We could have back-filled digests for them, but that would claim evidence that never existed. **Historical truth can’t be backfilled honestly.**

The precise language is: database privileges *prevent* changes; the chain *detects* supported changes after the fact. You want both.

Two operational notes if you copy our config:

- **Key rotation.** Deriving the key from`secret_key_base` is convenient, but rotating that secret rotates the ledger key. Keep old keys in`config.retired_ledger_keys` , or use a dedicated credential so the two rotations stay independent.
- **Cost.** Chained appends serialize on the chain head. Upstream benchmarks report roughly 0.5 ms per write on SQLite and about 1 ms on PostgreSQL. We haven’t benchmarked it in production.

The 0.6.0 upgrade shipped as Heroku release v50. The release phase applied the migration, the full suite passed (449 examples, 0 failures, run locally since the repo has no GitHub CI checks), and health checks came back 200. We have *not* run a tamper drill against production or captured a production verification report. I’m saying that out loud because it’s exactly the kind of thing a case study tends to imply.

## The honest part: 3 of 20

The CRM registers twenty MCP tools. Three go through Reeve today: `search_leads`, `get_lead`, and `get_pipeline_overview`.

The other seventeen, including every write and `send_email`, are called directly. And here’s the catch: from Reeve’s point of view, those calls aren’t “unguarded.” They’re **invisible**. `unguarded_tools = :deny` only fires for calls that reach Reeve.

That’s why 0.5.0 added `Reeve::Inventory`. It compares the tools the endpoint actually registers against the tools routed through Reeve and any explicit exemptions (each with a written reason), and reports what’s bypassing. Strict inventory dispatch can make bypass impossible at the central dispatcher.

We upgraded to 0.5.0 *without* turning that on. A dependency bump and a production routing change are two different reviews, and I didn’t want one hiding inside the other.

The roadmap is concrete: guard the update, delete, promote, disqualify, restore, outcome, email, and task mutations; call `authorize!` before email delivery; run compliance checks against the full inventory. Speaking of compliance checks, there’s a trap there too:

```
report = Reeve::Checks.run_all(principals: [alice, bob])
abort report.to_s if report.failed? || report.nothing_checked?
```

`nothing_checked?` exists because a compliance run over zero tools is green, and green over zero tools proves nothing. A suite that only walks guarded classes can’t find the class that forgot its guard. And if the dispatcher bypasses the library, the library can’t see the gap at all.

## What I’d tell past-me

1. **Reuse the app’s policies.** If the web UI, JSON API, and MCP path each grow their own permissions, they’ll drift. One Pundit policy, three surfaces.
2. **Authorization describes effects, not responses.** If a denied call changed the database, it wasn’t denied. Roll it back, and`authorize!` before anything you can’t roll back.
3. **Scope before you aggregate.** You can’t un-count a row.
4. **Treat an audit claim as something a test must prove.** Rollback, redaction, contract version, and chain fields all have specs, not assumptions.
5. **Version your audit contract and change it additively.** Never invent values for past rows.
6. **Keep dependency upgrades and behavior changes in separate PRs.**
7. **Partial rollout is fine. Partial claims aren’t.** Three guarded reads with named gaps is a solid, honest place to be. Calling it “our MCP endpoint is secure” would not be.

If you’re exposing a Rails app over MCP, start with your highest-value read tools. Route them through one envelope, reuse your existing policies, and write the test that proves a user from another organization gets nothing back. Then make the list of everything you haven’t routed yet, and keep it somewhere people can see.

Reeve is MIT-licensed and on [RubyGems](https://rubygems.org/gems/reeve). It needs Ruby 3.0+ and Rails 7.0+, and has no runtime dependencies. Details in the [README](https://github.com/vicmaster/reeve/blob/main/README.md) and [changelog](https://github.com/vicmaster/reeve/blob/main/CHANGELOG.md). Everything above is as of Reeve 0.6.0, October 10, 2026.
