Project

reeve

0.0
The project is in a healthy, maintained state
Reeve makes it safe for a Rails application to expose MCP (Model Context Protocol) tools to AI agents. Authentication says who is at the door; Reeve decides what they may touch and remembers what they touched: declarative per-record authorization scoped to the human the agent acts for, an append-only audit ledger of every call, and a testing kit that turns both guarantees into CI assertions. Works alongside fast-mcp, ActionMCP, and the official mcp gem. Developed under the working name mcp-guardrails.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies
 Project Readme

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.6.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.metadata.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. The initializer also derives the key that chains the audit ledger from secret_key_base, so that works in every environment with nothing to set up — see Tamper-evident, within stated limits.

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.

Tools that write

A denial means nothing happened. Authorization runs before the tool, but with the model class as its subject — for an index-style check there is no record yet — so the per-record answer only arrives once the tool has returned something to scope. A write tool sits between those two points:

def call(id:, number:)
  invoice = Invoice.find(id)        # unscoped fetch
  invoice.update!(number: number)   # ...then a write
  invoice
end

The tool body runs in a transaction, and a scope denial rolls it back. Someone else's record is refused and unchanged, and the ledger's deny is a true statement about the database. A transaction the host opened around the invocation is untouched — the rollback reaches the tool's work and stops there.

What a rollback cannot reach is anything that was never in the transaction. A tool that sends an email, calls a webhook or writes a file before it knows whether it is allowed to must ask first:

def call(id:, to:)
  invoice = authorize!(Invoice.find(id))   # raises DeniedError if the policy says no
  InvoiceMailer.reminder(invoice, to).deliver_now
  invoice
end

authorize! asks the declared policy about one record and returns it, so it reads inline. The denial carries the policy's own rule into the ledger, and names no record — a refusal and a record that does not exist have to read the same.

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.

If your application wraps invocations in a transaction — a controller that opens one per request, a job runner — a rollback takes the ledger row with it, and exactly the calls worth auditing are the ones that rolled back. Write on a connection the rollback cannot reach:

config.audit_recorder = Reeve::Audit::IsolatedRecorder

It needs a database with concurrent writers and refuses on SQLite, where an open transaction holds the write lock; IsolatedRecorder.available? lets you branch if you develop on one and deploy on the other. The default recorder warns when it detects the case rather than failing silently.

Tamper-evident, within stated limits

Every row Reeve writes is linked to the one before it by a keyed digest (HMAC-SHA256), with a key the database does not hold. Someone who can write to the ledger table but does not hold the key can still change it, but the change shows when the chain is checked:

bin/rails reeve:verify_ledger
reeve ledger: INTACT — sequences 1–48,211 verified (head at start: 48,211); 5,000 earlier entries not covered (5,000 when the chain started)

It exits 0 when the chain is intact, 1 when it is broken, and 2 when it could not verify (no chain, no key, or rows signed by a key it does not hold). Run it on a schedule and alert on anything but 0. LIMIT= caps the breaks it lists, never the totals; FORMAT=json writes every break. The same answer from Ruby, or with an auditor's own copy of the key:

result = Reeve::Audit::Chain.verify
result.status   # => :intact, :broken, :unverifiable or :no_chain
result.covered  # => 1..48211
puts result     # the report above, with every break listed under it

In CI the compliance suite you already run includes LedgerChain, in both frameworks: it writes entries of its own and fails on a broken chain, a missing key, or entries that were not chained.

The key. The generated initializer derives it from secret_key_base:

config.ledger_key = Rails.application.key_generator.generate_key("reeve audit ledger", 32)

Rotating secret_key_base therefore rotates the ledger key. Keep the old one, or the entries it signed become unverifiable — reported as such, never as tampered:

config.retired_ledger_keys = [old_key]   # verify with these; never sign with them

A dedicated key decouples the two: config.ledger_key = Rails.application.credentials.reeve_ledger_key. Keys must be at least 32 bytes and are only ever printed as 16-character fingerprints. Without a key, every guarded call fails closed; under audit_failure_mode = :warn it is recorded unchained instead, logged each time, and reported by verification.

Detected, provided the key has not leaked:

  • a chained row whose recorded content was changed;
  • a chained row removed from anywhere before the newest one;
  • chained rows swapped, renumbered or moved, or a row inserted that Reeve did not write — including one numbered past the head;
  • the newest rows removed while the chain record was left alone;
  • the chain record removed while chained rows remain.

Not detected — stated plainly, because these are the limits of the guarantee:

  1. A leaked key. Anyone holding it can rewrite the chain from any point forward. By default that is anyone holding secret_key_base.
  2. The newest rows deleted by someone who also rewinds the chain record's head. Only a checkpoint held outside the database could catch that; Reeve does not ship one.
  3. The whole ledger deleted, chain record included. Reported as no chain, never as a pass — but indistinguishable from a ledger that was never chained.
  4. Rows written before you upgraded. They are counted as "not covered" and are exactly as trustworthy as they were before.

And it prevents nothing: it detects a change after the fact. The GRANT INSERT, SELECT in the generated migration is what prevents one. The chain record needs UPDATE as well, which is why a role allowed to rewind it is limit 2.

