mcp_toolkit 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -112,6 +112,214 @@ class McpToolkit::Configuration
112
112
  # @return [#call, nil]
113
113
  attr_accessor :token_authenticator
114
114
 
115
+ # --- auth: OAuth authorization bridge (authority-only) ---------------------
116
+ #
117
+ # An OAuth 2.1 authorization-code + PKCE envelope around the tokens the host
118
+ # ALREADY issues, for hosted MCP clients that will only authenticate by
119
+ # discovering an authorization server and running a browser flow (and that
120
+ # cannot be handed a token in the request URI, which the MCP authorization spec
121
+ # forbids). It authenticates nobody: its authorization page asks the operator
122
+ # to paste an existing access token and hands that same token back. See
123
+ # McpToolkit::Oauth::ControllerMethods.
124
+
125
+ # The exact redirect URIs an authorization code may be handed to — the
126
+ # allowlist a REMOTE client's `redirect_uri` is matched against by exact
127
+ # string. This is the bridge's load-bearing control: without it the authorize
128
+ # endpoint would be an open redirect that emits authorization codes.
129
+ #
130
+ # Why it cannot just be opened up to "any client": the page is served from the
131
+ # host's OWN origin under its own certificate and asks for a live token, so an
132
+ # unvetted `redirect_uri` makes it a credential-phishing page hosted by the host
133
+ # itself — an attacker sends the operator an authorize link carrying the
134
+ # attacker's `code_challenge`, the operator pastes, and the code goes to the
135
+ # attacker, who redeems it with the verifier they chose. PKCE cannot help; they
136
+ # own the verifier. This list IS the redirect-URI registration a real
137
+ # authorization server does, and it is the only thing standing in for it.
138
+ #
139
+ # Be precise about what it does NOT cover, because the boundary is easy to
140
+ # overstate. It binds which URL a code may be sent to — not whose session at
141
+ # that URL receives it. Point it at a MULTI-TENANT client (which is the usual
142
+ # case: a hosted MCP client is one callback shared by every user) and an
143
+ # attacker can still start a flow in their OWN account there, send the operator
144
+ # the resulting authorize link, and have the code land back at that client
145
+ # carrying the attacker's `state`. Whether the operator's token then ends up in
146
+ # the attacker's account is decided entirely by whether the CLIENT binds
147
+ # `state` to the browser session that began the flow (RFC 6819 §4.4.1.7; RFC
148
+ # 9700 §4.7.1) — an authorization server cannot bind a code to a session it
149
+ # never saw, so no amount of consent or authentication here would close it. A
150
+ # real authorization server has exactly the same exposure. Only allowlist
151
+ # clients you believe handle `state` correctly.
152
+ #
153
+ # EMPTY BY DEFAULT. Empty, and with `oauth_allow_loopback_redirects` off,
154
+ # the bridge is DISABLED entirely (see `oauth_bridge?`) — so it cannot be
155
+ # switched on without naming who may receive a code, and a host that wants
156
+ # nothing to do with it sets nothing.
157
+ #
158
+ # c.oauth_allowed_redirect_uris = ["https://client.example/callback"]
159
+ #
160
+ # @return [Array<String>]
161
+ attr_reader :oauth_allowed_redirect_uris
162
+
163
+ # Assigns the allowlist, rejecting an entry the bridge must not, or could not,
164
+ # redirect to. Validated at CONFIG time for the same reason
165
+ # `filter_operator_overrides=` is: the alternative is a request-time failure,
166
+ # and here that failure lands at the worst possible moment — the authorize page
167
+ # renders, the operator pastes a live token, the token is verified, a code is
168
+ # written to the cache, and only THEN does the callback URL turn out to be
169
+ # unusable. A typo would cost the operator their paste.
170
+ #
171
+ # The result is FROZEN, so validation is a gate rather than a suggestion: an
172
+ # `<<` onto the reader would otherwise slip an unchecked entry straight past
173
+ # this method. (`config.oauth_allowed_redirect_uris << "…#frag"` used to work,
174
+ # and a fragment is not a harmless typo — the emitted `…/cb#frag?code=…` puts
175
+ # the code in the browser's HASH, so it never reaches the client's server and
176
+ # is readable by any script on the page.)
177
+ #
178
+ # See `oauth_redirect_uri_problem` for what is rejected and why.
179
+ def oauth_allowed_redirect_uris=(uris)
180
+ entries = Array(uris).map { |uri| uri.to_s.dup.freeze }
181
+ entries.each do |uri|
182
+ problem = oauth_redirect_uri_problem(uri)
183
+ next unless problem
184
+
185
+ raise ArgumentError, "oauth_allowed_redirect_uris contains #{uri.inspect}: #{problem}"
186
+ end
187
+ @oauth_allowed_redirect_uris = entries.freeze
188
+ end
189
+
190
+ # Permits LOOPBACK redirect targets on any port without naming each one:
191
+ # `http://127.0.0.1:54321/cb`, `http://localhost:*/cb`, `http://[::1]:*/cb`.
192
+ #
193
+ # This is the one target that cannot be allowlisted even in principle — an MCP
194
+ # client on an operator's machine listens on an ephemeral port chosen at
195
+ # runtime, so no list could enumerate it (RFC 8252 §7.3 exists for exactly
196
+ # this). And it is safe to accept unnamed for the same reason the list above
197
+ # cannot be opened up: a loopback address resolves on the operator's OWN
198
+ # machine, so the phishing described above — which needs the code to reach a
199
+ # REMOTE attacker — does not work through it.
200
+ #
201
+ # Note what this deliberately does NOT cover: a private-use scheme
202
+ # (`cursor://…`, RFC 8252 §7.1). Those keep the code on the device too, but
203
+ # their redirect URI is a FIXED STRING, so it simply goes in the list above —
204
+ # there is no forcing reason to accept one unnamed. And whole schemes cannot be
205
+ # accepted generically anyway: separating a private-use scheme from a
206
+ # registered network one (`ssh:`, `ldap:`, `gopher:` — each naming a remote
207
+ # host) would mean enumerating the IANA registry, and a denylist of the ones
208
+ # you happened to think of is the shape that fails open.
209
+ #
210
+ # A remote `https://` callback is never covered by this, whatever it is set to.
211
+ #
212
+ # OFF BY DEFAULT: switching it on says "any client on my operators' machines may
213
+ # receive a code", which is a decision, not a default — and so is an opt-in
214
+ # signal in its own right.
215
+ #
216
+ # c.oauth_allow_loopback_redirects = true
217
+ #
218
+ # @return [Boolean]
219
+ attr_accessor :oauth_allow_loopback_redirects
220
+
221
+ # The path McpToolkit::Engine is mounted at, used to build the `resource`
222
+ # identifier, the issuer, the two metadata locations, and the bridge's own
223
+ # endpoint URLs (their origin comes from the live request, so every host name
224
+ # the app answers on works). MUST match the actual mount point, and the
225
+ # `resource` it yields MUST equal the MCP endpoint URL as an operator types it
226
+ # into their client.
227
+ #
228
+ # This path is ALSO what keeps the bridge out of the origin's global namespace
229
+ # — see `oauth_protected_resource_path`.
230
+ #
231
+ # @return [String]
232
+ attr_accessor :oauth_resource_path
233
+
234
+ # Seconds an issued authorization code stays redeemable. Codes are single-use
235
+ # (read-and-deleted at exchange); this only bounds a code that is never
236
+ # redeemed. Short by design — a client exchanges immediately.
237
+ #
238
+ # @return [Integer]
239
+ attr_accessor :oauth_authorization_code_ttl
240
+
241
+ # The server-held secret mixed into the key that seals a cached authorization
242
+ # code's payload (McpToolkit::Oauth::ControllerMethods#mcp_oauth_encryptor).
243
+ #
244
+ # It exists because the code alone must NOT be the key: Rails logs an
245
+ # authorization code twice per flow at INFO, so the logs would otherwise carry
246
+ # the key to the cache entry. This secret never reaches a log or a response, so
247
+ # the cache and the logs together still open nothing without it.
248
+ #
249
+ # Defaults to the Rails app's `secret_key_base` (lazily, so load order and a
250
+ # non-Rails host are both fine), which is exactly the "server-held, in ENV,
251
+ # never logged" property wanted — so a Rails host configures nothing. A
252
+ # non-Rails host MUST set it; the bridge refuses to run without one
253
+ # (`oauth_bridge?`), rather than silently sealing with a weak key.
254
+ #
255
+ # c.oauth_signing_secret = ENV.fetch("MCP_OAUTH_SIGNING_SECRET")
256
+ #
257
+ # Use `fetch`, not `ENV["..."]`: a nil from a missing var falls back to
258
+ # `secret_key_base` silently, leaving a host believing it runs a dedicated
259
+ # secret when it does not.
260
+ #
261
+ # Rotating it invalidates in-flight codes (a 60s window), nothing else — the
262
+ # tokens themselves are the host's and are untouched.
263
+ #
264
+ # @return [String, nil]
265
+ #
266
+ # Validated at assignment, like `oauth_allowed_redirect_uris=` — a non-String
267
+ # here passed `oauth_bridge?`, drew the routes, and then raised a TypeError out
268
+ # of `OpenSSL::HMAC` at request time: after the operator pasted a live token and
269
+ # a code was already cached. That is the exact failure the sibling setter exists
270
+ # to prevent, so it gets the same treatment.
271
+ def oauth_signing_secret=(secret)
272
+ raise ArgumentError, "oauth_signing_secret must be a String, got #{secret.class}" if secret && !secret.is_a?(String)
273
+
274
+ if secret.is_a?(String) && !secret.empty? && secret.bytesize < MINIMUM_SIGNING_SECRET_BYTES
275
+ raise ArgumentError,
276
+ "oauth_signing_secret is #{secret.bytesize} bytes; use at least " \
277
+ "#{MINIMUM_SIGNING_SECRET_BYTES} (a real secret_key_base is 128)"
278
+ end
279
+
280
+ @oauth_signing_secret = secret
281
+ end
282
+
283
+ # Short enough to admit any real secret, long enough to catch a placeholder.
284
+ #
285
+ # Deliberately NOT applied to the `secret_key_base` fallback, which is checked
286
+ # for presence only. The minimum exists to catch a placeholder a host typed into
287
+ # THIS setting; `secret_key_base` is Rails' own, is 128 chars in a real
288
+ # deployment, and is short only in environments where Rails generates a throwaway
289
+ # (a stock test env is ~15 bytes). A genuinely weak `secret_key_base` is an
290
+ # app-wide problem — signed cookies, message verifiers, Active Record encryption
291
+ # — and not this gem's to police from the outside.
292
+ MINIMUM_SIGNING_SECRET_BYTES = 32
293
+
294
+ # Reads the configured secret, else the Rails app's `secret_key_base`. Resolved
295
+ # lazily rather than in the initializer: the gem loads before a Rails app is
296
+ # fully configured, and a non-Rails host has no `Rails` constant at all.
297
+ def oauth_signing_secret
298
+ return @oauth_signing_secret if @oauth_signing_secret
299
+
300
+ return nil unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
301
+
302
+ ::Rails.application.secret_key_base
303
+ end
304
+
305
+ # The parent class of the bridge's controller, SEPARATE from
306
+ # `parent_controller` and defaulting to ActionController::Base.
307
+ #
308
+ # They are separate because the two controllers have opposite needs. The MCP
309
+ # transport is a JSON-only endpoint, so a host quite reasonably points
310
+ # `parent_controller` at `ActionController::API` — which cannot render an HTML
311
+ # view. The bridge's authorization page IS an HTML view. Deriving it from
312
+ # `parent_controller` would therefore force a host to weaken its transport's
313
+ # superclass just to switch the bridge on; keeping them apart means enabling
314
+ # the bridge changes nothing about the transport.
315
+ #
316
+ # Point this at your own `ApplicationController` to inherit app branding (the
317
+ # page renders with `layout: false` regardless, so an app layout that needs
318
+ # asset-pipeline context is not pulled in).
319
+ #
320
+ # @return [String]
321
+ attr_accessor :oauth_parent_controller
322
+
115
323
  # --- caching ---------------------------------------------------------------
