ruby-mcp-client 1.0.1 → 2.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.
@@ -11,6 +11,18 @@ module MCPClient
11
11
  # Handles the complete OAuth flow including server discovery, client registration,
12
12
  # authorization, token exchange, and refresh
13
13
  class OAuthProvider
14
+ # One auth-param (name = token / quoted-string) as it appears in a
15
+ # WWW-Authenticate challenge (RFC 7235 §2.1, optional whitespace around
16
+ # '='). Mirrors HttpTransportBase::AUTH_PARAM so provider-side challenge
17
+ # parsing segments headers exactly like the transport does.
18
+ AUTH_PARAM = /[A-Za-z0-9._~+-]+\s*=\s*(?:"(?:[^"\\]|\\.)*"|[^,\s]*)/
19
+ # A run of comma/space separated auth-params anchored at the start of a
20
+ # string. The run ends before a token that is NOT followed by '=' — the
21
+ # auth-scheme introducing the next challenge — while commas inside quoted
22
+ # values are consumed by the quoted-string branch, not treated as
23
+ # boundaries. Mirrors HttpTransportBase::AUTH_PARAMS_RUN.
24
+ AUTH_PARAMS_RUN = /\A(?:[\s,]*#{AUTH_PARAM})*/
25
+
14
26
  # @!attribute [rw] redirect_uri
15
27
  # @return [String] OAuth redirect URI
16
28
  # @!attribute [rw] scope
@@ -21,8 +33,10 @@ module MCPClient
21
33
  # @return [Object] Storage backend for tokens and client info
22
34
  # @!attribute [r] server_url
23
35
  # @return [String] The MCP server URL (normalized)
36
+ # @!attribute [r] client_id_metadata_url
37
+ # @return [String, nil] HTTPS URL of this client's Client ID Metadata Document (SEP-991)
24
38
  attr_accessor :redirect_uri, :scope, :logger, :storage
25
- attr_reader :server_url
39
+ attr_reader :server_url, :client_id_metadata_url
26
40
 
27
41
  # Initialize OAuth provider
28
42
  # @param server_url [String] The MCP server URL (used as OAuth resource parameter)
@@ -32,15 +46,28 @@ module MCPClient
32
46
  # @param storage [Object, nil] Storage backend for tokens and client info
33
47
  # @param client_metadata [Hash] Extra OIDC client metadata fields for DCR registration.
34
48
  # Supported keys: :client_name, :client_uri, :logo_uri, :tos_uri, :policy_uri, :contacts
49
+ # @param client_id_metadata_url [String, nil] HTTPS URL identifying this client per
50
+ # MCP 2025-11-25 Client ID Metadata Documents (SEP-991). The URL doubles as the OAuth
51
+ # client_id when the authorization server advertises client_id_metadata_document_supported,
52
+ # skipping dynamic registration. Hosting the metadata JSON at that URL is the
53
+ # application's responsibility.
54
+ # @raise [ArgumentError] if client_id_metadata_url is not an HTTPS URL with a path component
35
55
  def initialize(server_url:, redirect_uri: 'http://localhost:8080/callback', scope: nil, logger: nil, storage: nil,
36
- client_metadata: {})
56
+ client_metadata: {}, client_id_metadata_url: nil)
37
57
  self.server_url = server_url
38
58
  self.redirect_uri = redirect_uri
39
59
  self.scope = scope
40
60
  self.logger = logger || Logger.new($stdout, level: Logger::WARN)
41
61
  self.storage = storage || MemoryStorage.new
62
+ self.client_id_metadata_url = client_id_metadata_url
42
63
  @extra_client_metadata = client_metadata
43
64
  @http_client = create_http_client
65
+ # Protected resource metadata learned from a 401 WWW-Authenticate
66
+ # challenge, reused by discovery so a challenge-advertised metadata URL
67
+ # is not re-derived (and possibly missed). The URL itself is retained
68
+ # separately so a failed fetch is retried authoritatively by discovery.
69
+ @challenge_resource_metadata = nil
70
+ @challenge_metadata_url = nil
44
71
  end
45
72
 
46
73
  # @param url [String] Server URL to normalize
@@ -48,6 +75,15 @@ module MCPClient
48
75
  @server_url = normalize_server_url(url)
49
76
  end
50
77
 
78
+ # Set the Client ID Metadata Document URL (SEP-991), validating it per the
79
+ # MCP spec: the client_id URL MUST use the "https" scheme and contain a
80
+ # path component (e.g. https://example.com/client.json).
81
+ # @param url [String, nil] HTTPS URL of this client's metadata document, or nil to clear
82
+ # @raise [ArgumentError] if the URL is not HTTPS or lacks a path component
83
+ def client_id_metadata_url=(url)
84
+ @client_id_metadata_url = url.nil? ? nil : validate_client_id_metadata_url(url)
85
+ end
86
+
51
87
  # Get current access token (refresh if needed)
