Authentication isn't authorization: guarding Rails MCP tools with Reeve
TL;DR: 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:
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 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:
- What may this authenticated agent touch on behalf of this human principal?
- What did the invocation attempt, what came back, and which rule decided it?
Every guarded call goes through one funnel:
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_leadstosend_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:
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 anensureblock, 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.:warnexists 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:
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:
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:
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_baseis convenient, but rotating that secret rotates the ledger key. Keep old keys inconfig.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
- 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.
- 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. - Scope before you aggregate. You can’t un-count a row.
- Treat an audit claim as something a test must prove. Rollback, redaction, contract version, and chain fields all have specs, not assumptions.
- Version your audit contract and change it additively. Never invent values for past rows.
- Keep dependency upgrades and behavior changes in separate PRs.
- 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. It needs Ruby 3.0+ and Rails 7.0+, and has no runtime dependencies. Details in the README and changelog. Everything above is as of Reeve 0.6.0, October 10, 2026.