116
324
 
117
325
  # The cache store backing sessions and introspection results. Must satisfy the
@@ -434,6 +642,8 @@ class McpToolkit::Configuration
434
642
 
435
643
  @token_authenticator = nil
436
644
 
645
+ initialize_oauth_bridge_defaults
646
+
437
647
  @cache_store = ActiveSupport::Cache::MemoryStore.new
438
648
  initialize_data_path_defaults
439
649
 
@@ -454,6 +664,18 @@ class McpToolkit::Configuration
454
664
  @upstreams = McpToolkit::Gateway::UpstreamRegistry.new
455
665
  end
456
666
 
667
+ # OAuth bridge defaults. The empty redirect allowlist AND the off native-client
668
+ # switch are jointly what keep the bridge OFF (`oauth_bridge?`), so a host that
669
+ # never configures it is unaffected.
670
+ def initialize_oauth_bridge_defaults
671
+ @oauth_allowed_redirect_uris = []
672
+ @oauth_allow_loopback_redirects = false
673
+ @oauth_resource_path = "/mcp"
674
+ @oauth_authorization_code_ttl = 60
675
+ @oauth_parent_controller = "ActionController::Base"
676
+ @oauth_signing_secret = nil # falls back to Rails' secret_key_base — see the reader
677
+ end
678
+
457
679
  # Session-TTL and list-executor defaults: the :tokenized / :created_at
