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...
Problem. 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, not expressing anything.
Solution. Register a producer โ a callable that mints the classes and registers them. The gem calls it when it loads the rest of the surface.
# config/initializers/mcp_authorization.rb
McpAuthorization.configure do |config|
config.tool_producers << -> { GeneratedTools.register_all! }
end# app/service/generated_tools.rb
module GeneratedTools
# Container for the generated classes. Deliberately outside every autoload
# path: Zeitwerk unloads what it defined, and a generated class parked in a
# managed namespace would survive an unload it should not survive.
module Registry; end
def self.register_all!
EXPOSED_ACTIONS.map do |spec|
const = :"#{spec.name.camelize}Tool"
next Registry.const_get(const, false) if Registry.const_defined?(const, false)
tool = Class.new(McpAuthorization::Tool) # self-registers via inherited
tool.tool_name(spec.name)
tool.tags(*spec.domains)
tool.authorization(spec.permission)
tool.read_only! if spec.read_only
tool.dynamic_contract(spec.handler)
Registry.const_set(const, tool) # names it, for backtraces
end
end
endResult. Generated tools appear in tools/list beside the file-defined ones, with the same gating, schema compilation, and facade grouping. Nothing else in your config changes.
Why a producer rather than a Rails callback. Both obvious callbacks are traps, and the gem exists to take them off your plate:
config.to_prepareruns during:run_prepare_callbacks, before:eager_load!and before railties copyconfig.i18nontoI18n. Generating tools means loading the code they derive from, so from there you load application classes against an emptyI18n.load_pathโ and anything resolving a translation in its class body (a validation message, aninclusion:list) freezes"Translation missing: โฆ"in permanently. The failures land in models and validations, nowhere near MCP.- Registering before the gem's own load used to make the registry non-empty and skip the
tool_pathspass entirely โ every file-defined tool silently disappearing fromtools/listin any environment that doesn't eager-load. Producers run after that pass, so the hazard is gone.
A producer runs from a registry read, after the tool_paths pass and after each reload, so neither is reachable.
Two rules. Make the producer idempotent โ register dedupes by object identity, not by tool_name, so minting a fresh class per call registers a duplicate name and leaves find_tool picking arbitrarily (the const_defined? reuse above is the pattern). And expect exceptions to propagate: a malformed generated tool fails the read. In production, where the host eager-loads, that surfaces at boot rather than on the first tools/list.
Collected from COOKBOOK.md in the repository. Edit it there, not here.