Class: McpAuthorization::ToolRegistry

Inherits:
Object
  • Object
show all
Defined in:
lib/mcp_authorization/tool_registry.rb

Overview

Global registry of all McpAuthorization::Tool subclasses.

Tools self-register via the inherited hook in Tool, so there is no manual registration step — defining a class that inherits from Tool is enough.

The registry is the entry point for two main operations:

  • Listinglist_tools returns JSON-serialisable tool definitions filtered by domain and the current user's permissions.

  • Materializingtool_classes_for returns concrete MCP::Tool subclasses with per-user schemas baked in, ready to be handed to an MCP::Server for request handling.

Class Method Summary collapse

Class Method Details

.ensure_tools_loaded!Object

Force-loads tool directories so tool classes self-register, then runs config.tool_producers for tools the host generates at runtime.

Deliberately lazy, and the ordering here is the contract:

  • Producers run last, inside this method, so a host cannot register a generated tool ahead of the tool_paths eager-load and suppress it.

  • Producers run on a registry read, never from a boot callback. Generating tool classes means loading the code they derive from, which in a Rails app pulls in a large share of the application. Doing that from config.to_prepare runs it during :run_prepare_callbacks, which precedes :eager_load! and :finisher_hook — and config.i18n is only copied onto I18n from a railtie-level after_initialize. 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 validators and option lists permanently. Deferring to first read sidesteps the whole ordering question rather than asking each host to solve it.

  • Producers re-run after reset!. The Engine resets the registry on every code reload; the next read repopulates it, producers included.

@tools_loaded tracks completion, and is set only after every producer has returned. It deliberately does NOT reuse "is @registered_tools non-empty?" as the signal, because a non-empty registry does not mean loading succeeded: eager_load_tool_paths! registers the file-defined tools first, so by the time a producer raises the array is already populated — as it also is when a producer registers 40 tools and raises on the 41st. Guarding on that would make the failure loud exactly once and silent forever after, leaving a permanently incomplete surface. The contract is the opposite: a producer that raises fails every read until it is fixed. Re-running is safe — register dedupes by identity and eager_load_dir is a no-op on an already-loaded directory.

The reentrancy guard lets a producer call back into the registry (to inspect what is already registered, say) without recursing forever.

Signature:

  • () -> void



75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/mcp_authorization/tool_registry.rb', line 75

def ensure_tools_loaded!
  return if @tools_loaded
  return if @loading_tools

  @loading_tools = true
  begin
    eager_load_tool_paths!
    McpAuthorization.config.tool_producers.each(&:call)
    @tools_loaded = true
  ensure
    @loading_tools = false
  end
end

.facade_for(domain:, name:, server_context:) ⇒ Object

A single facade by name within a faceted domain, or nil when the domain is not faceted or the name matches no non-empty group for this caller. The facade analogue of tool_class_for — a tools/call targeting one facade should not build every facade in the domain.

Signature:

  • (domain: String, name: String, server_context: untyped) -> singleton(MCP::Tool)?



150
151
152
# File 'lib/mcp_authorization/tool_registry.rb', line 150

def facade_for(domain:, name:, server_context:)
  McpAuthorization::FacadeBuilder.facade_for(domain: domain, name: name, server_context: server_context)
end

.facades_for(domain:, server_context:) ⇒ Object

Grouped facade tools for a faceted domain — one synthetic MCP::Tool per non-empty category the caller has at least one permitted tool in. Returns [] for domains not configured via facet_domain. See FacadeBuilder and docs/designs/tool-grouping-facades.md.

Signature:

  • (domain: String, server_context: untyped) -> Array[singleton(MCP::Tool)]



141
142
143
# File 'lib/mcp_authorization/tool_registry.rb', line 141

def facades_for(domain:, server_context:)
  McpAuthorization::FacadeBuilder.facades_for(domain: domain, server_context: server_context)
end

.find_tool(name) ⇒ Object

Look up a tool by its MCP tool name across all domains.

Signature:

  • (String) -> singleton(McpAuthorization::Tool)?