458
680
  # data-path semantics (a host preserving a pre-gem contract overrides these —
459
681
  # see each accessor's docs).
@@ -556,6 +778,149 @@ class McpToolkit::Configuration
556
778
  auth_role.to_sym == :authority
557
779
  end
558
780
 
781
+ # The well-known prefixes the two metadata documents hang off. The resource
782
+ # path is INSERTED after these, never appended to the origin — see
783
+ # `oauth_protected_resource_path`.
784
+ PROTECTED_RESOURCE_WELL_KNOWN = "/.well-known/oauth-protected-resource"
785
+ AUTHORIZATION_SERVER_WELL_KNOWN = "/.well-known/oauth-authorization-server"
786
+
787
+ # `oauth_resource_path` normalized for URL building: no trailing slash, and
788
+ # empty when the MCP endpoint IS the origin root (where there is no path
789
+ # component to insert).
790
+ #
791
+ # @return [String] e.g. "/mcp", or "" for a root-mounted endpoint.
792
+ def oauth_resource_path_component
793
+ path = oauth_resource_path.to_s.chomp("/")
794
+ path == "/" ? "" : path
795
+ end
796
+
797
+ # Where the protected-resource metadata (RFC 9728) answers, and where
798
+ # `WWW-Authenticate` points.
799
+ #
800
+ # Path-SCOPED (`/.well-known/oauth-protected-resource/mcp`), never the bare
801
+ # path, because the bare ones are ORIGIN-GLOBAL: they describe the authorization
802
+ # server of the whole origin, which on a host already running an unrelated OAuth
803
+ # provider is that provider's claim to make, not an MCP server's. RFC 8414 §3.1
804
+ # exists for this — "Using path components enables supporting multiple issuers
805
+ # per host" — and MCP's 2025-11-25 authorization spec gives a path-ful issuer no
806
+ # root fallback, so scoping is the correct reading rather than a workaround.
807
+ #
808
+ # A root-mounted endpoint has no path to insert and gets the bare paths, which is
809
+ # correct there: it really is that origin's only authorization server.
810
+ #
811
+ # @return [String]
812
+ def oauth_protected_resource_path
813
+ "#{PROTECTED_RESOURCE_WELL_KNOWN}#{oauth_resource_path_component}"
814
+ end
815
+
816
+ # Where the authorization-server metadata (RFC 8414) answers. Path-inserted for
817
+ # the same reason, and it MUST agree with the issuer: a client constructs this
818
+ # URL from the issuer it was given.
819
+ #
820
+ # @return [String]
821
+ def oauth_authorization_server_path
822
+ "#{AUTHORIZATION_SERVER_WELL_KNOWN}#{oauth_resource_path_component}"
823
+ end
824
+
825
+ # Whether the OAuth authorization bridge is live: its routes are drawn, and the
826
+ # authority transport advertises it on a 401 via `WWW-Authenticate`.
827
+ #
828
+ # Gated on three conditions, each for its own reason.
829
+ #
830
+ # AUTHORITY-ONLY, because the flow hands back a token this app itself
831
+ # authenticates — a satellite's tokens belong to its central app, so there is
832
+ # nothing here for it to authorize against.
833
+ #
834
+ # A `token_authenticator` must be set, because the bridge cannot function
835
+ # without one: it verifies the pasted token through it on both legs. Gated
836
+ # rather than left to fail at request time so a misconfigured host serves no
837
+ # bridge at all, instead of an authorization page that accepts an operator's
838
+ # token and then errors — the sibling introspection endpoint fails safe the
839
+ # same way.
840
+ #
841
+ # An `oauth_signing_secret` must resolve, for the same reason: without one the
842
+ # bridge cannot seal a code's payload, and it must not fall back to sealing
843
+ # with something weaker. A Rails host gets `secret_key_base` for free.
844
+ #
845
+ # And at least one redirect target must be named — an allowlist entry, or the
846
+ # loopback switch — so the bridge cannot be running without a bound answer to
847
+ # "who may receive a code". Both are empty/off by default, which is what makes
848
+ # an unconfigured host byte-identical to one without the bridge.
849
+ #
850
+ # NOT gated on a shared `cache_store`, deliberately, even though a per-worker
851
+ # one breaks the flow (see `oauth_per_process_cache_store?`): a MemoryStore is
852
+ # correct in a single process, `Rails.cache` IS a MemoryStore in a stock
853
+ # development environment, and the gem cannot see the worker count. Gating
854
+ # would make the bridge undevelopable locally to prevent a production mistake,
855
+ # so that one is a loud warning at boot instead.
856
+ #
857
+ # @return [Boolean]
858
+ def oauth_bridge?
859
+ return false unless authority?
860
+ return false if token_authenticator.nil?
861
+ return false if oauth_signing_secret.to_s.empty?
862
+
863
+ Array(oauth_allowed_redirect_uris).any? || !!oauth_allow_loopback_redirects
864
+ end
865
+
866
+ # Why a given allowlist entry must not or cannot receive a code, or nil if it
867
+ # may. A lint over values the HOST wrote, not a boundary against an attacker —
868
+ # which is why naming bad schemes is sound here and would not be at request
869
+ # time: nothing an attacker sends reaches this list, so there is no unlisted
870
+ # scheme for them to slip through. (Request-time policy takes the opposite
871
+ # shape for exactly that reason — see
872
+ # McpToolkit::Oauth::ControllerMethods#mcp_oauth_loopback_redirect_uri?.)
873
+ def oauth_redirect_uri_problem(uri)
874
+ parsed = oauth_parse_redirect_uri(uri)
875
+ return "it is not a valid URI" if parsed.nil?
876
+
877
+ scheme = parsed.scheme&.downcase
878
+ return "it names no scheme" if scheme.nil?
879
+ return "it carries a fragment, which OAuth forbids on a redirect_uri" unless parsed.fragment.nil?
880
+ unless parsed.opaque.nil?
881
+ return "it is opaque (#{scheme}:#{parsed.opaque}) so it cannot carry the code — " \
882
+ "write it with a path, e.g. #{scheme}:/#{parsed.opaque}"
883
+ end
884
+
885
+ oauth_redirect_scheme_problem(scheme, parsed)
886
+ end
887
+
888
+ # Schemes a browser may treat as script or local content. A code handed to one
889
+ # is at best lost and at worst executed; no client legitimately registers one.
890
+ ACTIVE_CONTENT_SCHEMES = %w[javascript data vbscript file blob about view-source].freeze
891
+
892
+ def oauth_redirect_scheme_problem(scheme, parsed)
893
+ if ACTIVE_CONTENT_SCHEMES.include?(scheme)
894
+ return "#{scheme}: is not a redirect target — a browser treats it as script or local content"
895
+ end
896
+ return nil unless scheme == "http"
897
+ # RFC 8252 §7.3: cleartext is fine to a loopback address, which never leaves
898
+ # the operator's machine. Anywhere else it puts the code on the wire.
899
+ return nil if McpToolkit::Oauth.loopback_host?(parsed.host)
900
+
901
+ "it is cleartext http to a remote host, which would put the authorization code on the wire — " \
902
+ "use https (cleartext is only accepted for loopback)"
903
+ end
904
+
905
+ def oauth_parse_redirect_uri(uri)
906
+ URI.parse(uri)
907
+ rescue URI::InvalidURIError
908
+ nil
909
+ end
910
+
911
+ # Whether `cache_store` is per-process, which the bridge cannot survive on a
912
+ # multi-worker deployment: a code written on the worker that ran leg 1 is
913
+ # invisible to the worker that runs leg 2, so the exchange reads nil and
914
+ # answers `invalid_grant` — roughly (N-1)/N of the time, AFTER the operator has
915
+ # pasted a live token, and intermittently enough to read as a fluke rather than
916
+ # a misconfiguration. (It also quietly voids the payload sealing: a per-process
917
+ # store has no snapshot to steal, and its key would be in the same heap.)
918
+ #
919
+ # Fine in one process, which is why this warns rather than gates.
920
+ def oauth_per_process_cache_store?
921
+ cache_store.is_a?(ActiveSupport::Cache::MemoryStore)
922
+ end
923
+
559
924
  # Full introspection URL the satellite POSTs to. Raises a clear error if the
