mcp_toolkit 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -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.0"
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.0
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