Three layers of authorization
The gem gives you three independent controls over what each user sees:
The gem gives you three independent controls over what each user sees:
| Layer | Mechanism | Effect |
|---|---|---|
| Tool visibility | authorization :manage_workflows (RBAC) or gate :feature, :sms (any predicate) on the tool class | Tool hidden entirely from users who fail any check |
| Input fields | @requires(:backward_routing) or @feature(:sms) on a param in #: annotation | Field excluded from the input schema; a top-level one sent anyway is rejected at call time, a nested one is stripped |
| Output variants | @requires(:backward_routing) or @feature(:sms) on a variant in @rbs type output | Variant excluded from the oneOf and fields projected out of the handler's return value before it crosses the wire |
Tool-level authorization :perm is RBAC (calls current_user.can?). Tool-level gate :predicate, :value and the field-level annotations are generic โ any predicate name works, as long as the server context implements {predicate}?(value). See Generic predicate tags below.
Enforcement, not just shaping๐
@requires is a security boundary, not a hint. At tool-call time the gem:
Rejects inbound params outside the caller's compiled input schema. The schema the caller received from
tools/listis the contract. A top-level key outside it โ one the tool never declared, or one gated behind a predicate this caller fails โ does not exist from the caller's point of view. Such a key used to be dropped silently, which returned a success-shaped result that ignored part of the request: an LLM that never saw the per-tool schema (e.g. behind a facade) invented a filter, got the unfiltered page back assuccess: true, and acted on it; a non-admin sendingforce: truewas told the flag was applied. Sofilter_inputraisesMcpAuthorization::UnknownInputKeysErrorand the materialized tool returns it as an in-band tool error (isError: true). The text is written for a model that will read it as a tool result:list_applicants: Unknown parameters: data, limit. Nothing was executed. Accepted parameters: funnel_id, page, per_page, query, stage_id. If your task depends on a rejected parameter (for example filtering by it), do not re-run this call without it: the result would be unfiltered. Do not substitute another parameter to approximate it. Use a tool that supports it, or tell the user it is not possible.Both lists come from the caller's own schema, so the message reveals nothing
tools/listdid not already show that caller. The handler never runs, so a gated field is still never applied for a caller who lacks it โ the boundary holds, it just stops being silent. A facade call is checked twice: a key besidetool_nameandargumentsis rejected by the facade, which tells the caller to re-send it insidearguments(a flattened parameter is supported, just misplaced), and a key insideargumentsby the target tool, which tells it not to re-run without the parameter. Name a client's envelope key inconfig.ignored_input_keysto have it dropped instead, on both surfaces. Keys nested inside a declared object param are still projected silently. Withstrict_schemaon, the compiled root carriesadditionalProperties: false, so an MCP SDK that validates arguments (validate_tool_call_arguments, on by default in themcpgem) rejects the call with its own generic message before this one is produced; turn that validation off on the server if the model must read the guidance. Setconfig.reject_unknown_input_keys = falseto restore the pre-0.9 drop-everything behavior. A host that overridesmaterialize_foror callsfilter_inputdirectly must rescueUnknownInputKeysErroritself; unrescued it surfaces as a JSON-RPC internal error carrying the same message. The non-materializedTool.calllets it propagate.Projects the handler's return value onto the user's compiled output schema. A variant hidden by
@requireshas its shape unavailable, so if the handler erroneously emits that variant's extra fields, they are stripped before serialization. A handler bug or refactor accident cannot leak admin-only fields to a non-admin.
This means handler authors don't have to remember to re-check can? at every branch -- the schema is the boundary. can? inside #call is still useful for logic that changes behavior (not just field visibility), but it is no longer load-bearing for security.
Collected from README.md in the repository. Edit it there, not here.