Reeve
Authentication says who's at the door. Reeve decides what they can touch, and remembers what they touched.
A Ruby gem that makes it safe for a Rails application to expose MCP tools to AI agents: declarative per-record authorization, an append-only audit ledger, and a testing kit that proves both hold.
Status: 0.2.0. Read the known limitations in CHANGELOG.md before adopting it — particularly the one about a transaction wrapped around an invocation, if your application wraps requests in one.
A reeve is an official who acts with delegated authority on behalf of someone else — the root of "sheriff" (shire-reeve). That delegation is exactly what this gem governs: an agent acting for a human, allowed to reach only what that human may reach.
Developed under the working name mcp-guardrails.
The problem
The Ruby MCP server stack — the official mcp
gem, fast-mcp, and
ActionMCP — is healthy and consolidating. All three
authenticate the connection. None of them scope what an authenticated agent may see per
person, none produce a compliance-grade audit trail, and none ship a way to prove either
in CI.
That's the gap Reeve fills. It's an extension layer, not a competitor — it rides all three.
Three steps
bundle add reeve
bin/rails generate reeve:install
bin/rails db:migrate
1. Say who the agent is acting for. This is the only thing Reeve cannot work out for itself, and the generated initializer leaves it as a TODO:
# config/initializers/reeve.rb
Reeve.configure do |config|
config.principal_resolver = lambda do |context|
ApiToken.find_by(token: context..dig(:headers, "Authorization"))&.user
end
config.unguarded_tools = :deny # :deny | :allow_with_warning
config.redact_arguments = %i[password token ssn]
end
Until that resolver is set, every guarded call denies with no_principal. That is the
intended behaviour: the library is safe before it is configured.
2. Guard a tool.
class InvoiceSearchTool < FastMcp::Tool
guard_with InvoicePolicy # the only line you add
redact :customer_ssn
def call(query:)
Invoice.where("number LIKE ?", "%#{query}%")
end
end
3. Prove it in CI.
RSpec.describe InvoiceSearchTool do
it { is_expected.to deny_access_for(stranger).with(query: "AC") }
it { is_expected.to audit_every_call }
end
Three things now hold, and each is a test you can run rather than a claim you have to trust.
Deny by default
A tool returns only the records its principal may see. No principal, no guard_with, or a
policy that raises means no records — never a silent pass. A single record outside the
principal's scope is refused in a way that does not reveal whether it exists:
Reeve.invoke(tool: InvoiceShowTool, arguments: { id: 41 }, principal: alice)
# => Reeve::DeniedError: reeve denied invoice_show_tool for principal 1:
# out_of_scope_record (the requested record is not within this principal's scope)
The error names the rule and never names the record. One caveat worth knowing: fetching
from the unscoped model still lets a caller tell "someone else's" (a denial) from "no such
record" (nil). If that distinction matters, fetch through scoped, where both answers
are nil:
def call(id:)
scoped(Invoice).find_by(id: id)
end
Anything that is not a record — a count, a sum, a summary — is safe only when it was
computed from scoped, because then the tool never held unscoped data:
class OverdueTotalTool
include Reeve::Guard
guard_with InvoicePolicy
def call
scoped(Invoice).where(overdue: true).sum(:cents) # safe by construction
end
end
Reaching for Invoice.sum(:cents) there is denied with unscoped_derived_result. The
guarantee is structural, not a matter of remembering.
Every call leaves a trace
One append-only row per invocation, allowed or denied, naming the agent, the principal, the arguments (post-redaction), what came back, and the rule that decided:
Reeve::Audit::Query
.for_principal(user)
.for_agent("claude-desktop")
.between(1.week.ago, Time.current)
.pluck(:occurred_at, :tool_name, :outcome, :rule, :record_type, :record_ids)
A call whose tool raised is still recorded — that trace is the one most worth having. A
call that cannot be recorded fails, unless the host has explicitly opted into
audit_failure_mode = :warn.
Provable in CI, in whichever framework you already use
All the logic lives in framework-neutral checks. RSpec and Minitest are thin front-ends over the same objects, emitting the same messages, so no guarantee is provable in one framework only.
# RSpec — require "reeve/rspec"
it { is_expected.to deny_access_for(stranger) }
it_behaves_like "a reeve-compliant server"
# Minitest — require "reeve/minitest"
class ComplianceTest < ActiveSupport::TestCase
include Reeve::Testing::Assertions
include Reeve::Testing::ComplianceAssertions
def test_search_denies_a_stranger
assert_denies_access_for InvoiceSearchTool, stranger, query: "AC"
end
end
A stock rails new application — Minitest, no RSpec — proves every guarantee without
adding a test framework. That is a spec in this repo, not an aspiration.
Without a test framework at all
The checks are plain objects, so the same guarantees are assertable from a rake task, a CI script, or a boot-time assertion in staging:
require "reeve/testing"
report = Reeve::Checks.run_all(principals: [alice, bob])
abort report.to_s unless report.passed?
alice and bob are two fixture principals with disjoint records — that disjointness is
what makes a shared record identifier proof of a leak. As a Rails rake task:
# lib/tasks/reeve.rake
namespace :reeve do
desc "Fail the build if any guarded tool leaks or goes unaudited"
task compliance: :environment do
require "reeve/testing"
report = Reeve::Checks.run_all(principals: Reeve::Testing.compliance_principals)
abort report.to_s unless report.passed?
puts report
end
end
A failing run names the check, the tool, and the records that leaked:
reeve compliance: 13 checks, 12 passed, 1 failed
FAIL CrossPrincipalLeak
expected InvoiceSearchTool to return no records belonging to another principal, but it
returned 3 records to User#1 that also belong to User#2: Invoice#7, Invoice#8,
Invoice#9 (guard: InvoicePolicy, decision: allow via InvoicePolicy#index)
Without Rails, or without fast-mcp
The core needs neither. Reeve.invoke is the same envelope with the same guarantees:
require "reeve"
Reeve.invoke(
tool: InvoiceSearchTool,
arguments: { query: "AC" },
principal: current_user,
agent: { id: "claude-desktop" }
)
Wrapping your own JSON-RPC server
Plenty of Rails apps expose /mcp from a controller they wrote themselves, with their own
tool registry and their own bearer-token authentication. There is no adapter to install
for that, and none is needed: Reeve.invoke is the adapter interface. An MCP integration
is a function from a JSON-RPC request to one Reeve.invoke call.
Keep the authentication you have. Reeve does not do connection auth (see What you have not gained) — the controller still decides whether the caller gets in the door, and Reeve decides what they may touch once inside.
Dispatch through the envelope. Map the JSON-RPC tool name to the class, then call:
# app/controllers/mcp_controller.rb
def call_tool
tool = McpServer.registry.fetch(params.dig(:params, :name))
records = Reeve.invoke(
tool: tool,
arguments: params.dig(:params, :arguments).to_h.symbolize_keys,
agent: { id: request.headers["X-MCP-Client"] || "unknown" },
metadata: { headers: request.headers.to_h.slice(*AUDITED_HEADERS) }
)
render json: { jsonrpc: "2.0", id: params[:id], result: serialize(records) }
rescue Reeve::DeniedError => e
render json: { jsonrpc: "2.0", id: params[:id],
error: { code: -32_003, message: e. } }
end
Note what is not passed: principal:. Omit it and the resolver in your initializer runs,
which is what you want when the controller has already set Current.user — one place
decides who the principal is, and the ledger records the same answer the guard used.
Passing principal: explicitly overrides the resolver for that call, which is useful in
tests and in scripts.
metadata: is transport detail, and it is recorded. It reaches the resolver as
context.metadata and is written to the ledger's metadata column, so it is what a
reviewer has to reconstruct which request a row came from. It goes through the same
redactor as the arguments, so Authorization and friends are replaced by name — but pass
the headers you would want in an audit rather than all of them.
Resolve the principal from whichever the controller established:
Reeve.configure do |config|
config.principal_resolver = lambda do |context|
Current.user || ApiToken.find_by(
token: context..dig(:headers, "Authorization").to_s.delete_prefix("Bearer ")
)&.user
end
end
A resolver that returns nil — or raises — denies with no_principal and still writes a
row. There is no configuration in which an unidentified caller reaches a tool.
Adopt one tool at a time. A registry of thirty tools does not need thirty policies before any of this is worth turning on:
config.unguarded_tools = :allow_with_warning # migrating
Tools with guard_with are authorized and scoped normally. Tools without one still run —
unscoped, which is the entire point of the warning — and are recorded with guard: "none"
and rule unguarded_tool, so the ledger itself is your worklist:
Reeve::Audit::Entry.where(guard: "none").distinct.pluck(:tool_name)
Flip to :deny when that comes back empty, and the mode stops being reachable by accident.
The compliance checks work here too, and they take an invoke: argument precisely so they
run against your dispatcher rather than a synthetic call:
Reeve::Checks.run_all(
principals: [alice, bob],
invoke: ->(tool:, principal:, arguments:) { McpServer.dispatch(tool, principal, arguments) }
)
That is the whole integration: one call site, your auth untouched, and the same three guarantees the fast-mcp adapter gets.
Policies are plain objects unless you want Pundit (authorize and scope, two methods).
The ledger is an ActiveRecord table unless you supply your own recorder. Records are
ActiveRecord unless they are not — a plain object with an id works.
What you have not gained
Rate limiting, prompt-injection defence, cost control, and connection authentication are out of scope. This library governs what an authenticated agent may touch and what it touched. Keep your existing auth.
Ruby 3.0+, Rails 7.0+. No runtime dependencies. The fast-mcp adapter needs Ruby 3.1+, because fast-mcp does.
License
MIT. See LICENSE.txt.