mcp_authorization
Rails engine for serving MCP tools with per-request schema discrimination compiled from RBS type annotations.
Add it to your Gemfile and your Rails app speaks MCP. Write @rbs type comments in plain Ruby service classes, tag fields and variants with @requires(:flag), and the gem compiles tailored JSON Schema per request. The type definitions are the authorization policy.
Looking for task-oriented "how do I X?" recipes rather than reference? See the Cookbook.
Cookbook
Task-oriented recipes: I want to do X, what do I write?
- 1. Expose your first tool (read-only, end to end)
You have a Rails app and want one MCP tool that returns data. No auth subtleties yet.
- 2. Hide an entire tool behind a permission
Only users who can manage_workflows should even see the advance_step tool. Everyone else gets a clean tool list with no hint it exists.
- 3. Hide a tool behind an account feature, not a role
Visibility shouldn't depend on the user's role but on whether their account has SMS provisioned. RBAC is the wrong axis.
- 4. Hide one input field from some users
advance_step should let managers jump an applicant to any stage via a stage_id param โ but operators shouldn't even see that the parameter exists.
- 5. Give privileged users a richer output shape
When a manager reroutes an applicant, the response should include previous_stage and an audit_trail. Operators should never receive those fields โ not even i...
- 6. Pre-fill a field from the current user
Most users operate on one default workflow. You want the schema's default for workflow_id to be that user's default, not a hardcoded constant.
- 7. Validate input with constraints
You want the schema to enforce shape โ a pattern on an ID, a length range on a reason, a numeric bound โ so the LLM gets it right the first time and bad inpu...
- 8. Share a type across handlers
Every tool returns the same error shape and the same applicant record. You don't want to redeclare them in each handler.
- 9. Model success-or-error as a discriminated union
You want the LLM to reliably tell a success response from an error and branch on it โ like a TypeScript discriminated union.
- 10. Require a field only when another is present
reason is meaningless without stage_id. You want "if stage_id is provided, reason becomes required" โ without making either unconditionally required.
- 11. Change the tool description by role
The same tool does more for managers. You want its one-line description to reflect that, so the LLM understands the fuller capability when it's available.
- 12. Serve different tool surfaces from one app (domains)
One Rails app, but you want a recruiting tool surface and an operations tool surface at different URLs, each exposing its own set of tools.
- 13. Invent your own predicate vocabulary
@requires (RBAC) and @feature (account flags) aren't enough. You want to gate on plan tier, beta enrollment, A/B bucket โ whatever your domain needs.
- 14. See exactly what a role sees (debugging)
You added @requires tags and want to confirm an operator really can't see stage_id โ without wiring up a client and forging auth headers.
- 15. Mark a tool read-only / destructive (annotation hints)
You want to advertise behavioral hints โ this tool only reads, that one might delete โ so clients can warn users or auto-approve safe calls.
- Scaling a large tool surface
The two recipes below are about cost, not authorization: what happens when one domain grows past what a model can choose from well, and past what you want to...
- 16. Group a large domain into facades
A domain has grown to a hundred-plus tools. tools/list is dominated by call-time argument schemas for tools the model will never pick, and choosing from a fl...
- 17. Cache the tools/list response
tools/list compiles a schema for every visible tool, per caller. tools/call already compiles only the tool it invokes, and lifecycle methods compile nothing...
- 18. Generate tools instead of writing them
One tool per controller action, or per row in a config table, or per endpoint in a family you already ship. Hand-writing a file each is copying a declaration...
- Talking to the outside world
Every recipe so far returned a canned hash. Real handlers do I/O โ they query databases and open sockets. The gem doesn't get in the way: a handler is a plai...
- 19. Query a database from a tool
search_applicants should hit the real database, filter by what the LLM asked for, and return only the columns this user is allowed to see.
- 20. Talk to a TCP socket from a tool
A tool should open a raw TCP connection to a service, send a probe, read the reply, and report latency โ without hanging the request thread or becoming an SS...
- Where to go next
Reference
Every option, DSL method, annotation tag, and lifecycle detail.
- Three layers of authorization
The gem gives you three independent controls over what each user sees:
- Install
Routes install automatically at /mcp. No mount needed.
- Configuration
Two configuration methods (not attributes) turn a domain into grouped facades โ see Tool grouping:
- The contract
The gem has two opinions about your app:
- Quick example
Define reusable types as .rbs files. These are plain RBS -- no comment markers.
- Handler interface
A handler includes McpAuthorization::DSL and implements two methods:
- @requires rules
On input params -- the param is excluded from the input schema when can? returns false. Tag them in the #: annotation above def call:
- Shared types
Define reusable types as .rbs files in sig/shared/ (configurable via shared_type_paths):
- Tool DSL
authorization :perm is just a convenience for gate :requires, :perm โ internally there is one gate pipeline, not two. Multiple gate declarations AND together...
- Contract validation
If a handler is missing required methods or schema definitions, the gem raises an ArgumentError on first request with a full diagnostic:
- Multi-domain routing
Tag a tool with multiple domains to make it available in each:
- Tool grouping (facades)
As a domain grows into the hundreds of tools, a flat tools/list spends the
- RBS type syntax
The @rbs type comments compile to JSON Schema:
- Performance
Source files are parsed once at boot and cached in memory. Only @requires filtering runs per request (hash lookups and can? calls). In development, caches ar...
- Caching tools/list
tools/list must materialize a per-user schema for every tool in a domain. That cost can be cached. Default is no caching, so nothing changes unless you opt in:
- Development
In development mode, the gem wires into the Rails reloader. Edit an @rbs type annotation, save, and the next MCP request returns the updated schema. No serve...
- How it works
Different users hitting the same endpoint can see different tools, different descriptions, different input fields, and different output shapes.
- Stateless transport and schema lifetime
The gem uses the MCP SDK's Streamable HTTP transport in stateless mode. Each HTTP request creates a fresh MCP::Server, materialized with tools filtered and s...
- Requirements
- License
MIT
Design docs
The reasoning behind larger features, as written before they shipped.
- Declarative tool grouping with summarized facades + deferred schema loading
Tracking issue: #30
Releases
Every released version, what changed in it, and why.
Every page on this site is collected from the markdown in the repository by site/collect.rb at build time. The repo docs are the source of truth.