mcp_toolkit 0.4.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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +418 -0
  3. data/README.md +174 -0
  4. data/app/views/mcp_toolkit/oauth/authorize.html.erb +83 -0
  5. data/config/routes.rb +22 -1
  6. data/lib/mcp_toolkit/authority/controller_methods.rb +39 -0
  7. data/lib/mcp_toolkit/authority/registry_tool_provider.rb +9 -10
  8. data/lib/mcp_toolkit/authority/single_tool_provider.rb +25 -0
  9. data/lib/mcp_toolkit/authority/tools/base.rb +31 -3
  10. data/lib/mcp_toolkit/authority/tools/get.rb +2 -1
  11. data/lib/mcp_toolkit/authority/tools/list.rb +49 -1
  12. data/lib/mcp_toolkit/authority/tools/resource_schema.rb +10 -2
  13. data/lib/mcp_toolkit/authority/tools/resources.rb +26 -4
  14. data/lib/mcp_toolkit/configuration.rb +526 -7
  15. data/lib/mcp_toolkit/dispatcher.rb +17 -3
  16. data/lib/mcp_toolkit/engine.rb +20 -2
  17. data/lib/mcp_toolkit/engine_controllers.rb +80 -5
  18. data/lib/mcp_toolkit/filtering.rb +168 -23
  19. data/lib/mcp_toolkit/gateway/aggregator.rb +25 -5
  20. data/lib/mcp_toolkit/list_executor.rb +48 -4
  21. data/lib/mcp_toolkit/oauth/controller_methods.rb +426 -0
  22. data/lib/mcp_toolkit/oauth.rb +23 -0
  23. data/lib/mcp_toolkit/resource.rb +55 -6
  24. data/lib/mcp_toolkit/resource_schema.rb +125 -16
  25. data/lib/mcp_toolkit/serializer/association_descriptor.rb +11 -0
  26. data/lib/mcp_toolkit/serializer/target_ref.rb +7 -0
  27. data/lib/mcp_toolkit/session.rb +2 -2
  28. data/lib/mcp_toolkit/tool_reference_rewriter.rb +33 -0
  29. data/lib/mcp_toolkit/tools/authority_base.rb +23 -2
  30. data/lib/mcp_toolkit/tools/base.rb +23 -3
  31. data/lib/mcp_toolkit/tools/get.rb +1 -1
  32. data/lib/mcp_toolkit/tools/list.rb +27 -9
  33. data/lib/mcp_toolkit/tools/resource_schema.rb +12 -3
  34. data/lib/mcp_toolkit/tools/resources.rb +30 -7
  35. data/lib/mcp_toolkit/usage_metering/recorder.rb +28 -2
  36. data/lib/mcp_toolkit/version.rb +1 -1
  37. data/lib/mcp_toolkit.rb +8 -2
  38. metadata +8 -1
@@ -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
@@ -169,6 +377,94 @@ class McpToolkit::Configuration
169
377
  # @return [#sanitize_sql_like]
170
378
  attr_accessor :sql_sanitizer
171
379
 
