mcp_authorization v0.7.1

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
end

Result. 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_prepare runs during :run_prepare_callbacks, before :eager_load! and before railties copy config.i18n onto I18n. Generating tools means loading the code they derive from, so from there you load application classes against an empty I18n.load_path โ€” and anything resolving a translation in its class body (a validation message, an inclusion: 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_paths pass entirely โ€” every file-defined tool silently disappearing from tools/list in 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.