ruby-mcp-client 2.1.0 → 3.0.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.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/OAUTH.md +555 -0
  3. data/README.md +825 -48
  4. data/lib/mcp_client/audio_content.rb +1 -1
  5. data/lib/mcp_client/auth/browser_oauth.rb +131 -21
  6. data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
  7. data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
  8. data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
  9. data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
  10. data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
  11. data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
  12. data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
  13. data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
  14. data/lib/mcp_client/auth/peer_text.rb +174 -0
  15. data/lib/mcp_client/auth.rb +298 -32
  16. data/lib/mcp_client/cached_result.rb +145 -0
  17. data/lib/mcp_client/called_tool_definition.rb +138 -0
  18. data/lib/mcp_client/client/cache_slices.rb +195 -0
  19. data/lib/mcp_client/client/list_aggregation.rb +243 -0
  20. data/lib/mcp_client/client/notification_routing.rb +155 -0
  21. data/lib/mcp_client/client/sampling_validation.rb +200 -0
  22. data/lib/mcp_client/client/task_api.rb +531 -0
  23. data/lib/mcp_client/client/task_lifetimes.rb +269 -0
  24. data/lib/mcp_client/client/task_registry.rb +254 -0
  25. data/lib/mcp_client/client/task_shape.rb +102 -0
  26. data/lib/mcp_client/client/task_support.rb +1166 -0
  27. data/lib/mcp_client/client/task_updates.rb +457 -0
  28. data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
  29. data/lib/mcp_client/client/task_workers.rb +63 -0
  30. data/lib/mcp_client/client.rb +796 -518
  31. data/lib/mcp_client/deep_copy.rb +49 -0
  32. data/lib/mcp_client/deprecation_notices.rb +94 -0
  33. data/lib/mcp_client/deprecations.rb +419 -0
  34. data/lib/mcp_client/errors.rb +474 -7
  35. data/lib/mcp_client/header_params.rb +320 -0
  36. data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
  37. data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
  38. data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
  39. data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
  40. data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
  41. data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
  42. data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
  43. data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
  44. data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
  45. data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
  46. data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
  47. data/lib/mcp_client/http_transport_base.rb +666 -120
  48. data/lib/mcp_client/input_round_trips.rb +128 -0
  49. data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
  50. data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
  51. data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
  52. data/lib/mcp_client/json_rpc_common.rb +900 -13
  53. data/lib/mcp_client/oauth_client.rb +14 -5
  54. data/lib/mcp_client/prompt.rb +4 -0
  55. data/lib/mcp_client/request_authorization.rb +128 -0
  56. data/lib/mcp_client/request_meta_scope.rb +77 -0
  57. data/lib/mcp_client/request_metadata.rb +287 -0
  58. data/lib/mcp_client/resource.rb +4 -0
  59. data/lib/mcp_client/resource_content.rb +20 -0
  60. data/lib/mcp_client/resource_template.rb +4 -0
  61. data/lib/mcp_client/result_caching.rb +999 -0
  62. data/lib/mcp_client/result_completeness.rb +34 -0
  63. data/lib/mcp_client/root.rb +6 -0
  64. data/lib/mcp_client/round_trip_marker.rb +28 -0
  65. data/lib/mcp_client/schema_validator/annotations.rb +82 -0
  66. data/lib/mcp_client/schema_validator/composition.rb +86 -0
  67. data/lib/mcp_client/schema_validator/dialects.rb +66 -0
  68. data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
  69. data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
  70. data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
  71. data/lib/mcp_client/schema_validator/instances.rb +449 -0
  72. data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
  73. data/lib/mcp_client/schema_validator/normalization.rb +104 -0
  74. data/lib/mcp_client/schema_validator/references.rb +610 -0
  75. data/lib/mcp_client/schema_validator/scalars.rb +126 -0
  76. data/lib/mcp_client/schema_validator/shapes.rb +319 -0
  77. data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
  78. data/lib/mcp_client/schema_validator.rb +882 -208
  79. data/lib/mcp_client/server_base.rb +233 -5
  80. data/lib/mcp_client/server_factory.rb +9 -3
  81. data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
  82. data/lib/mcp_client/server_http.rb +307 -90
  83. data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
  84. data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
  85. data/lib/mcp_client/server_sse.rb +227 -62
  86. data/lib/mcp_client/server_stdio/child_session.rb +98 -0
  87. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
  88. data/lib/mcp_client/server_stdio.rb +772 -183
  89. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
  90. data/lib/mcp_client/server_streamable_http.rb +302 -115
  91. data/lib/mcp_client/session_pin.rb +119 -0
  92. data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
  93. data/lib/mcp_client/subscription.rb +852 -0
  94. data/lib/mcp_client/subscription_support.rb +715 -0
  95. data/lib/mcp_client/task.rb +286 -14
  96. data/lib/mcp_client/tool.rb +31 -3
  97. data/lib/mcp_client/version.rb +21 -6
  98. data/lib/mcp_client.rb +108 -19
  99. metadata +68 -2