What it costs. About half a millisecond per ledger write on SQLite and about one on PostgreSQL. Every chained write locks the chain record, so writes serialize. Under the default recorder inside a transaction your application opened, that lock is held until the transaction ends, and every other guarded call waits behind it — up to the database's own lock wait: indefinitely on PostgreSQL (lock_timeout is 0 by default), 50 seconds on MySQL (innodb_lock_wait_timeout), and ActiveRecord's timeout on SQLite (5 seconds in a generated database.yml). IsolatedRecorder holds it for the length of one insert. On Rails 7.0 with SQLite, a thread waiting for SQLite's write lock holds Ruby's GVL (sqlite3 1.x), so concurrent guarded calls in one process can fail closed after the busy timeout. Rails 7.1+ or a server database avoids it.

Your own recorder. A recorder that writes to Reeve's table chains its rows through the same door the built-in ones use, or verification reports them as unchained:

Reeve::Audit::Chain.new(entry_class: Reeve::Audit::Entry).append!(row)

Upgrading from 0.5.0:

bin/rails generate reeve:upgrade
bin/rails db:migrate

Then add the config.ledger_key line the generator prints. Existing rows are not touched; the next guarded call starts the chain. Servers still on 0.5.0 during a rolling deploy keep writing unchained rows, which are counted as not covered rather than reported as tampering.

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 if report.failed? || report.nothing_checked?

alice and bob are two fixture principals with disjoint records — that disjointness is what makes a shared record identifier proof of a leak. passed? only means nothing failed, so a run over no tools at all passes; nothing_checked? is what stops this gate when the registry is empty, since a script has no pending test to show it.

The suite walks every tool that included Reeve::Guard, not only the ones that declared a guard. A tool that forgot guard_with is the one worth finding, and it is absent from the guard registry by definition — so a run that only inspected guarded tools reported all-green on precisely the application that had a problem.

Mid-retrofit that means a red build, which is honest but unreadable if it stays red for weeks. Say what you have certified so far, and grow the list:

config.compliance_tools = -> { [InvoiceSearchTool, InvoiceShowTool] }

Reeve can only see tools that reached it. If your MCP server dispatches tools Reeve has never been told about — a custom controller with its own registry — declare the server's real inventory (below), or the run certifies the subset Reeve happens to know. Until you do, the suite says so: EndpointCoverage is reported as skipped, never as passed.

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 if report.failed? || report.nothing_checked?
    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.message } }
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.metadata.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.

Every tool the endpoint dispatches

Reeve protects the tools routed through it, and only those. A server that registers tools as names and handler blocks can send some through Reeve.invoke and run the rest directly — and the direct ones are not unguarded, they are invisible: no denial, no scope, no ledger row, and nothing that says so. Deny-by-default cannot deny a call it never sees.

Reeve::Inventory makes the server's own list of names the thing that gets checked:

# config/initializers/reeve.rb
McpInventory = Reeve::Inventory.new(
  registered: -> { McpServer.tool_names },
  routed: -> { { "search_invoices" => InvoiceSearchTool, "get_invoice" => InvoiceShowTool } },
  exemptions: { "healthcheck" => { reason: "returns no application data" } }
)

Reeve.configure { |config| config.inventory = McpInventory }

Each part takes a value or a callable. Callables are read when asked, so the initializer neither autoloads application constants nor runs before the server has registered its tools.

reeve inventory: 4 registered
  2 protected and guarded
  0 routed through reeve but unguarded
  1 bypasses reeve
  1 exempted
coverage: incomplete

BYPASS export_invoices
  registered with the server but neither routed through reeve nor exempted — route it to
  a guarded tool, or exempt it with a reason

EXEMPT healthcheck
  returns no application data

The report names tools, classes and exemption reasons — never an argument, a principal or a record — so it is safe to print in CI. An exemption without a reason is refused, and one with a reason is listed every time, complete or not.

Fail boot or CI on a gap:

McpInventory.verify!   # raises Reeve::IncompleteInventoryError, carrying the report

Dispatch through it, and a bypass stops being possible:

def call_tool
  name = params.dig(:params, :name)
  arguments = params.dig(:params, :arguments).to_h.symbolize_keys

  result = McpInventory.dispatch(name, arguments: arguments,
                                       agent: { id: request.headers["X-MCP-Client"] }) do
    McpServer.handlers.fetch(name).call(arguments)
  end

  render json: { jsonrpc: "2.0", id: params[:id], result: serialize(result) }
rescue Reeve::DeniedError => e
  render json: { jsonrpc: "2.0", id: params[:id],
                 error: { code: -32_003, message: e.message } }
end
  • A routed name runs through Reeve.invoke under its class's guard, with the handler block as the body. A registry of blocks needs one guarded class per tool to name its policy, and nothing else — the block still does the work, and what it returns is scoped. Handlers dispatched this way should not call Reeve.invoke themselves.
  • An exempt name runs the block as written.
  • Any other name is a tool with no guard, and unguarded_tools decides: refused with unbound_tool under :deny, the default; run unscoped and recorded with guard: "none" under :allow_with_warning. Recorded either way, so a name the inventory missed lands on the same ledger worklist as an unguarded class.

In the compliance suite, EndpointCoverage compares the run against the inventory. It passes only when every registered tool is guarded or exempt and the run covered all of them. It fails when the run claimed the whole endpoint and something bypasses Reeve. A run narrowed by compliance_tools is reported as a subset — skipped, naming what it left out — so a retrofit stays green without passing for complete coverage.

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, and the ledger's chain detects tampering rather than preventing it. 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.