52
88
  # @return [Token, nil] Current valid access token or nil
53
89
  def access_token
@@ -142,16 +178,125 @@ module MCPClient
142
178
  www_authenticate = response.headers['WWW-Authenticate'] || response.headers['www-authenticate']
143
179
  return nil unless www_authenticate
144
180
 
145
- # Parse WWW-Authenticate header to extract resource metadata URL
146
- # Format: Bearer resource="https://example.com/.well-known/oauth-protected-resource"
147
- if (match = www_authenticate.match(/resource="([^"]+)"/))
148
- resource_metadata_url = match[1]
149
- fetch_resource_metadata(resource_metadata_url)
181
+ # Challenge parameters are read from the Bearer challenge's own
182
+ # segment only — never from the whole (possibly multi-challenge)
183
+ # header so parameters belonging to Basic or another scheme cannot
184
+ # drive Bearer scope selection or resource metadata discovery. A
185
+ # header without a Bearer challenge carries no usable Bearer params.
186
+ bearer_params = bearer_challenge_segment(www_authenticate)
187
+
188
+ # MCP 2025-11-25: "Clients MUST treat the scopes provided in the
189
+ # challenge as authoritative for satisfying the current request" —
190
+ # including resetting a previously challenged scope when the current
191
+ # challenge carries none.
192
+ @challenge_scope = bearer_params && extract_challenge_param(bearer_params, 'scope')
193
+
194
+ url = extract_resource_metadata_url(www_authenticate)
195
+ return nil unless url
196
+
197
+ # Remember the advertised URL even if the fetch below fails, so a
198
+ # later discovery retries it instead of probing well-known URIs the
199
+ # challenge already superseded.
200
+ @challenge_metadata_url = url
201
+
202
+ # This URL was explicitly advertised by the 401 challenge, so a 404 is a
203
+ # misconfiguration to surface (strict), not a speculative miss to skip.
204
+ metadata = fetch_resource_metadata(url, strict: true)
205
+ # Reuse this challenge-advertised metadata during the subsequent OAuth
206
+ # flow instead of re-deriving (and possibly missing) the well-known URL.
207
+ @challenge_resource_metadata = metadata
208
+ metadata
209
+ end
210
+
211
+ # Extract the protected-resource-metadata URL from a WWW-Authenticate header.
212
+ # Per RFC 9728 the parameter is `resource_metadata`; a legacy `resource`
213
+ # parameter is accepted as a fallback for older servers. Only the Bearer
214
+ # challenge's own segment is consulted, so a parameter belonging to
215
+ # another scheme's challenge can never drive discovery.
216
+ # @param header [String] the WWW-Authenticate header value
217
+ # @return [String, nil] the metadata URL if present
218
+ def extract_resource_metadata_url(header)
219
+ params = bearer_challenge_segment(header)
220
+ return nil unless params
221
+
222
+ # Auth-params may include optional whitespace around '=' (RFC 7235).
223
+ # Quoted form: resource_metadata = "https://..."
224
+ if (m = params.match(/resource_metadata\s*=\s*"([^"]+)"/))
225
+ return m[1]
150
226
  end
227
+
228
+ # Unquoted token form: resource_metadata = https://...
229
+ if (m = params.match(/resource_metadata\s*=\s*([^,\s]+)/))
230
+ return m[1]
231
+ end
232
+
233
+ # Legacy fallback: resource="https://..."
234
+ params.match(/resource\s*=\s*"([^"]+)"/)&.captures&.first
151
235
  end
152
236
 
