Authentication isn't authorization: guarding Rails MCP tools with Reeve MagmaLabs released Reeve, a Ruby gem that wraps MCP tool calls in Rails applications with per-record authorization, fail-closed behavior, and an audit ledger, and is running it in production on an internal lead-scoring CRM. Reeve reuses the app's existing Pundit policies and reached version 0.6.0, which adds a tamper-evident ledger; the gem does not authenticate connections, prevent prompt injection, rate-limit, or guard tool calls that bypass it. 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.