156
157
158
# File 'lib/mcp_authorization/tool_registry.rb', line 156

def find_tool(name)
  registered_tools.find { |t| t.tool_name == name }
end

.list_tools(domain:, server_context:) ⇒ Object

Tool definitions for tools/list, filtered by domain and permissions.

Signature:

  • (domain: String, server_context: untyped) -> Array[Hash[Symbol, untyped]]



102
103
104
105
106
107
# File 'lib/mcp_authorization/tool_registry.rb', line 102

def list_tools(domain:, server_context:)
  candidates = tools_by_domain[domain] || []
  candidates.filter_map do |tool_class|
    tool_class.to_mcp_definition(server_context: server_context)
  end
end

.register(tool_class) ⇒ Object

Register a tool class. Called automatically by Tool.inherited.

Signature:

  • (Class) -> void



21
22
23
24
# File 'lib/mcp_authorization/tool_registry.rb', line 21

def register(tool_class)
  tools = (@registered_tools ||= [])
  tools << tool_class unless tools.include?(tool_class)
end

.registered_toolsObject

All registered tool classes. Triggers eager loading on first access.

Gated on "loading finished", not on "the array has entries" — see ensure_tools_loaded! for why those must not be conflated.

Signature:

  • () -> Array[singleton(McpAuthorization::Tool)]



31
32
33
34
35
# File 'lib/mcp_authorization/tool_registry.rb', line 31

def registered_tools
  tools = (@registered_tools ||= [])
  ensure_tools_loaded! unless @tools_loaded
  tools
end

.reset!Object

Clear the registry. Called by the Engine's reloader on code change. The next read reloads tool_paths and re-runs tool_producers.

Signature:

  • () -> void



163
164
165
166
167
# File 'lib/mcp_authorization/tool_registry.rb', line 163

def reset!
  @registered_tools = []
  @loading_tools = false
  @tools_loaded = false
end

.tool_class_for(domain:, name:, server_context:) ⇒ Object

Concrete MCP::Tool subclass for a single named tool within a domain, or nil when the tool is unknown in that domain or the current user is not permitted to use it.

Materializing a per-user schema is the dominant cost of handling an MCP request, so a tools/call — which targets exactly one tool — should compile that one tool rather than the whole domain (what tool_classes_for does for tools/list).

Signature:

  • (domain: String, name: String, server_context: untyped) -> singleton(MCP::Tool)?



128
129
130
131
132
133
134
# File 'lib/mcp_authorization/tool_registry.rb', line 128

def tool_class_for(domain:, name:, server_context:)
  tool_class = (tools_by_domain[domain] || []).find { |tc| tc.tool_name == name }
  return nil unless tool_class
  return nil unless tool_class.permitted?(server_context)

  tool_class.materialize_for(server_context)
end

.tool_classes_for(domain:, server_context:) ⇒ Object

Concrete MCP::Tool subclasses with per-user schemas baked in.

Signature:

  • (domain: String, server_context: untyped) -> Array[singleton(MCP::Tool)]



111
112
113
114
115
116
117
# File 'lib/mcp_authorization/tool_registry.rb', line 111

def tool_classes_for(domain:, server_context:)
  candidates = tools_by_domain[domain] || []
  candidates.filter_map do |tool_class|
    next unless tool_class.permitted?(server_context)
    tool_class.materialize_for(server_context)
  end
end

.tools_by_domainObject

Groups registered tools by their domain tags.

Signature:

  • () -> Hash[String, Array[singleton(McpAuthorization::Tool)]]



91
92
93
94
95
96
97
98
# File 'lib/mcp_authorization/tool_registry.rb', line 91

def tools_by_domain
  initial = Hash.new { |h, k| h[k] = [] } #: Hash[String, Array[singleton(McpAuthorization::Tool)]]
  registered_tools.each_with_object(initial) do |tool_class, map|
    (tool_class._tags || ["default"]).each do |tag|
      map[tag] << tool_class
    end
  end
end