237
+ # Extract the Bearer challenge's own parameter segment from a (possibly
238
+ # multi-challenge) WWW-Authenticate header, so params belonging to other
239
+ # schemes (e.g. `Basic resource_metadata="...", Bearer realm="x"`) are
240
+ # never attributed to the Bearer challenge. Mirrors
241
+ # HttpTransportBase#bearer_challenge_segment.
242
+ # @param header [String, nil] the WWW-Authenticate header value
243
+ # @return [String, nil] the Bearer challenge's parameters (possibly
244
+ # empty), or nil when the header has no Bearer challenge
245
+ def bearer_challenge_segment(header)
246
+ return nil unless header
247
+
248
+ # Locate the Bearer scheme token only OUTSIDE quoted strings: a
249
+ # quoted value such as realm="prefix Bearer x" must not anchor the
250
+ # segment.
251
+ masked = header.gsub(/"(?:\\.|[^"\\])*"/) { |q| "\"#{' ' * (q.length - 2)}\"" }
252
+ match = masked.match(/(?:\A|[\s,])Bearer(?=[\s,]|\z)/i)
253
+ return nil unless match
254
+
255
+ header[match.end(0)..][AUTH_PARAMS_RUN]
256
+ end
257
+
258
+ # Extract an auth-param value from a WWW-Authenticate header
259
+ # (quoted or unquoted form, optional whitespace around '=').
260
+ # @param header [String] the WWW-Authenticate header value
261
+ # @param name [String] the auth-param name
262
+ # @return [String, nil] the parameter value if present
263
+ def extract_challenge_param(header, name)
264
+ if (m = header.match(/(?:^|[\s,])#{Regexp.escape(name)}\s*=\s*"([^"]*)"/i))
265
+ return m[1]
266
+ end
267
+
268
+ header.match(/(?:^|[\s,])#{Regexp.escape(name)}\s*=\s*([^,\s]+)/i)&.captures&.first
269
+ end
270
+
271
+ # Scope requested by the most recent WWW-Authenticate challenge.
272
+ # @return [String, nil]
273
+ attr_reader :challenge_scope
274
+
153
275
  private
154
276
 
277
+ # Resolve the scope for authorization/registration requests using the
278
+ # MCP 2025-11-25 scope selection strategy: the challenge's scope
279
+ # parameter is authoritative; then an explicitly configured scope
280
+ # (:all resolves to the AS-advertised scope list); then the Protected
281
+ # Resource Metadata's scopes_supported; otherwise omit scope entirely.
282
+ # @return [String, nil]
283
+ def resolved_scope
284
+ return @challenge_scope if @challenge_scope && !@challenge_scope.empty?
285
+
286
+ if scope == :all
287
+ all_scopes = supported_scopes
288
+ return all_scopes.join(' ') unless all_scopes.empty?
289
+ elsif scope
290
+ return scope
291
+ end
292
+
293
+ prm = @challenge_resource_metadata || @resource_metadata
294
+ prm_scopes = prm&.scopes_supported
295
+ return prm_scopes.join(' ') if prm_scopes && !prm_scopes.empty?
296
+
297
+ nil
298
+ end
299
+
155
300
  # Normalize server URL to canonical form
156
301
  # @param url [String] Server URL
157
302
  # @return [String] Normalized URL
@@ -213,6 +358,55 @@ module MCPClient
213
358
  (uri.scheme == 'https' && uri.port == 443)
214
359
  end
215
360
 
361
+ # Build the scheme://host[:port] origin for a parsed URI.
362
+ # @param uri [URI] Parsed URI
363
+ # @return [String] origin string
364
+ def origin_of(uri)
365
+ origin = "#{uri.scheme}://#{uri.host}"
366
+ origin += ":#{uri.port}" if uri.port && !default_port?(uri)
367
+ origin
368
+ end
369
+
370
+ # Protected Resource Metadata well-known URLs to try, in priority order
371
+ # (RFC 9728 §3.1). When the resource identifier has a path, the well-known
372
+ # segment is inserted between the host and the path; a root-level URL is
373
+ # always tried as a fallback.
374
+ # @param server_url [String] the MCP server (protected resource) URL
375
+ # @return [Array<String>] ordered candidate URLs
376
+ def protected_resource_metadata_urls(server_url)
377
+ uri = URI.parse(server_url)
378
+ origin = origin_of(uri)
379
+ path = uri.path.to_s
380
+
381
+ urls = []
382
+ urls << "#{origin}/.well-known/oauth-protected-resource#{path}" unless path.empty? || path == '/'
383
+ urls << "#{origin}/.well-known/oauth-protected-resource"
384
+ urls.uniq
385
+ end
386
+
387
+ # Authorization Server Metadata well-known URLs to try, in priority order
388
+ # (RFC 8414 §3.1 path-insertion plus OpenID Connect Discovery). When the
389
+ # issuer has a path, the well-known segment is INSERTED between host and
390
+ # path (not appended); the OIDC path-append form is also tried.
391
+ # @param issuer [String] the authorization server issuer URL
392
+ # @return [Array<String>] ordered candidate URLs
393
+ def authorization_server_metadata_urls(issuer)
394
+ uri = URI.parse(issuer)
395
+ origin = origin_of(uri)
396
+ path = uri.path.to_s
397
+
398
+ urls = []
399
+ if path.empty? || path == '/'
400
+ urls << "#{origin}/.well-known/oauth-authorization-server"
401
+ urls << "#{origin}/.well-known/openid-configuration"
402
+ else
403
+ urls << "#{origin}/.well-known/oauth-authorization-server#{path}"
404
+ urls << "#{origin}/.well-known/openid-configuration#{path}"
405
+ urls << "#{origin}#{path}/.well-known/openid-configuration"
406
+ end
407
+ urls.uniq
408
+ end
409
+
216
410
  # Discover authorization server metadata
217
411
  # Tries multiple discovery patterns:
218
412
  # 1. oauth-authorization-server (MCP spec pattern - server is its own auth server)
@@ -220,69 +414,307 @@ module MCPClient
220
414
  # @return [ServerMetadata] Authorization server metadata
221
415
  # @raise [MCPClient::Errors::ConnectionError] if discovery fails
222
416
  def discover_authorization_server
223
- # Try to get from storage first
224
- if (cached = storage.get_server_metadata(server_url))
417
+ # A fresh 401 challenge is authoritative and overrides any cached
418
+ # (possibly stale or direct-discovered) authorization server metadata —
419
+ # whether the challenge-advertised PRM was already fetched or only its
420
+ # URL is pending (e.g. the initial fetch failed and must be retried).
421
+ challenge_pending = @challenge_resource_metadata || @challenge_metadata_url
422
+ cached = storage.get_server_metadata(server_url) unless challenge_pending
423
+ if cached
424
+ # Validate the cached entry before use so a persisted/older cache with
425
+ # an HTTP endpoint or without S256 is still rejected.
426
+ validate_server_metadata!(cached)
225
427
  return cached
226
428
  end
227
429
 
228
- server_metadata = nil
430
+ discover_and_cache_authorization_server
431
+ end
229
432
 
230
- # Primary discovery: oauth-authorization-server (MCP spec pattern)
231
- # Used by servers that are both resource and authorization server
232
- begin
233
- discovery_url = build_discovery_url(server_url, :authorization_server)
234
- logger.debug("Attempting OAuth discovery: #{discovery_url}")
235
- server_metadata = fetch_server_metadata(discovery_url)
236
- rescue MCPClient::Errors::ConnectionError => e
237
- logger.debug("oauth-authorization-server discovery failed: #{e.message}")
238
- end
433
+ # Discover authorization server metadata, validate it, and cache it.
434
+ # @return [ServerMetadata]
435
+ # @raise [MCPClient::Errors::ConnectionError] if discovery or validation fails
436
+ def discover_and_cache_authorization_server
437
+ previous = storage.get_server_metadata(server_url)
239
438
 
240
- # Fallback discovery: oauth-protected-resource (delegation pattern)
241
- # Used by resource servers that delegate to external authorization servers
242
- unless server_metadata
243
- begin
244
- discovery_url = build_discovery_url(server_url, :protected_resource)
245
- logger.debug("Attempting OAuth discovery: #{discovery_url}")
246
-
247
- resource_metadata = fetch_resource_metadata(discovery_url)
248
- auth_server_url = resource_metadata.authorization_servers.first
249
-
250
- if auth_server_url
251
- server_metadata = fetch_server_metadata("#{auth_server_url}/.well-known/oauth-authorization-server")
252
- end
253
- rescue MCPClient::Errors::ConnectionError => e
254
- logger.debug("oauth-protected-resource discovery failed: #{e.message}")
255
- end
256
- end
439
+ # RFC 9728: Protected Resource Metadata is authoritative — try it first,
440
+ # then fall back to treating the MCP server origin as its own AS.
441
+ server_metadata = discover_via_protected_resource || discover_via_direct_authorization_server
257
442
 
258
443
  unless server_metadata
259
444
  raise MCPClient::Errors::ConnectionError,
260
- 'OAuth discovery failed: no valid endpoints found'
445
+ 'OAuth discovery failed: no valid authorization server metadata found'
261
446
  end
262
447
 
263
- # Cache the metadata
448
+ # Validate BEFORE caching so invalid metadata is never persisted.
449
+ validate_server_metadata!(server_metadata)
450
+
451
+ invalidate_client_info_on_as_change(previous, server_metadata)
452
+
264
453
  storage.set_server_metadata(server_url, server_metadata)
454
+ @challenge_resource_metadata = nil # consumed
455
+ @challenge_metadata_url = nil # consumed
456
+ server_metadata
457
+ end
458
+
459
+ # When a 401 challenge changes the authorization server, per-AS state cached
460
+ # under this server_url becomes invalid: a client_id registered with the
461
+ # previous AS would fail as invalid_client, and memoized scopes belong to
462
+ # the old AS. Discard both so the new flow re-registers and re-discovers.
463
+ # @param previous [ServerMetadata, nil] previously cached AS metadata
464
+ # @param current [ServerMetadata] newly discovered AS metadata
465
+ def invalidate_client_info_on_as_change(previous, current)
466
+ return unless previous && previous.issuer != current.issuer
467
+
468
+ logger.debug('Authorization server changed; discarding client and scopes from the previous AS')
469
+
470
+ # Prefer an explicit delete; fall back to the always-available
471
+ # set_client_info(nil) so custom storage backends are handled too.
472
+ if storage.respond_to?(:delete_client_info)
473
+ storage.delete_client_info(server_url)
474
+ else
475
+ storage.set_client_info(server_url, nil)
476
+ end
477
+
478
+ @supported_scopes = nil
479
+ end
480
+
481
+ # Apply the PKCE-support and HTTPS-endpoint checks to server metadata.
482
+ # @param server_metadata [ServerMetadata]
483
+ # @raise [MCPClient::Errors::ConnectionError] if a check fails
484
+ def validate_server_metadata!(server_metadata)
485
+ verify_pkce_support!(server_metadata)
486
+ enforce_https_endpoints!(server_metadata)
487
+ end
488
+
489
+ # PRM-first discovery: fetch Protected Resource Metadata and then the
490
+ # authorization server it advertises.
491
+ # @return [ServerMetadata, nil] AS metadata, or nil if no PRM document exists
492
+ # @raise [MCPClient::Errors::ConnectionError] if PRM is malformed or mismatched
493
+ def discover_via_protected_resource
494
+ # A missing PRM document (return nil) permits the direct-AS fallback. But
495
+ # once PRM IS found it is authoritative: any subsequent failure must be a
496
+ # hard error, never a silent fallback to an authorization server the PRM
497
+ # did not advertise.
498
+ resource_metadata = challenge_or_well_known_resource_metadata
499
+ return nil unless resource_metadata
500
+
501
+ validate_resource_matches!(resource_metadata)
502
+
503
+ auth_server_url = Array(resource_metadata.authorization_servers).first
504
+ unless auth_server_url
505
+ raise MCPClient::Errors::ConnectionError,
506
+ 'Protected resource metadata does not advertise any authorization_servers'
507
+ end
508
+
509
+ server_metadata = fetch_first_server_metadata(authorization_server_metadata_urls(auth_server_url))
510
+ unless server_metadata
511
+ raise MCPClient::Errors::ConnectionError,
512
+ "Authorization server advertised by protected resource metadata (#{auth_server_url}) " \
513
+ 'could not be discovered'
514
+ end
265
515
 
266
516
  server_metadata
267
517
  end
268
518
 
269
- # Fetch resource metadata from URL
519
+ # Resolve the Protected Resource Metadata for discovery.
520
+ #
521
+ # MCP 2025-11-25 MUST: use the resource metadata URL from the parsed
522
+ # WWW-Authenticate headers when present; otherwise fall back to the
523
+ # well-known URIs. A challenge-advertised URL is therefore authoritative:
524
+ # it is fetched strictly, and any failure (including a 404) raises rather
525
+ # than falling through to constructed well-known candidates the challenge
526
+ # superseded.
527
+ # @return [ResourceMetadata, nil] metadata, or nil when no PRM exists at
528
+ # the speculative well-known URLs (permitting the direct-AS fallback)
529
+ # @raise [MCPClient::Errors::ConnectionError] if the challenge-advertised
530
+ # URL cannot be fetched, or a well-known candidate exists but is invalid
531
+ def challenge_or_well_known_resource_metadata
532
+ return @challenge_resource_metadata if @challenge_resource_metadata
533
+ return fetch_resource_metadata(@challenge_metadata_url, strict: true) if @challenge_metadata_url
534
+
535
+ fetch_first_resource_metadata(protected_resource_metadata_urls(server_url))
536
+ end
537
+
538
+ # Legacy/self-hosted discovery: treat the MCP server ORIGIN as its own
539
+ # authorization server issuer.
540
+ # @return [ServerMetadata, nil]
541
+ def discover_via_direct_authorization_server
542
+ origin = origin_of(URI.parse(server_url))
543
+ fetch_first_server_metadata(authorization_server_metadata_urls(origin))
544
+ end
545
+
546
+ # Fetch the first Protected Resource Metadata document that resolves.
547
+ #
548
+ # PRM is authoritative: a candidate that genuinely does not exist (HTTP
549
+ # 404) is skipped so the next candidate is tried, but a candidate that
550
+ # exists yet is malformed or errors (bad JSON, 5xx, network failure) is a
551
+ # HARD failure — it must not silently fall through to a root PRM or the
552
+ # direct-AS path, which could point at a different authorization server.
553
+ # @param urls [Array<String>] candidate URLs
554
+ # @return [ResourceMetadata, nil]
555
+ def fetch_first_resource_metadata(urls)
556
+ urls.each do |url|
557
+ md = fetch_resource_metadata(url) # returns nil only for a 404 (absent); raises otherwise
558
+ return md if md
559
+ end
560
+ nil
561
+ end
562
+
563
+ # Fetch the first Authorization Server Metadata document that resolves.
564
+ # The oauth-authorization-server and openid-configuration forms are
565
+ # genuine alternatives, so any failing candidate is skipped to try the next.
566
+ # @param urls [Array<String>] candidate URLs
567
+ # @return [ServerMetadata, nil]
568
+ def fetch_first_server_metadata(urls)
569
+ urls.each do |url|
570
+ md = try_fetch_server_metadata(url)
571
+ return md if md
572
+ end
573
+ nil
574
+ end
575
+
576
+ # Non-raising server-metadata fetch used while iterating candidates.
577
+ def try_fetch_server_metadata(url)
578
+ fetch_server_metadata(url)
579
+ rescue MCPClient::Errors::ConnectionError => e
580
+ logger.debug("Authorization server metadata candidate failed (#{url}): #{e.message}")
581
+ nil
582
+ end
583
+
584
+ # Verify the authorization server supports PKCE S256 (RFC 8414 / MCP).
585
+ # Per MCP 2025-11-25, "If code_challenge_methods_supported is absent,
586
+ # the authorization server does not support PKCE and MCP clients MUST
587
+ # refuse to proceed" — absence is a hard stop, not a warning, because
588
+ # proceeding would defeat the authorization-code downgrade protection.
589
+ # @param server_metadata [ServerMetadata]
590
+ # @raise [MCPClient::Errors::ConnectionError] if PKCE S256 support cannot be verified
591
+ def verify_pkce_support!(server_metadata)
592
+ methods = server_metadata.code_challenge_methods_supported
593
+ if methods.nil?
594
+ raise MCPClient::Errors::ConnectionError,
595
+ 'Authorization server metadata omits code_challenge_methods_supported; ' \
596
+ 'the server does not support PKCE and the client must refuse to proceed'
597
+ elsif !methods.include?('S256')
598
+ raise MCPClient::Errors::ConnectionError,
599
+ 'Authorization server does not support PKCE S256 ' \
600
+ '(code_challenge_methods_supported does not include "S256")'
601
+ end
602
+ end
603
+
604
+ # Require HTTPS for all discovered authorization server endpoints, with a
605
+ # localhost exception for local development.
606
+ # @param server_metadata [ServerMetadata]
607
+ def enforce_https_endpoints!(server_metadata)
608
+ enforce_https!(server_metadata.authorization_endpoint, 'authorization endpoint')
609
+ enforce_https!(server_metadata.token_endpoint, 'token endpoint')
610
+ return unless server_metadata.registration_endpoint
611
+
612
+ enforce_https!(server_metadata.registration_endpoint, 'registration endpoint')
613
+ end
614
+
615
+ # @param url [String, nil] endpoint URL
616
+ # @param label [String] human-readable endpoint name for errors
617
+ # @raise [MCPClient::Errors::ConnectionError] if the URL is not HTTPS (non-localhost)
618
+ def enforce_https!(url, label)
619
+ return if url.nil?
620
+
621
+ uri = URI.parse(url)
622
+ return if uri.scheme == 'https'
623
+ # Dev exception is only for plain HTTP on a loopback host — not any other
624
+ # scheme (e.g. ftp://localhost). Use #hostname (not #host) so an IPv6
625
+ # loopback like http://[::1]:9292 matches without the surrounding brackets.
626
+ return if uri.scheme == 'http' && %w[localhost 127.0.0.1 ::1].include?(uri.hostname)
627
+
628
+ raise MCPClient::Errors::ConnectionError, "OAuth #{label} must use HTTPS: #{url}"
629
+ rescue URI::InvalidURIError
630
+ raise MCPClient::Errors::ConnectionError, "OAuth #{label} is not a valid URL: #{url}"
631
+ end
632
+
633
+ # Validate the PRM `resource` identifies this server (RFC 9728 confused
634
+ # deputy protection). Path/canonicalization differences on the same host
635
+ # are tolerated; a different host is rejected.
636
+ # @param resource_metadata [ResourceMetadata]
637
+ # @raise [MCPClient::Errors::ConnectionError] on host mismatch
638
+ def validate_resource_matches!(resource_metadata)
639
+ # RFC 9728 requires `resource`; without it the confused-deputy check is
640
+ # impossible, so a PRM that omits it must be rejected (not trusted).
641
+ if resource_metadata.resource.nil?
642
+ raise MCPClient::Errors::ConnectionError,
643
+ 'Protected resource metadata is missing the required "resource" identifier'
644
+ end
645
+
646
+ # Require an EXACT canonical match (scheme/host/port/path). A same-host
647
+ # match is not enough: a host that serves multiple resources/tenants
648
+ # must not have one tenant's PRM trusted for another.
649
+ advertised = resource_identity(resource_metadata.resource)
650
+ expected = resource_identity(server_url)
651
+ return if advertised == expected
652
+
653
+ raise MCPClient::Errors::ConnectionError,
654
+ "Protected resource metadata resource (#{resource_metadata.resource}) " \
655
+ "does not match the server URL (#{server_url})"
656
+ end
657
+
658
+ # Canonical resource identity (scheme, host, port, path, query) used for
659
+ # the confused-deputy comparison. The query is included because the
660
+ # `resource` parameter sent in the authorization and token requests is the
661
+ # full server URL (some servers distinguish resources by query, e.g.
662
+ # ?serverId=a vs ?serverId=b); only the fragment is ignored. Rejects a
663
+ # non-absolute URL, since the value is untrusted server input.
664
+ # @param url [String] a resource URL
665
+ # @return [String] canonical identity string
666
+ # @raise [MCPClient::Errors::ConnectionError] if the URL is not an absolute URI
667
+ def resource_identity(url)
668
+ uri = URI.parse(url)
669
+ unless uri.scheme && uri.host
670
+ raise MCPClient::Errors::ConnectionError, "Invalid resource URL (must be absolute): #{url}"
671
+ end
672
+
673
+ scheme = uri.scheme.downcase
674
+ host = uri.host.downcase
675
+ port = uri.port
676
+ port = nil if (scheme == 'http' && port == 80) || (scheme == 'https' && port == 443)
677
+ path = uri.path.to_s
678
+ path = '' if path == '/'
679
+ path = path.chomp('/') while path.end_with?('/')
680
+ query = uri.query ? "?#{uri.query}" : ''
681
+
682
+ "#{scheme}://#{host}#{":#{port}" if port}#{path}#{query}"
683
+ rescue URI::InvalidURIError
684
+ raise MCPClient::Errors::ConnectionError, "Invalid resource URL: #{url}"
685
+ end
686
+
687
+ # Fetch resource metadata from URL.
688
+ #
689
+ # Returns nil when the document is genuinely absent (HTTP 404) so a
690
+ # discovery loop can try the next candidate. Any other failure — a
691
+ # non-404 error status, malformed JSON, or a network error — raises,
692
+ # because PRM is authoritative and such a response must not be silently
693
+ # skipped in favor of a different authorization server.
270
694
  # @param url [String] Resource metadata URL
271
- # @return [ResourceMetadata] Resource metadata
272
- # @raise [MCPClient::Errors::ConnectionError] if fetch fails
273
- def fetch_resource_metadata(url)
695
+ # @param strict [Boolean] when true, a 404 raises instead of returning nil
696
+ # (used for a URL explicitly advertised by a 401 challenge)
697
+ # @return [ResourceMetadata, nil] metadata, or nil if a speculative URL returns 404
698
+ # @raise [MCPClient::Errors::ConnectionError] on any non-404 failure, or on 404 when strict
699
+ def fetch_resource_metadata(url, strict: false)
274
700
  logger.debug("Fetching resource metadata from: #{url}")
275
701
 
276
702
  response = @http_client.get(url) do |req|
277
703
  req.headers['Accept'] = 'application/json'
278
704
  end
279
705
 
706
+ return nil if response.status == 404 && !strict
707
+
280
708
  unless response.success?
281
709
  raise MCPClient::Errors::ConnectionError, "Failed to fetch resource metadata: HTTP #{response.status}"
282
710
  end
283
711
 
284
712
  data = JSON.parse(response.body)
285
- ResourceMetadata.from_h(data)
713
+ metadata = ResourceMetadata.from_h(data)
714
+ # Retain the most recently fetched PRM so scope resolution can fall
715
+ # back to its scopes_supported (MCP scope selection priority 2).
716
+ @resource_metadata = metadata
717
+ metadata
286
718
  rescue JSON::ParserError => e
287
719
  raise MCPClient::Errors::ConnectionError, "Invalid resource metadata JSON: #{e.message}"
288
720
  rescue Faraday::Error => e
@@ -312,18 +744,28 @@ module MCPClient
312
744
  raise MCPClient::Errors::ConnectionError, "Network error fetching server metadata: #{e.message}"
313
745
  end
314
746
 
315
- # Get or register OAuth client
747
+ # Get or register OAuth client, following the MCP 2025-11-25 client
748
+ # registration priority order: pre-registered/cached client information
749
+ # first, then Client ID Metadata Documents (SEP-991) when the
750
+ # authorization server advertises support and a metadata URL is
751
+ # configured, then Dynamic Client Registration as a fallback.
316
752
  # @param server_metadata [ServerMetadata] Authorization server metadata
317
753
  # @return [ClientInfo] Client information
318
754
  # @raise [MCPClient::Errors::ConnectionError] if registration fails
319
755
  def get_or_register_client(server_metadata)
320
- # Try to get existing client info from storage
756
+ # 1. Pre-registered or previously registered client info from storage
321
757
  if (client_info = storage.get_client_info(server_url)) && !client_info.client_secret_expired?
322
758
  logger.debug("Using cached OAuth client for #{server_url}")
323
759
  return client_info
324
760
  end
325
761
 
326
- # Register new client if server supports it
762
+ # 2. Client ID Metadata Documents (SEP-991): the HTTPS metadata URL is
763
+ # itself the client_id — no registration request is needed.
764
+ if client_id_metadata_url && server_metadata.supports_client_id_metadata_documents?
765
+ return client_info_from_metadata_url
766
+ end
767
+
768
+ # 3. Dynamic Client Registration (RFC 7591) fallback
327
769
  logger.debug('No cached client found, registering new OAuth client...')
328
770
  if server_metadata.supports_registration?
329
771
  register_client(server_metadata)
@@ -333,6 +775,67 @@ module MCPClient
333
775
  end
334
776
  end
335
777
 
778
+ # Build client information for a Client ID Metadata Document client
779
+ # (SEP-991): the configured HTTPS metadata URL is used directly as the
780
+ # client_id in authorization and token requests, without a dynamic
781
+ # registration POST. Serving the metadata JSON at that URL is the
782
+ # application's responsibility, not this library's.
783
+ # @return [ClientInfo] Client information with the metadata URL as client_id
784
+ def client_info_from_metadata_url
785
+ logger.debug("Using Client ID Metadata Document URL as client_id: #{client_id_metadata_url}")
786
+
787
+ metadata = ClientMetadata.new(
788
+ redirect_uris: [redirect_uri],
789
+ token_endpoint_auth_method: 'none', # Public client
790
+ grant_types: %w[authorization_code refresh_token],
791
+ response_types: ['code'],
792
+ scope: resolved_scope,
793
+ **@extra_client_metadata
794
+ )
795
+
796
+ client_info = ClientInfo.new(client_id: client_id_metadata_url, metadata: metadata)
797
+
798
+ # Persist so complete_authorization_flow and token refresh can find it
799
+ storage.set_client_info(server_url, client_info)
800
+
801
+ client_info
802
+ end
803
+
804
+ # Validate a Client ID Metadata Document URL (SEP-991): "The client_id
805
+ # URL MUST use the 'https' scheme and contain a path component, e.g.
806
+ # https://example.com/client.json". Per the Client ID Metadata Document
807
+ # draft the URL must also have a host and must not contain userinfo,
808
+ # a fragment, or single-/double-dot path segments.
809
+ # @param url [String] Candidate metadata URL
810
+ # @return [String] The validated URL
811
+ # @raise [ArgumentError] if the URL is invalid, not HTTPS, lacks a host or path
812
+ # component, or contains userinfo, a fragment, or dot path segments
813
+ def validate_client_id_metadata_url(url)
814
+ uri = URI.parse(url)
815
+ raise ArgumentError, "client_id_metadata_url must be an HTTPS URL: #{url.inspect}" unless uri.is_a?(URI::HTTPS)
816
+
817
+ raise ArgumentError, "client_id_metadata_url must include a host: #{url.inspect}" if uri.host.to_s.empty?
818
+
819
+ raise ArgumentError, "client_id_metadata_url must not contain userinfo: #{url.inspect}" if uri.userinfo
820
+
821
+ raise ArgumentError, "client_id_metadata_url must not contain a fragment: #{url.inspect}" if uri.fragment
822
+
823
+ if uri.path.to_s.empty? || uri.path == '/'
824
+ raise ArgumentError,
825
+ 'client_id_metadata_url must contain a path component ' \
826
+ "(e.g. https://example.com/client.json): #{url.inspect}"
827
+ end
828
+
829
+ if uri.path.split('/').intersect?(['.', '..'])
830
+ raise ArgumentError,
831
+ "client_id_metadata_url must not contain '.' or '..' path segments: #{url.inspect}"
832
+ end
833
+
834
+ url
835
+ rescue URI::InvalidURIError
836
+ raise ArgumentError, "client_id_metadata_url is not a valid URL: #{url.inspect}"
837
+ end
838
+
336
839
  # Register OAuth client dynamically
337
840
  # @param server_metadata [ServerMetadata] Authorization server metadata
338
841
  # @return [ClientInfo] Registered client information
@@ -340,8 +843,6 @@ module MCPClient
340
843
  def register_client(server_metadata)
341
844
  logger.debug("Registering OAuth client at: #{server_metadata.registration_endpoint}")
342
845
 
343
- resolved_scope = scope == :all ? supported_scopes.join(' ') : scope
344
-
345
846
  metadata = ClientMetadata.new(
346
847
  redirect_uris: [redirect_uri],
347
848
  token_endpoint_auth_method: 'none', # Public client
@@ -417,8 +918,6 @@ module MCPClient
417
918
  # Use the redirect_uri that was actually registered
418
919
  registered_redirect_uri = client_info.metadata.redirect_uris.first
419
920
 
420
- resolved_scope = scope == :all ? supported_scopes.join(' ') : scope
421
-
422
921
  params = {
423
922
  response_type: 'code',
424
923
  client_id: client_info.client_id,
@@ -612,6 +1111,10 @@ module MCPClient
612
1111
  @client_infos[server_url] = client_info
613
1112
  end
614
1113
 
1114
+ def delete_client_info(server_url)
1115
+ @client_infos.delete(server_url)
1116
+ end
1117
+
615
1118
  def get_server_metadata(server_url)
616
1119
  @server_metadata[server_url]
617
1120
  end