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
@@ -0,0 +1,426 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The AUTHORITY-side OAuth 2.1 authorization bridge (routes: config/routes.rb;
4
+ # setup + rationale: README).
5
+ #
6
+ # NOT an identity provider, and reading it as a half-built one will mislead. It
7
+ # mints no credential, stores no client, models no consent, issues no refresh
8
+ # token. It is a standards-shaped envelope around tokens the host ALREADY issues
9
+ # by its own means, for clients that will only authenticate by discovering an
10
+ # authorization server and running a browser flow: the page asks an operator to
11
+ # paste a token they hold, and the `access_token` returned IS that token, verified
12
+ # through the same `config.token_authenticator` the transport uses. Scopes,
13
+ # expiry, revocation and tenancy stay with the host; nothing here widens reach.
14
+ #
15
+ # So the stubs are deliberate, not unfinished: no endpoint reads the `client_id`
16
+ # it hands out (a public client's identifier is self-asserted and gates nothing);
17
+ # pasting a token you already hold IS the grant; and the pasted token's own expiry
18
+ # is the real lifetime, so a client re-runs the flow rather than refreshing a
19
+ # shadow of it.
20
+ #
21
+ # Two things are NOT mocked, because faking them would be a vulnerability rather
22
+ # than a skipped ceremony: `redirect_uri` is checked against the host's policy on
23
+ # BOTH legs (an unvetted REMOTE target is an open redirect handing out
24
+ # authorization codes — see Configuration#oauth_allowed_redirect_uris for the
25
+ # attack it stops), and the PKCE `code_verifier` is verified.
26
+ module McpToolkit::Oauth::ControllerMethods
27
+ extend ActiveSupport::Concern
28
+
29
+ CODE_CACHE_PREFIX = "mcp_toolkit:oauth:code:"
30
+ CODE_BYTES = 32
31
+
32
+ # Query parameters the callback response owns: whatever a client put in its own
33
+ # redirect_uri, these are set by the redirect and not carried over from it.
34
+ RESPONSE_OWNED_QUERY_KEYS = %w[code state].freeze
35
+
36
+ # RFC 7636 §4.1: 43–128 unreserved characters. The challenge is §4.2's
37
+ # base64url of a SHA-256, which is always exactly 43 of the same alphabet.
38
+ PKCE_VALUE = /\A[A-Za-z0-9\-._~]{43,128}\z/
39
+
40
+ included do
41
+ # Safe to disable: the token endpoint is called server-to-server without a CSRF
42
+ # token, and `approve` never acts on ambient authority — it reads no session and
43
+ # no cookie, only a pasted token, redirecting to a host-permitted URI. (The GET
44
+ # leg does SET a session cookie, because `form_tag` emits an authenticity
45
+ # token; nothing ever reads it back.)
46
+ protect_from_forgery with: :null_session if respond_to?(:protect_from_forgery)
47
+
48
+ # A before_action, not a guard clause in each action, because a callback runs
49
+ # even when the action itself does not. `authorize` is a common enough method
50
+ # name that a gem patching ActionController::Base with one would knock this
51
+ # action out of Rails' `action_methods` — and Rails would then serve
52
+ # `authorize.html.erb` by implicit render, skipping a body guard entirely and
53
+ # showing an attacker's `redirect_uri` a paste page. Here the check cannot be
54
+ # routed around.
55
+ # Raises rather than skipping when the parent has no filter chain. Skipping
56
+ # would leave both browser legs unguarded — an open redirect issuing codes to
57
+ # any URI — and it is the same shape as the scheme denylist this branch
58
+ # removed for failing open. A guard that installs itself conditionally is not
59
+ # a guard.
60
+ unless respond_to?(:before_action)
61
+ raise McpToolkit::Errors::ConfigurationError,
62
+ "#{name || "The OAuth bridge's parent"} has no `before_action`; " \
63
+ "config.oauth_parent_controller must be an ActionController with a filter chain."
64
+ end
65
+
66
+ before_action :mcp_oauth_validate_request!, only: %i[authorize approve]
67
+ end
68
+
69
+ # `resource` MUST equal the MCP endpoint URL as the operator typed it into the
70
+ # client, hence derived from the live request origin rather than pinned.
71
+ def protected_resource
72
+ mcp_oauth_forbid_caching
73
+ render json: {
74
+ resource: mcp_oauth_resource_url,
75
+ authorization_servers: [mcp_oauth_issuer],
76
+ bearer_methods_supported: ["header"]
77
+ }
78
+ end
79
+
80
+ # S256 because clients send a `code_challenge` regardless; `none` because the
81
+ # clients here are public and unverified.
82
+ def authorization_server
83
+ mcp_oauth_forbid_caching
84
+ render json: {
85
+ issuer: mcp_oauth_issuer,
86
+ authorization_endpoint: mcp_oauth_endpoint_url("authorize"),
87
+ token_endpoint: mcp_oauth_endpoint_url("token"),
88
+ registration_endpoint: mcp_oauth_endpoint_url("register"),
89
+ response_types_supported: ["code"],
90
+ grant_types_supported: ["authorization_code"],
91
+ code_challenge_methods_supported: ["S256"],
92
+ token_endpoint_auth_methods_supported: ["none"]
93
+ }
94
+ end
95
+
96
+ # Stateless: persisting a `client_id` nothing reads would only grow a table of
97
+ # strings the bridge never consults.
98
+ def register
99
+ render json: {
100
+ client_id: SecureRandom.uuid,
101
+ token_endpoint_auth_method: "none",
102
+ grant_types: ["authorization_code"],
103
+ response_types: ["code"]
104
+ }, status: :created
105
+ end
106
+
107
+ # `formats: [:html]` because there is only an HTML template and `Accept` picks
108
+ # the format just as a `.json` suffix would — without it, `Accept:
109
+ # application/json` raises MissingTemplate on an unauthenticated endpoint.
110
+ def authorize
111
+ render :authorize, layout: false, formats: [:html]
112
+ end
113
+
114
+ # The token is verified here, not only at exchange, so a typo fails on the page
115
+ # the operator is looking at.
116
+ def approve
117
+ access_token = params[:access_token].to_s
118
+ return mcp_oauth_reject_paste if mcp_oauth_authenticate(access_token).nil?
119
+
120
+ # 303, not Rails' default 302: this POST carried the operator's token in its
121
+ # body, and only 303 unambiguously tells the browser to fetch the redirect
122
+ # target with GET and no body. A 302 leaves re-sending it to the client's
123
+ # discretion, which would hand the token itself to the callback (RFC 9700
124
+ # §4.12).
125
+ redirect_to mcp_oauth_callback_url(mcp_oauth_issue_code(access_token)),
126
+ allow_other_host: true, status: :see_other
127
+ end
128
+
129
+ def token
130
+ return mcp_oauth_render_token_error("unsupported_grant_type") unless params[:grant_type] == "authorization_code"
131
+ # Shape first, and BEFORE the code is consumed: a request that could never
132
+ # verify shouldn't cost a legitimate client its code. A well-formed but WRONG
133
+ # verifier still burns it below — that is deliberate, and the only thing
134
+ # stopping someone who intercepted a code from retrying verifiers against it.
135
+ return mcp_oauth_render_token_error("invalid_request") unless mcp_oauth_verifier_well_formed?
136
+
137
+ payload = mcp_oauth_consume_code(params[:code].to_s)
138
+ return mcp_oauth_render_token_error("invalid_grant") if payload.nil?
139
+ return mcp_oauth_render_token_error("invalid_grant") unless mcp_oauth_exchange_valid?(payload)
140
+
141
+ access_token = payload[:access_token].to_s
142
+ return mcp_oauth_render_token_error("invalid_grant") if mcp_oauth_authenticate(access_token).nil?
143
+
144
+ mcp_oauth_forbid_caching
145
+ render json: { access_token:, token_type: "Bearer" }
146
+ end
147
+
148
+ private
149
+
150
+ def mcp_oauth_config
151
+ McpToolkit.config
152
+ end
153
+
154
+ # ---- request validation ---------------------------------------------------
155
+
156
+ # Halts both legs before their action runs. Both problems RENDER rather than
157
+ # bounce an OAuth error back to the caller: a disallowed redirect_uri must never
158
+ # be redirected TO — that is the attack.
159
+ def mcp_oauth_validate_request!
160
+ problem = mcp_oauth_request_problem
161
+ mcp_oauth_render_bad_request(problem) if problem
162
+ end
163
+
164
+ def mcp_oauth_request_problem
165
+ return mcp_oauth_reject_redirect_uri unless mcp_oauth_redirect_uri_allowed?
166
+ return "Missing or unsupported PKCE code_challenge." unless mcp_oauth_code_challenge_supported?
167
+
168
+ nil
169
+ end
170
+
171
+ # Exact matching makes a legitimate client rejected over a trailing slash look
172
+ # identical to an attack, so log the offered value (a public callback, never a
173
+ # credential) — otherwise an operator is left guessing what to allowlist.
174
+ def mcp_oauth_reject_redirect_uri
175
+ mcp_oauth_config.logger&.warn(
176
+ "[mcp_toolkit] OAuth authorize rejected: redirect_uri #{params[:redirect_uri].inspect} is not in " \
177
+ "config.oauth_allowed_redirect_uris (#{Array(mcp_oauth_config.oauth_allowed_redirect_uris).inspect}) " \
178
+ "and is not a native-client target permitted by config.oauth_allow_loopback_redirects " \
179
+ "(#{mcp_oauth_config.oauth_allow_loopback_redirects ? "enabled" : "disabled"})"
180
+ )
181
+ "Unregistered redirect_uri."
182
+ end
183
+
184
+ # Every target must be named exactly, with ONE exception: loopback, whose port
185
+ # cannot be named ahead of time. See `mcp_oauth_loopback_redirect_uri?`.
186
+ def mcp_oauth_redirect_uri_allowed?
187
+ redirect_uri = params[:redirect_uri].to_s
188
+ return false if redirect_uri.empty?
189
+ return true if Array(mcp_oauth_config.oauth_allowed_redirect_uris).include?(redirect_uri)
190
+
191
+ mcp_oauth_loopback_redirect_uri?(redirect_uri)
192
+ end
193
+
194
+ # RFC 8252 §7.3 loopback — the one target accepted unnamed, and only http(s) to
195
+ # a loopback host: NOT private-use schemes, however local they look. See
196
+ # Configuration#oauth_allow_loopback_redirects for why that line is drawn there.
197
+ #
198
+ # Judged on the PARSED URI, never the string, because `host` is what a browser
199
+ # resolves: `http://127.0.0.1@evil.example/` (userinfo — host is evil.example)
200
+ # and `http://127.0.0.1.evil.example/` are both correctly remote. A fragment is
201
+ # refused because OAuth forbids one on a redirect_uri.
202
+ def mcp_oauth_loopback_redirect_uri?(redirect_uri)
203
+ return false unless mcp_oauth_config.oauth_allow_loopback_redirects
204
+
205
+ uri = mcp_oauth_parse_uri(redirect_uri)
206
+ return false if uri.nil?
207
+ return false unless %w[http https].include?(uri.scheme&.downcase)
208
+ return false unless uri.fragment.nil?
209
+
210
+ mcp_oauth_loopback_host?(uri.host)
211
+ end
212
+
213
+ def mcp_oauth_parse_uri(value)
214
+ URI.parse(value)
215
+ rescue URI::InvalidURIError
216
+ nil
217
+ end
218
+
219
+ # `URI` keeps the brackets on an IPv6 literal (`[::1]`); strip them so the
220
+ # literal compares against the bare form clients actually send.
221
+ def mcp_oauth_loopback_host?(host)
222
+ McpToolkit::Oauth.loopback_host?(host)
223
+ end
224
+
225
+ # RFC 7636 §4.1/§4.2 shapes, not just presence. This cannot make a client's
226
+ # PKCE strong — the challenge is a 43-char digest whatever the verifier was, so
227
+ # a client that chose a one-character verifier is indistinguishable here and has
228
+ # only defeated its own protection. It is enforced because a public gem should
229
+ # not quietly accept what the spec forbids, and because a malformed value can
230
+ # then be refused early rather than after a paste.
231
+ def mcp_oauth_code_challenge_supported?
232
+ params[:code_challenge].to_s.match?(PKCE_VALUE) && params[:code_challenge_method].to_s == "S256"
233
+ end
234
+
235
+ def mcp_oauth_verifier_well_formed?
236
+ params[:code_verifier].to_s.match?(PKCE_VALUE)
237
+ end
238
+
239
+ # ---- authorization codes --------------------------------------------------
240
+
241
+ # The whole "authorization server" state: one cache entry, short-lived, bound
242
+ # to the challenge and redirect it was issued for.
243
+ #
244
+ # The entry is keyed by the code's DIGEST and its payload is sealed, so a dump
245
+ # of the store yields nothing on its own. Worth the few lines because what is
246
+ # parked there is not the short-lived credential an authorization server would
247
+ # normally hold: it is the operator's pre-existing, long-lived, full-scope
248
+ # token, in a store a host is told to point at its shared `Rails.cache`.
249
+ def mcp_oauth_issue_code(access_token)
250
+ code = SecureRandom.urlsafe_base64(CODE_BYTES)
251
+ payload = {
252
+ access_token:, code_challenge: params[:code_challenge].to_s, redirect_uri: params[:redirect_uri].to_s
253
+ }
254
+ mcp_oauth_config.cache_store.write(
255
+ mcp_oauth_code_key(code),
256
+ mcp_oauth_encryptor(code).encrypt_and_sign(JSON.generate(payload)),
257
+ expires_in: mcp_oauth_config.oauth_authorization_code_ttl
258
+ )
259
+ code
260
+ end
261
+
262
+ # Single-use for real: the DELETE decides, not the read. `Cache::Store#delete`
263
+ # answers whether the entry was still there, so of two concurrent redemptions
264
+ # exactly one proceeds. (The race was never exploitable — both would return the
265
+ # same token, and both need the verifier — but the guarantee is cheap to keep
266
+ # honest, and a code is burnt even when the exchange that follows fails.)
267
+ def mcp_oauth_consume_code(code)
268
+ return nil if code.empty?
269
+
270
+ key = mcp_oauth_code_key(code)
271
+ blob = mcp_oauth_config.cache_store.read(key)
272
+ return nil if blob.nil?
273
+ return nil unless mcp_oauth_config.cache_store.delete(key)
274
+
275
+ mcp_oauth_decrypt_payload(code, blob)
276
+ end
277
+
278
+ def mcp_oauth_decrypt_payload(code, blob)
279
+ JSON.parse(mcp_oauth_encryptor(code).decrypt_and_verify(blob), symbolize_names: true)
280
+ rescue ActiveSupport::MessageEncryptor::InvalidMessage, JSON::ParserError
281
+ nil
282
+ end
283
+
284
+ def mcp_oauth_code_key(code)
285
+ "#{CODE_CACHE_PREFIX}#{Digest::SHA256.hexdigest(code)}"
286
+ end
287
+
288
+ # HMAC because two independent inputs are being combined (why the secret is one
289
+ # of them: Configuration#oauth_signing_secret). No password-stretching — both
290
+ # are already high-entropy, so a PBKDF2 run per request would buy nothing.
291
+ #
292
+ # Cipher and serializer are pinned, not inherited: the gem supports
293
+ # ActiveSupport >= 6.1, where both defaults are Rails-configuration-dependent
294
+ # (`:marshal`, and 7.1+'s `:json_allow_marshal`). The payload is already a JSON
295
+ # String, so NullSerializer costs nothing and leaves JSON.parse the only parser
296
+ # this code hands the plaintext to.
297
+ #
298
+ # It does NOT buy immunity from a writable cache: `Cache::Store` marshals the
299
+ # entry itself, so a store that can be written to is a code-execution problem
300
+ # before anything here is reached. This is determinism across the supported
301
+ # range, not a mitigation.
302
+ def mcp_oauth_encryptor(code)
303
+ key = OpenSSL::HMAC.digest("SHA256", mcp_oauth_signing_secret, "#{CODE_CACHE_PREFIX}key:#{code}")
304
+ ActiveSupport::MessageEncryptor.new(
305
+ key, cipher: "aes-256-gcm", serializer: ActiveSupport::MessageEncryptor::NullSerializer
306
+ )
307
+ end
308
+
309
+ def mcp_oauth_signing_secret
310
+ mcp_oauth_config.oauth_signing_secret.tap do |secret|
311
+ raise McpToolkit::Errors::ConfigurationError, "oauth_signing_secret is not configured" if secret.to_s.empty?
312
+ end
313
+ end
314
+
315
+ def mcp_oauth_exchange_valid?(payload)
316
+ payload[:redirect_uri] == params[:redirect_uri].to_s &&
317
+ mcp_oauth_pkce_valid?(params[:code_verifier].to_s, payload[:code_challenge].to_s)
318
+ end
319
+
320
+ # S256: base64url(sha256(verifier)), unpadded. Packed rather than via base64 so
321
+ # the gem needs no extra dependency.
322
+ def mcp_oauth_pkce_valid?(verifier, challenge)
323
+ return false if verifier.empty? || challenge.empty?
324
+
325
+ digest = [Digest::SHA256.digest(verifier)].pack("m0").tr("+/", "-_").delete("=")
326
+ ActiveSupport::SecurityUtils.secure_compare(digest, challenge)
327
+ end
328
+
329
+ # ---- token verification ---------------------------------------------------
330
+
331
+ # The same authenticator the transport authenticates every MCP request with —
332
+ # this bridge introduces no second notion of a valid token.
333
+ def mcp_oauth_authenticate(access_token)
334
+ return nil if access_token.empty?
335
+
336
+ McpToolkit::Auth::Authority.authenticate(access_token, config: mcp_oauth_config)
337
+ end
338
+
339
+ # ---- urls -----------------------------------------------------------------
340
+
341
+ # Deliberately path-ful: a client path-INSERTS this to find the metadata, which
342
+ # is what keeps the bridge off the origin-global bare path (see
343
+ # Configuration#oauth_protected_resource_path). Issuer path == resource path, so
344
+ # a client derives the same URL from either.
345
+ def mcp_oauth_issuer
346
+ mcp_oauth_resource_url
347
+ end
348
+
349
+ def mcp_oauth_resource_url
350
+ "#{request.base_url}#{mcp_oauth_config.oauth_resource_path_component}"
351
+ end
352
+
353
+ def mcp_oauth_endpoint_url(action)
354
+ "#{mcp_oauth_resource_url}/oauth/#{action}"
355
+ end
356
+
357
+ # Sets `code` (and echoes `state`) on the client's redirect_uri, preserving any
358
+ # other query it already carries.
359
+ #
360
+ # SETS, not appends: a loopback redirect_uri is not an exact-matched string, so
361
+ # a caller can put `?code=…` in it themselves. Appending would emit
362
+ # `?code=theirs&code=ours` and leave which one wins to the client's parser.
363
+ # Dropping any inbound `code`/`state` keeps the response OAuth-shaped whatever
364
+ # was passed in.
365
+ #
366
+ # The base is taken as the checked string rather than round-tripped through
367
+ # `URI`, so the host part of what is emitted is byte-identical to what the
368
+ # policy approved. The query IS re-encoded (`?a=1?b=2` normalises to
369
+ # `?a=1%3Fb%3D2`), which is the point — that is where `code` gets stripped.
370
+ def mcp_oauth_callback_url(code)
371
+ redirect_uri = params[:redirect_uri].to_s
372
+ base, _, existing = redirect_uri.partition("?")
373
+ pairs = mcp_oauth_preserved_query_pairs(existing)
374
+ pairs << ["code", code]
375
+ pairs << ["state", params[:state].to_s] if params[:state].present?
376
+ "#{base}?#{URI.encode_www_form(pairs)}"
377
+ end
378
+
379
+ # A client's own query survives; the two parameters this response owns do not,
380
+ # whoever put them there. The rescue is a backstop, not a designed path: both
381
+ # sanctioned redirect_uris are `URI.parse`d before they reach here, which
382
+ # already refuses what `decode_www_form` would raise on.
383
+ def mcp_oauth_preserved_query_pairs(query)
384
+ return [] if query.empty?
385
+
386
+ URI.decode_www_form(query).reject { |pair| RESPONSE_OWNED_QUERY_KEYS.include?(pair.first) }
387
+ rescue ArgumentError
388
+ []
389
+ end
390
+
391
+ # ---- responses ------------------------------------------------------------
392
+
393
+ # 422 as an integer, not a symbol: the gemspec pins no Rack floor, and neither
394
+ # symbol spans the range it allows — `:unprocessable_content` raises below Rack
395
+ # 3.1, `:unprocessable_entity` is deprecated above it. A symbol that raises here
396
+ # would turn a mistyped paste into an unauthenticated 500 on the one page whose
397
+ # job is to say "that token isn't valid".
398
+ def mcp_oauth_reject_paste
399
+ @mcp_oauth_error = "That access token is not valid, or it has expired or been revoked."
400
+ render :authorize, layout: false, formats: [:html], status: 422
401
+ end
402
+
403
+ def mcp_oauth_render_bad_request(message)
404
+ render plain: message, status: :bad_request
405
+ end
406
+
407
+ # Applied to the token response, where RFC 6749 §5.1 makes both headers a MUST
408
+ # for anything carrying a token, and to both metadata documents, where the
409
+ # reason is subtler: they name the `authorization_endpoint` an operator will be
410
+ # sent to and are built from the live request origin (`request.base_url`, which
411
+ # honours `X-Forwarded-Host`), so a shared cache that stored one keyed only by
412
+ # path could serve every client an origin an attacker chose — with the document
413
+ # itself vouching for it. A host MUST pin `config.hosts` — Rails'
414
+ # HostAuthorization then rejects a forged header before it reaches here, but
415
+ # Rails does NOT do that for you: `config.hosts` is populated in development and
416
+ # left EMPTY in production, where an empty list means no checking at all. This
417
+ # header is the half that does not depend on the host getting that right.
418
+ def mcp_oauth_forbid_caching
419
+ response.headers["Cache-Control"] = "no-store"
420
+ response.headers["Pragma"] = "no-cache"
421
+ end
422
+
423
+ def mcp_oauth_render_token_error(code)
424
+ render json: { error: code }, status: :bad_request
425
+ end
426
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Namespace for the OAuth authorization bridge (McpToolkit::Oauth::ControllerMethods),
4
+ # and the home of the one policy value both the request path and the config path
5
+ # need to agree on.
6
+ module McpToolkit::Oauth
7
+ # RFC 8252 §7.3 loopback hosts — the only hosts a code may be sent to over
8
+ # cleartext, and the only redirect target accepted without being named. The RFC
9
+ # prefers the IP literals over the name (a name is only as trustworthy as the
10
+ # resolver — §8.3), but real clients use all three, and RFC 6761 has OS
11
+ # resolvers and browsers hardcode `localhost` to loopback.
12
+ #
13
+ # Lives here rather than on the concern because Configuration reads it too, to
14
+ # decide whether an allowlisted `http://` entry is a mistake: cleartext is fine
15
+ # to an address that never leaves the operator's machine, and puts the code on
16
+ # the wire anywhere else. One list, so the two paths cannot drift apart.
17
+ LOOPBACK_HOSTS = ["127.0.0.1", "::1", "localhost"].freeze
18
+
19
+ # `[::1]` arrives bracketed from `URI`; compare against the bare form.
20
+ def self.loopback_host?(host)
21
+ LOOPBACK_HOSTS.include?(host.to_s.downcase.delete_prefix("[").delete_suffix("]"))
22
+ end
23
+ end
@@ -42,6 +42,8 @@ class McpToolkit::Resource
42
42
  @superusers_only = false
43
43
  @filterable = {}
44
44
  @filterable_source = nil
45
+ @filter_requirements = {}
46
+ @filter_requirements_source = nil
45
47
  @custom_filters = {}
46
48
  @required_permissions_scope = nil
47
49
  @extras = {}
@@ -194,6 +196,35 @@ class McpToolkit::Resource
194
196
  filterable_columns.keys.sort
195
197
  end
196
198
 
199
+ # Declares companion-key requirements for filter keys: a request-facing key
200
+ # that is only valid when another key is passed alongside it (the canonical
201
+ # case is a polymorphic foreign key, type-ambiguous without its `*_type`):
202
+ #
203
+ # filter_requirements created_by_id: :created_by_type
204
+ #
205
+ # The list executor rejects a filter using the key without its companion, and
206
+ # `resource_schema` surfaces the requirement (`relationships[].filter.requires`)
207
+ # so a client can discover it. The companion key MUST itself be declared
208
+ # `filterable` — otherwise the requirement is unsatisfiable (the executor
209
+ # rejects the companion as an unknown key) while the schema still advertises
210
+ # it. Like `filterable`, accepts a Hash (merged now) OR a callable returning
211
+ # one (resolved lazily on first successful read, then memoized) so a host can
212
+ # derive the map without touching the DB at boot. Read with no arg.
213
+ def filter_requirements(mapping = nil, &block)
214
+ source = block || mapping
215
+ if source.nil?
216
+ resolve_filter_requirements_source!
217
+ return @filter_requirements
218
+ end
219
+
220
+ if source.respond_to?(:call)
221
+ @filter_requirements_source = source
222
+ else
223
+ merge_filter_requirements!(source)
224
+ end
225
+ self
226
+ end
227
+
197
228
  # Request-facing filter key (symbol) => backing column (symbol). Consumed by
198
229
  # the list executor to build the WHERE clause.
199
230
  def filterable_columns
@@ -229,19 +260,37 @@ class McpToolkit::Resource
229
260
 
230
261
  private
231
262
 
232
- # Resolves a lazily-provided filterable source (a callable) exactly once — on the
233
- # first `filterable_columns` / `filterable_keys` read — then drops it, so later
234
- # reads are pure Hash access. This is what keeps a DB-derived map (e.g.
235
- # `Model.column_names`) out of registration/boot time.
263
+ # Resolves a lazily-provided filterable source (a callable) on the first
264
+ # SUCCESSFUL `filterable_columns` / `filterable_keys` read — then drops it, so
265
+ # later reads are pure Hash access. This is what keeps a DB-derived map (e.g.
266
+ # `Model.column_names`) out of registration/boot time. The source is cleared
267
+ # only AFTER it returns: a raising callable (a transient DB hiccup) stays
268
+ # registered and is retried on the next read, instead of permanently and
269
+ # silently resolving the allowlist to `{}`.
236
270
  def resolve_filterable_source!
237
271
  return unless @filterable_source
238
272
 
239
- source = @filterable_source
273
+ resolved = @filterable_source.call || {}
240
274
  @filterable_source = nil
241
- merge_filterable!(source.call || {})
275
+ merge_filterable!(resolved)
242
276
  end
243
277
 
244
278
  def merge_filterable!(mapping)
245
279
  mapping.each { |request_key, column| @filterable[request_key.to_sym] = column.to_sym }
246
280
  end
281
+
282
+ # Same lazy-resolution contract as the filterable source: resolved on the
283
+ # first successful read, cleared only after the callable returns so a
284
+ # transient failure is retried rather than silently dropping the map.
285
+ def resolve_filter_requirements_source!
286
+ return unless @filter_requirements_source
287
+
288
+ resolved = @filter_requirements_source.call || {}
289
+ @filter_requirements_source = nil
290
+ merge_filter_requirements!(resolved)
291
+ end
292
+
293
+ def merge_filter_requirements!(mapping)
294
+ mapping.each { |key, required| @filter_requirements[key.to_sym] = required.to_sym }
295
+ end
247
296
  end