mcp_authorization v0.7.1

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...

On this page
class MyTool < McpAuthorization::Tool
  tool_name "my_tool"
  authorization :some_flag        # RBAC: hidden unless current_user.can?(:some_flag)
  gate :feature, :order_tracking  # any predicate: hidden unless server_context.feature?(:order_tracking)
  gate :tier, :enterprise         # multiple gates AND together
  tags "recruiting", "operations" # which domains this tool appears in
  category :orders                # group this tool belongs to in a faceted domain
  read_only!                      # MCP annotation hints
  dynamic_contract MyService      # handler class
end
MethodPurpose
tool_name "name"MCP tool name
authorization :symTool-level RBAC visibility gate. Convenience alias for gate :requires, :sym โ€” routes through the generic gate pipeline and falls back to current_user.can?(:sym) when the server context lacks a requires? method. Omit for public tools.
gate :predicate, :valueTool-level generic predicate gate. Calls server_context.{predicate}?(value). Repeat for AND. Fail-open when the predicate method is missing (warning logged in dev).
tags "domain1", ...Domain(s) this tool appears under. Defaults to ["default"].
category :nameGroup this tool belongs to when its domain is faceted (see Tool grouping). Ignored in flat domains. Optional summary: kwarg supplies the group summary for single-tool groups.
dynamic_contract HandlerClassHandler providing description, schemas, and execution
read_only!Annotation: tool only reads data
not_destructive!Annotation: tool does not destroy data
destructive!Annotation: tool may destroy data
idempotent!Annotation: multiple calls have same effect
open_world!Annotation: tool may access external services
closed_world!Annotation: tool stays within the system

authorization :perm is just a convenience for gate :requires, :perm โ€” internally there is one gate pipeline, not two. Multiple gate declarations AND together with authorization: the tool is shown only when every check passes. This makes tool-level gating symmetric with the field-level annotations (@requires, @feature, any custom predicate).

Tools self-register when loaded. Put them anywhere under tool_paths (default: app/mcp/).

Generated tools๐Ÿ”—

Some tools aren't worth writing by hand โ€” one per controller action, one per row in a config table, one per endpoint in a family. For those, register a producer: a callable that mints the classes and registers them.

McpAuthorization.configure do |config|
  config.tool_producers << -> { MyApp::GeneratedTools.register_all! }
end

Producers run from ToolRegistry.ensure_tools_loaded! โ€” on the first read of an empty registry, immediately after tool_paths is eager-loaded, and again after every reload. Two properties follow from that, and both matter:

  • Don't register from a Rails boot callback instead. Generating tools usually means loading the code they derive from, and from config.to_prepare that happens during :run_prepare_callbacks โ€” before :eager_load!, and before railties copy config.i18n onto I18n. Application code loaded that early sees an empty I18n.load_path, so any class resolving a translation in its class body freezes "Translation missing: โ€ฆ" into its validators and option lists for the life of the process. A producer sidesteps the ordering question entirely.
  • Don't register before the registry loads its own tools. Historically ensure_tools_loaded! skipped the tool_paths pass once the registry was non-empty, so registering first made every file-defined tool silently disappear from tools/list. Producers run after that pass, and completion is now tracked separately from "the registry has entries", so the ordering is not yours to get right.

A producer must be idempotent. register dedupes by object identity, not by tool_name, so minting a fresh class on every call registers a second tool under the same name and leaves find_tool resolving an arbitrary one. Reuse the class while its inputs are unchanged. Exceptions propagate โ€” a malformed generated tool fails the read rather than vanishing.

When the host eager-loads (production), the engine reads the registry at after_initialize, so a producer that raises fails the deploy instead of the first tools/list. Development and test stay lazy.

Introspecting a tool class๐Ÿ”—

Every declaration is readable back off the class, which is what the registry, the facade builder, and the cache digest use:

ReaderReturns
_permissionThe symbol passed to authorization
_gates[{ name:, value: }, ...] for every declared gate
_tagsDeclared domains
_categoryDeclared category symbol, or nil
_category_summarySummary passed to category(summary:), or nil
_contract_handlerThe handler class

ToolRegistry is the entry point for turning those declarations into MCP tools:

Registry methodPurpose
tool_classes_for(domain:, server_context:)Every permitted tool in a domain (the tools/list path)
tool_class_for(domain:, name:, server_context:)One named tool, or nil (the tools/call path)
facades_for(domain:, server_context:)Facades for a faceted domain
facade_for(domain:, name:, server_context:)One facade by name

Collected from README.md in the repository. Edit it there, not here.