mcp 1.6.1 → 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.
@@ -20,6 +20,8 @@ module MCP
20
20
  # request. Must be one of `redirect_uris` in `client_metadata`.
21
21
  # - `redirect_handler` - Callable invoked with the fully-built authorization
22
22
  # URL (a `URI`). Implementations typically open the user's browser.
23
+ #
24
+ # Optional keyword arguments:
23
25
  # - `callback_handler` - Callable invoked after `redirect_handler`. Returns
24
26
  # `[code, state]` or `[code, state, iss]`, where `code` is the authorization code,
25
27
  # `state` is the `state` parameter received on the redirect URI, and `iss` is
@@ -28,16 +30,27 @@ module MCP
28
30
  # must match the authorization server's issuer, and a nil `iss` is
29
31
  # rejected when the AS advertises `authorization_response_iss_parameter_supported`.
30
32
  # The 2-element form skips the check for backward compatibility.
31
- #
32
- # Optional keyword arguments:
33
+ # Omit it when the redirect arrives in a later request, as it does in a web application:
34
+ # the flow then stops after `redirect_handler` with a pending authorization saved in `storage`,
35
+ # and the request that receives the redirect finishes it with `Flow#finish!`.
36
+ # - `pending_authorization_max_age` - Seconds a pending authorization stays redeemable, counted from the moment
37
+ # `run!` saves it, when `callback_handler` is omitted. Defaults to `DEFAULT_PENDING_AUTHORIZATION_MAX_AGE`.
33
38
  # - `scope` - String of space-separated scopes to request when the server's
34
39
  # `WWW-Authenticate` does not specify one.
40
+ # - `scope_selector` - Callable receiving a read-only Array of PRM `scopes_supported` tokens when
41
+ # those defaults are selected without a challenged scope. Return an Array containing a subset;
42
+ # `[]` requests none of the PRM defaults. Challenged scopes and provider fallback bypass it.
43
+ # The existing `offline_access` policy runs afterward; endpoint query parameters are unchanged.
35
44
  # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
36
45
  # `client_information`, and `save_client_information(info)`. Defaults to
37
46
  # an `InMemoryStorage`. Persisted `client_information` is stamped with
38
47
  # an `"issuer"` member binding it to the authorization server that
39
48
  # issued it (SEP-2352); when the authorization server changes, the SDK discards
40
49
  # the stale registration and tokens and re-registers.
50
+ # Without `callback_handler`, it must also respond to `save_pending_authorization(state, pending)`,
51
+ # `pending_authorization(state)`, and `delete_pending_authorization(state)`; the last must remove the entry
52
+ # and return it atomically, returning `nil` when there was none, and the storage should expire entries
53
+ # older than `pending_authorization_max_age`, as `InMemoryStorage` does.
41
54
  # - `client_id_metadata_document_url` - URL where the client publishes its Client ID Metadata Document
42
55
  # (`draft-ietf-oauth-client-id-metadata-document-00` and the MCP authorization specification).
43
56
  # When the authorization server advertises `client_id_metadata_document_supported: true`,
@@ -78,25 +91,47 @@ module MCP
78
91
  # applies and the value must unambiguously identify the document.
79
92
  class InvalidClientIDMetadataDocumentURLError < ArgumentError; end
80
93
 
94
+ # Raised when `Provider#initialize` is called without `callback_handler` and with a `storage`
95
+ # that cannot hold a pending authorization between the request that sends the user to the authorization server
96
+ # and the request that receives the redirect.
97
+ class PendingAuthorizationStorageError < ArgumentError; end
98
+
99
+ # Seconds a pending authorization stays redeemable, counted from the moment `run!` saves it, when the provider has
100
+ # no `callback_handler`; long enough for a user to sign in and consent at the authorization server. It is also
101
+ # the age past which `InMemoryStorage` drops an authorization that was never finished, at its next save, so that
102
+ # an abandoned one does not keep its PKCE verifier in memory for the life of the process; a custom storage should
103
+ # expire entries at the same age.
104
+ DEFAULT_PENDING_AUTHORIZATION_MAX_AGE = 600
105
+
106
+ PENDING_AUTHORIZATION_STORAGE_METHODS = [
107
+ :save_pending_authorization,
108
+ :pending_authorization,
109
+ :delete_pending_authorization,
110
+ ].freeze
111
+
81
112
  attr_reader :client_metadata,
