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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/mcp/client/http.rb +94 -17
- data/lib/mcp/client/oauth/flow.rb +68 -21
- data/lib/mcp/client/oauth/provider.rb +11 -0
- data/lib/mcp/client/stdio.rb +23 -4
- data/lib/mcp/server/oauth/access_token.rb +89 -0
- data/lib/mcp/server/oauth/authenticator.rb +150 -0
- data/lib/mcp/server/oauth/challenge.rb +114 -0
- data/lib/mcp/server/oauth/errors.rb +58 -0
- data/lib/mcp/server/oauth/introspection_verifier.rb +151 -0
- data/lib/mcp/server/oauth/jwt_verifier.rb +330 -0
- data/lib/mcp/server/oauth/middleware.rb +60 -0
- data/lib/mcp/server/oauth/protected_resource_metadata.rb +138 -0
- data/lib/mcp/server/oauth/protected_resource_metadata_middleware.rb +86 -0
- data/lib/mcp/server/oauth/token_verifier.rb +135 -0
- data/lib/mcp/server/oauth.rb +29 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +488 -120
- data/lib/mcp/server.rb +55 -28
- data/lib/mcp/server_context.rb +26 -2
- data/lib/mcp/server_session.rb +14 -4
- data/lib/mcp/version.rb +1 -1
- metadata +13 -2
|
@@ -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
|