380
+ # NOTE: (all data-path settings below): the list/get executors and the schema
381
+ # builder read the PROCESS-GLOBAL `McpToolkit.config` — a per-instance config
382
+ # bound to a provider affects tool prose only. Configure these globally.
383
+ #
384
+ # How a BARE (non-operator) filter value is interpreted by the list executor:
385
+ #
386
+ # :tokenized (default) — a comma-separated string becomes an IN set, the
387
+ # "null" token (and a JSON null) matches NULL rows, an Array of non-null
388
+ # scalars is an IN set (nil/Hash/nested-Array elements rejected), and an
389
+ # empty string means "no filter".
390
+ # :literal — the value is handed to the WHERE clause verbatim (an op-less
391
+ # Hash is still rejected). This preserves an EXISTING API contract for a
392
+ # host whose pre-gem endpoint matched bare values literally: "a,b" is one
393
+ # literal string, "null" is the literal string, "" matches empty-string
394
+ # rows, an Array (including nil elements) gets the adapter's native IN /
395
+ # OR-IS-NULL handling.
396
+ #
397
+ # Operator conditions ({ op:, value: }) behave identically in both modes.
398
+ #
399
+ # @return [Symbol] :tokenized or :literal
400
+ attr_accessor :bare_filter_value_semantics
401
+
402
+ # Default ordering for a resource whose primary key is NON-numeric (numeric
403
+ # PKs always order by :id):
404
+ #
405
+ # :created_at (default) — ORDER BY created_at, <pk> (chronological pages
406
+ # with a total-order tiebreaker).
407
+ # :primary_key — ORDER BY <pk> only. Preserves an EXISTING API contract for
408
+ # a host whose pre-gem endpoint ordered every list by id.
409
+ #
410
+ # @return [Symbol] :created_at or :primary_key
411
+ attr_accessor :non_numeric_pk_order
412
+
413
+ # Per-column-type overrides for the operator sets advertised by
414
+ # `resource_schema` and enforced by the list executor, merged over
415
+ # McpToolkit::Filtering::OPERATORS_BY_TYPE. Lets a host preserve an EXISTING
416
+ # operator contract exactly — both the schema bytes and which conditions are
417
+ # accepted — e.g. `{ text: %w[eq in], date: %w[eq in] }` for a pre-gem
418
+ # endpoint that never offered comparisons on those types. Empty by default
419
+ # (the gem's own sets apply).
420
+ #
421
+ # @return [Hash{Symbol => Array<String>}]
422
+ attr_reader :filter_operator_overrides
423
+
424
+ # Assigns per-type operator overrides, rejecting any operator the gem cannot
425
+ # safely dispatch. Only McpToolkit::Filtering::AREL_PREDICATIONS map onto an
426
+ # Arel predication that binds/quotes its value; anything else (e.g. "extract")
427
+ # would be public_send to an Arel attribute with the request value passed
428
+ # through VERBATIM — an SQL-injection surface. Fail fast at config time so a
429
+ # typo can never open that door at request time.
430
+ def filter_operator_overrides=(overrides)
431
+ overrides ||= {}
432
+ unless overrides.is_a?(Hash)
433
+ raise ArgumentError,
434
+ "filter_operator_overrides must be a Hash of { column_type => [operator, ...] }, " \
435
+ "got #{overrides.class}"
436
+ end
437
+
438
+ overrides.each do |type, operators|
439
+ unsupported = Array(operators).map(&:to_s) - McpToolkit::Filtering::AREL_PREDICATIONS
440
+ next if unsupported.empty?
441
+
442
+ raise ArgumentError,
443
+ "filter_operator_overrides[#{type.inspect}] has unsupported operator(s): " \
444
+ "#{unsupported.join(", ")}. Allowed: #{McpToolkit::Filtering::AREL_PREDICATIONS.join(", ")}."
445
+ end
446
+
447
+ @filter_operator_overrides = overrides
448
+ end
449
+
450
+ # Caps how many values an IN-set filter may resolve to, and how many operator
451
+ # conditions may be ANDed on a single attribute, so a valid token can't emit
452
+ # an unbounded IN clause / AND-chain (oversized SQL + Arel AST and expensive
453
+ # planning; rate limiting is opt-in via rate_limit_max_requests). Applies to
454
+ # the default :tokenized bare-value semantics and to { op:, value: }
455
+ # conditions. nil disables the cap.
456
+ #
457
+ # @return [Integer, nil]
458
+ attr_accessor :max_filter_values
459
+
460
+ # Caps how many JSON-RPC calls a single POST batch may carry on the authority
461
+ # transport. Rate limiting is per-HTTP-request, so an uncapped batch would let
462
+ # one request fan out unbounded work (N tool executions / N upstream calls)
463
+ # under a single rate-limit tick. nil disables the cap.
464
+ #
465
+ # @return [Integer, nil]
466
+ attr_accessor :max_batch_size
467
+
172
468
  # --- protocol / transport --------------------------------------------------
173
469
 
174
470
  # @return [String, nil] protocol version to pin on the underlying MCP::Server.
@@ -277,11 +573,31 @@ class McpToolkit::Configuration
277
573
  # where a tool object responds to `#required_permissions_scope` (String|nil, the
278
574
  # gem's scope gate) and `#call(context:, **arguments)` (returns Hash|String,
279
575
  # which the gem wraps into `{ content: [{ type: "text", text: }] }`). See
280
- # McpToolkit::Tools::AuthorityBase for a base that satisfies this. nil = the host
281
- # contributes no own tools (a pure gateway).
576
+ # McpToolkit::Tools::AuthorityBase for a base that satisfies this.
577
+ #
578
+ # UNSET (the default), the provider is composed automatically: the generic
579
+ # RegistryToolProvider (bound to this config) first, then every entry of
580
+ # `extra_tool_providers` — so a host that only serves the generic tools plus
581
+ # its own bespoke ones needs no provider plumbing at all. Assign explicitly to
582
+ # take full control of the catalog.
583
+ #
584
+ # @return [#tool_definitions, #find]
585
+ attr_writer :tool_provider
586
+
587
+ def tool_provider
588
+ @tool_provider || composed_tool_provider
589
+ end
590
+
591
+ # Additional tool providers (or bare TOOL classes, auto-wrapped in a
592
+ # SingleToolProvider) composed AFTER the generic Registry-backed tools when
593
+ # `tool_provider` is not explicitly assigned. The registry provider is always
594
+ # first, so a generic tool name resolves to it; extras only answer their own
595
+ # names.
596
+ #
597
+ # config.extra_tool_providers = [MyApp::Tools::AuditLog]
282
598
  #