82
113
  :redirect_uri,
83
114
  :scope,
115
+ :scope_selector,
84
116
  :storage,
85
117
  :redirect_handler,
86
118
  :callback_handler,
87
- :client_id_metadata_document_url
119
+ :client_id_metadata_document_url,
120
+ :pending_authorization_max_age
88
121
 
89
122
  def initialize(
90
123
  client_metadata:,
91
124
  redirect_uri:,
92
125
  redirect_handler:,
93
- callback_handler:,
126
+ callback_handler: nil,
94
127
  scope: nil,
128
+ scope_selector: nil,
95
129
  storage: nil,
96
130
  client_id_metadata_document_url: nil,
97
131
  authorization_request_validator: nil,
98
132
  token_request_params: nil,
99
- http_client_customizer: nil
133
+ http_client_customizer: nil,
134
+ pending_authorization_max_age: DEFAULT_PENDING_AUTHORIZATION_MAX_AGE
100
135
  )
101
136
  unless Discovery.secure_url?(redirect_uri)
102
137
  raise InsecureRedirectURIError,
@@ -118,18 +153,40 @@ module MCP
118
153
  "per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`."
119
154
  end
120
155
 
156
+ unless scope_selector.nil? || scope_selector.respond_to?(:call)
157
+ raise ArgumentError, "scope_selector must respond to call (got #{scope_selector.class})."
158
+ end
159
+
121
160
  http_client_customizer = validated_http_client_customizer(http_client_customizer)
122
161
 
162
+ unless pending_authorization_max_age.is_a?(Integer) && pending_authorization_max_age.positive?
163
+ raise ArgumentError, "pending_authorization_max_age must be a positive Integer number of seconds (got #{pending_authorization_max_age.inspect})."
164
+ end
165
+
166
+ # The default storage drops abandoned pending authorizations at the same age the flow stops redeeming them.
167
+ storage ||= InMemoryStorage.new(pending_authorization_max_age: pending_authorization_max_age)
168
+
169
+ if callback_handler.nil?
170
+ missing = PENDING_AUTHORIZATION_STORAGE_METHODS.reject { |method| storage.respond_to?(method) }
171
+ unless missing.empty?
172
+ raise PendingAuthorizationStorageError,
173
+ "Without a callback_handler the authorization finishes in a later request, so storage must also respond to " \
174
+ "#{missing.join(", ")} (#{storage.class} does not)."
175
+ end
176
+ end
177
+
123
178
  @client_metadata = client_metadata
124
179
  @redirect_uri = redirect_uri
125
180
  @redirect_handler = redirect_handler
126
181
  @callback_handler = callback_handler
127
182
  @scope = scope
128
- @storage = storage || InMemoryStorage.new
183
+ @scope_selector = scope_selector
184
+ @storage = storage
129
185
  @client_id_metadata_document_url = client_id_metadata_document_url
130
186
  @authorization_request_validator = authorization_request_validator
131
187
  @token_request_params = frozen_token_request_params(token_request_params)
132
188
  @http_client_customizer = http_client_customizer
189
+ @pending_authorization_max_age = pending_authorization_max_age
133
190
  end
134
191
 
135
192
  # Identifies the OAuth flow this provider drives.
@@ -138,6 +195,20 @@ module MCP
138
195
  def authorization_flow
139
196
  :authorization_code
140
197
  end
198
+
199
+ def save_pending_authorization(state, pending)
200
+ @storage.save_pending_authorization(state, pending)
201
+ end
202
+
203
+ def pending_authorization(state)
204
+ @storage.pending_authorization(state)
205
+ end
206
+
207
+ # Returns the entry the storage removed, or `nil`. `Flow#finish!` redeems the code only when it gets
208
+ # the entry back, so of two callbacks racing on the same `state`, only one can.
209
+ def delete_pending_authorization(state)
210
+ @storage.delete_pending_authorization(state)
211
+ end
141
212
  end
