mcp_toolkit 0.5.0 → 0.6.1

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.
@@ -0,0 +1,461 @@
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
+ # What the token endpoint actually honours. Named once because the discovery
41
+ # document and the registration response MUST agree — a client reads the first
42
+ # to find the second, so a disagreement is this server contradicting itself.
43
+ SUPPORTED_GRANT_TYPES = %w[authorization_code].freeze
44
+ SUPPORTED_RESPONSE_TYPES = %w[code].freeze
45
+
46
+ included do
47
+ # Safe to disable: the token endpoint is called server-to-server without a CSRF
48
+ # token, and `approve` never acts on ambient authority — it reads no session and
49
+ # no cookie, only a pasted token, redirecting to a host-permitted URI. (The GET
50
+ # leg does SET a session cookie, because `form_tag` emits an authenticity
51
+ # token; nothing ever reads it back.)
52
+ protect_from_forgery with: :null_session if respond_to?(:protect_from_forgery)
53
+
54
+ # A before_action, not a guard clause in each action, because a callback runs
55
+ # even when the action itself does not. `authorize` is a common enough method
56
+ # name that a gem patching ActionController::Base with one would knock this
57
+ # action out of Rails' `action_methods` — and Rails would then serve
58
+ # `authorize.html.erb` by implicit render, skipping a body guard entirely and
59
+ # showing an attacker's `redirect_uri` a paste page. Here the check cannot be
60
+ # routed around.
61
+ # Raises rather than skipping when the parent has no filter chain. Skipping
62
+ # would leave both browser legs unguarded — an open redirect issuing codes to
63
+ # any URI — and it is the same shape as the scheme denylist this branch
64
+ # removed for failing open. A guard that installs itself conditionally is not
65
+ # a guard.
66
+ unless respond_to?(:before_action)
67
+ raise McpToolkit::Errors::ConfigurationError,
68
+ "#{name || "The OAuth bridge's parent"} has no `before_action`; " \
69
+ "config.oauth_parent_controller must be an ActionController with a filter chain."
70
+ end
71
+
72
+ before_action :mcp_oauth_validate_request!, only: %i[authorize approve]
73
+ end
74
+
75
+ # `resource` MUST equal the MCP endpoint URL as the operator typed it into the
76
+ # client, hence derived from the live request origin rather than pinned.
77
+ def protected_resource
78
+ mcp_oauth_forbid_caching
79
+ render json: {
80
+ resource: mcp_oauth_resource_url,
81
+ authorization_servers: [mcp_oauth_issuer],
82
+ bearer_methods_supported: ["header"]
83
+ }
84
+ end
85
+
86
+ # S256 because clients send a `code_challenge` regardless; `none` because the
87
+ # clients here are public and unverified.
88
+ def authorization_server
89
+ mcp_oauth_forbid_caching
90
+ render json: {
91
+ issuer: mcp_oauth_issuer,
92
+ authorization_endpoint: mcp_oauth_endpoint_url("authorize"),
93
+ token_endpoint: mcp_oauth_endpoint_url("token"),
94
+ registration_endpoint: mcp_oauth_endpoint_url("register"),
95
+ response_types_supported: SUPPORTED_RESPONSE_TYPES,
96
+ grant_types_supported: SUPPORTED_GRANT_TYPES,
97
+ code_challenge_methods_supported: ["S256"],
98
+ token_endpoint_auth_methods_supported: ["none"]
99
+ }
100
+ end
101
+
102
+ # Stateless: nothing here is persisted (no endpoint reads a `client_id`). The
103
+ # response still names the `redirect_uris` the client sent, because a strict
104
+ # client validates that they come back and abandons a registration that drops
105
+ # them — the failure a hosted client hit against the pre-0.6.1 stub, which
106
+ # returned only a `client_id`. That echo AUTHORIZES nothing: `authorize` and
107
+ # `token` check every `redirect_uri` against the host's allowlist independently.
108
+ #
109
+ # The auth method and the grant/response types are SUBSTITUTED rather than
110
+ # echoed — RFC 7591 §3.2.1 states the metadata as REGISTERED, and lets a server
111
+ # replace what it does not support. Reflecting the client's request instead
112
+ # would contradict `authorization_server`, which is where that client got this
113
+ # endpoint, and would promise a flow `token` rejects: a `refresh_token` echoed
114
+ # back is a refresh answered `unsupported_grant_type`. No `client_secret` is
115
+ # issued, so `none` is the only auth method a client here could perform.
116
+ def register
117
+ body = {
118
+ client_id: SecureRandom.uuid,
119
+ client_id_issued_at: Time.now.to_i,
120
+ redirect_uris: mcp_oauth_param_list(:redirect_uris),
121
+ token_endpoint_auth_method: "none",
122
+ grant_types: mcp_oauth_registered_subset(:grant_types, SUPPORTED_GRANT_TYPES),
123
+ response_types: mcp_oauth_registered_subset(:response_types, SUPPORTED_RESPONSE_TYPES)
124
+ }
125
+ body[:client_name] = params[:client_name].to_s if params[:client_name].present?
126
+ render json: body, status: :created
127
+ end
128
+
129
+ # `formats: [:html]` because there is only an HTML template and `Accept` picks
130
+ # the format just as a `.json` suffix would — without it, `Accept:
131
+ # application/json` raises MissingTemplate on an unauthenticated endpoint.
132
+ def authorize
133
+ render :authorize, layout: false, formats: [:html]
134
+ end
135
+
136
+ # The token is verified here, not only at exchange, so a typo fails on the page
137
+ # the operator is looking at.
138
+ def approve
139
+ access_token = params[:access_token].to_s
140
+ return mcp_oauth_reject_paste if mcp_oauth_authenticate(access_token).nil?
141
+
142
+ # 303, not Rails' default 302: this POST carried the operator's token in its
143
+ # body, and only 303 unambiguously tells the browser to fetch the redirect
144
+ # target with GET and no body. A 302 leaves re-sending it to the client's
145
+ # discretion, which would hand the token itself to the callback (RFC 9700
146
+ # §4.12).
147
+ redirect_to mcp_oauth_callback_url(mcp_oauth_issue_code(access_token)),
148
+ allow_other_host: true, status: :see_other
149
+ end
150
+
151
+ def token
152
+ return mcp_oauth_render_token_error("unsupported_grant_type") unless params[:grant_type] == "authorization_code"
153
+ # Shape first, and BEFORE the code is consumed: a request that could never
154
+ # verify shouldn't cost a legitimate client its code. A well-formed but WRONG
155
+ # verifier still burns it below — that is deliberate, and the only thing
156
+ # stopping someone who intercepted a code from retrying verifiers against it.
157
+ return mcp_oauth_render_token_error("invalid_request") unless mcp_oauth_verifier_well_formed?
158
+
159
+ payload = mcp_oauth_consume_code(params[:code].to_s)
160
+ return mcp_oauth_render_token_error("invalid_grant") if payload.nil?
161
+ return mcp_oauth_render_token_error("invalid_grant") unless mcp_oauth_exchange_valid?(payload)
162
+
163
+ access_token = payload[:access_token].to_s
164
+ return mcp_oauth_render_token_error("invalid_grant") if mcp_oauth_authenticate(access_token).nil?
165
+
166
+ mcp_oauth_forbid_caching
167
+ render json: { access_token:, token_type: "Bearer" }
168
+ end
169
+
170
+ private
171
+
172
+ def mcp_oauth_config
173
+ McpToolkit.config
174
+ end
175
+
176
+ # A registration parameter as a plain array of strings — RFC 7591 lists arrive
177
+ # as JSON arrays; empty when the client sent none.
178
+ def mcp_oauth_param_list(key)
179
+ Array(params[key]).map(&:to_s)
180
+ end
181
+
182
+ # What the client asked for, narrowed to what this server honours. A client that
183
+ # asked for NOTHING supported gets the default rather than an empty array, which
184
+ # would state a registration able to do nothing at all.
185
+ def mcp_oauth_registered_subset(key, supported)
186
+ (mcp_oauth_param_list(key) & supported).presence || supported
187
+ end
188
+
189
+ # ---- request validation ---------------------------------------------------
190
+
191
+ # Halts both legs before their action runs. Both problems RENDER rather than
192
+ # bounce an OAuth error back to the caller: a disallowed redirect_uri must never
193
+ # be redirected TO — that is the attack.
194
+ def mcp_oauth_validate_request!
195
+ problem = mcp_oauth_request_problem
196
+ mcp_oauth_render_bad_request(problem) if problem
197
+ end
198
+
199
+ def mcp_oauth_request_problem
200
+ return mcp_oauth_reject_redirect_uri unless mcp_oauth_redirect_uri_allowed?
201
+ return "Missing or unsupported PKCE code_challenge." unless mcp_oauth_code_challenge_supported?
202
+
203
+ nil
204
+ end
205
+
206
+ # Exact matching makes a legitimate client rejected over a trailing slash look
207
+ # identical to an attack, so log the offered value (a public callback, never a
208
+ # credential) — otherwise an operator is left guessing what to allowlist.
209
+ def mcp_oauth_reject_redirect_uri
210
+ mcp_oauth_config.logger&.warn(
211
+ "[mcp_toolkit] OAuth authorize rejected: redirect_uri #{params[:redirect_uri].inspect} is not in " \
212
+ "config.oauth_allowed_redirect_uris (#{Array(mcp_oauth_config.oauth_allowed_redirect_uris).inspect}) " \
213
+ "and is not a native-client target permitted by config.oauth_allow_loopback_redirects " \
214
+ "(#{mcp_oauth_config.oauth_allow_loopback_redirects ? "enabled" : "disabled"})"
215
+ )
216
+ "Unregistered redirect_uri."
217
+ end
218
+
219
+ # Every target must be named exactly, with ONE exception: loopback, whose port
220
+ # cannot be named ahead of time. See `mcp_oauth_loopback_redirect_uri?`.
221
+ def mcp_oauth_redirect_uri_allowed?
222
+ redirect_uri = params[:redirect_uri].to_s
223
+ return false if redirect_uri.empty?
224
+ return true if Array(mcp_oauth_config.oauth_allowed_redirect_uris).include?(redirect_uri)
225
+
226
+ mcp_oauth_loopback_redirect_uri?(redirect_uri)
227
+ end
228
+
229
+ # RFC 8252 §7.3 loopback — the one target accepted unnamed, and only http(s) to
230
+ # a loopback host: NOT private-use schemes, however local they look. See
231
+ # Configuration#oauth_allow_loopback_redirects for why that line is drawn there.
232
+ #
233
+ # Judged on the PARSED URI, never the string, because `host` is what a browser
234
+ # resolves: `http://127.0.0.1@evil.example/` (userinfo — host is evil.example)
235
+ # and `http://127.0.0.1.evil.example/` are both correctly remote. A fragment is
236
+ # refused because OAuth forbids one on a redirect_uri.
237
+ def mcp_oauth_loopback_redirect_uri?(redirect_uri)
238
+ return false unless mcp_oauth_config.oauth_allow_loopback_redirects
239
+
240
+ uri = mcp_oauth_parse_uri(redirect_uri)
241
+ return false if uri.nil?
242
+ return false unless %w[http https].include?(uri.scheme&.downcase)
243
+ return false unless uri.fragment.nil?
244
+
245
+ mcp_oauth_loopback_host?(uri.host)
246
+ end
247
+
248
+ def mcp_oauth_parse_uri(value)
249
+ URI.parse(value)
250
+ rescue URI::InvalidURIError
251
+ nil
252
+ end
253
+
254
+ # `URI` keeps the brackets on an IPv6 literal (`[::1]`); strip them so the
255
+ # literal compares against the bare form clients actually send.
256
+ def mcp_oauth_loopback_host?(host)
257
+ McpToolkit::Oauth.loopback_host?(host)
258
+ end
259
+
260
+ # RFC 7636 §4.1/§4.2 shapes, not just presence. This cannot make a client's
261
+ # PKCE strong — the challenge is a 43-char digest whatever the verifier was, so
262
+ # a client that chose a one-character verifier is indistinguishable here and has
263
+ # only defeated its own protection. It is enforced because a public gem should
264
+ # not quietly accept what the spec forbids, and because a malformed value can
265
+ # then be refused early rather than after a paste.
266
+ def mcp_oauth_code_challenge_supported?
267
+ params[:code_challenge].to_s.match?(PKCE_VALUE) && params[:code_challenge_method].to_s == "S256"
268
+ end
269
+
270
+ def mcp_oauth_verifier_well_formed?
271
+ params[:code_verifier].to_s.match?(PKCE_VALUE)
272
+ end
273
+
274
+ # ---- authorization codes --------------------------------------------------
275
+
276
+ # The whole "authorization server" state: one cache entry, short-lived, bound
277
+ # to the challenge and redirect it was issued for.
278
+ #
279
+ # The entry is keyed by the code's DIGEST and its payload is sealed, so a dump
280
+ # of the store yields nothing on its own. Worth the few lines because what is
281
+ # parked there is not the short-lived credential an authorization server would
282
+ # normally hold: it is the operator's pre-existing, long-lived, full-scope
283
+ # token, in a store a host is told to point at its shared `Rails.cache`.
284
+ def mcp_oauth_issue_code(access_token)
285
+ code = SecureRandom.urlsafe_base64(CODE_BYTES)
286
+ payload = {
287
+ access_token:, code_challenge: params[:code_challenge].to_s, redirect_uri: params[:redirect_uri].to_s
288
+ }
289
+ mcp_oauth_config.cache_store.write(
290
+ mcp_oauth_code_key(code),
291
+ mcp_oauth_encryptor(code).encrypt_and_sign(JSON.generate(payload)),
292
+ expires_in: mcp_oauth_config.oauth_authorization_code_ttl
293
+ )
294
+ code
295
+ end
296
+
297
+ # Single-use for real: the DELETE decides, not the read. `Cache::Store#delete`
298
+ # answers whether the entry was still there, so of two concurrent redemptions
299
+ # exactly one proceeds. (The race was never exploitable — both would return the
300
+ # same token, and both need the verifier — but the guarantee is cheap to keep
301
+ # honest, and a code is burnt even when the exchange that follows fails.)
302
+ def mcp_oauth_consume_code(code)
303
+ return nil if code.empty?
304
+
305
+ key = mcp_oauth_code_key(code)
306
+ blob = mcp_oauth_config.cache_store.read(key)
307
+ return nil if blob.nil?
308
+ return nil unless mcp_oauth_config.cache_store.delete(key)
309
+
310
+ mcp_oauth_decrypt_payload(code, blob)
311
+ end
312
+
313
+ def mcp_oauth_decrypt_payload(code, blob)
314
+ JSON.parse(mcp_oauth_encryptor(code).decrypt_and_verify(blob), symbolize_names: true)
315
+ rescue ActiveSupport::MessageEncryptor::InvalidMessage, JSON::ParserError
316
+ nil
317
+ end
318
+
319
+ def mcp_oauth_code_key(code)
320
+ "#{CODE_CACHE_PREFIX}#{Digest::SHA256.hexdigest(code)}"
321
+ end
322
+
323
+ # HMAC because two independent inputs are being combined (why the secret is one
324
+ # of them: Configuration#oauth_signing_secret). No password-stretching — both
325
+ # are already high-entropy, so a PBKDF2 run per request would buy nothing.
326
+ #
327
+ # Cipher and serializer are pinned, not inherited: the gem supports
328
+ # ActiveSupport >= 6.1, where both defaults are Rails-configuration-dependent
329
+ # (`:marshal`, and 7.1+'s `:json_allow_marshal`). The payload is already a JSON
330
+ # String, so NullSerializer costs nothing and leaves JSON.parse the only parser
331
+ # this code hands the plaintext to.
332
+ #
333
+ # It does NOT buy immunity from a writable cache: `Cache::Store` marshals the
334
+ # entry itself, so a store that can be written to is a code-execution problem
335
+ # before anything here is reached. This is determinism across the supported
336
+ # range, not a mitigation.
337
+ def mcp_oauth_encryptor(code)
338
+ key = OpenSSL::HMAC.digest("SHA256", mcp_oauth_signing_secret, "#{CODE_CACHE_PREFIX}key:#{code}")
339
+ ActiveSupport::MessageEncryptor.new(
340
+ key, cipher: "aes-256-gcm", serializer: ActiveSupport::MessageEncryptor::NullSerializer
341
+ )
342
+ end
343
+
344
+ def mcp_oauth_signing_secret
345
+ mcp_oauth_config.oauth_signing_secret.tap do |secret|
346
+ raise McpToolkit::Errors::ConfigurationError, "oauth_signing_secret is not configured" if secret.to_s.empty?
347
+ end
348
+ end
349
+
350
+ def mcp_oauth_exchange_valid?(payload)
351
+ payload[:redirect_uri] == params[:redirect_uri].to_s &&
352
+ mcp_oauth_pkce_valid?(params[:code_verifier].to_s, payload[:code_challenge].to_s)
353
+ end
354
+
355
+ # S256: base64url(sha256(verifier)), unpadded. Packed rather than via base64 so
356
+ # the gem needs no extra dependency.
357
+ def mcp_oauth_pkce_valid?(verifier, challenge)
358
+ return false if verifier.empty? || challenge.empty?
359
+
360
+ digest = [Digest::SHA256.digest(verifier)].pack("m0").tr("+/", "-_").delete("=")
361
+ ActiveSupport::SecurityUtils.secure_compare(digest, challenge)
362
+ end
363
+
364
+ # ---- token verification ---------------------------------------------------
365
+
366
+ # The same authenticator the transport authenticates every MCP request with —
367
+ # this bridge introduces no second notion of a valid token.
368
+ def mcp_oauth_authenticate(access_token)
369
+ return nil if access_token.empty?
370
+
371
+ McpToolkit::Auth::Authority.authenticate(access_token, config: mcp_oauth_config)
372
+ end
373
+
374
+ # ---- urls -----------------------------------------------------------------
375
+
376
+ # Deliberately path-ful: a client path-INSERTS this to find the metadata, which
377
+ # is what keeps the bridge off the origin-global bare path (see
378
+ # Configuration#oauth_protected_resource_path). Issuer path == resource path, so
379
+ # a client derives the same URL from either.
380
+ def mcp_oauth_issuer
381
+ mcp_oauth_resource_url
382
+ end
383
+
384
+ def mcp_oauth_resource_url
385
+ "#{request.base_url}#{mcp_oauth_config.oauth_resource_path_component}"
386
+ end
387
+
388
+ def mcp_oauth_endpoint_url(action)
389
+ "#{mcp_oauth_resource_url}/oauth/#{action}"
390
+ end
391
+
392
+ # Sets `code` (and echoes `state`) on the client's redirect_uri, preserving any
393
+ # other query it already carries.
394
+ #
395
+ # SETS, not appends: a loopback redirect_uri is not an exact-matched string, so
396
+ # a caller can put `?code=…` in it themselves. Appending would emit
397
+ # `?code=theirs&code=ours` and leave which one wins to the client's parser.
398
+ # Dropping any inbound `code`/`state` keeps the response OAuth-shaped whatever
399
+ # was passed in.
400
+ #
401
+ # The base is taken as the checked string rather than round-tripped through
402
+ # `URI`, so the host part of what is emitted is byte-identical to what the
403
+ # policy approved. The query IS re-encoded (`?a=1?b=2` normalises to
404
+ # `?a=1%3Fb%3D2`), which is the point — that is where `code` gets stripped.
405
+ def mcp_oauth_callback_url(code)
406
+ redirect_uri = params[:redirect_uri].to_s
407
+ base, _, existing = redirect_uri.partition("?")
408
+ pairs = mcp_oauth_preserved_query_pairs(existing)
409
+ pairs << ["code", code]
410
+ pairs << ["state", params[:state].to_s] if params[:state].present?
411
+ "#{base}?#{URI.encode_www_form(pairs)}"
412
+ end
413
+
414
+ # A client's own query survives; the two parameters this response owns do not,
415
+ # whoever put them there. The rescue is a backstop, not a designed path: both
416
+ # sanctioned redirect_uris are `URI.parse`d before they reach here, which
417
+ # already refuses what `decode_www_form` would raise on.
418
+ def mcp_oauth_preserved_query_pairs(query)
419
+ return [] if query.empty?
420
+
421
+ URI.decode_www_form(query).reject { |pair| RESPONSE_OWNED_QUERY_KEYS.include?(pair.first) }
422
+ rescue ArgumentError
423
+ []
424
+ end
425
+
426
+ # ---- responses ------------------------------------------------------------
427
+
428
+ # 422 as an integer, not a symbol: the gemspec pins no Rack floor, and neither
429
+ # symbol spans the range it allows — `:unprocessable_content` raises below Rack
430
+ # 3.1, `:unprocessable_entity` is deprecated above it. A symbol that raises here
431
+ # would turn a mistyped paste into an unauthenticated 500 on the one page whose
432
+ # job is to say "that token isn't valid".
433
+ def mcp_oauth_reject_paste
434
+ @mcp_oauth_error = "That access token is not valid, or it has expired or been revoked."
435
+ render :authorize, layout: false, formats: [:html], status: 422
436
+ end
437
+
438
+ def mcp_oauth_render_bad_request(message)
439
+ render plain: message, status: :bad_request
440
+ end
441
+
442
+ # Applied to the token response, where RFC 6749 §5.1 makes both headers a MUST
443
+ # for anything carrying a token, and to both metadata documents, where the
444
+ # reason is subtler: they name the `authorization_endpoint` an operator will be
445
+ # sent to and are built from the live request origin (`request.base_url`, which
446
+ # honours `X-Forwarded-Host`), so a shared cache that stored one keyed only by
447
+ # path could serve every client an origin an attacker chose — with the document
448
+ # itself vouching for it. A host MUST pin `config.hosts` — Rails'
449
+ # HostAuthorization then rejects a forged header before it reaches here, but
450
+ # Rails does NOT do that for you: `config.hosts` is populated in development and
451
+ # left EMPTY in production, where an empty list means no checking at all. This
452
+ # header is the half that does not depend on the host getting that right.
453
+ def mcp_oauth_forbid_caching
454
+ response.headers["Cache-Control"] = "no-store"
455
+ response.headers["Pragma"] = "no-cache"
456
+ end
457
+
458
+ def mcp_oauth_render_token_error(code)
459
+ render json: { error: code }, status: :bad_request
460
+ end
461
+ 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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module McpToolkit
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.1"
5
5
  end
