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| Method | Purpose |
|---|---|
tool_name "name" | MCP tool name |
authorization :sym | Tool-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, :value | Tool-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 :name | Group 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 HandlerClass | Handler 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! }
endProducers 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_preparethat happens during:run_prepare_callbacksโ before:eager_load!, and before railties copyconfig.i18nontoI18n. Application code loaded that early sees an emptyI18n.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 thetool_pathspass once the registry was non-empty, so registering first made every file-defined tool silently disappear fromtools/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:
| Reader | Returns |
|---|---|
_permission | The symbol passed to authorization |
_gates | [{ name:, value: }, ...] for every declared gate |
_tags | Declared domains |
_category | Declared category symbol, or nil |
_category_summary | Summary passed to category(summary:), or nil |
_contract_handler | The handler class |
ToolRegistry is the entry point for turning those declarations into MCP tools:
| Registry method | Purpose |
|---|---|
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.