560
925
  # central URL was never configured.
561
926
  def introspect_url
@@ -36,4 +36,19 @@ class McpToolkit::Engine < Rails::Engine
36
36
  # reload so a changed parent (or a reloaded app parent class) takes effect on the
37
37
  # next reference. Runs before `:eager_load!`, so the fresh classes exist for it.
38
38
  config.to_prepare { McpToolkit.reset_engine_controllers! }
39
+
40
+ # The OAuth bridge takes the operator's live access token in a POST body, and
41
+ # Rails logs request parameters at INFO. Filtering it is therefore the gem's
42
+ # business, not a deployment note: a `rails new` app happens to ship a `:token`
43
+ # entry that covers `access_token` by substring, but that is a host default this
44
+ # gem does not own and an `--api` or hand-rolled host may not have. Additive, so
45
+ # a host's own list is untouched, and harmless when the bridge is off.
46
+ #
47
+ # `code_verifier` is belt-and-braces (a logged verifier is worthless once the
48
+ # code is spent, and the code is burnt on read). `code` is deliberately NOT
49
+ # filtered: it is single-use, useless without the verifier, and matching it
50
+ # would filter every `country_code`/`state_code` a host logs.
51
+ initializer "mcp_toolkit.filter_oauth_parameters" do |app|
52
+ app.config.filter_parameters += %i[access_token code_verifier]
53
+ end
39
54
  end