data/lib/mcp_toolkit.rb CHANGED
@@ -8,12 +8,15 @@ require "zeitwerk"
8
8
  # subfiles that happen to touch them:
9
9
  #
10
10
  # json - JSON.parse / JSON.generate (introspection parse, tools, transport)
11
- # digest - Digest::SHA256 (introspection cache key)
11
+ # digest - Digest::SHA256 (introspection cache key; OAuth bridge PKCE digest)
12
12
  # time - Time.iso8601 / Time.parse (introspection expiry parsing)
13
- # securerandom - SecureRandom.uuid (Session ids)
13
+ # securerandom - SecureRandom.uuid (Session ids; OAuth bridge codes/client ids)
14
+ # uri - URI.parse / encode_www_form (OAuth bridge redirect construction)
14
15
  # mcp - the official MCP SDK (Server wraps it; Tools::Base subclasses MCP::Tool)
15
16
  # active_support/concern - Transport::ControllerMethods is an includable concern
16
17
  # active_support/cache - the default MemoryStore cache_store
18
+ # active_support/security_utils - constant-time compare (OAuth bridge PKCE)
19
+ # active_support/message_encryptor - encrypts the OAuth bridge's cached code payload
17
20
  #
18
21
  # Two third-party libs are the exception to the centralize-here rule: each is