142
213
  end
143
214
  end
@@ -442,17 +442,22 @@ module MCP
442
442
  # other server-to-client requests over stdio stay unsupported as documented,
443
443
  # and notifications carry no id.
444
444
  if parsed.is_a?(Hash) && parsed.key?("method")
445
- # A JSON-RPC id is a String or a Number; the reference SDKs reject other shapes at
446
- # schema validation, so a ping carrying one is skipped rather than echoed back.
447
- answer_ping(parsed) if parsed["method"] == MCP::Methods::PING && json_rpc_id?(parsed["id"])
445
+ # A JSON-RPC id is a String or a Number, and `jsonrpc` is "2.0"; the reference SDKs reject
446
+ # other shapes at schema validation, so a ping carrying one is skipped rather than echoed back.
447
+ answer_ping(parsed) if answerable_ping?(parsed)
448
448
  next
449
449
  end
450
450
 
451
451
  # A JSON-RPC message is an object; skip a non-object frame (array or scalar)
452
452
  # the same way as a frame without an id.
453
453
  next unless parsed.is_a?(Hash) && parsed.key?("id")
454
+ next unless parsed["id"] == request_id
454
455
 
455
- return parsed if parsed["id"] == request_id
456
+ # Nothing else will answer the awaited request, so a response that is not JSON-RPC 2.0 fails it
457
+ # instead of being skipped: without a `read_timeout`, skipping would wait forever.
458
+ raise_invalid_response!(method, params) unless JsonRpcHandler.valid_version?(parsed["jsonrpc"])
459
+
460
+ return parsed
456
461
  end
457
462
  rescue JSON::ParserError => e
458
463
  raise RequestHandlerError.new(
@@ -475,6 +480,10 @@ module MCP
475
480
  # surface as an error of the unrelated request whose response the loop is reading.
476
481
  end
477
482
 
483
+ def answerable_ping?(parsed)
484
+ parsed["method"] == MCP::Methods::PING && json_rpc_id?(parsed["id"]) && JsonRpcHandler.valid_version?(parsed["jsonrpc"])
485
+ end
486
+
478
487
  def json_rpc_id?(id)
479
488
  id.is_a?(String) || id.is_a?(Numeric)
480
489
  end
@@ -532,6 +541,16 @@ module MCP
532
541
  error_type: :internal_error,
533
542
  )
534
543
  end
544
+
545
+ # The line framing is intact, so the transport stays usable; the received value is left out of
546
+ # the message because it can be any JSON value.
547
+ def raise_invalid_response!(method, params)
548
+ raise RequestHandlerError.new(
549
+ 'Server response is not a valid JSON-RPC 2.0 message: "jsonrpc" must be "2.0"',
550
+ { method: method, params: params },
551
+ error_type: :internal_error,
552
+ )
553
+ end
535
554
  end
536
555
  end
537
556
  end
data/lib/mcp/icon.rb CHANGED
@@ -9,9 +9,70 @@ module MCP
9
9
  # is not judged: `src` may be any non-empty `String` (the schema types it as a URI, which an empty `String`
10
10
  # is not, and the specification allows an HTTP/HTTPS URL or a `data:` URI), and a size may be any `String`
11
11
  # (the specification expects `WxH` or `"any"`).
12
+ #
13
+ # Wherever an `Icon` is accepted (`Server.new(icons:)` and the `icons` of a tool, prompt, resource, or resource template),
14
+ # a Hash is accepted too and converted through `Icon.from`, so it is checked the same way.
12
15
  class Icon
13
16
  SUPPORTED_THEMES = ["light", "dark"].freeze
14
17
 