@@ -0,0 +1,441 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module MCPClient
6
+ module Auth
7
+ class OAuthProvider
8
+ # Type checks for the four peer-controlled JSON documents a flow reads:
9
+ # the token response of RFC 6749 Section 5.1, the client registration
10
+ # response of RFC 7591 Section 3.2.1 (whose client metadata fields keep
11
+ # the types RFC 7591 Section 2 gives them), the protected resource
12
+ # metadata of RFC 9728 Section 2 and the authorization server metadata
13
+ # of RFC 8414 Section 2.
14
+ #
15
+ # Every document is read field by field and every field ends up
16
+ # somewhere that assumes its RFC type: `token_type` is capitalized into
17
+ # an `Authorization` header, `expires_in` is added to a `Time`,
18
+ # `redirect_uris` is asked for its first element,
19
+ # `client_secret_expires_at` is compared with a Unix timestamp,
20
+ # `scopes_supported` is joined into a scope parameter and
21
+ # `code_challenge_methods_supported` is asked whether it includes
22
+ # "S256". A peer that answers with the right names and the wrong JSON
23
+ # types would therefore crash the client with a `NoMethodError` or a
24
+ # `TypeError` deep inside the flow — sometimes only after a still-valid
25
+ # token had been overwritten — or, worse, be believed: a
26
+ # `code_challenge_methods_supported` of `"S256 plain"` is a String, and
27
+ # a String answers `include?("S256")` with true, so a server that
28
+ # supports no PKCE at all would read as one that does. A document whose
29
+ # fields are not of their RFC types is a protocol error and is refused
30
+ # as a whole, exactly as a token response without an access token is:
31
+ # no partial acceptance, no coercion of whatever JSON arrived.
32
+ #
33
+ # Mixed into OAuthProvider.
34
+ module ResponseValidation
35
+ # Where the token response's field types are specified.
36
+ TOKEN_RESPONSE_REFERENCE = 'RFC 6749 Section 5.1'
37
+
38
+ # RFC 6749 Section 5.1 makes BOTH access_token and token_type REQUIRED
39
+ # in a successful token response, and defines no default for either:
40
+ # "Bearer" is one value token_type may carry (RFC 6750), not what its
41
+ # absence means. A response that names no type says nothing about how
42
+ # the credential it carries may be presented, and RFC 6749 Section
43
+ # 7.1 is explicit that "the client MUST NOT use an access token if it
44
+ # does not understand the token type" — which a client that was told
45
+ # no type does not. So an omitted type is refused exactly as an
46
+ # omitted access_token is, and exactly as the DPoP and MAC types this
47
+ # client cannot present are, rather than guessed at and sent out
48
+ # behind `Authorization: Bearer`. (access_token has a check of its
49
+ # own, {#issued_access_token?}, which also asks for usable bytes.)
50
+ REQUIRED_TOKEN_RESPONSE_FIELDS = %w[token_type].freeze
51
+
52
+ # Where the registration response's field types are specified.
53
+ REGISTRATION_RESPONSE_REFERENCE = 'RFC 7591 Section 3.2.1'
54
+
55
+ # The fields of a successful token response and the types RFC 6749
56
+ # Section 5.1 gives them. access_token and token_type are REQUIRED and
57
+ # end up in an `Authorization` header, so they must be bytes a header
58
+ # can carry: an empty string is no more usable than a JSON array, and
59
+ # a CR or an LF would not be part of the value at all but the start of
60
+ # another header line. token_type must moreover name a type this
61
+ # client can present (see {SUPPORTED_TOKEN_TYPE}), and it is REQUIRED:
62
+ # see {REQUIRED_TOKEN_RESPONSE_FIELDS}.
63
+ # refresh_token is OPTIONAL but is a credential
64
+ # too: bytes or nothing, because "" would be persisted over the
65
+ # refresh token the client already holds. scope is free text.
66
+ # Fields the RFC does not name are ignored: an authorization server
67
+ # may return anything else it likes.
68
+ TOKEN_RESPONSE_FIELDS = {
69
+ 'access_token' => :header_value,
70
+ 'token_type' => :token_type,
71
+ 'expires_in' => :integer,
72
+ 'refresh_token' => :non_empty_string,
73
+ 'scope' => :string
74
+ }.freeze
75
+
76
+ # The fields of a client registration response and their types: the
77
+ # registration-specific fields of RFC 7591 Section 3.2.1 followed by
78
+ # the client metadata of Section 2 that the server echoes back.
79
+ REGISTRATION_RESPONSE_FIELDS = {
80
+ 'client_id' => :non_empty_string,
81
+ 'client_secret' => :string,
82
+ 'client_id_issued_at' => :integer,
83
+ 'client_secret_expires_at' => :integer,
84
+ 'redirect_uris' => :redirect_uri_array,
85
+ 'token_endpoint_auth_method' => :string,
86
+ 'grant_types' => :string_array,
87
+ 'response_types' => :string_array,
88
+ 'scope' => :string,
89
+ 'client_name' => :string,
90
+ 'client_uri' => :string,
91
+ 'logo_uri' => :string,
92
+ 'tos_uri' => :string,
93
+ 'policy_uri' => :string,
94
+ 'contacts' => :string_array,
95
+ 'application_type' => :string
96
+ }.freeze
97
+
98
+ # Where the protected resource document's field types are specified.
99
+ RESOURCE_METADATA_REFERENCE = 'RFC 9728 Section 2'
100
+
101
+ # Where the authorization server document's field types are specified.
102
+ SERVER_METADATA_REFERENCE = 'RFC 8414 Section 2'
103
+
104
+ # RFC 9728 Section 2 makes `resource` REQUIRED, and this client cannot
105
+ # do without it: it is the identifier the confused-deputy check
106
+ # compares with the server URL, and a document that omits it is not a
107
+ # protected resource's metadata at all. Refusing it here rather than
108
+ # at the comparison keeps the document out of the copy
109
+ # {#fetch_resource_metadata} retains for scope resolution.
110
+ REQUIRED_RESOURCE_METADATA_FIELDS = %w[resource].freeze
111
+
112
+ # The protected resource metadata fields this client reads, and their
113
+ # RFC 9728 Section 2 types. `resource` is compared with the server
114
+ # URL, `authorization_servers` supplies the issuer discovery is driven
115
+ # from, and `scopes_supported` is joined into the `scope` parameter of
116
+ # the authorization request.
117
+ RESOURCE_METADATA_FIELDS = {
118
+ 'resource' => :string,
119
+ 'authorization_servers' => :string_array,
120
+ 'scopes_supported' => :string_array
121
+ }.freeze
122
+
123
+ # The authorization server metadata fields this client reads, and
124
+ # their RFC 8414 Section 2 types. The two boolean advertisements
125
+ # (`client_id_metadata_document_supported` and
126
+ # `authorization_response_iss_parameter_supported`) are deliberately
127
+ # absent: a value that is not a boolean says nothing this client can
128
+ # act on, and both are already read fail-closed — "not supported" for
129
+ # the first, "advertised, so a response without `iss` is refused" for
130
+ # the second (see {MCPClient::Auth::ServerMetadata}) — which is a
131
+ # safer reading than refusing the document outright.
132
+ SERVER_METADATA_FIELDS = {
133
+ 'issuer' => :string,
134
+ 'authorization_endpoint' => :string,
135
+ 'token_endpoint' => :string,
136
+ 'registration_endpoint' => :string,
137
+ 'scopes_supported' => :string_array,
138
+ 'response_types_supported' => :string_array,
139
+ 'grant_types_supported' => :string_array,
140
+ 'code_challenge_methods_supported' => :string_array
141
+ }.freeze
142
+
143
+ # RFC 8414 Section 2 makes `issuer`, `authorization_endpoint` and
144
+ # `token_endpoint` REQUIRED, and every one of them is a URL this
145
+ # client parses: the issuer identifies the authorization server a
146
+ # token and a client are bound to, the authorization endpoint is what
147
+ # the browser is sent to, and the token endpoint is where the code is
148
+ # redeemed. A type check alone accepts a document that simply omits
149
+ # them — there is no field of the wrong type — so the document is
150
+ # cached as metadata and the flow crashes with a
151
+ # `URI::InvalidURIError` out of `start_authorization_flow` or
152
+ # `complete_authorization_flow`, by which time dynamic client
153
+ # registration has already created a client at the authorization
154
+ # server. What the RFC requires is required here, at discovery.
155
+ REQUIRED_SERVER_METADATA_FIELDS = %w[issuer authorization_endpoint token_endpoint].freeze
156
+
157
+ # The one access token type this client can present. RFC 6749 Section
158
+ # 7.1: "the client MUST NOT use an access token if it does not
159
+ # understand the token type". A bearer token is presented as it
160
+ # stands (RFC 6750 Section 2.1) and is what MCP 2026-07-28 requires;
161
+ # every other type is a credential this client cannot form a request
162
+ # with — a DPoP token needs a proof JWT of its own, a MAC token a
163
+ # signature — so putting its bytes behind `Authorization: DPoP` would
164
+ # present a credential in a way its authorization server never
165
+ # authorized. (It would not even be spelled right: the header is
166
+ # built with `String#capitalize`, which makes "DPoP" "Dpop".) The
167
+ # comparison is case-insensitive: RFC 6749 Section 5.1 makes the
168
+ # value case-insensitive, and servers do answer "bearer".
169
+ SUPPORTED_TOKEN_TYPE = 'bearer'
170
+
171
+ # How each type reads in a failure message.
172
+ TYPE_DESCRIPTIONS = {
173
+ string: 'a string',
174
+ non_empty_string: 'a non-empty string',
175
+ header_value: 'a non-empty string of bytes an HTTP header can carry',
176
+ token_type: 'a token type this client can present ("Bearer")',
177
+ integer: 'an integer',
178
+ string_array: 'an array of strings',
179
+ redirect_uri_array: 'an array of usable redirect URIs'
180
+ }.freeze
181
+
182
+ # Bytes no HTTP header field value may carry: the C0 controls (CR and
183
+ # LF above all, which would end the header line and start one of the
184
+ # peer's choosing) and DEL. RFC 6749 Appendix A is stricter still —
185
+ # an access token is 1*VSCHAR — but obs-text is at least transported,
186
+ # while a control byte is either refused by the HTTP stack or splits
187
+ # the request.
188
+ HEADER_UNSAFE_BYTE = ->(byte) { byte < 0x20 || byte == 0x7F }
189
+
190
+ # Schemes a callback can actually arrive on this client.
191
+ CALLBACK_SCHEMES = %w[http https].freeze
192
+
193
+ private
194
+
195
+ # Why a parsed token endpoint response is not a credential, if it is
196
+ # not one. The body is peer-controlled JSON of any shape: `200 []` and
197
+ # `200 null` parse to an Array and to nil, which cannot be asked for a
198
+ # member at all, so the shape is established before any value is read.
199
+ # @param data [Object, nil] the parsed JSON body
200
+ # @return [String, nil] the reason, or nil when the response is usable
201
+ def token_response_error(data)
202
+ unless issued_access_token?(data)
203
+ return "the token response carries no access_token (#{TOKEN_RESPONSE_REFERENCE})"
204
+ end
205
+
206
+ missing_field_error(data, REQUIRED_TOKEN_RESPONSE_FIELDS, 'token response',
207
+ TOKEN_RESPONSE_REFERENCE) ||
208
+ mistyped_field_error(data, TOKEN_RESPONSE_FIELDS, 'token response', TOKEN_RESPONSE_REFERENCE)
209
+ end
210
+
211
+ # Why a parsed client registration response registers no client, if it
212
+ # registers none.
213
+ # @param data [Object, nil] the parsed JSON body
214
+ # @return [String, nil] the reason, or nil when the response is usable
215
+ def registration_response_error(data)
216
+ unless registered_client?(data)
217
+ return "the registration response carries no client_id (#{REGISTRATION_RESPONSE_REFERENCE})"
218
+ end
219
+
220
+ mistyped_field_error(data, REGISTRATION_RESPONSE_FIELDS, 'registration response',
221
+ REGISTRATION_RESPONSE_REFERENCE)
222
+ end
223
+
224
+ # Why a parsed protected resource document is not usable metadata, if
225
+ # it is not. The document drives discovery and supplies the scopes of
226
+ # the authorization request, so a field of the wrong JSON type is
227
+ # refused here rather than asked for `first` or `join` later.
228
+ # @param data [Hash] the parsed JSON body (its Hash-ness is checked by the caller)
229
+ # @return [String, nil] the reason, or nil when the document is usable
230
+ def resource_metadata_error(data)
231
+ missing_field_error(data, REQUIRED_RESOURCE_METADATA_FIELDS, 'protected resource metadata',
232
+ RESOURCE_METADATA_REFERENCE) ||
233
+ mistyped_field_error(data, RESOURCE_METADATA_FIELDS, 'protected resource metadata',
234
+ RESOURCE_METADATA_REFERENCE)
235
+ end
236
+
237
+ # Why a parsed authorization server document is not usable metadata,
238
+ # if it is not.
239
+ # @param data [Hash] the parsed JSON body (its Hash-ness is checked by the caller)
240
+ # @return [String, nil] the reason, or nil when the document is usable
241
+ def server_metadata_error(data)
242
+ missing_field_error(data, REQUIRED_SERVER_METADATA_FIELDS, 'authorization server metadata',
243
+ SERVER_METADATA_REFERENCE) ||
244
+ mistyped_field_error(data, SERVER_METADATA_FIELDS, 'authorization server metadata',
245
+ SERVER_METADATA_REFERENCE)
246
+ end
247
+
248
+ # The first field a document's RFC makes REQUIRED and the document
249
+ # does not carry. A JSON null is an omission: it is no more a URL than
250
+ # an absent key is.
251
+ # @param data [Hash] the parsed JSON body
252
+ # @param fields [Array<String>] the field names the RFC requires
253
+ # @param label [String] what the document is, for the message
254
+ # @param reference [String] the RFC section the requirement comes from
255
+ # @return [String, nil]
256
+ def missing_field_error(data, fields, label, reference)
257
+ missing = fields.find { |field| data[field].nil? }
258
+ return nil unless missing
259
+
260
+ "the #{label} omits the required #{missing} (#{reference})"
261
+ end
262
+
263
+ # The first field of a response body that is present and not of the
264
+ # type its RFC gives it. A field that is absent (or JSON null) is not
265
+ # mistyped: the optional ones may be omitted, and the required ones
266
+ # are checked by name before this runs.
267
+ # @param data [Hash] the parsed JSON body
268
+ # @param fields [Hash{String => Symbol}] field name to expected type
269
+ # @param label [String] what the document is, for the message
270
+ # @param reference [String] the RFC section the types come from
271
+ # @return [String, nil]
272
+ def mistyped_field_error(data, fields, label, reference)
273
+ fields.each do |field, type|
274
+ value = data[field]
275
+ next if value.nil? || value_of_type?(value, type)
276
+
277
+ return "the #{label}'s #{field} is not #{TYPE_DESCRIPTIONS[type]} (#{reference})"
278
+ end
279
+ nil
280
+ end
281
+
282
+ # @param value [Object] a value read from a response body
283
+ # @param type [Symbol] one of the keys of TYPE_DESCRIPTIONS
284
+ # @return [Boolean]
285
+ def value_of_type?(value, type)
286
+ case type
287
+ when :string then value.is_a?(String)
288
+ when :non_empty_string then non_empty_string?(value)
289
+ when :header_value then header_value_bytes?(value)
290
+ when :token_type then presentable_token_type?(value)
291
+ # `true` and `false` are not Integers, so booleans are rejected here.
292
+ when :integer then value.is_a?(Integer)
293
+ when :string_array then value.is_a?(Array) && value.all?(String)
294
+ when :redirect_uri_array then value.is_a?(Array) && value.all? { |uri| redirect_uri_bytes?(uri) }
295
+ else false
296
+ end
297
+ end
298
+
299
+ # Whether a token record can be presented at all. Both fields
300
+ # {MCPClient::Auth::Token#to_header} builds the `Authorization` header
301
+ # out of are checked, not just the access token: a record whose
302
+ # access_token is absent, empty or of any other type is never a
303
+ # credential — its header would be a bare "Bearer " or, worse,
304
+ # `Bearer ["x"]`, a to_s of whatever JSON arrived, attributed to
305
+ # whatever authorization server is current — and a token_type that is
306
+ # not a string crashes `capitalize`, while one carrying CR or LF makes
307
+ # the header value two header lines. Storage answers with whatever it
308
+ # was given, so this is asked wherever a token is read, issued or
309
+ # applied, not only of what came off the wire.
310
+ # @param token [Object, nil] a token record
311
+ # @return [Boolean] whether it carries bytes an Authorization header can present
312
+ def token_bytes?(token)
313
+ return false unless token.respond_to?(:access_token) && access_token_bytes?(token.access_token)
314
+
315
+ token.respond_to?(:token_type) && presentable_token_type?(token.token_type)
316
+ end
317
+
318
+ # Whether an access token of this type can be presented at all: bytes
319
+ # an HTTP header can carry, and a type this client understands
320
+ # (RFC 6749 Section 7.1). A record read back from storage is asked the
321
+ # same question as a token response is, so a type this client cannot
322
+ # honour is never presented, whichever side it came from.
323
+ # @param value [Object, nil] a candidate token type
324
+ # @return [Boolean]
325
+ def presentable_token_type?(value)
326
+ header_value_bytes?(value) && value.casecmp(SUPPORTED_TOKEN_TYPE).zero?
327
+ end
328
+
329
+ # The same question about a parsed token endpoint response body.
330
+ # @param data [Object, nil] the parsed JSON body
331
+ # @return [Boolean]
332
+ def issued_access_token?(data)
333
+ data.is_a?(Hash) && access_token_bytes?(data['access_token'])
334
+ end
335
+
336
+ # RFC 7591 Section 3.2.1 makes client_id REQUIRED in a registration
337
+ # response, and it is a string: it goes into the authorization URL and
338
+ # into every token request. A response without usable bytes has
339
+ # registered nothing — accepting it sends the user to the
340
+ # authorization endpoint with an empty (or a `to_s`-mangled)
341
+ # client_id, and the flow only fails on the way back, after the
342
+ # browser has already been opened.
343
+ # @param data [Object, nil] the parsed JSON registration response
344
+ # @return [Boolean]
345
+ def registered_client?(data)
346
+ data.is_a?(Hash) && client_id_bytes?(data['client_id'])
347
+ end
348
+
349
+ # @param value [Object, nil] a candidate access token
350
+ # @return [Boolean] whether it is token bytes an Authorization header can carry
351
+ def access_token_bytes?(value)
352
+ header_value_bytes?(value)
353
+ end
354
+
355
+ # @param value [Object, nil] a candidate header field value
356
+ # @return [Boolean] whether it is a non-empty string an HTTP header can carry
357
+ def header_value_bytes?(value)
358
+ non_empty_string?(value) && value.each_byte.none?(&HEADER_UNSAFE_BYTE)
359
+ end
360
+
361
+ # A registered redirect URI is asked for its `first` and put into the
362
+ # authorization URL the browser is sent to. An empty string is an
363
+ # array element of the right JSON type and no redirect URI at all: it
364
+ # opens the browser with `redirect_uri=`, and the authorization server
365
+ # rejects the request the user was just sent into. Having a scheme is
366
+ # not enough either — `javascript:alert(1)` and `data:text/html,...`
367
+ # have one, and a browser that follows them runs the peer's script in
368
+ # the page instead of delivering a code anywhere; a bare `http:` has
369
+ # one and no host to deliver to. So an array of strings registers
370
+ # redirect URIs only when every element is one a callback could
371
+ # actually arrive on AND one MCP 2026-07-28 allows: an HTTPS URL with
372
+ # a host, a plain-HTTP URL on the loopback interface (the callback
373
+ # server {MCPClient::Auth::BrowserOAuth} runs), or an RFC 8252
374
+ # Section 7.1 private-use scheme the host application registered with
375
+ # the operating system — and, either way, without the fragment RFC
376
+ # 6749 Section 3.1.2 forbids.
377
+ # @param value [Object, nil] a candidate redirect URI
378
+ # @return [Boolean]
379
+ def redirect_uri_bytes?(value)
380
+ return false unless non_empty_string?(value)
381
+
382
+ uri = URI.parse(value)
383
+ scheme = uri.scheme.to_s.downcase
384
+ return false unless uri.fragment.nil?
385
+ return allowed_callback_url?(uri, scheme) if CALLBACK_SCHEMES.include?(scheme)
386
+
387
+ private_use_redirect_uri?(uri, scheme)
388
+ rescue URI::InvalidURIError
389
+ false
390
+ end
391
+
392
+ # MCP 2026-07-28 "Communication Security": "All redirect URIs MUST be
393
+ # either `localhost` or use HTTPS." Plain HTTP is the exception the
394
+ # loopback interface gets — the code never leaves the machine — and
395
+ # `http://app.example.com/callback` is not that exception: it carries
396
+ # the authorization code, and the authorization server's own error
397
+ # text, across the network in the clear.
398
+ # @param uri [URI::Generic] the parsed redirect URI
399
+ # @param scheme [String] its downcased scheme
400
+ # @return [Boolean]
401
+ def allowed_callback_url?(uri, scheme)
402
+ return false if uri.host.to_s.empty?
403
+ return true if scheme == 'https'
404
+
405
+ loopback_address?(uri.hostname.to_s)
406
+ end
407
+
408
+ # RFC 8252 Section 7.1: a native application may receive its callback
409
+ # on a private-use URI scheme, "a scheme based on a domain name under
410
+ # their control, expressed in reverse order"
411
+ # (`com.example.app:/oauth2redirect`, and the `com.example.app://oauth`
412
+ # authority spelling operating systems and SDKs also register). Such a
413
+ # URI is hierarchical — it has a path or an authority, not an opaque
414
+ # body — which is what separates it from the `javascript:` and `data:`
415
+ # URIs that are not redirect targets at all. The callback never leaves
416
+ # the device, so the localhost-or-HTTPS requirement that governs the
417
+ # network schemes does not reach it.
418
+ # @param uri [URI::Generic] the parsed redirect URI
419
+ # @param scheme [String] its downcased scheme
420
+ # @return [Boolean]
421
+ def private_use_redirect_uri?(uri, scheme)
422
+ return false unless scheme.include?('.') && uri.opaque.nil?
423
+
424
+ uri.path.to_s.start_with?('/') || !uri.host.to_s.empty?
425
+ end
426
+
427
+ # @param value [Object, nil] a candidate client id
428
+ # @return [Boolean] whether it is a non-empty string of client id bytes
429
+ def client_id_bytes?(value)
430
+ non_empty_string?(value)
431
+ end
432
+
433
+ # @param value [Object, nil]
434
+ # @return [Boolean] whether it is a String with at least one character
435
+ def non_empty_string?(value)
436
+ value.is_a?(String) && !value.empty?
437
+ end
438
+ end
439
+ end
440
+ end
441
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ module Auth
5
+ class OAuthProvider
6
+ # How an authorization or registration request decides what scope to ask
7
+ # for: the MCP scope SELECTION strategy picks what the request needs, and
8
+ # the 2026-07-28 step-up rule adds back what this client has already
9
+ # asked for or been granted. Mixed into {MCPClient::Auth::OAuthProvider};
10
+ # every method is private there.
11
+ module ScopeSelection
12
+ private
13
+
14
+ # Resolve the scope for authorization/registration requests: the MCP
15
+ # scope SELECTION strategy picks what this request needs (see
16
+ # {#selected_scope}), and the 2026-07-28 step-up rule adds back what has
17
+ # already been asked for (see {#accumulated_scope}).
18
+ # @return [String, nil]
19
+ def resolved_scope
20
+ accumulated_scope(selected_scope)
21
+ end
22
+
23
+ # What this request needs, by the MCP 2025-11-25 scope selection
24
+ # strategy: the challenge's scope parameter is authoritative; then an
25
+ # explicitly configured scope (:all resolves to the AS-advertised scope
26
+ # list); then the Protected Resource Metadata's scopes_supported;
27
+ # otherwise no scope at all.
28
+ # @return [String, nil]
29
+ def selected_scope
30
+ return @challenge_scope if @challenge_scope && !@challenge_scope.empty?
31
+
32
+ if scope == :all
33
+ all_scopes = supported_scopes
34
+ return all_scopes.join(' ') unless all_scopes.empty?
35
+ elsif scope
36
+ return scope
37
+ end
38
+
39
+ prm = @challenge_resource_metadata || @resource_metadata
40
+ prm_scopes = advertised_scopes(prm&.scopes_supported)
41
+ return prm_scopes.join(' ') unless prm_scopes.empty?
42
+
43
+ nil
44
+ end
45
+
46
+ # MCP 2026-07-28 "Step-Up Authorization Flow", step 2: "Determine
47
+ # required scopes by computing the union of the client's previously
48
+ # requested scope set and the scopes from the current challenge. This
49
+ # ensures previously granted permissions are preserved when servers
50
+ # emit per-operation scope challenges." A challenge is authoritative for
51
+ # what the CURRENT operation needs, not for what the client already had:
52
+ # re-authorizing with the challenge's scope alone trades the permissions
53
+ # every other operation depends on for the one being retried, and the
54
+ # next operation challenges again.
55
+ # @param selected [String, nil] the scope this request selects on its own
56
+ # @return [String, nil] the union, or nil when no scope is to be sent
57
+ def accumulated_scope(selected)
58
+ scopes = (previously_requested_scopes + selected.to_s.split).uniq
59
+ scopes.empty? ? nil : scopes.join(' ')
60
+ end
61
+
62
+ # "The client's previously requested scope set". Three things say what
63
+ # this client already asked for, and a step-up that consulted only the
64
+ # first would trade away permissions:
65
+ #
66
+ # * the last authorization request this provider made — in-process, and
67
+ # gone the moment the process restarts or the host builds another
68
+ # provider;
69
+ # * the scope the host configured, which is what this client asks for
70
+ # whenever a challenge is not overriding it;
71
+ # * the scope of the token in hand, which is what the authorization
72
+ # server actually granted (RFC 6749 Section 5.1) and is the only one
73
+ # of the three that survives a restart.
74
+ #
75
+ # The granted set counts only while the token belongs to the
76
+ # authorization server in use: what one server granted is not a
77
+ # permission another one ever gave, and asking B for A's scopes is at
78
+ # best a rejected request. The in-process set is dropped for the same
79
+ # reason when the authorization server changes, and with the rest of
80
+ # the per-server state when the provider is retargeted.
81
+ #
82
+ # The configured scope counts only once this client has actually asked
83
+ # for something or holds a grant. The rule preserves PREVIOUSLY
84
+ # REQUESTED permissions; on a first authorization there are none, and
85
+ # the challenge is authoritative for what the operation needs (MCP
86
+ # 2026-07-28 scope selection). Adding the configured set there would
87
+ # widen the very first request beyond what was challenged for — with
88
+ # `scope: :all`, to everything the authorization server advertises.
89
+ # @return [Array<String>]
90
+ def previously_requested_scopes
91
+ asked = @requested_scope.to_s.split
92
+ granted = granted_scopes
93
+ return (asked + granted).uniq if asked.empty? && granted.empty?
94
+
95
+ (asked + configured_scopes + granted).uniq
96
+ end
97
+
98
+ # The scope the host configured, as a list.
99
+ # @return [Array<String>]
100
+ def configured_scopes
101
+ return supported_scopes if scope == :all
102
+ return [] unless scope.is_a?(String)
103
+
104
+ scope.split
105
+ end
106
+
107
+ # The scope the authorization server in use granted the token in hand.
108
+ # A token of another authorization server — or one this client
109
+ # retired — grants nothing here.
110
+ # @return [Array<String>]
111
+ def granted_scopes
112
+ token = stored_token_or_nil
113
+ return [] unless token.respond_to?(:scope) && token.scope.is_a?(String)
114
+ return [] unless token_for_current_issuer?(token) || bindable_to_current_issuer?(token)
115
+
116
+ token.scope.split
117
+ end
118
+
119
+ # A scopes_supported value as a scope list: RFC 8414 Section 2 and RFC
120
+ # 9728 Section 2 both make it an array of strings, and a document that
121
+ # breaks that is refused on the wire — but a record read back from a
122
+ # storage backend that persists plain hashes is not, and `"a b".join`
123
+ # is a NoMethodError out of the flow.
124
+ # @param scopes [Object, nil] the advertised value
125
+ # @return [Array<String>] the scopes, or none
126
+ def advertised_scopes(scopes)
127
+ return [] unless scopes.is_a?(Array)
128
+
129
+ scopes.grep(String)
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end