283
- # @return [#tool_definitions, #find, nil]
284
- attr_accessor :tool_provider
599
+ # @return [Array<#tool_definitions, Class>]
600
+ attr_accessor :extra_tool_providers
285
601
 
286
602
  # --- generic tool naming ---------------------------------------------------
287
603
 
@@ -326,10 +642,10 @@ class McpToolkit::Configuration
326
642
 
327
643
  @token_authenticator = nil
328
644
 
329
- @cache_store = ActiveSupport::Cache::MemoryStore.new
330
- @session_ttl = 3600 # 1 hour
645
+ initialize_oauth_bridge_defaults
331
646
 
332
- @sql_sanitizer = McpToolkit::SqlSanitizer.new
647
+ @cache_store = ActiveSupport::Cache::MemoryStore.new
648
+ initialize_data_path_defaults
333
649
 
334
650
  @protocol_version = nil
335
651
  @supported_protocol_versions = McpToolkit::Protocol::SUPPORTED_VERSIONS
@@ -348,6 +664,52 @@ class McpToolkit::Configuration
348
664
  @upstreams = McpToolkit::Gateway::UpstreamRegistry.new
349
665
  end
350
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
+
679
+ # Session-TTL and list-executor defaults: the :tokenized / :created_at
680
+ # data-path semantics (a host preserving a pre-gem contract overrides these —
681
+ # see each accessor's docs).
682
+ def initialize_data_path_defaults
683
+ @session_ttl = 3600 # 1 hour
684
+
685
+ @sql_sanitizer = McpToolkit::SqlSanitizer.new
686
+ @bare_filter_value_semantics = :tokenized
687
+ @non_numeric_pk_order = :created_at
688
+ @filter_operator_overrides = {}
689
+ @max_filter_values = 500
690
+ @max_batch_size = 50
691
+ end
692
+
693
+ # The default authority tool catalog when no explicit `tool_provider` is
694
+ # assigned: the generic Registry-backed tools first (so a generic name always
695
+ # resolves to them; included only when the host registered resources — a pure
696
+ # gateway keeps contributing nothing), then each extra provider — a bare tool
697
+ # CLASS is wrapped in a SingleToolProvider. Built per read (cheap, stateless
698
+ # providers), so a reload that re-assigns `extra_tool_providers` or registers
699
+ # resources takes effect immediately.
700
+ def composed_tool_provider
701
+ providers = []
702
+ providers << McpToolkit::Authority::RegistryToolProvider.new(config: self) if registry.resources.any?
703
+ Array(extra_tool_providers).each do |provider|
704
+ providers << (provider.respond_to?(:tool_definitions) ? provider : McpToolkit::Authority::SingleToolProvider.new(provider))
705
+ end
706
+ return nil if providers.empty?
707
+ return providers.first if providers.one?
708
+
709
+ McpToolkit::Authority::CompositeToolProvider.new(*providers)
710
+ end
711
+ private :composed_tool_provider
712
+
351
713
  # The authority transport's injection points all default to nil (a no-op): a
352
714
  # pure satellite/gateway never touches them. `rate_limit_window` is the sole
353
715
  # non-nil default (the window size only matters once a cap opts in).
@@ -357,6 +719,7 @@ class McpToolkit::Configuration
357
719
  @usage_flusher = nil
358
720
  @session_data_builder = nil
359
721
  @tool_provider = nil
722
+ @extra_tool_providers = []
360
723
  @rate_limit_max_requests = nil # nil = rate limiting disabled
361
724
  @rate_limit_window = 3600 # 1 hour
362
725
  @superuser_resolver = nil # nil = duck-type principal.superuser?
@@ -372,6 +735,19 @@ class McpToolkit::Configuration
372
735
  upstreams.register(key:, url:, public_tool_list:)
373
736
  end
374
737
 
738
+ # Declares the gateway's upstreams from an ENV-var map — `{ key => env var
739
+ # name }` — the shape every authority host repeats: resets the registry first
740
+ # (idempotent across code reloads, where the registration typically re-runs in
741
+ # a `to_prepare`), and an upstream whose ENV url is blank is never registered,
742
+ # so an unconfigured environment behaves like no-upstreams. `env` is
743
+ # injectable for tests.
744
+ #
745
+ # config.register_upstreams_from_env("billing" => "BILLING_MCP_URL")
746
+ def register_upstreams_from_env(mapping, env: ENV)
747
+ upstreams.reset!
748
+ mapping.each { |key, env_var| register_upstream(key:, url: env[env_var.to_s]) }
749
+ end
750
+
375
751
  # The gateway handshake client name, defaulting to the server identity when the