18
+ # The keys `from` accepts in a Hash: the keyword names of `new` and the wire name `mimeType`,
19
+ # as Symbols or Strings.
20
+ HASH_KEYWORDS = {
21
+ "src" => :src,
22
+ "mimeType" => :mime_type,
23
+ "mime_type" => :mime_type,
24
+ "sizes" => :sizes,
25
+ "theme" => :theme,
26
+ }.freeze
27
+ private_constant :HASH_KEYWORDS
28
+
29
+ class << self
30
+ # Returns `value` when it is already an `Icon`, builds one from a Hash, and refuses anything else.
31
+ def from(value)
32
+ return value if value.is_a?(Icon)
33
+
34
+ unless value.is_a?(Hash)
35
+ raise ArgumentError, "An icon must be an MCP::Icon or a Hash (got #{value.class})."
36
+ end
37
+
38
+ keywords = {}
39
+ value.each do |key, member|
40
+ unless key.is_a?(Symbol) || key.is_a?(String)
41
+ raise ArgumentError, "An icon Hash key must be a Symbol or a String (got #{key.class})."
42
+ end
43
+
44
+ keyword = HASH_KEYWORDS[key.to_s]
45
+ unless keyword
46
+ raise ArgumentError, "An icon Hash may only hold src, mimeType (or mime_type), sizes, and theme (got #{key.inspect})."
47
+ end
48
+ raise ArgumentError, "An icon Hash gives #{keyword} twice." if keywords.key?(keyword)
49
+
50
+ keywords[keyword] = member
51
+ end
52
+
53
+ new(**keywords)
54
+ end
55
+
56
+ # Converts the `icons` argument of a server, tool, prompt, resource, or resource template: `nil` stays `nil`,
57
+ # each element of an Array goes through `from` into a frozen Array, and anything else is refused.
58
+ # The frozen Array keeps a later `<<` on a reader from adding an element these checks never saw.
59
+ def from_list(value)
60
+ return if value.nil?
61
+
62
+ unless value.is_a?(Array)
63
+ raise ArgumentError, "icons must be nil or an Array of MCP::Icon or Hash (got #{value.class})."
64
+ end
65
+
66
+ icons = value.each_with_index.map do |icon, index|
67
+ from(icon)
68
+ rescue ArgumentError => e
69
+ raise ArgumentError, "icons[#{index}]: #{e.message}"
70
+ end
71
+
72
+ icons.freeze
73
+ end
74
+ end
75
+
15
76
  attr_reader :mime_type, :sizes, :src, :theme
16
77
 
17
78
  def initialize(mime_type: nil, sizes: nil, src:, theme: nil)
data/lib/mcp/prompt.rb CHANGED
@@ -72,7 +72,7 @@ module MCP
72
72
  if value == NOT_SET
73
73
  @icons_value
74
74
  else
75
- @icons_value = value
75
+ @icons_value = Icon.from_list(value)
76
76
  end
77
77
  end
78
78
 
data/lib/mcp/resource.rb CHANGED
@@ -88,7 +88,7 @@ module MCP
88
88
  if value == NOT_SET
89
89
  @icons_value
90
90
  else
91
- @icons_value = value
91
+ @icons_value = Icon.from_list(value)
92
92
  end
93
93
  end
94
94
 
@@ -147,7 +147,7 @@ module MCP
147
147
  @name = name
148
148
  @title = title
149
149
  @description = description
150
- @icons = icons
150
+ @icons = Icon.from_list(icons)
151
151
  @mime_type = mime_type
152
152
  @annotations = annotations
153
153
  @size = size
@@ -89,7 +89,7 @@ module MCP
89
89
  if value == NOT_SET
90
90
  @icons_value
91
91
  else
92
- @icons_value = value
92
+ @icons_value = Icon.from_list(value)
93
93
  end
94
94
  end
95
95
 
@@ -158,7 +158,7 @@ module MCP
158
158
  @name = name
159
159
  @title = title
160
160
  @description = description
161
- @icons = icons
161
+ @icons = Icon.from_list(icons)
162
162
  @mime_type = mime_type
163
163
  @annotations = annotations