@@ -28,7 +28,7 @@ module McpToolkit
28
28
  # The controllers built directly under McpToolkit (the engine's routes point at
29
29
  # these). McpToolkit::Authority::ServerController is built alongside them but is
30
30
  # fetched through McpToolkit::Authority's own const_missing.
31
- ENGINE_CONTROLLER_NAMES = %i[ServerController TokensController].freeze
31
+ ENGINE_CONTROLLER_NAMES = %i[ServerController TokensController OauthController].freeze
32
32
 
33
33
  # (Re)builds the engine controllers + the authority base from the current
34
34
  # config. Idempotent: an existing constant is replaced so a rebuild reflects a
@@ -37,6 +37,11 @@ module McpToolkit
37
37
  parent = config.parent_controller.constantize
38
38
  define_controller(self, :ServerController, build_server_controller(parent))
39
39
  define_controller(self, :TokensController, build_tokens_controller(parent))
40
+ # Only when the bridge is on — matching its routes, which are equally gated.
41
+ # Its parent (default ActionController::Base) would otherwise be constantized
42
+ # on every host, pulling view machinery into an API-only app that never
43
+ # enables the bridge, and breaking a non-Rails host outright.
44
+ define_controller(self, :OauthController, build_oauth_controller) if config.oauth_bridge?
40
45
  define_controller(Authority, :ServerController, build_authority_server_controller(parent))
