mcp 1.7.0 → 1.8.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,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+ require "mcp/client/oauth/discovery"
7
+ require_relative "token_verifier"
8
+
9
+ # This file is autoloaded only when `JWTVerifier` is referenced, so the `jwt` dependency does not affect users of other verifiers.
10
+ begin
11
+ require "jwt"
12
+ rescue LoadError
13
+ raise LoadError, "The 'jwt' gem is required to use MCP::Server::OAuth::JWTVerifier. Add it to your Gemfile: gem 'jwt'"
14
+ end
15
+
16
+ module MCP
17
+ class Server
18
+ module OAuth
19
+ # Verifies JWT access tokens locally: signature (via a JWKS endpoint, a static JWKS document, or a single key), `iss`, `aud`, `exp`, and `nbf`.
20
+ #
21
+ #
22
+ # verifier = MCP::Server::OAuth::JWTVerifier.new(
23
+ # resource_metadata: metadata, # the ProtectedResourceMetadata this server publishes
24
+ # jwks_uri: "https://as.example.com/.well-known/jwks.json",
25
+ # )
26
+ #
27
+ # `aud` is checked against the document's `resource`, the canonical resource URL spec-conformant authorization servers
28
+ # put into `aud` via the RFC 8707 `resource` parameter, and `iss` against the document's authorization server, of which
29
+ # there must be exactly one: this verifier holds one key set, so it verifies the tokens of one issuer.
30
+ #
31
+ # `alg: none` is refused whatever `algorithms:` says, and HMAC and asymmetric algorithms cannot share one allowlist, which defeats HS256 key-confusion attacks:
32
+ # a token symmetrically signed with the public key bytes never meets an HMAC verifier holding that key. The default allowlist contains only asymmetric algorithms.
33
+ # Pass `algorithms: ["HS256"]` together with `key: shared_secret` only when the authorization server genuinely issues HMAC-signed tokens, and understand
34
+ # that anyone holding the secret can mint tokens.
35
+ class JWTVerifier < TokenVerifier
36
+ # Raised when the JWKS endpoint cannot be fetched or parsed. Deliberately not an `OAuth::Error`:
37
+ # an unreachable key set is an internal failure (HTTP 500), not a problem with the client's token.
38
+ class JWKSFetchError < StandardError; end
39
+
40
+ DEFAULT_ALGORITHMS = ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512", "EdDSA"].freeze
41
+
42
+ HMAC_ALGORITHM_PATTERN = /\AHS\d+\z/i.freeze
43
+ private_constant :HMAC_ALGORITHM_PATTERN
44
+
45
+ # Minimum seconds between JWKS refetches triggered by an unknown `kid`, so a stream of forged tokens cannot hammer the JWKS endpoint.
46
+ JWKS_REFETCH_COOLDOWN = 30
47
+
48
+ # The RFC 7519 NumericDate claims the jwt gem compares with the clock under the `verify_expiration` and
49
+ # `verify_not_before` options passed in `decode`; `iat` joins the list only if its verification is ever enabled.
50
+ NUMERIC_DATE_CLAIMS = ["exp", "nbf"].freeze
51
+ private_constant :NUMERIC_DATE_CLAIMS
52
+
53
+ def initialize(resource_metadata:, jwks_uri: nil, jwks: nil, key: nil, algorithms: DEFAULT_ALGORITHMS, leeway: 0, jwks_cache_ttl: 300, jwks_max_stale: 3600, open_timeout: 5, read_timeout: 5)
54
+ super()
55
+
56
+ issuer = issuer_from(resource_metadata)
57
+ audience = resource_from(resource_metadata)
58
+ require_expected_claim!(:issuer, issuer)
59
+ require_expected_claim!(:audience, audience)
60
+ require_non_negative_number!(:leeway, leeway)
61
+ require_non_negative_number!(:jwks_cache_ttl, jwks_cache_ttl)
62
+ require_non_negative_number!(:jwks_max_stale, jwks_max_stale)
63
+ require_positive_number!(:open_timeout, open_timeout)
64
+ require_positive_number!(:read_timeout, read_timeout)
65
+
66
+ key_sources = [jwks_uri, jwks, key].compact
67
+ raise ArgumentError, "exactly one of jwks_uri, jwks, or key is required" unless key_sources.size == 1
68
+
69
+ if jwks_uri && !Client::OAuth::Discovery.secure_url?(jwks_uri)
70
+ raise ArgumentError, "jwks_uri must use https (http is allowed only on loopback): #{jwks_uri.inspect}"
71
+ end
72
+
73
+ @issuer = issuer
74
+ @audience = audience
75
+ @jwks_uri = jwks_uri
76
+ @static_jwks = jwks && deep_symbolize(jwks)
77
+ @key = key
78
+ @algorithms = validate_algorithms!(algorithms, key)
79
+ @leeway = leeway
80
+ @jwks_cache_ttl = jwks_cache_ttl
81
+ @jwks_max_stale = jwks_max_stale
82
+ @open_timeout = open_timeout
83
+ @read_timeout = read_timeout
84
+ @cached_jwks = nil
85
+ @jwks_fetched_at = nil
86
+ @kid_refetch_at = nil
87
+ @refetch_failed_at = nil
88
+ @state_mutex = Mutex.new
89
+ @fetch_mutex = Mutex.new
90
+ end
91
+
92
+ def verify(token)
93
+ validate_time_claims!(token)
94
+ claims, _header = decode(token)
95
+
96
+ access_token_from_claims(token, claims, resource: @audience)
97
+ rescue JWT::ExpiredSignature
98
+ raise InvalidTokenError, "Token expired"
99
+ rescue JWT::ImmatureSignature
100
+ raise InvalidTokenError, "Token not yet valid"
101
+ rescue JWT::InvalidIssuerError
102
+ raise InvalidTokenError, "Invalid issuer"
103
+ rescue JWT::InvalidAudError
104
+ raise InvalidTokenError, "Invalid audience"
105
+ rescue JWT::DecodeError
106
+ # Covers malformed tokens, signature mismatches, disallowed algorithms (including `alg: none`), missing required claims,
107
+ # and unknown key IDs. The generic message is deliberate: it goes into the `WWW-Authenticate` header, and detailing why
108
+ # verification failed would only help an attacker probe.
109
+ raise InvalidTokenError, "Invalid token"
110
+ end
111
+
112
+ private
113
+
114
+ def decode(token)
115
+ options = {
116
+ algorithms: @algorithms,
117
+ iss: @issuer,
118
+ verify_iss: true,
119
+ aud: @audience,
120
+ verify_aud: true,
121
+ verify_expiration: true,
122
+ verify_not_before: true,
123
+ # `verify_expiration` alone lets a token without `exp` through, and a token that never expires cannot be aged out.
124
+ # Spec-conformant access tokens always carry `exp` (RFC 9068 requires it).
125
+ required_claims: ["exp"],
126
+ leeway: @leeway,
127
+ }
128
+
129
+ if @key
130
+ JWT.decode(token, @key, true, options)
131
+ elsif @static_jwks
132
+ JWT.decode(token, nil, true, options.merge(jwks: @static_jwks))
133
+ else
134
+ JWT.decode(token, nil, true, options.merge(jwks: jwk_loader))
135
+ end
136
+ end
137
+
138
+ # The jwt gem calls `to_i` on `exp` and `nbf` while verifying, so a claim of the wrong type raises inside the gem
139
+ # instead of failing the token. The unverified payload is inspected first: a claim that is neither a finite number
140
+ # nor a digit string makes the token invalid, the same answer `expiry_from_claims` gives an introspection response.
141
+ def validate_time_claims!(token)
142
+ payload, = JWT.decode(token, nil, false)
143
+ raise InvalidTokenError, "Invalid token" unless payload.is_a?(Hash)
144
+
145
+ NUMERIC_DATE_CLAIMS.each do |name|
146
+ value = payload[name]
147
+ next if value.nil? || (value.is_a?(Numeric) && value.finite?) || (value.is_a?(String) && value.match?(/\A\d+\z/))
148
+
149
+ raise InvalidTokenError, "Malformed #{name} claim"
150
+ end
151
+ end
152
+
153
+ # `none` is never acceptable, and HMAC must not share an allowlist with asymmetric algorithms: an attacker who picks `alg`
154
+ # would otherwise sign with the public key bytes as the HMAC secret. A String key can only be an HMAC secret,
155
+ # so it is refused for asymmetric algorithms at construction rather than failing every verification later, and a `JWT::JWK`
156
+ # is refused outright because the decode path would reject every token signed by it.
157
+ def validate_algorithms!(algorithms, key)
158
+ if key.is_a?(JWT::JWK::KeyBase)
159
+ raise ArgumentError, "key must be an OpenSSL::PKey or an HMAC secret String; pass a JWK through jwks: { keys: [jwk.export] }"
160
+ end
161
+
162
+ list = Array(algorithms).map(&:to_s)
163
+ raise ArgumentError, "algorithms must name at least one signature algorithm" if list.empty?
164
+
165
+ if list.any? { |algorithm| algorithm.casecmp?("none") }
166
+ raise ArgumentError, "algorithms must not include none: unsigned tokens would be accepted"
167
+ end
168
+
169
+ hmac, asymmetric = list.partition { |algorithm| algorithm.match?(HMAC_ALGORITHM_PATTERN) }
170
+
171
+ if hmac.any? && asymmetric.any?
172
+ raise ArgumentError, "algorithms must not mix HMAC (#{hmac.join(", ")}) with asymmetric algorithms (#{asymmetric.join(", ")})"
173
+ end
174
+ if key.is_a?(String) && hmac.empty?
175
+ raise ArgumentError, "key must be an OpenSSL::PKey for asymmetric algorithms; a String key is an HMAC secret and needs an HS* allowlist"
176
+ end
177
+
178
+ list
179
+ end
180
+
181
+ # Loader for the `jwt` gem's `jwks:` option. The gem invokes it once per decode and again with `kid_not_found: true` when
182
+ # the token's `kid` is not in the returned set, which is the signal that the authorization server may have rotated its keys.
183
+ def jwk_loader
184
+ lambda do |options|
185
+ load_jwks(options)
186
+ end
187
+ end
188
+
189
+ # Refreshes the JWKS cache without performing network I/O under a lock other verifications wait on: only cache-state reads
190
+ # and swaps are synchronized, one thread fetches at a time, and the remaining threads keep verifying against the previous key
191
+ # set in the meantime. A failed refresh also falls back to that set, so a flaky JWKS endpoint degrades to slightly stale keys
192
+ # instead of taking verification down, but only for `jwks_max_stale` seconds past the TTL: beyond that the failure surfaces,
193
+ # so a key set the authorization server retired cannot stay trusted indefinitely. A failed unknown-`kid` refetch starts
194
+ # the cooldown all the same, or the endpoint would be retried on every such token while it is down.
195
+ def load_jwks(options)
196
+ # The key set and its fetch time are read as one snapshot and judged together: a refresh another thread completes
197
+ # in the meantime must not lend its fetch time to the set this thread is holding.
198
+ cached, fetched_at, refetch = @state_mutex.synchronize { [@cached_jwks, @jwks_fetched_at, refetch_required?(options)] }
199
+ unless refetch
200
+ # A refresh that failed moments ago is not retried yet, and keys past the stale bound are not served meanwhile.
201
+ raise JWKSFetchError, "JWKS endpoint could not be fetched" if cached && stale_beyond_bound?(fetched_at)
202
+
203
+ return cached
204
+ end
205
+
206
+ if @fetch_mutex.try_lock
207
+ begin
208
+ fresh = fetch_jwks
209
+ @state_mutex.synchronize do
210
+ @cached_jwks = fresh
211
+ @jwks_fetched_at = monotonic_now
212
+ @refetch_failed_at = nil
213
+ @kid_refetch_at = monotonic_now if options[:kid_not_found]
214
+ end
215
+
216
+ fresh
217
+ rescue JWKSFetchError
218
+ @state_mutex.synchronize do
219
+ @refetch_failed_at = monotonic_now
220
+ @kid_refetch_at = monotonic_now if options[:kid_not_found]
221
+ end
222
+ raise if cached.nil? || stale_beyond_bound?(fetched_at)
223
+
224
+ cached
225
+ ensure
226
+ @fetch_mutex.unlock
227
+ end
228
+ elsif cached && !stale_beyond_bound?(fetched_at)
229
+ cached
230
+ else
231
+ # Cold start, or a cached set past its stale bound, while another thread performs the fetch: wait for that fetch
232
+ # instead of failing the request with an empty or outdated key set. A fetch time that moved means the refresh succeeded,
233
+ # and its set is served the way the fetching thread serves it; an unchanged one means the refresh failed,
234
+ # and the set it left behind is judged by the bound.
235
+ @fetch_mutex.synchronize {}
236
+ fetched, refreshed_at = @state_mutex.synchronize { [@cached_jwks, @jwks_fetched_at] }
237
+ if fetched.nil? || (refreshed_at == fetched_at && stale_beyond_bound?(refreshed_at))
238
+ raise JWKSFetchError, "JWKS endpoint could not be fetched"
239
+ end
240
+
241
+ fetched
242
+ end
243
+ end
244
+
245
+ # An unknown `kid` refetches immediately (the usual key-rotation case) but at most once per cooldown window, so forged tokens with
246
+ # random `kid`s cannot turn every request into a JWKS fetch. Outside of a `kid` miss, the cache is refreshed only when the TTL lapses.
247
+ def refetch_required?(options)
248
+ return true if @cached_jwks.nil?
249
+
250
+ if options[:kid_not_found]
251
+ return @kid_refetch_at.nil? || monotonic_now - @kid_refetch_at >= JWKS_REFETCH_COOLDOWN
252
+ end
253
+
254
+ # A refresh that just failed is not retried on every request while the endpoint stays down.
255
+ return false if @refetch_failed_at && monotonic_now - @refetch_failed_at < JWKS_REFETCH_COOLDOWN
256
+
257
+ monotonic_now - @jwks_fetched_at >= @jwks_cache_ttl
258
+ end
259
+
260
+ # Whether a key set fetched at `fetched_at` has outlived its TTL by more than `jwks_max_stale`, the point past which
261
+ # a refresh failure is no longer bridged with the stale keys.
262
+ def stale_beyond_bound?(fetched_at)
263
+ monotonic_now - fetched_at > @jwks_cache_ttl + @jwks_max_stale
264
+ end
265
+
266
+ def fetch_jwks
267
+ uri = URI.parse(@jwks_uri)
268
+ request = Net::HTTP::Get.new(uri.request_uri, { "Accept" => "application/json" })
269
+ body = "".dup
270
+
271
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: @open_timeout, read_timeout: @read_timeout) do |http|
272
+ http.request(request) do |response|
273
+ unless response.is_a?(Net::HTTPOK)
274
+ raise JWKSFetchError, "JWKS endpoint responded with status #{response.code}"
275
+ end
276
+
277
+ response.read_body do |chunk|
278
+ body << chunk
279
+ if body.bytesize > MAX_UPSTREAM_RESPONSE_BYTES
280
+ raise JWKSFetchError, "JWKS response exceeded #{MAX_UPSTREAM_RESPONSE_BYTES} bytes"
281
+ end
282
+ end
283
+ end
284
+ end
285
+
286
+ parsed = JSON.parse(body, symbolize_names: true)
287
+ raise JWKSFetchError, "JWKS endpoint returned a non-object JSON document" unless parsed.is_a?(Hash)
288
+
289
+ # A document without a `keys` array would replace a good cache with one that verifies nothing.
290
+ raise JWKSFetchError, "JWKS endpoint returned a document without a keys array" unless parsed[:keys].is_a?(Array)
291
+
292
+ # So would a set with a member the jwt gem cannot turn into a key: the gem refuses the whole set for a malformed member
293
+ # (jwt 3.3.0 and later skip a member of an unsupported type instead, see below), and unlike an unknown `kid` that refusal
294
+ # never triggers a refetch, so the document counts as a failed refresh instead of replacing the cache.
295
+ unless parsed[:keys].all? { |key| key.is_a?(Hash) }
296
+ raise JWKSFetchError, "JWKS endpoint returned a key that is not a JSON object"
297
+ end
298
+
299
+ key_set = begin
300
+ JWT::JWK::Set.new(parsed)
301
+ rescue JWT::JWKError => e
302
+ raise JWKSFetchError, "JWKS endpoint returned a key set that cannot be loaded: #{e.message}"
303
+ end
304
+
305
+ # jwt 3.3.0 and later skip a member whose key type they do not support instead of refusing the set (RFC 7517 Section 5.1),
306
+ # so a document whose every member is skipped loads as a set that verifies nothing; it counts as a failed refresh
307
+ # the same way an unloadable set does.
308
+ raise JWKSFetchError, "JWKS endpoint returned a key set with no loadable keys" if key_set.keys.empty?
309
+
310
+ parsed
311
+ rescue JSON::ParserError
312
+ raise JWKSFetchError, "JWKS endpoint returned invalid JSON"
313
+ rescue SocketError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError, IOError, Net::HTTPBadResponse, Net::ProtocolError => e
314
+ # A connection-level failure is the same event as an HTTP error from the caller's point of view: the key set could not be refreshed,
315
+ # and `load_jwks` decides whether the cached one still bridges the gap.
316
+ raise JWKSFetchError, "JWKS endpoint could not be fetched: #{e.class}"
317
+ end
318
+
319
+ def monotonic_now
320
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
321
+ end
322
+
323
+ # The `jwt` gem's JWKS handling expects symbol keys; user-supplied static documents commonly arrive as parsed JSON with string keys.
324
+ def deep_symbolize(jwks)
325
+ JSON.parse(JSON.generate(jwks), symbolize_names: true)
326
+ end
327
+ end
328
+ end
329
+ end
330
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "authenticator"
4
+ require_relative "challenge"
5
+ require_relative "errors"
6
+
7
+ module MCP
8
+ class Server
9
+ module OAuth
10
+ # Rack middleware that enforces bearer authentication for everything it wraps, per RFC 6750 and the MCP authorization specification:
11
+ #
12
+ # use MCP::Server::OAuth::Middleware,
13
+ # token_verifier: verifier,
14
+ # required_scopes: ["mcp:tools"],
15
+ # resource_metadata: metadata
16
+ # run transport
17
+ #
18
+ # See `Authenticator` for the option semantics; this class only adapts it to the Rack middleware calling convention.
19
+ # The streamable HTTP transport embeds the same authenticator via its `token_verifier:` option, so use this middleware when composition
20
+ # at the Rack layer is preferable (for example to share one authenticator across several apps).
21
+ #
22
+ # Tokens are accepted from the `Authorization` header only, never from a query string. On success the verified `AccessToken` is stored in
23
+ # `env[MCP::Server::OAuth::ENV_KEY]`, where the streamable HTTP transport picks it up and exposes it to handlers as `server_context.auth_info`.
24
+ #
25
+ # Because the middleware wraps the whole app, POST, GET (SSE), and DELETE requests are protected uniformly. Mount the metadata document outside
26
+ # the protected scope: it is how unauthenticated clients bootstrap. When a browser-based client is involved, run the CORS middleware before
27
+ # this one; otherwise its preflight OPTIONS request dies here with a 401 that the browser will not let the client see.
28
+ class Middleware
29
+ def initialize(app, token_verifier:, required_scopes: [], resource_metadata: nil, resource_metadata_url: nil, scope_matcher: nil)
30
+ @app = app
31
+ @authenticator = Authenticator.new(
32
+ token_verifier: token_verifier,
33
+ required_scopes: required_scopes,
34
+ resource_metadata: resource_metadata,
35
+ resource_metadata_url: resource_metadata_url,
36
+ scope_matcher: scope_matcher,
37
+ )
38
+ end
39
+
40
+ def call(env)
41
+ begin
42
+ env[ENV_KEY] = @authenticator.authenticate(env)
43
+ rescue Error => e
44
+ return @authenticator.challenge_response(e)
45
+ rescue => e
46
+ # A misbehaving verifier (e.g. an unreachable JWKS or introspection endpoint) is an internal failure: report it
47
+ # and answer 500 without a challenge, so the client does not discard a perfectly good token.
48
+ MCP.configuration.exception_reporter.call(e, { middleware: self.class.name })
49
+
50
+ return [500, { "content-type" => "application/json" }, [{ error: "server_error" }.to_json]]
51
+ end
52
+
53
+ # Outside the rescue on purpose: only verification is this middleware's business, and a failure in the wrapped app
54
+ # must reach whatever handles that app's errors, as it does with the Python SDK's bearer middleware.
55
+ @app.call(env)
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+ require "mcp/client/oauth/discovery"
6
+
7
+ module MCP
8
+ class Server
9
+ module OAuth
10
+ # OAuth 2.0 Protected Resource Metadata (RFC 9728) for an MCP server.
11
+ # The MCP authorization spec requires servers to publish this document so clients can discover which authorization servers can
12
+ # issue tokens for the resource.
13
+ #
14
+ # - `resource` - the canonical resource identifier: the URL clients connect to, without a fragment. Clients send this back to
15
+ # the authorization server as the RFC 8707 `resource` parameter.
16
+ # - `authorization_servers` - issuer URLs of the authorization servers that protect this resource (at least one).
17
+ # - `scopes_supported` - an Array of the scope strings clients may request (also the default scope hint in `WWW-Authenticate` challenges);
18
+ # `offline_access` is dropped, and a result with nothing left omits the member.
19
+ # - `bearer_methods_supported` - defaults to `["header"]`: the SDK accepts bearer tokens only in the `Authorization` header,
20
+ # never in a query string.
21
+ # - `extra` - additional RFC 9728 fields (e.g. `jwks_uri`, `resource_signing_alg_values_supported`, `resource_policy_uri`) merged into
22
+ # the document as given; the members above cannot be overridden through it.
23
+ #
24
+ # https://www.rfc-editor.org/rfc/rfc9728
25
+ # https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery
26
+ class ProtectedResourceMetadata
27
+ WELL_KNOWN_PATH_PREFIX = "/.well-known/oauth-protected-resource"
28
+
29
+ DEFAULT_BEARER_METHODS_SUPPORTED = ["header"].freeze
30
+ private_constant :DEFAULT_BEARER_METHODS_SUPPORTED
31
+
32
+ # The members `initialize` validates. `extra` may not name them, or the merge in `to_h` would undo the validation.
33
+ VALIDATED_MEMBERS = ["resource", "authorization_servers", "scopes_supported", "resource_name", "resource_documentation", "bearer_methods_supported"].freeze
34
+ private_constant :VALIDATED_MEMBERS
35
+
36
+ attr_reader :resource, :authorization_servers, :scopes_supported, :resource_name, :resource_documentation, :bearer_methods_supported, :extra
37
+
38
+ def initialize(resource:, authorization_servers:, scopes_supported: nil, resource_name: nil, resource_documentation: nil, bearer_methods_supported: DEFAULT_BEARER_METHODS_SUPPORTED, extra: {})
39
+ @resource_uri = parse_resource_uri(resource)
40
+ @resource = resource
41
+
42
+ servers = Array(authorization_servers)
43
+ raise ArgumentError, "authorization_servers must contain at least one issuer URL" if servers.empty?
44
+
45
+ servers.each do |server|
46
+ next if Client::OAuth::Discovery.secure_url?(server)
47
+
48
+ raise ArgumentError, "authorization_servers must use https (http is allowed only on loopback): #{server.inspect}"
49
+ end
50
+
51
+ overridden = extra.keys.map(&:to_s) & VALIDATED_MEMBERS
52
+ unless overridden.empty?
53
+ raise ArgumentError, "extra must not override #{overridden.join(", ")}; pass them as keyword arguments so they are validated"
54
+ end
55
+
56
+ @authorization_servers = servers
57
+ @scopes_supported = advertisable_scopes(scopes_supported)
58
+ @resource_name = resource_name
59
+ @resource_documentation = resource_documentation
60
+ @bearer_methods_supported = bearer_methods_supported
61
+ @extra = extra
62
+ end
63
+
64
+ def to_h
65
+ {
66
+ resource: resource,
67
+ authorization_servers: authorization_servers,
68
+ scopes_supported: scopes_supported,
69
+ resource_name: resource_name,
70
+ resource_documentation: resource_documentation,
71
+ bearer_methods_supported: bearer_methods_supported,
72
+ }.compact.merge(extra)
73
+ end
74
+
75
+ def to_json(*args)
76
+ to_h.to_json(*args)
77
+ end
78
+
79
+ # The path-inserted well-known path per RFC 9728 Section 3: a resource of `https://example.com/mcp` is described at
80
+ # `/.well-known/oauth-protected-resource/mcp`, and a resource at the origin root is described at `/.well-known/oauth-protected-resource`.
81
+ # The path math intentionally matches what `MCP::Client::OAuth::Discovery.protected_resource_metadata_urls` probes.
82
+ def well_known_path
83
+ path = @resource_uri.path
84
+ path = "" if path == "/"
85
+
86
+ "#{WELL_KNOWN_PATH_PREFIX}#{path}"
87
+ end
88
+
89
+ # The absolute URL of the metadata document. Pass this to `Middleware` (or `Challenge`) as `resource_metadata_url` so 401/403 challenges point
90
+ # clients at the document.
91
+ def well_known_url
92
+ port = @resource_uri.port && @resource_uri.port != @resource_uri.default_port ? ":#{@resource_uri.port}" : ""
93
+
94
+ "#{@resource_uri.scheme}://#{@resource_uri.host}#{port}#{well_known_path}"
95
+ end
96
+
97
+ private
98
+
99
+ # The MCP authorization specification tells protected resources not to advertise `offline_access`: refresh token issuance
100
+ # is the authorization server's business, not a resource requirement. `Authenticator` already drops it from the `scope` parameter of
101
+ # a `WWW-Authenticate` challenge, so dropping it here keeps the document and the challenges saying the same thing.
102
+ # https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization
103
+ # Nothing left to advertise drops the member from the document instead of emitting `[]`, which RFC 9728 tells servers to omit,
104
+ # and a non-Array is refused because a bare String would be ambiguous between one scope and a list.
105
+ def advertisable_scopes(scopes_supported)
106
+ return if scopes_supported.nil?
107
+ raise ArgumentError, "scopes_supported must be an Array of scope strings" unless scopes_supported.is_a?(Array)
108
+
109
+ scopes = scopes_supported.map(&:to_s) - ["offline_access"]
110
+
111
+ scopes.empty? ? nil : scopes
112
+ end
113
+
114
+ def parse_resource_uri(resource)
115
+ uri = begin
116
+ URI.parse(resource.to_s)
117
+ rescue URI::InvalidURIError
118
+ raise ArgumentError, "resource must be a valid URI: #{resource.inspect}"
119
+ end
120
+
121
+ unless ["http", "https"].include?(uri.scheme.to_s.downcase) && uri.host && !uri.host.empty?
122
+ raise ArgumentError, "resource must be an absolute http(s) URL: #{resource.inspect}"
123
+ end
124
+
125
+ if uri.fragment
126
+ raise ArgumentError, "resource must not contain a fragment per RFC 8707: #{resource.inspect}"
127
+ end
128
+
129
+ unless Client::OAuth::Discovery.secure_url?(resource.to_s)
130
+ raise ArgumentError, "resource must use https (http is allowed only on loopback): #{resource.inspect}"
131
+ end
132
+
133
+ uri
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module MCP
6
+ class Server
7
+ module OAuth
8
+ # Rack middleware that serves a Protected Resource Metadata document (RFC 9728) at its well-known path,
9
+ # with the CORS headers browser-based MCP clients need, and passes every other request down the stack.
10
+ # The Rack shape of the Python SDK's `create_protected_resource_routes` and the TypeScript SDK's `mcpAuthMetadataRouter`.
11
+ #
12
+ # Use it at the top of the stack: the well-known path lives at the origin root, not under the MCP endpoint
13
+ # (`/.well-known/oauth-protected-resource/mcp` for a resource at `/mcp`), so inside a `map` block it would never see that path.
14
+ # The metadata is how unauthenticated clients bootstrap, so it belongs above any bearer enforcement.
15
+ #
16
+ # metadata = MCP::Server::OAuth::ProtectedResourceMetadata.new(...)
17
+ # use MCP::Server::OAuth::ProtectedResourceMetadataMiddleware, metadata
18
+ # map("/mcp") { run(transport) }
19
+ class ProtectedResourceMetadataMiddleware
20
+ ALLOWED_METHODS = "GET, HEAD, OPTIONS"
21
+ private_constant :ALLOWED_METHODS
22
+
23
+ # @param app [#call] the rest of the Rack stack
24
+ # @param metadata [ProtectedResourceMetadata] the document to serve, which also names the path it is served at
25
+ def initialize(app, metadata)
26
+ # The document is published as `to_json`, so only the class whose serialization is a validated RFC 9728 document will do.
27
+ unless metadata.is_a?(ProtectedResourceMetadata)
28
+ raise ArgumentError, "metadata must be a ProtectedResourceMetadata (got #{metadata.class})"
29
+ end
30
+
31
+ @app = app
32
+ @well_known_path = metadata.well_known_path
33
+ @metadata_json = metadata.to_json
34
+ end
35
+
36
+ def call(env)
37
+ # An exact match only: a deeper well-known path describes another resource on this host (RFC 9728),
38
+ # and every other path is the application's.
39
+ return @app.call(env) unless env["PATH_INFO"] == @well_known_path
40
+
41
+ case env["REQUEST_METHOD"]
42
+ when "GET"
43
+ metadata_response
44
+ when "HEAD"
45
+ metadata_response(head: true)
46
+ when "OPTIONS"
47
+ preflight_response
48
+ else
49
+ method_not_allowed_response
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def metadata_response(head: false)
56
+ headers = {
57
+ "content-type" => "application/json",
58
+ "cache-control" => "public, max-age=3600",
59
+ "access-control-allow-origin" => "*",
60
+ }
61
+
62
+ [200, headers, head ? [] : [@metadata_json]]
63
+ end
64
+
65
+ def preflight_response
66
+ headers = {
67
+ "access-control-allow-origin" => "*",
68
+ "access-control-allow-methods" => ALLOWED_METHODS,
69
+ "access-control-allow-headers" => "*",
70
+ }
71
+
72
+ [204, headers, []]
73
+ end
74
+
75
+ def method_not_allowed_response
76
+ headers = {
77
+ "content-type" => "application/json",
78
+ "allow" => ALLOWED_METHODS,
79
+ }
80
+
81
+ [405, headers, [{ error: "method_not_allowed" }.to_json]]
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end