164
164
  @meta = meta
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Server
5
+ module OAuth
6
+ # The result of a successful token verification: the resource-server-side view of an access token.
7
+ # Instances are produced by a `TokenVerifier` and reach tool/prompt/resource handlers as `server_context.auth_info`.
8
+ #
9
+ # The field set is the union of what the official SDKs converged on (Python `AccessToken` and TypeScript `AuthInfo`):
10
+ #
11
+ # - `token` - the raw bearer token as presented by the client
12
+ # - `client_id` - the OAuth client the token was issued to
13
+ # - `scopes` - granted scopes as an array of strings
14
+ # - `expires_at` - expiry as an Integer unix timestamp, or nil when the token does not expire
15
+ # - `subject` - the end user (`sub` claim), when known
16
+ # - `issuer` - the authorization server that issued the token (`iss` claim)
17
+ # - `audience` - the raw `aud` value (String or Array), when known
18
+ # - `resource` - the canonical RFC 8707 resource identifier the verifier matched the token against,
19
+ # useful for tenancy checks inside handlers
20
+ # - `claims` - the full claim/introspection-response Hash for anything not covered by the named fields
21
+ class AccessToken
22
+ attr_reader :token, :client_id, :scopes, :expires_at, :subject, :issuer, :audience, :resource, :claims
23
+
24
+ def initialize(token:, client_id: nil, scopes: [], expires_at: nil, subject: nil, issuer: nil, audience: nil, resource: nil, claims: {})
25
+ @token = token
26
+ @client_id = client_id
27
+ @scopes = scopes
28
+ @expires_at = expires_at
29
+ @subject = subject
30
+ @issuer = issuer
31
+ @audience = audience
32
+ @resource = resource
33
+ @claims = claims
34
+ @scope_matcher = nil
35
+ end
36
+
37
+ # Per RFC 7519 Section 4.1.4, a token must not be accepted on or after its expiry time.
38
+ # Tokens without an expiry never count as expired here; verifiers that require an expiry must enforce that themselves
39
+ # (`JWTVerifier` does, the TypeScript SDK rejects such tokens outright, and Python accepts them like this class —
40
+ # the divergence is deliberate: opaque-token verifiers may have no expiry to report even though the authorization server enforces one).
41
+ def expired?(now: Time.now.to_i)
42
+ return false if expires_at.nil?
43
+
44
+ expires_at <= now
45
+ end
46
+
47
+ # Whether the token grants `scope`: exact membership by default. When the authenticator attached its `scope_matcher:`
48
+ # (see `with_scope_matcher`), that callable decides instead, so a hierarchical scope scheme is honored the same way
49
+ # at the endpoint gate and inside handlers via `require_scopes!`.
50
+ def scope?(scope)
51
+ return scopes.include?(scope.to_s) if @scope_matcher.nil?
52
+
53
+ !!@scope_matcher.call(scope.to_s, scopes)
54
+ end
55
+
56
+ # A copy of this token whose `scope?` consults `matcher`, a `(required_scope, granted_scopes) -> Boolean` callable.
57
+ # The receiver is left untouched; a nil matcher returns the receiver itself.
58
+ def with_scope_matcher(matcher)
59
+ return self if matcher.nil?
60
+
61
+ dup.tap { |copy| copy.scope_matcher = matcher }
62
+ end
63
+
64
+ # Mirrors `to_h`: the default `inspect` would print `@token`, leaking the credential into exception reports and debug output.
65
+ def inspect
66
+ "#<#{self.class.name} #{to_h.inspect}>"
67
+ end
68
+
69
+ # Omits `token` so the result is safe to log without leaking the credential.
70
+ def to_h
71
+ {
72
+ client_id: client_id,
73
+ scopes: scopes,
74
+ expires_at: expires_at,
75
+ subject: subject,
76
+ issuer: issuer,
77
+ audience: audience,
78
+ resource: resource,
79
+ claims: claims,
80
+ }.compact
81
+ end
82
+
83
+ protected
84
+
85
+ attr_writer :scope_matcher
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "access_token"
4
+ require_relative "challenge"
5
+ require_relative "errors"
6
+
7
+ module MCP
8
+ class Server
9
+ module OAuth
10
+ # The verification core shared by `Middleware` and the streamable HTTP transport's built-in enforcement:
11
+ # extracts the bearer token from a Rack env, runs it through the verifier, checks scopes, and maps each failure
12
+ # to the RFC 6750 challenge response it deserves.
13
+ #
14
+ # - `token_verifier` - anything responding to `verify(token)`; see `TokenVerifier` for the contract.
15
+ # Built-ins: `JWTVerifier`, `IntrospectionVerifier`.
16
+ # - `required_scopes` - scopes the token must include, all of them; a shortfall produces 403 `insufficient_scope` (step-up).
17
+ # - `resource_metadata` - a `ProtectedResourceMetadata`; its well-known URL and `scopes_supported` feed the challenges.
18
+ # Pass `resource_metadata_url:` instead to point at a document served elsewhere.
19
+ # - `scope_matcher` - optional callable `(required_scope, granted_scopes) -> Boolean` for deployments
20
+ # whose scopes form a hierarchy; the MCP authorization specification requires a broader scope to satisfy
21
+ # the narrower scopes it implies, which exact membership (the default) cannot express. The matcher also rides
22
+ # on the returned `AccessToken`, so `require_scopes!` and `scope?` inside handlers apply the same semantics.
23
+ class Authenticator
24
+ # RFC 6750 Section 2.1: the scheme and the token are separated by spaces (`1*SP`), never by other whitespace.
25
+ BEARER_PATTERN = /\ABearer +(?<token>\S+)\z/i.freeze
26
+ # Whether the header names the Bearer scheme at all, well-formed or not. `Bearer` followed by anything other
27
+ # than a space names no Bearer credentials, the way the Python and TypeScript servers read it.
28
+ BEARER_SCHEME_PATTERN = /\ABearer(?: |\z)/i.freeze
29
+ private_constant :BEARER_PATTERN, :BEARER_SCHEME_PATTERN
30
+
31
+ # Upper bound on the presented token, applied before any verification so oversized garbage neither reaches
32
+ # cryptographic parsing nor gets forwarded to an introspection endpoint. Real-world access tokens stay far below this;
33
+ # web servers cap the whole header section around 16 KiB.
34
+ MAX_TOKEN_BYTES = 8192
35
+
36
+ def initialize(token_verifier:, required_scopes: [], resource_metadata: nil, resource_metadata_url: nil, scope_matcher: nil)
37
+ @verifier = token_verifier
38
+ @required_scopes = Array(required_scopes)
39
+ @resource_metadata = resource_metadata
40
+ @resource_metadata_url = resource_metadata_url || resource_metadata&.well_known_url
41
+ @scope_matcher = scope_matcher
42
+ end
43
+
44
+ # @param env [Hash] the Rack env
45
+ # @return [AccessToken]
46
+ # @raise [MissingTokenError, InvalidRequestError, InvalidTokenError, InsufficientScopeError]
47
+ def authenticate(env)
48
+ token = extract_bearer_token(env)
49
+
50
+ access_token = @verifier.verify(token)
51
+ raise InvalidTokenError if access_token.nil?
52
+
53
+ access_token = attach_scope_matcher(access_token)
54
+ check_scopes!(access_token)
55
+ access_token
56
+ end
57
+
58
+ # Maps an `OAuth::Error` raised by `authenticate` (or by a handler's `require_scopes!`) to the Rack response carrying
59
+ # the matching challenge. An `Error` outside the four built-in classes, such as one a custom verifier defines,
60
+ # is a token rejection and answers 401 like an invalid token; anything else is a programming error.
61
+ def challenge_response(error)
62
+ case error
63
+ when MissingTokenError
64
+ Challenge.missing_token_response(scope: scope_hint, resource_metadata: @resource_metadata_url)
65
+ when InvalidRequestError
66
+ Challenge.invalid_request_response(error_description: error.message, resource_metadata: @resource_metadata_url)
67
+ when InsufficientScopeError
68
+ Challenge.insufficient_scope_response(
69
+ error_description: error.message,
70
+ scope: scope_hint(error.required_scopes),
71
+ resource_metadata: @resource_metadata_url,
72
+ )
73
+ when Error
74
+ Challenge.invalid_token_response(
75
+ error_description: error.message,
76
+ scope: scope_hint,
77
+ resource_metadata: @resource_metadata_url,
78
+ )
79
+ else
80
+ raise ArgumentError, "unexpected error class: #{error.class}"
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ def extract_bearer_token(env)
87
+ header = env["HTTP_AUTHORIZATION"]
88
+ raise MissingTokenError if header.nil? || header.empty?
89
+
90
+ # Matched as bytes: a Rack server hands the header over as ASCII-8BIT, but a test request or a framework can tag it UTF-8,
91
+ # and a regexp raises on an invalid byte sequence in a UTF-8 string. As bytes, such a token simply fails verification.
92
+ bytes = header.b
93
+
94
+ # A header naming another scheme carries no Bearer credentials: RFC 6750 Section 3.1 counts a request that
95
+ # "attempted using an unsupported authentication method" among those lacking authentication information,
96
+ # which get the bare challenge, and the Python and TypeScript servers answer it 401 as well.
97
+ # A header that names Bearer but is malformed is a bad request (`invalid_request` in the same section).
98
+ raise MissingTokenError unless BEARER_SCHEME_PATTERN.match?(bytes)
99
+
100
+ match = BEARER_PATTERN.match(bytes)
101
+ raise InvalidRequestError, "Authorization header must carry a single Bearer token" if match.nil?
102
+
103
+ token = match[:token]
104
+ raise InvalidTokenError, "Token exceeds the maximum accepted length" if token.bytesize > MAX_TOKEN_BYTES
105
+
106
+ token
107
+ end
108
+
109
+ def check_scopes!(access_token)
110
+ missing_scopes = @required_scopes.reject { |scope| scope_satisfied?(scope, access_token) }
111
+ return if missing_scopes.empty?
112
+
113
+ raise InsufficientScopeError.new(
114
+ "Token is missing required scopes: #{missing_scopes.join(", ")}",
115
+ required_scopes: @required_scopes,
116
+ )
117
+ end
118
+
119
+ # A verifier result that is not an `AccessToken` (a duck-typed object from a custom verifier) keeps its own `scope?`;
120
+ # the endpoint gate below still applies the matcher to it directly.
121
+ def attach_scope_matcher(access_token)
122
+ return access_token if @scope_matcher.nil? || !access_token.respond_to?(:with_scope_matcher)
123
+
124
+ access_token.with_scope_matcher(@scope_matcher)
125
+ end
126
+
127
+ # A verifier result without a `scopes` reader (a duck-typed object offering only `scope?`) keeps its own judgement,
128
+ # since the matcher needs the granted list to reason about a hierarchy.
129
+ def scope_satisfied?(scope, access_token)
130
+ return access_token.scope?(scope) if @scope_matcher.nil? || !access_token.respond_to?(:scopes)
131
+
132
+ @scope_matcher.call(scope, access_token.scopes)
133
+ end
134
+
135
+ # The scope hint the spec recommends including in challenges: the scopes of the failing operation when known,
136
+ # the scopes this authenticator enforces otherwise, or the resource's advertised scopes as a fallback.
137
+ # `offline_access` is never advertised: refresh token issuance is not a resource requirement per
138
+ # the MCP authorization specification.
139
+ def scope_hint(operation_scopes = [])
140
+ scopes = operation_scopes
141
+ scopes = @required_scopes if scopes.empty?
142
+ scopes = Array(@resource_metadata&.scopes_supported) if scopes.empty?
143
+ scopes -= ["offline_access"]
144
+
145
+ scopes.empty? ? nil : scopes.join(" ")
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end