41
46
  ServerController
42
47
  end
@@ -44,7 +49,7 @@ module McpToolkit
44
49
  # Undefines the built controllers so the next reference rebuilds them from the
45
50
  # then-current config. Called from the engine's `to_prepare` on every reload.
46
51
  def self.reset_engine_controllers!
47
- [[self, :ServerController], [self, :TokensController], [Authority, :ServerController]].each do |mod, name|
52
+ ENGINE_CONTROLLER_NAMES.map { |name| [self, name] }.push([Authority, :ServerController]).each do |mod, name|
48
53
  mod.send(:remove_const, name) if mod.const_defined?(name, false)
49
54
  end
50
55
  end
@@ -96,12 +101,74 @@ module McpToolkit
96
101
  end
97
102
  end
98
103
 
104
+ # The OAuth authorization bridge the engine mounts at `<mcp>/oauth/*` and the
105
+ # host draws at the two `.well-known` metadata paths.
106
+ #
107
+ # Built from `config.oauth_parent_controller`, NOT the `parent_controller` its
108
+ # siblings use: the transport is a JSON-only endpoint that a host rightly points
109
+ # at ActionController::API, which cannot render an HTML view — and this
110
+ # controller's authorization page is one. Sharing the parent would force a host
111
+ # to weaken its transport's superclass just to enable the bridge. Read lazily
112
+ # here, like the rest, so the host's initializer/to_prepare has already run.
113
+ def self.build_oauth_controller
114
+ warn_about_per_process_cache_store
115
+ Class.new(config.oauth_parent_controller.constantize) { include McpToolkit::Oauth::ControllerMethods }
116
+ end
117
+
118
+ # Said once per boot (this runs from the engine's `to_prepare`), because the
119
+ # failure it predicts is otherwise hard to read as a misconfiguration at all:
120
+ # with a per-process cache the two legs of a flow land on different workers, so
121
+ # the exchange fails (N-1)/N of the time — intermittently, and only AFTER the
122
+ # operator has already pasted a live token. A warning rather than a gate,
123
+ # because one process is genuinely fine and `Rails.cache` is a MemoryStore in a
124
+ # stock development environment.
125
+ def self.warn_about_per_process_cache_store
126
+ return unless config.oauth_per_process_cache_store?
127
+
128
+ config.logger&.warn(
129
+ "[mcp_toolkit] The OAuth bridge is enabled but config.cache_store is an in-process MemoryStore. " \
130
+ "That is safe in a single process only: on a multi-worker deployment an authorization code issued " \
131
+ "by one worker is invisible to the worker that redeems it, so the flow fails intermittently AFTER " \
132
+ "the operator has pasted their token. Point config.cache_store at a shared store (Rails.cache " \
133
+ "backed by Redis/Memcached) before running the bridge in production."
134
+ )
135
+ end
136
+
99
137
  # The AUTHORITY base controller a host subclasses (the recommended path for a