376
752
  # host hasn't split it. Read (not stored) so a `server_name` change before the
377
753
  # split is set still flows through.
@@ -402,6 +778,149 @@ class McpToolkit::Configuration
402
778
  auth_role.to_sym == :authority
403
779
  end
404
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
+
405
924
  # Full introspection URL the satellite POSTs to. Raises a clear error if the
406
925
  # central URL was never configured.
407
926
  def introspect_url
@@ -42,9 +42,13 @@ class McpToolkit::Dispatcher
42
42
  config.logger&.error("MCP dispatcher error: #{e.message}\n#{e.backtrace&.join("\n")}")
43
43
  return nil unless request.key?("id")
44
44
 
45
+ # A generic message to the caller: the raw exception text of an UNEXPECTED
46
+ # error (ActiveRecord::StatementInvalid carrying SQL, a NoMethodError naming
47
+ # internal classes, an internal hostname) must not leak. Full detail is in
48
+ # the log line above.
45
49
  McpToolkit::Protocol.error_response(
46
50
  id: request["id"],
47
- error: McpToolkit::Protocol::InternalError.new(e.message)
51
+ error: McpToolkit::Protocol::InternalError.new("Internal error")
48
52
  )
49
53
  end
50
54
 
@@ -189,8 +193,13 @@ class McpToolkit::Dispatcher
189
193
  # JSON gives string keys; a tool's `call(context:, **arguments)` needs symbol
190
194
  # keys for the keyword splat. Deep-symbolized so nested argument hashes reach
191
195
  # the tool in the same shape a symbol-keyed caller would pass.
196
+ #
197
+ # `context` is stripped: a tool is invoked as `call(context:, **arguments)`, and
198
+ # a splatted keyword OVERRIDES the explicit one, so a caller-supplied `context`
199
+ # argument would replace the gem-resolved Authority::Context with attacker JSON
200
+ # (auth-context injection). `context` is never a legitimate tool argument.
192
201
  def symbolized_arguments(arguments)
193
- arguments.to_h.deep_symbolize_keys
202
+ arguments.to_h.deep_symbolize_keys.except(:context)
194
203
  end
195
204
 
196
205
  def ensure_tool_scope!(tool)
@@ -228,7 +237,12 @@ class McpToolkit::Dispatcher
228
237
  data: error.jsonrpc_error["data"]
229
238
  )
230
239
  else
231
- McpToolkit::Protocol::InternalError.new(error.message)
240
+ # A TRANSPORT failure (no upstream JSON-RPC error): its message embeds the
241
+ # internal upstream host:port (Faraday/Net::HTTP "Failed to open TCP
242
+ # connection to <host>:<port>"). The proxy already logged the detail
243
+ # (Gateway::Proxy#log_proxied_failure), so relay only a generic error —
244
+ # never the internal topology — to the caller.
245
+ McpToolkit::Protocol::InternalError.new("Internal error")
232
246
  end
233
247
  end
234
248
  end
@@ -3,12 +3,15 @@
3
3
  # Mountable Rails engine that draws the MCP transport routes plus the authority
4
4
  # introspection route (defined in the engine's config/routes.rb so they survive
5
5
  # Rails' route reloads) against the gem-provided McpToolkit::ServerController /
6
- # McpToolkit::TokensController. A satellite mounts it in one line:
6
+ # McpToolkit::TokensController. A satellite OR an authority mounts it in one line:
7
7
  #
8
8
  # # config/routes.rb
9
9
  # mount McpToolkit::Engine => "/mcp"
10
10
  #
11
- # yielding exactly the endpoints a hand-rolled satellite declared:
11
+ # The mounted McpToolkit::ServerController is role-aware (built lazily from
12
+ # config.auth_role): an authority host gets the hand-rolled dispatcher path, a
13
+ # satellite gets the SDK-backed one — see engine_controllers.rb. This yields
14
+ # exactly the endpoints a hand-rolled host declared:
12
15
  #
13
16
  # POST /mcp -> create (JSON-RPC requests/responses)
14
17
  # GET /mcp -> stream (405; no server-initiated SSE)
@@ -33,4 +36,19 @@ class McpToolkit::Engine < Rails::Engine
33
36
  # reload so a changed parent (or a reloaded app parent class) takes effect on the
34
37
  # next reference. Runs before `:eager_load!`, so the fresh classes exist for it.
35
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
36
54
  end