19
22
  # required alongside its owner file rather than up front.
@@ -25,9 +28,12 @@ require "json"
25
28
  require "digest"
26
29
  require "time"
27
30
  require "securerandom"
31
+ require "uri"
28
32
  require "mcp"
29
33
  require "active_support/concern"
30
34
  require "active_support/cache"
35
+ require "active_support/security_utils"
36
+ require "active_support/message_encryptor"
31
37
 
32
38
  # External dependencies (NOT autoloaded by Zeitwerk — only the gem's own tree is).
33
39
  # ActiveSupport's specific core extensions are required up front (rather than full
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mcp_toolkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Karol Galanciak
@@ -98,6 +98,7 @@ files:
98
98
  - LICENSE.txt
99
99
  - README.md
100
100
  - Rakefile
101
+ - app/views/mcp_toolkit/oauth/authorize.html.erb
101
102
  - config/routes.rb
102
103
  - lib/mcp_toolkit.rb
103
104
  - lib/mcp_toolkit/auth/authenticator.rb
@@ -134,6 +135,8 @@ files:
134
135
  - lib/mcp_toolkit/gateway/upstream_registry.rb
135
136
  - lib/mcp_toolkit/get_executor.rb
136
137
  - lib/mcp_toolkit/list_executor.rb
138
+ - lib/mcp_toolkit/oauth.rb
139
+ - lib/mcp_toolkit/oauth/controller_methods.rb
137
140
  - lib/mcp_toolkit/protocol.rb
138
141
  - lib/mcp_toolkit/rate_limiter.rb
139
142
  - lib/mcp_toolkit/registry.rb