100
138
  # host whose rate-limit/usage/account hooks touch app models).
101
139
  def self.build_authority_server_controller(parent)
102
140
  Class.new(parent) { include McpToolkit::Authority::ControllerMethods }
103
141
  end
104
142
 
143
+ # Draws the OAuth bridge's two metadata documents. A `/.well-known/*` path
144
+ # cannot be drawn by an engine mounted under a path, so it has to live in the
145
+ # host's own route set. The host calls this at the TOP LEVEL — not inside a
146
+ # locale/format/constraint scope, which would prefix the paths out of view:
147
+ #
148
+ # # config/routes.rb
149
+ # Rails.application.routes.draw do
150
+ # McpToolkit.draw_oauth_metadata_routes(self)
151
+ # mount McpToolkit::Engine => "/mcp"
152
+ # # ...
153
+ # end
154
+ #
155
+ # The paths are PATH-SCOPED to the engine's mount
156
+ # (`/.well-known/oauth-protected-resource/mcp`), so this claims NOTHING
157
+ # origin-global and cannot collide with an OAuth provider the host already runs
158
+ # — see Configuration#oauth_protected_resource_path for why that matters. A host
159
+ # mounted at its origin root gets the bare paths instead, which is correct there.
160
+ #
161
+ # A no-op unless the bridge is configured, so the call can sit in a host's routes
162
+ # unconditionally across environments.
163
+ def self.draw_oauth_metadata_routes(mapper)
164
+ return unless config.oauth_bridge?
165
+
166
+ mapper.get config.oauth_protected_resource_path,
167
+ to: "mcp_toolkit/oauth#protected_resource", format: false
168
+ mapper.get config.oauth_authorization_server_path,
169
+ to: "mcp_toolkit/oauth#authorization_server", format: false
170
+ end
171
+
105
172
  # Removes an existing same-named constant (avoiding a redefinition warning on a
106
173
  # rebuild) before setting the freshly-built class.
107
174
  def self.define_controller(mod, name, klass)