Module: McpToolkit
- Defined in:
- lib/mcp_toolkit.rb,
lib/mcp_toolkit/version.rb,
lib/mcp_toolkit/engine_controllers.rb,
sig/mcp_toolkit.rbs
Overview
Lazy parent_controller builder (Constraint B).
The engine's controllers (McpToolkit::ServerController,
McpToolkit::TokensController) and the authority base
(McpToolkit::Authority::ServerController) all subclass
config.parent_controller. If that superclass were resolved in a class body of
an autoloaded/eager-loaded file, it could be read BEFORE the host's
initializer/to_prepare had set it — defaulting to ActionController::Base and, in
turn, breaking CSRF handling on the introspection endpoint.
Instead, none of these controllers is a Zeitwerk-managed file. They are built here from the CURRENT config, and the build is triggered LAZILY:
* `const_missing` (below, and on McpToolkit::Authority) builds them the first
time they are referenced — which, in a host, is at eager-load or first-
request time, i.e. AFTER the app's initializers/to_prepare have run;
* the engine's `config.to_prepare` RESETS them on every code reload so a
changed `parent_controller` (or a reloaded app parent class) takes effect on
the next reference.
The whole config/initializers/mcp_toolkit.rb of a host can therefore live in
to_prepare: the parent is only ever read at build time.
This file reopens McpToolkit to add module methods, so it is Zeitwerk-IGNORED
(like engine.rb) and required explicitly from the gem entry point.
Defined Under Namespace
Modules: Authority, Filtering, Oauth, Protocol, Server, TokenKinds, ToolReferenceRewriter Classes: Configuration, Dispatcher, Engine, Error, FieldSelection, GetExecutor, ListExecutor, RateLimiter, Registry, Resource, ResourceSchema, Serialization, Session, SqlSanitizer, UnknownResourceMessage
Constant Summary collapse
- VERSION =
"0.6.0"- ENGINE_CONTROLLER_NAMES =
The controllers built directly under McpToolkit (the engine's routes point at these). McpToolkit::Authority::ServerController is built alongside them but is fetched through McpToolkit::Authority's own const_missing.
%i[ServerController TokensController OauthController].freeze
Class Method Summary collapse
-
.build_authority_server_controller(parent) ⇒ Object
The AUTHORITY base controller a host subclasses (the recommended path for a host whose rate-limit/usage/account hooks touch app models).
-
.build_engine_controllers! ⇒ Object
(Re)builds the engine controllers + the authority base from the current config.
-
.build_oauth_controller ⇒ Object
The OAuth authorization bridge the engine mounts at
<mcp>/oauth/*and the host draws at the two.well-knownmetadata paths. -
.build_server_controller(parent) ⇒ Object
The transport controller the engine mounts at POST/GET/DELETE /mcp, chosen by ROLE so a single
mount McpToolkit::Engine => "/mcp"works for either kind of host: an AUTHORITY (auth_role == :authority) gets the hand-rolled dispatcher path (Authority::ControllerMethods — local token auth, gateway proxying, usage metering, rate limiting), a SATELLITE (the default) gets the SDK-backed path (Transport::ControllerMethods, which forwards tokens to a central app). -
.build_tokens_controller(parent) ⇒ Object
The AUTHORITY introspection endpoint the engine mounts at
POST /mcp/tokens/introspect. -
.config ⇒ Object
The active Configuration (created on first access).
-
.configure {|config| ... } ⇒ Object
Yields the active configuration for mutation, returning it.
-
.const_missing(name) ⇒ Object
Backstop: build the engine controllers the first time one is referenced before any
to_prepare/eager-load pass has built them (e.g. a bespoke route that names McpToolkit::ServerController directly). -
.define_controller(mod, name, klass) ⇒ Object
Removes an existing same-named constant (avoiding a redefinition warning on a rebuild) before setting the freshly-built class.
-
.draw_oauth_metadata_routes(mapper) ⇒ Object
Draws the OAuth bridge's two metadata documents.
-
.registry ⇒ Object
The active config's resource registry.
-
.reset_config! ⇒ Object
Replaces the active configuration with a fresh default.
-
.reset_engine_controllers! ⇒ Object
Undefines the built controllers so the next reference rebuilds them from the then-current config.
-
.warn_about_per_process_cache_store ⇒ Object
Said once per boot (this runs from the engine's
to_prepare), because the failure it predicts is otherwise hard to read as a misconfiguration at all: with a per-process cache the two legs of a flow land on different workers, so the exchange fails (N-1)/N of the time — intermittently, and only AFTER the operator has already pasted a live token.
Class Method Details
.build_authority_server_controller(parent) ⇒ Object
The AUTHORITY base controller a host subclasses (the recommended path for a host whose rate-limit/usage/account hooks touch app models).
139 140 141 |
# File 'lib/mcp_toolkit/engine_controllers.rb', line 139 def self.(parent) Class.new(parent) { include McpToolkit::Authority::ControllerMethods } end |
.build_engine_controllers! ⇒ Object
(Re)builds the engine controllers + the authority base from the current
config. Idempotent: an existing constant is replaced so a rebuild reflects a
changed parent_controller. Reads config.parent_controller at call time.
36 37 38 39 40 41 42 43 44 45 46 47 |
# File 'lib/mcp_toolkit/engine_controllers.rb', line 36 def self.build_engine_controllers! parent = config.parent_controller.constantize define_controller(self, :ServerController, build_server_controller(parent)) define_controller(self, :TokensController, build_tokens_controller(parent)) # Only when the bridge is on — matching its routes, which are equally gated. # Its parent (default ActionController::Base) would otherwise be constantized # on every host, pulling view machinery into an API-only app that never # enables the bridge, and breaking a non-Rails host outright. define_controller(self, :OauthController, build_oauth_controller) if config.oauth_bridge? define_controller(Authority, :ServerController, (parent)) ServerController end |
.build_oauth_controller ⇒ Object
The OAuth authorization bridge the engine mounts at <mcp>/oauth/* and the
host draws at the two .well-known metadata paths.
Built from config.oauth_parent_controller, NOT the parent_controller its
siblings use: the transport is a JSON-only endpoint that a host rightly points
at ActionController::API, which cannot render an HTML view — and this
controller's authorization page is one. Sharing the parent would force a host
to weaken its transport's superclass just to enable the bridge. Read lazily
here, like the rest, so the host's initializer/to_prepare has already run.
113 114 115 116 |
# File 'lib/mcp_toolkit/engine_controllers.rb', line 113 def self.build_oauth_controller warn_about_per_process_cache_store Class.new(config.oauth_parent_controller.constantize) { include McpToolkit::Oauth::ControllerMethods } end |
.build_server_controller(parent) ⇒ Object
The transport controller the engine mounts at POST/GET/DELETE /mcp, chosen by
ROLE so a single mount McpToolkit::Engine => "/mcp" works for either kind of
host: an AUTHORITY (auth_role == :authority) gets the hand-rolled dispatcher
path (Authority::ControllerMethods — local token auth, gateway proxying, usage
metering, rate limiting), a SATELLITE (the default) gets the SDK-backed path
(Transport::ControllerMethods, which forwards tokens to a central app). Built
lazily so both config.parent_controller and config.auth_role are read after
the host's initializer/to_prepare has run. A host that would rather draw its own
routes still subclasses McpToolkit::Authority::ServerController directly.
66 67 68 69 |
# File 'lib/mcp_toolkit/engine_controllers.rb', line 66 def self.build_server_controller(parent) concern = config. ? McpToolkit::Authority::ControllerMethods : McpToolkit::Transport::ControllerMethods Class.new(parent) { include concern } end |
.build_tokens_controller(parent) ⇒ Object
The AUTHORITY introspection endpoint the engine mounts at
POST /mcp/tokens/introspect. Behavior is preserved exactly: it authenticates
the bearer against config.token_authenticator (via Auth::Authority) and
renders the introspection payload; a non-authority app answers { valid: false } rather than erroring.
76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 |
# File 'lib/mcp_toolkit/engine_controllers.rb', line 76 def self.build_tokens_controller(parent) Class.new(parent) do def introspect token = McpToolkit::Auth::Authority.authenticate(mcp_extract_token, config: mcp_config) return render(json: McpToolkit::Auth::Authority.invalid_payload, status: :unauthorized) if token.nil? render json: McpToolkit::Auth::Authority.introspection_payload(token) rescue McpToolkit::Errors::ConfigurationError # Not configured as an authority (no token_authenticator): behave as if the # token were invalid instead of surfacing a 500. render json: McpToolkit::Auth::Authority.invalid_payload, status: :unauthorized end private def mcp_config McpToolkit.config end def mcp_extract_token auth_header = request.headers["Authorization"] return auth_header.sub("Bearer ", "") if auth_header&.start_with?("Bearer ") request.headers["X-MCP-Token"].presence || params[:token].presence end end end |
.config ⇒ Object
The active Configuration (created on first access).
113 114 115 |
# File 'lib/mcp_toolkit.rb', line 113 def self.config @config ||= Configuration.new end |
.configure {|config| ... } ⇒ Object
Yields the active configuration for mutation, returning it.
McpToolkit.configure do |c|
c.server_name = "my-app-mcp"
c.central_app_url = ENV.fetch("MCP_CENTRAL_APP_URL")
c.registry. "my_app__read"
end
107 108 109 110 |
# File 'lib/mcp_toolkit.rb', line 107 def self.configure yield(config) if block_given? config end |
.const_missing(name) ⇒ Object
Backstop: build the engine controllers the first time one is referenced before
any to_prepare/eager-load pass has built them (e.g. a bespoke route that
names McpToolkit::ServerController directly). McpToolkit::Authority defines the
sibling backstop for its ServerController.
183 184 185 186 187 188 189 190 |
# File 'lib/mcp_toolkit/engine_controllers.rb', line 183 def self.const_missing(name) if ENGINE_CONTROLLER_NAMES.include?(name) build_engine_controllers! return const_get(name) if const_defined?(name, false) end super end |
.define_controller(mod, name, klass) ⇒ Object
Removes an existing same-named constant (avoiding a redefinition warning on a rebuild) before setting the freshly-built class.
174 175 176 177 |
# File 'lib/mcp_toolkit/engine_controllers.rb', line 174 def self.define_controller(mod, name, klass) mod.send(:remove_const, name) if mod.const_defined?(name, false) mod.const_set(name, klass) end |
.draw_oauth_metadata_routes(mapper) ⇒ Object
Draws the OAuth bridge's two metadata documents. A /.well-known/* path
cannot be drawn by an engine mounted under a path, so it has to live in the
host's own route set. The host calls this at the TOP LEVEL — not inside a
locale/format/constraint scope, which would prefix the paths out of view:
# config/routes.rb
Rails.application.routes.draw do
McpToolkit.(self)
mount McpToolkit::Engine => "/mcp"
# ...
end
The paths are PATH-SCOPED to the engine's mount
(/.well-known/oauth-protected-resource/mcp), so this claims NOTHING
origin-global and cannot collide with an OAuth provider the host already runs
— see Configuration#oauth_protected_resource_path for why that matters. A host
mounted at its origin root gets the bare paths instead, which is correct there.
A no-op unless the bridge is configured, so the call can sit in a host's routes unconditionally across environments.
163 164 165 166 167 168 169 170 |
# File 'lib/mcp_toolkit/engine_controllers.rb', line 163 def self.(mapper) return unless config.oauth_bridge? mapper.get config.oauth_protected_resource_path, to: "mcp_toolkit/oauth#protected_resource", format: false mapper.get config., to: "mcp_toolkit/oauth#authorization_server", format: false end |
.registry ⇒ Object
The active config's resource registry. Register resources against this in a boot initializer.
119 120 121 |
# File 'lib/mcp_toolkit.rb', line 119 def self.registry config.registry end |
.reset_config! ⇒ Object
Replaces the active configuration with a fresh default. Primarily for tests.
124 125 126 |
# File 'lib/mcp_toolkit.rb', line 124 def self.reset_config! @config = Configuration.new end |
.reset_engine_controllers! ⇒ Object
Undefines the built controllers so the next reference rebuilds them from the
then-current config. Called from the engine's to_prepare on every reload.
51 52 53 54 55 |
# File 'lib/mcp_toolkit/engine_controllers.rb', line 51 def self.reset_engine_controllers! ENGINE_CONTROLLER_NAMES.map { |name| [self, name] }.push([Authority, :ServerController]).each do |mod, name| mod.send(:remove_const, name) if mod.const_defined?(name, false) end end |
.warn_about_per_process_cache_store ⇒ Object
Said once per boot (this runs from the engine's to_prepare), because the
failure it predicts is otherwise hard to read as a misconfiguration at all:
with a per-process cache the two legs of a flow land on different workers, so
the exchange fails (N-1)/N of the time — intermittently, and only AFTER the
operator has already pasted a live token. A warning rather than a gate,
because one process is genuinely fine and Rails.cache is a MemoryStore in a
stock development environment.
125 126 127 128 129 130 131 132 133 134 135 |
# File 'lib/mcp_toolkit/engine_controllers.rb', line 125 def self.warn_about_per_process_cache_store return unless config.oauth_per_process_cache_store? config.logger&.warn( "[mcp_toolkit] The OAuth bridge is enabled but config.cache_store is an in-process MemoryStore. " \ "That is safe in a single process only: on a multi-worker deployment an authorization code issued " \ "by one worker is invisible to the worker that redeems it, so the flow fails intermittently AFTER " \ "the operator has pasted their token. Point config.cache_store at a shared store (Rails.cache " \ "backed by Redis/Memcached) before running the bridge in production." ) end |