mcp 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,587 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "openssl"
6
+ require "securerandom"
7
+ require "uri"
8
+
9
+ module MCP
10
+ class Client
11
+ module OAuth
12
+ # Internal orchestrator for the MCP OAuth 2.1 + PKCE + DCR authorization flow.
13
+ # Driven by `MCP::Client::HTTP` on a 401 response. The user-facing surface is
14
+ # `Provider`; this class consumes a Provider plus signal data extracted from
15
+ # the failing response (resource_metadata URL, scope challenge).
16
+ class Flow
17
+ class AuthorizationError < StandardError; end
18
+
19
+ # Raised specifically when the token endpoint rejects a grant with
20
+ # `error: "invalid_grant"` (RFC 6749 §5.2). Callers use this to
21
+ # distinguish "the stored refresh token is dead, discard it" from
22
+ # transient failures (network, 5xx, other RFC 6749 error codes) that
23
+ # should leave the refresh token intact.
24
+ class InvalidGrantError < AuthorizationError; end
25
+
26
+ def initialize(provider:, http_client_factory: nil)
27
+ @provider = provider
28
+ @http_client_factory = http_client_factory || -> { default_http_client }
29
+ end
30
+
31
+ # Runs the full discovery, registration, authorization, and token exchange flow.
32
+ # On success, persists tokens via the provider and returns `:authorized`.
33
+ def run!(server_url:, resource_metadata_url: nil, scope: nil)
34
+ # The `resource_metadata` URL ships in `WWW-Authenticate` and is the very
35
+ # first thing we contact in the OAuth flow, so it has to clear the same
36
+ # Communication Security bar as the OAuth endpoints downstream.
37
+ if resource_metadata_url
38
+ ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
39
+ end
40
+
41
+ prm = fetch_protected_resource_metadata(
42
+ server_url: server_url,
43
+ resource_metadata_url: resource_metadata_url,
44
+ )
45
+ authorization_server = first_authorization_server(prm)
46
+ ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")
47
+
48
+ # Per RFC 8707 + MCP authorization, the canonical MCP server URI is sent on
49
+ # both the authorization and token requests. When PRM advertises a `resource`,
50
+ # it MUST identify the same MCP server we are talking to; otherwise we are
51
+ # being redirected to credentials minted for a different audience.
52
+ resource = canonical_resource(server_url: server_url, prm_resource: prm["resource"])
53
+
54
+ as_metadata = fetch_authorization_server_metadata(issuer_url: authorization_server)
55
+ ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
56
+ ensure_secure_endpoints!(as_metadata)
57
+ ensure_pkce_supported!(as_metadata)
58
+
59
+ client_info = ensure_client_registered(as_metadata: as_metadata)
60
+
61
+ effective_scope = resolve_scope(scope: scope, prm: prm)
62
+ pkce = PKCE.generate
63
+ state = SecureRandom.urlsafe_base64(32)
64
+
65
+ authorization_url = build_authorization_url(
66
+ as_metadata: as_metadata,
67
+ client_id: client_info_required_value(client_info, "client_id"),
68
+ scope: effective_scope,
69
+ state: state,
70
+ code_challenge: pkce[:code_challenge],
71
+ resource: resource,
72
+ )
73
+
74
+ @provider.redirect_handler.call(authorization_url)
75
+ code, returned_state = Array(@provider.callback_handler.call)
76
+ raise AuthorizationError, "Authorization callback did not return an authorization code." unless code
77
+
78
+ unless states_match?(returned_state, state)
79
+ raise AuthorizationError, "OAuth state mismatch (CSRF protection)."
80
+ end
81
+
82
+ tokens = exchange_authorization_code(
83
+ as_metadata: as_metadata,
84
+ client_info: client_info,
85
+ code: code,
86
+ code_verifier: pkce[:code_verifier],
87
+ resource: resource,
88
+ )
89
+
90
+ @provider.save_tokens(tokens)
91
+ :authorized
92
+ end
93
+
94
+ # Exchanges the saved `refresh_token` for a fresh access token (RFC 6749
95
+ # Section 6). Re-discovers PRM and AS metadata so we always pick up a moved
96
+ # token endpoint, and re-runs the audience / issuer / security checks
97
+ # before talking to it.
98
+ #
99
+ # Returns `:refreshed` on success. Raises `AuthorizationError` when
100
+ # the provider has no refresh token, no client information, or when
101
+ # the token endpoint refuses the refresh request.
102
+ # https://www.rfc-editor.org/rfc/rfc6749#section-6
103
+ def refresh!(server_url:, resource_metadata_url: nil)
104
+ refresh_token = read_token("refresh_token")
105
+ raise AuthorizationError, "Cannot refresh: no refresh_token in provider storage." unless refresh_token
106
+
107
+ client_info = @provider.client_information
108
+ unless client_info.is_a?(Hash) && client_info_required_value(client_info, "client_id")
109
+ raise AuthorizationError, "Cannot refresh: no client_information in provider storage."
110
+ end
111
+
112
+ if resource_metadata_url
113
+ ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
114
+ end
115
+
116
+ prm = fetch_protected_resource_metadata(
117
+ server_url: server_url,
118
+ resource_metadata_url: resource_metadata_url,
119
+ )
120
+ authorization_server = first_authorization_server(prm)
121
+ ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")
122
+
123
+ resource = canonical_resource(server_url: server_url, prm_resource: prm["resource"])
124
+
125
+ as_metadata = fetch_authorization_server_metadata(issuer_url: authorization_server)
126
+ ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
127
+ ensure_secure_endpoints!(as_metadata)
128
+
129
+ new_tokens = exchange_refresh_token(
130
+ as_metadata: as_metadata,
131
+ client_info: client_info,
132
+ refresh_token: refresh_token,
133
+ resource: resource,
134
+ )
135
+
136
+ @provider.save_tokens(preserve_refresh_token(new_tokens, refresh_token))
137
+ :refreshed
138
+ end
139
+
140
+ private
141
+
142
+ def read_token(key)
143
+ tokens = @provider.tokens
144
+ return unless tokens.is_a?(Hash)
145
+
146
+ value = tokens[key] || tokens[key.to_sym]
147
+ value.to_s.empty? ? nil : value
148
+ end
149
+
150
+ # Per RFC 6749 Section 6, the refresh response MAY omit `refresh_token`, in
151
+ # which case the previous one stays valid. Preserve it explicitly so
152
+ # downstream refresh attempts still work.
153
+ def preserve_refresh_token(new_tokens, previous_refresh_token)
154
+ return new_tokens if new_tokens["refresh_token"] || new_tokens[:refresh_token]
155
+
156
+ new_tokens.merge("refresh_token" => previous_refresh_token)
157
+ end
158
+
159
+ def fetch_protected_resource_metadata(server_url:, resource_metadata_url:)
160
+ urls = Discovery.protected_resource_metadata_urls(
161
+ server_url: server_url,
162
+ resource_metadata_url: resource_metadata_url,
163
+ )
164
+ fetch_metadata_json(urls, label: "protected resource metadata")
165
+ end
166
+
167
+ def fetch_authorization_server_metadata(issuer_url:)
168
+ urls = Discovery.authorization_server_metadata_urls(issuer_url)
169
+ fetch_metadata_json(urls, label: "authorization server metadata")
170
+ end
171
+
172
+ # Reads `authorization_servers` from a PRM document and returns
173
+ # the first entry, raising `AuthorizationError` for any of the malformed
174
+ # shapes a non-compliant server could emit (missing field, non-Array
175
+ # value, empty Array, non-String first entry). Centralizing this lets
176
+ # both the full flow and the refresh flow share the same defensive
177
+ # parse instead of each one duplicating a Hash-and-Array check.
178
+ def first_authorization_server(prm)
179
+ authorization_servers = prm["authorization_servers"]
180
+ unless authorization_servers.is_a?(Array)
181
+ raise AuthorizationError,
182
+ "Protected resource metadata `authorization_servers` is not an array " \
183
+ "(got #{authorization_servers.class})."
184
+ end
185
+
186
+ if authorization_servers.empty?
187
+ raise AuthorizationError, "Protected resource metadata has no authorization_servers."
188
+ end
189
+
190
+ first = authorization_servers.first
191
+ unless first.is_a?(String) && !first.empty?
192
+ raise AuthorizationError,
193
+ "Protected resource metadata `authorization_servers[0]` is not a non-empty string."
194
+ end
195
+
196
+ first
197
+ end
198
+
199
+ # Walks candidate metadata URLs and returns the parsed JSON body of
200
+ # the first 2xx response. Raises `AuthorizationError` for transport
201
+ # failures (`Faraday::Error`) and malformed bodies (`JSON::ParserError`)
202
+ # so callers do not have to handle raw Faraday/JSON exceptions.
203
+ def fetch_metadata_json(urls, label:)
204
+ last_error = nil
205
+ urls.each do |url|
206
+ response = begin
207
+ http_get(url)
208
+ rescue Faraday::Error => e
209
+ last_error = "GET #{url} raised #{e.class}: #{e.message}"
210
+ next
211
+ end
212
+
213
+ if response.status >= 200 && response.status < 300
214
+ parsed = begin
215
+ JSON.parse(response_body_string(response))
216
+ rescue JSON::ParserError => e
217
+ raise AuthorizationError, "Failed to parse #{label} from #{url}: #{e.message}."
218
+ end
219
+
220
+ # Even valid JSON can be the wrong shape (a top-level array,
221
+ # a bare `null`, a string, ...). The discovery callers index by
222
+ # name (`prm["authorization_servers"]`, etc.), so anything that
223
+ # is not a Hash would raise `TypeError` / `NoMethodError`
224
+ # downstream. Surface that as `AuthorizationError` instead so
225
+ # callers see a single, documented error type.
226
+ unless parsed.is_a?(Hash)
227
+ raise AuthorizationError,
228
+ "#{label} from #{url} is not a JSON object (got #{parsed.class})."
229
+ end
230
+
231
+ return parsed
232
+ end
233
+
234
+ last_error = "GET #{url} returned #{response.status}"
235
+ end
236
+ raise AuthorizationError, "Failed to fetch #{label}: #{last_error}."
237
+ end
238
+
239
+ def ensure_pkce_supported!(as_metadata)
240
+ methods = as_metadata["code_challenge_methods_supported"]
241
+ return if methods.is_a?(Array) && methods.include?("S256")
242
+
243
+ raise AuthorizationError,
244
+ "Authorization server does not advertise S256 PKCE support; refusing to proceed."
245
+ end
246
+
247
+ # Per the MCP authorization spec's Communication Security requirement,
248
+ # OAuth endpoints MUST use HTTPS unless the host is a loopback address.
249
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#communication-security
250
+ def ensure_secure_url!(url, label:)
251
+ return if Discovery.secure_url?(url)
252
+
253
+ raise AuthorizationError,
254
+ "#{label} #{url.inspect} is not over HTTPS; refusing to use it (MCP authorization Communication Security)."
255
+ end
256
+
257
+ def ensure_secure_endpoints!(as_metadata)
258
+ ["authorization_endpoint", "token_endpoint", "registration_endpoint"].each do |key|
259
+ endpoint = as_metadata[key]
260
+ ensure_secure_url!(endpoint, label: "Authorization server #{key}") if endpoint
261
+ end
262
+ end
263
+
264
+ # Per RFC 8414 Section 3.3, the AS metadata document's `issuer` value MUST be
265
+ # identical (literal byte-for-byte equality, no normalization) to
266
+ # the issuer URL the client used to discover that document. This guards
267
+ # against a CDN/relay returning the metadata of a *different*
268
+ # authorization server than the one PRM advertised, and against
269
+ # ambiguities like trailing `/`, fragments, or case differences that
270
+ # could mask a confused-deputy attempt.
271
+ # https://www.rfc-editor.org/rfc/rfc8414#section-3.3
272
+ def ensure_issuer_matches!(expected:, returned:)
273
+ unless returned
274
+ raise AuthorizationError, "Authorization server metadata is missing the `issuer` field."
275
+ end
276
+
277
+ return if expected.to_s == returned.to_s
278
+
279
+ raise AuthorizationError,
280
+ "Authorization server metadata `issuer` does not match the discovery URL " \
281
+ "(expected #{expected.inspect}, got #{returned.inspect})."
282
+ end
283
+
284
+ def ensure_client_registered(as_metadata:)
285
+ existing = @provider.client_information
286
+ return existing if existing.is_a?(Hash) && client_info_required_value(existing, "client_id")
287
+
288
+ registration_endpoint = as_metadata["registration_endpoint"]
289
+ unless registration_endpoint
290
+ raise AuthorizationError,
291
+ "Authorization server has no registration_endpoint and no pre-registered client information was provided."
292
+ end
293
+
294
+ response = begin
295
+ http_post_json(registration_endpoint, @provider.client_metadata)
296
+ rescue Faraday::Error => e
297
+ raise AuthorizationError,
298
+ "Dynamic client registration failed: #{e.class}: #{e.message}."
299
+ end
300
+
301
+ if response.status < 200 || response.status >= 300
302
+ raise AuthorizationError, "Dynamic client registration failed with status #{response.status}."
303
+ end
304
+
305
+ info = begin
306
+ JSON.parse(response_body_string(response))
307
+ rescue JSON::ParserError => e
308
+ raise AuthorizationError,
309
+ "Failed to parse dynamic client registration response: #{e.message}."
310
+ end
311
+
312
+ unless info.is_a?(Hash) && client_info_required_value(info, "client_id")
313
+ raise AuthorizationError,
314
+ "Dynamic client registration response is missing `client_id`."
315
+ end
316
+
317
+ @provider.save_client_information(info)
318
+ info
319
+ end
320
+
321
+ # Reads `key` from a `client_information` hash that may use either string or
322
+ # symbol keys, so users can persist the result of `JSON.parse` *or* a hand-built
323
+ # `{ client_id:, client_secret: }` and have both work.
324
+ def client_info_value(info, key)
325
+ info[key] || info[key.to_sym]
326
+ end
327
+
328
+ # Same as `client_info_value` but treats blank strings (`""` or only
329
+ # whitespace) as absent. Used for fields where empty values are never
330
+ # meaningful (`client_id`, `client_secret`, `token_endpoint_auth_method`)
331
+ # and would otherwise let a misbehaving AS or hand-built
332
+ # `client_information` short-circuit the "is the client registered?"
333
+ # check, or send a literal `client_secret: " "` to the token endpoint.
334
+ def client_info_required_value(info, key)
335
+ value = client_info_value(info, key)
336
+ return if value.nil?
337
+ return if value.is_a?(String) && value.strip.empty?
338
+
339
+ value
340
+ end
341
+
342
+ # Returns the canonical RFC 8707 `resource` URI to send on authorization and
343
+ # token requests. When PRM advertises `resource`, that value is
344
+ # the authorization server's idea of the resource identifier and is preferred.
345
+ # When PRM omits it, the canonicalized MCP server URL is used.
346
+ #
347
+ # Either way, we validate that PRM's `resource` covers the MCP server URL
348
+ # the client is actually talking to (same origin, with PRM's path as a prefix of
349
+ # the server URL's path) to prevent a malicious or misconfigured PRM from
350
+ # redirecting credentials to a different audience.
351
+ def canonical_resource(server_url:, prm_resource:)
352
+ server_canonical = safe_canonicalize_url(server_url, label: "MCP server URL")
353
+ return server_canonical unless prm_resource
354
+
355
+ prm_canonical = safe_canonicalize_url(prm_resource, label: "PRM `resource`")
356
+ unless Discovery.resource_covers?(prm: prm_canonical, server: server_canonical)
357
+ raise AuthorizationError,
358
+ "Protected resource metadata `resource` does not match the MCP server URL " \
359
+ "(server=#{server_canonical}, prm=#{prm_canonical})."
360
+ end
361
+
362
+ prm_canonical
363
+ end
364
+
365
+ # Wraps `Discovery.canonicalize_url` so that any URI parsing failure
366
+ # caused by malformed input from the server (`PRM.resource`, AS metadata
367
+ # endpoints, ...) surfaces as `AuthorizationError` instead of leaking
368
+ # a raw `URI::InvalidURIError` / `ArgumentError`.
369
+ def safe_canonicalize_url(url, label:)
370
+ Discovery.canonicalize_url(url)
371
+ rescue URI::InvalidURIError, ArgumentError => e
372
+ raise AuthorizationError, "#{label} #{url.inspect} is not a valid URI: #{e.message}."
373
+ end
374
+
375
+ # Constant-time comparison for the OAuth `state` parameter to prevent timing-based discovery
376
+ # of the expected value.
377
+ # `OpenSSL.fixed_length_secure_compare` would be ideal, but it is not available on Ruby 2.7
378
+ # (the project's minimum supported version).
379
+ # The hand-rolled XOR-sum walks every byte of the equal-length operands, so the running time
380
+ # does not leak the position of the first mismatching byte.
381
+ def states_match?(returned, expected)
382
+ returned = returned.to_s
383
+ return false unless returned.bytesize == expected.bytesize
384
+
385
+ result = 0
386
+ returned.bytes.zip(expected.bytes) { |a, b| result |= a ^ b }
387
+ result.zero?
388
+ end
389
+
390
+ # Per MCP 2025-11-25 Authorization and the TS/Python SDKs, scope resolution
391
+ # prefers the `WWW-Authenticate` challenge first, then `scopes_supported`
392
+ # from the Protected Resource Metadata, and falls back to a provider-supplied
393
+ # scope only if both are absent. The provider-supplied scope must not pre-empt
394
+ # a server-advertised one.
395
+ def resolve_scope(scope:, prm:)
396
+ return scope if scope && !scope.empty?
397
+
398
+ supported = prm["scopes_supported"]
399
+ return supported.join(" ") if supported.is_a?(Array) && !supported.empty?
400
+
401
+ return @provider.scope if @provider.scope && !@provider.scope.empty?
402
+
403
+ nil
404
+ end
405
+
406
+ def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:)
407
+ authorization_endpoint = as_metadata["authorization_endpoint"]
408
+ unless authorization_endpoint
409
+ raise AuthorizationError,
410
+ "Authorization server metadata is missing `authorization_endpoint`."
411
+ end
412
+
413
+ uri = begin
414
+ URI.parse(authorization_endpoint)
415
+ rescue URI::InvalidURIError => e
416
+ raise AuthorizationError,
417
+ "Authorization server metadata `authorization_endpoint` is not a valid URI: #{e.message}."
418
+ end
419
+
420
+ params = URI.decode_www_form(uri.query.to_s)
421
+ params << ["response_type", "code"]
422
+ params << ["client_id", client_id]
423
+ params << ["redirect_uri", @provider.redirect_uri]
424
+ params << ["code_challenge", code_challenge]
425
+ params << ["code_challenge_method", "S256"]
426
+ params << ["state", state]
427
+ params << ["scope", scope] if scope
428
+ params << ["resource", resource] if resource
429
+ uri.query = URI.encode_www_form(params)
430
+ uri
431
+ end
432
+
433
+ def exchange_authorization_code(as_metadata:, client_info:, code:, code_verifier:, resource:)
434
+ form = {
435
+ "grant_type" => "authorization_code",
436
+ "code" => code,
437
+ "redirect_uri" => @provider.redirect_uri,
438
+ "code_verifier" => code_verifier,
439
+ }
440
+ form["resource"] = resource if resource
441
+
442
+ post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
443
+ end
444
+
445
+ def exchange_refresh_token(as_metadata:, client_info:, refresh_token:, resource:)
446
+ form = {
447
+ "grant_type" => "refresh_token",
448
+ "refresh_token" => refresh_token,
449
+ }
450
+ form["resource"] = resource if resource
451
+
452
+ post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
453
+ end
454
+
455
+ # Submits a form-encoded request to the token endpoint, applying
456
+ # the client authentication method advertised in `client_information` and
457
+ # adding `client_id` (and `client_secret` when not using HTTP Basic).
458
+ def post_to_token_endpoint(as_metadata:, client_info:, form:)
459
+ client_id = client_info_required_value(client_info, "client_id")
460
+ unless client_id
461
+ raise AuthorizationError,
462
+ "Cannot post to token endpoint: client_information is missing `client_id`."
463
+ end
464
+
465
+ client_secret = client_info_required_value(client_info, "client_secret")
466
+ token_endpoint_auth_method = client_info_value(client_info, "token_endpoint_auth_method")
467
+
468
+ form = form.merge("client_id" => client_id)
469
+ headers = {}
470
+ if client_secret
471
+ case token_endpoint_auth_method
472
+ when "client_secret_post"
473
+ form["client_secret"] = client_secret
474
+ when "none"
475
+ # Public client; no credential.
476
+ else
477
+ # RFC 6749 §2.3.1 recommends Basic for confidential clients and
478
+ # both Python and TypeScript SDKs default here when
479
+ # the authentication method is not explicitly stored.
480
+ headers["Authorization"] = "Basic " + basic_auth_credentials(client_id, client_secret)
481
+ end
482
+ end
483
+
484
+ token_endpoint = as_metadata["token_endpoint"]
485
+ unless token_endpoint
486
+ raise AuthorizationError,
487
+ "Authorization server metadata is missing `token_endpoint`."
488
+ end
489
+
490
+ response = begin
491
+ http_post_form(token_endpoint, form, headers: headers)
492
+ rescue Faraday::Error => e
493
+ raise AuthorizationError,
494
+ "Token request to #{token_endpoint} failed: #{e.class}: #{e.message}."
495
+ end
496
+
497
+ if response.status < 200 || response.status >= 300
498
+ if token_endpoint_error_code(response) == "invalid_grant"
499
+ raise InvalidGrantError, "Token endpoint rejected the grant: invalid_grant."
500
+ end
501
+
502
+ raise AuthorizationError, "Token endpoint returned status #{response.status}."
503
+ end
504
+
505
+ parsed = begin
506
+ JSON.parse(response_body_string(response))
507
+ rescue JSON::ParserError => e
508
+ raise AuthorizationError, "Failed to parse token endpoint response: #{e.message}."
509
+ end
510
+
511
+ # Token responses MUST be a JSON object per RFC 6749 §5.1. Anything
512
+ # else (`null`, `[]`, a bare string) would otherwise be persisted
513
+ # as `provider.tokens` and raise raw `NoMethodError` / `TypeError`
514
+ # the next time `provider.access_token` is read.
515
+ unless parsed.is_a?(Hash)
516
+ raise AuthorizationError,
517
+ "Token endpoint response is not a JSON object (got #{parsed.class})."
518
+ end
519
+
520
+ parsed
521
+ end
522
+
523
+ # Extracts the `error` code from an RFC 6749 §5.2 error response body
524
+ # when one is parseable. Returns nil on any parse failure or when
525
+ # the body is not JSON.
526
+ def token_endpoint_error_code(response)
527
+ body = response_body_string(response).to_s
528
+ return if body.empty?
529
+
530
+ parsed = JSON.parse(body)
531
+ parsed["error"] if parsed.is_a?(Hash)
532
+ rescue JSON::ParserError
533
+ nil
534
+ end
535
+
536
+ # Per RFC 6749 Section 2.3.1, the `client_id` and `client_secret` MUST be
537
+ # `application/x-www-form-urlencoded` encoded before they are joined with
538
+ # `:` and base64-encoded for the `Authorization: Basic` header. This is
539
+ # what prevents credentials containing `:` or other special characters
540
+ # from being mis-parsed by the authorization server.
541
+ # https://www.rfc-editor.org/rfc/rfc6749#section-2.3.1
542
+ def basic_auth_credentials(client_id, client_secret)
543
+ encoded_id = URI.encode_www_form_component(client_id)
544
+ encoded_secret = URI.encode_www_form_component(client_secret)
545
+ Base64.strict_encode64("#{encoded_id}:#{encoded_secret}")
546
+ end
547
+
548
+ def http_get(url)
549
+ http_client.get(url)
550
+ end
551
+
552
+ def http_post_json(url, body)
553
+ http_client.post(url) do |req|
554
+ req.headers["Content-Type"] = "application/json"
555
+ req.headers["Accept"] = "application/json"
556
+ req.body = JSON.generate(body)
557
+ end
558
+ end
559
+
560
+ def http_post_form(url, form, headers: {})
561
+ http_client.post(url) do |req|
562
+ req.headers["Content-Type"] = "application/x-www-form-urlencoded"
563
+ req.headers["Accept"] = "application/json"
564
+ headers.each { |key, value| req.headers[key] = value }
565
+ req.body = URI.encode_www_form(form)
566
+ end
567
+ end
568
+
569
+ def http_client
570
+ @http_client ||= @http_client_factory.call
571
+ end
572
+
573
+ def default_http_client
574
+ require "faraday"
575
+ Faraday.new do |faraday|
576
+ faraday.headers["Accept"] = "application/json"
577
+ end
578
+ end
579
+
580
+ def response_body_string(response)
581
+ body = response.body
582
+ body.is_a?(String) ? body : body.to_s
583
+ end
584
+ end
585
+ end
586
+ end
587
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Client
5
+ module OAuth
6
+ # Default reference implementation of the storage contract that
7
+ # `Provider` uses to persist OAuth state. Holds the two pieces of data
8
+ # the flow saves and reads on every request:
9
+ #
10
+ # - `tokens`: the hash returned by the token endpoint
11
+ # (`access_token`, optional `refresh_token`, `expires_in`, `scope`, etc.).
12
+ # - `client_information`: the hash returned by Dynamic Client Registration
13
+ # or supplied as pre-registered credentials
14
+ # (`client_id`, optional `client_secret`, optional
15
+ # `token_endpoint_auth_method`).
16
+ #
17
+ # This class keeps everything in process memory, so the credentials live
18
+ # only for the lifetime of the Ruby process. Applications that need
19
+ # persistence across restarts should supply a custom object responding to
20
+ # the same four-method contract (`tokens`, `save_tokens(t)`,
21
+ # `client_information`, `save_client_information(info)`) and pass it via
22
+ # `Provider.new(storage: ...)`. The shape mirrors Python SDK's
23
+ # `TokenStorage` Protocol; TypeScript's `OAuthClientProvider` rolls
24
+ # the same responsibilities into a single object.
25
+ class InMemoryStorage
26
+ attr_accessor :tokens, :client_information
27
+
28
+ def initialize
29
+ @tokens = nil
30
+ @client_information = nil
31
+ end
32
+
33
+ def save_tokens(tokens)
34
+ @tokens = tokens
35
+ end
36
+
37
+ def save_client_information(info)
38
+ @client_information = info
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "digest"
5
+ require "securerandom"
6
+
7
+ module MCP
8
+ class Client
9
+ module OAuth
10
+ # Generates the Proof Key for Code Exchange (PKCE) pair (RFC 7636) used to
11
+ # bind an OAuth 2.1 authorization request to the same client that later
12
+ # redeems the resulting authorization code. Without PKCE, an attacker
13
+ # who steals an authorization code in transit (e.g. through a logged redirect URI)
14
+ # can exchange it for an access token; with PKCE, the attacker would also need
15
+ # the per-request `code_verifier`, which is never sent to the browser or any intermediary.
16
+ #
17
+ # MCP authorization mandates the `S256` method, so this module always returns
18
+ # that method and refuses to expose `plain` as an option.
19
+ #
20
+ # The module is stateless: callers ask for a fresh pair with `generate` for
21
+ # every authorization request and discard the result once the token endpoint has accepted it.
22
+ #
23
+ # - https://datatracker.ietf.org/doc/html/rfc7636
24
+ # - https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-code-protection
25
+ module PKCE
26
+ class << self
27
+ # Generates a PKCE pair (code_verifier and S256 code_challenge).
28
+ def generate
29
+ verifier = SecureRandom.urlsafe_base64(64)
30
+ challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
31
+
32
+ { code_verifier: verifier, code_challenge: challenge, code_challenge_method: "S256" }
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end