ruby-mcp-client 1.1.0 → 2.1.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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +216 -10
  3. data/lib/mcp_client/auth/oauth_provider.rb +325 -27
  4. data/lib/mcp_client/auth.rb +38 -11
  5. data/lib/mcp_client/client.rb +523 -150
  6. data/lib/mcp_client/elicitation_validator.rb +99 -13
  7. data/lib/mcp_client/errors.rb +43 -1
  8. data/lib/mcp_client/http_transport_base.rb +254 -41
  9. data/lib/mcp_client/json_rpc_common.rb +196 -14
  10. data/lib/mcp_client/oauth_client.rb +8 -3
  11. data/lib/mcp_client/prompt.rb +17 -2
  12. data/lib/mcp_client/resource.rb +13 -2
  13. data/lib/mcp_client/resource_content.rb +8 -3
  14. data/lib/mcp_client/resource_link.rb +14 -3
  15. data/lib/mcp_client/resource_template.rb +13 -2
  16. data/lib/mcp_client/root.rb +61 -7
  17. data/lib/mcp_client/schema_validator.rb +329 -0
  18. data/lib/mcp_client/server_base.rb +66 -0
  19. data/lib/mcp_client/server_factory.rb +4 -1
  20. data/lib/mcp_client/server_http/json_rpc_transport.rb +3 -2
  21. data/lib/mcp_client/server_http.rb +18 -12
  22. data/lib/mcp_client/server_sse/json_rpc_transport.rb +97 -14
  23. data/lib/mcp_client/server_sse/origin_policy.rb +57 -0
  24. data/lib/mcp_client/server_sse/reconnect_monitor.rb +17 -4
  25. data/lib/mcp_client/server_sse/sse_parser.rb +78 -10
  26. data/lib/mcp_client/server_sse.rb +132 -35
  27. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +31 -8
  28. data/lib/mcp_client/server_stdio.rb +98 -20
  29. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +222 -26
  30. data/lib/mcp_client/server_streamable_http.rb +472 -108
  31. data/lib/mcp_client/tool.rb +16 -3
  32. data/lib/mcp_client/version.rb +6 -1
  33. data/lib/mcp_client.rb +9 -1
  34. metadata +5 -6
@@ -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,19 +46,30 @@ 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
44
65
  # Protected resource metadata learned from a 401 WWW-Authenticate
45
66
  # challenge, reused by discovery so a challenge-advertised metadata URL
46
- # is not re-derived (and possibly missed).
67
+ # is not re-derived (and possibly missed). The URL itself is retained
68
+ # separately so a failed fetch is retried authoritatively by discovery.
47
69
  @challenge_resource_metadata = nil
70
+ @challenge_metadata_url = nil
71
+ # Why a peer-advertised challenge URL was refused, if one was
72
+ @challenge_error = nil
48
73
  end
49
74
 
50
75
  # @param url [String] Server URL to normalize
@@ -52,6 +77,15 @@ module MCPClient
52
77
  @server_url = normalize_server_url(url)
53
78
  end
54
79
 
80
+ # Set the Client ID Metadata Document URL (SEP-991), validating it per the
81
+ # MCP spec: the client_id URL MUST use the "https" scheme and contain a
82
+ # path component (e.g. https://example.com/client.json).
83
+ # @param url [String, nil] HTTPS URL of this client's metadata document, or nil to clear
84
+ # @raise [ArgumentError] if the URL is not HTTPS or lacks a path component
85
+ def client_id_metadata_url=(url)
86
+ @client_id_metadata_url = url.nil? ? nil : validate_client_id_metadata_url(url)
87
+ end
88
+
55
89
  # Get current access token (refresh if needed)
56
90
  # @return [Token, nil] Current valid access token or nil
57
91
  def access_token
@@ -146,9 +180,34 @@ module MCPClient
146
180
  www_authenticate = response.headers['WWW-Authenticate'] || response.headers['www-authenticate']
147
181
  return nil unless www_authenticate
148
182
 
183
+ # Challenge parameters are read from the Bearer challenge's own
184
+ # segment only — never from the whole (possibly multi-challenge)
185
+ # header — so parameters belonging to Basic or another scheme cannot
186
+ # drive Bearer scope selection or resource metadata discovery. A
187
+ # header without a Bearer challenge carries no usable Bearer params.
188
+ bearer_params = bearer_challenge_segment(www_authenticate)
189
+
190
+ # MCP 2025-11-25: "Clients MUST treat the scopes provided in the
191
+ # challenge as authoritative for satisfying the current request" —
192
+ # including resetting a previously challenged scope when the current
193
+ # challenge carries none.
149
194
  url = extract_resource_metadata_url(www_authenticate)
195
+
196
+ # The challenge header is peer-controlled input: validate the
197
+ # advertised URL BEFORE storing, fetching, or recording any challenge
198
+ # state, so a malicious challenge cannot pivot this host into requests
199
+ # against internal services (SSRF) and cannot leave the provider
200
+ # holding half of a rejected challenge.
201
+ validate_peer_advertised_url!(url, 'resource metadata URL (from WWW-Authenticate challenge)') if url
202
+
203
+ @challenge_scope = bearer_params && extract_challenge_param(bearer_params, 'scope')
150
204
  return nil unless url
151
205
 
206
+ # Remember the advertised URL even if the fetch below fails, so a
207
+ # later discovery retries it instead of probing well-known URIs the
208
+ # challenge already superseded.
209
+ @challenge_metadata_url = url
210
+
152
211
  # This URL was explicitly advertised by the 401 challenge, so a 404 is a
153
212
  # misconfiguration to surface (strict), not a speculative miss to skip.
154
213
  metadata = fetch_resource_metadata(url, strict: true)
@@ -160,27 +219,93 @@ module MCPClient
160
219
 
161
220
  # Extract the protected-resource-metadata URL from a WWW-Authenticate header.
162
221
  # Per RFC 9728 the parameter is `resource_metadata`; a legacy `resource`
163
- # parameter is accepted as a fallback for older servers.
222
+ # parameter is accepted as a fallback for older servers. Only the Bearer
223
+ # challenge's own segment is consulted, so a parameter belonging to
224
+ # another scheme's challenge can never drive discovery.
164
225
  # @param header [String] the WWW-Authenticate header value
165
226
  # @return [String, nil] the metadata URL if present
166
227
  def extract_resource_metadata_url(header)
228
+ params = bearer_challenge_segment(header)
229
+ return nil unless params
230
+
167
231
  # Auth-params may include optional whitespace around '=' (RFC 7235).
168
232
  # Quoted form: resource_metadata = "https://..."
169
- if (m = header.match(/resource_metadata\s*=\s*"([^"]+)"/))
233
+ if (m = params.match(/resource_metadata\s*=\s*"([^"]+)"/))
170
234
  return m[1]
171
235
  end
172
236
 
173
237
  # Unquoted token form: resource_metadata = https://...
174
- if (m = header.match(/resource_metadata\s*=\s*([^,\s]+)/))
238
+ if (m = params.match(/resource_metadata\s*=\s*([^,\s]+)/))
175
239
  return m[1]
176
240
  end
177
241
 
178
242
  # Legacy fallback: resource="https://..."
179
- header.match(/resource\s*=\s*"([^"]+)"/)&.captures&.first
243
+ params.match(/resource\s*=\s*"([^"]+)"/)&.captures&.first
244
+ end
245
+
246
+ # Extract the Bearer challenge's own parameter segment from a (possibly
247
+ # multi-challenge) WWW-Authenticate header, so params belonging to other
248
+ # schemes (e.g. `Basic resource_metadata="...", Bearer realm="x"`) are
249
+ # never attributed to the Bearer challenge. Mirrors
250
+ # HttpTransportBase#bearer_challenge_segment.
251
+ # @param header [String, nil] the WWW-Authenticate header value
252
+ # @return [String, nil] the Bearer challenge's parameters (possibly
253
+ # empty), or nil when the header has no Bearer challenge
254
+ def bearer_challenge_segment(header)
255
+ return nil unless header
256
+
257
+ # Locate the Bearer scheme token only OUTSIDE quoted strings: a
258
+ # quoted value such as realm="prefix Bearer x" must not anchor the
259
+ # segment.
260
+ masked = header.gsub(/"(?:\\.|[^"\\])*"/) { |q| "\"#{' ' * (q.length - 2)}\"" }
261
+ match = masked.match(/(?:\A|[\s,])Bearer(?=[\s,]|\z)/i)
262
+ return nil unless match
263
+
264
+ header[match.end(0)..][AUTH_PARAMS_RUN]
180
265
  end
181
266
 
267
+ # Extract an auth-param value from a WWW-Authenticate header
268
+ # (quoted or unquoted form, optional whitespace around '=').
269
+ # @param header [String] the WWW-Authenticate header value
270
+ # @param name [String] the auth-param name
271
+ # @return [String, nil] the parameter value if present
272
+ def extract_challenge_param(header, name)
273
+ if (m = header.match(/(?:^|[\s,])#{Regexp.escape(name)}\s*=\s*"([^"]*)"/i))
274
+ return m[1]
275
+ end
276
+
277
+ header.match(/(?:^|[\s,])#{Regexp.escape(name)}\s*=\s*([^,\s]+)/i)&.captures&.first
278
+ end
279
+
280
+ # Scope requested by the most recent WWW-Authenticate challenge.
281
+ # @return [String, nil]
282
+ attr_reader :challenge_scope
283
+
182
284
  private
183
285
 
286
+ # Resolve the scope for authorization/registration requests using the
287
+ # MCP 2025-11-25 scope selection strategy: the challenge's scope
288
+ # parameter is authoritative; then an explicitly configured scope
289
+ # (:all resolves to the AS-advertised scope list); then the Protected
290
+ # Resource Metadata's scopes_supported; otherwise omit scope entirely.
291
+ # @return [String, nil]
292
+ def resolved_scope
293
+ return @challenge_scope if @challenge_scope && !@challenge_scope.empty?
294
+
295
+ if scope == :all
296
+ all_scopes = supported_scopes
297
+ return all_scopes.join(' ') unless all_scopes.empty?
298
+ elsif scope
299
+ return scope
300
+ end
301
+
302
+ prm = @challenge_resource_metadata || @resource_metadata
303
+ prm_scopes = prm&.scopes_supported
304
+ return prm_scopes.join(' ') if prm_scopes && !prm_scopes.empty?
305
+
306
+ nil
307
+ end
308
+
184
309
  # Normalize server URL to canonical form
185
310
  # @param url [String] Server URL
186
311
  # @return [String] Normalized URL
@@ -298,9 +423,18 @@ module MCPClient
298
423
  # @return [ServerMetadata] Authorization server metadata
299
424
  # @raise [MCPClient::Errors::ConnectionError] if discovery fails
300
425
  def discover_authorization_server
426
+ # A challenge we refused is still authoritative: it says the cached
427
+ # authorization server is no longer the right one. Falling back to
428
+ # that cache (or to speculative well-known probing) would quietly
429
+ # undo the rejection, so surface it instead.
430
+ raise MCPClient::Errors::ConnectionError, @challenge_error if @challenge_error
431
+
301
432
  # A fresh 401 challenge is authoritative and overrides any cached
302
- # (possibly stale or direct-discovered) authorization server metadata.
303
- cached = storage.get_server_metadata(server_url) unless @challenge_resource_metadata
433
+ # (possibly stale or direct-discovered) authorization server metadata
434
+ # whether the challenge-advertised PRM was already fetched or only its
435
+ # URL is pending (e.g. the initial fetch failed and must be retried).
436
+ challenge_pending = @challenge_resource_metadata || @challenge_metadata_url
437
+ cached = storage.get_server_metadata(server_url) unless challenge_pending
304
438
  if cached
305
439
  # Validate the cached entry before use so a persisted/older cache with
306
440
  # an HTTP endpoint or without S256 is still rejected.
@@ -333,6 +467,8 @@ module MCPClient
333
467
 
334
468
  storage.set_server_metadata(server_url, server_metadata)
335
469
  @challenge_resource_metadata = nil # consumed
470
+ @challenge_metadata_url = nil # consumed
471
+ @challenge_error = nil
336
472
  server_metadata
337
473
  end
338
474
 
@@ -375,8 +511,7 @@ module MCPClient
375
511
  # once PRM IS found it is authoritative: any subsequent failure must be a
376
512
  # hard error, never a silent fallback to an authorization server the PRM
377
513
  # did not advertise.
378
- resource_metadata = @challenge_resource_metadata ||
379
- fetch_first_resource_metadata(protected_resource_metadata_urls(server_url))
514
+ resource_metadata = challenge_or_well_known_resource_metadata
380
515
  return nil unless resource_metadata
381
516
 
382
517
  validate_resource_matches!(resource_metadata)
@@ -387,6 +522,13 @@ module MCPClient
387
522
  'Protected resource metadata does not advertise any authorization_servers'
388
523
  end
389
524
 
525
+ # authorization_servers is untrusted PRM content: validate the
526
+ # advertised origin BEFORE constructing and fetching well-known URLs
527
+ # on it, so a malicious protected resource cannot drive discovery GETs
528
+ # against internal services (SSRF).
529
+ validate_peer_advertised_url!(auth_server_url,
530
+ 'authorization server (advertised by protected resource metadata)')
531
+
390
532
  server_metadata = fetch_first_server_metadata(authorization_server_metadata_urls(auth_server_url))
391
533
  unless server_metadata
392
534
  raise MCPClient::Errors::ConnectionError,
@@ -397,6 +539,25 @@ module MCPClient
397
539
  server_metadata
398
540
  end
399
541
 
542
+ # Resolve the Protected Resource Metadata for discovery.
543
+ #
544
+ # MCP 2025-11-25 MUST: use the resource metadata URL from the parsed
545
+ # WWW-Authenticate headers when present; otherwise fall back to the
546
+ # well-known URIs. A challenge-advertised URL is therefore authoritative:
547
+ # it is fetched strictly, and any failure (including a 404) raises rather
548
+ # than falling through to constructed well-known candidates the challenge
549
+ # superseded.
550
+ # @return [ResourceMetadata, nil] metadata, or nil when no PRM exists at
551
+ # the speculative well-known URLs (permitting the direct-AS fallback)
552
+ # @raise [MCPClient::Errors::ConnectionError] if the challenge-advertised
553
+ # URL cannot be fetched, or a well-known candidate exists but is invalid
554
+ def challenge_or_well_known_resource_metadata
555
+ return @challenge_resource_metadata if @challenge_resource_metadata
556
+ return fetch_resource_metadata(@challenge_metadata_url, strict: true) if @challenge_metadata_url
557
+
558
+ fetch_first_resource_metadata(protected_resource_metadata_urls(server_url))
559
+ end
560
+
400
561
  # Legacy/self-hosted discovery: treat the MCP server ORIGIN as its own
401
562
  # authorization server issuer.
402
563
  # @return [ServerMetadata, nil]
@@ -444,17 +605,18 @@ module MCPClient
444
605
  end
445
606
 
446
607
  # Verify the authorization server supports PKCE S256 (RFC 8414 / MCP).
447
- # Refuses when S256 is explicitly not offered; warns (and proceeds, since
448
- # the client always uses S256) when the field is not advertised at all.
608
+ # Per MCP 2025-11-25, "If code_challenge_methods_supported is absent,
609
+ # the authorization server does not support PKCE and MCP clients MUST
610
+ # refuse to proceed" — absence is a hard stop, not a warning, because
611
+ # proceeding would defeat the authorization-code downgrade protection.
449
612
  # @param server_metadata [ServerMetadata]
450
- # @raise [MCPClient::Errors::ConnectionError] if S256 is explicitly unsupported
613
+ # @raise [MCPClient::Errors::ConnectionError] if PKCE S256 support cannot be verified
451
614
  def verify_pkce_support!(server_metadata)
452
615
  methods = server_metadata.code_challenge_methods_supported
453
616
  if methods.nil?
454
- logger.warn(
455
- 'Authorization server metadata omits code_challenge_methods_supported; ' \
456
- 'proceeding with PKCE S256'
457
- )
617
+ raise MCPClient::Errors::ConnectionError,
618
+ 'Authorization server metadata omits code_challenge_methods_supported; ' \
619
+ 'the server does not support PKCE and the client must refuse to proceed'
458
620
  elsif !methods.include?('S256')
459
621
  raise MCPClient::Errors::ConnectionError,
460
622
  'Authorization server does not support PKCE S256 ' \
@@ -473,6 +635,71 @@ module MCPClient
473
635
  enforce_https!(server_metadata.registration_endpoint, 'registration endpoint')
474
636
  end
475
637
 
638
+ # Validate a URL that a peer advertised to us (a 401 challenge's
639
+ # resource_metadata, or PRM authorization_servers).
640
+ #
641
+ # Stricter than enforce_https!, which exists for URLs the OPERATOR
642
+ # configured and therefore tolerates plain-HTTP loopback for local
643
+ # development. Applying that exception to peer-supplied input would
644
+ # leave the reported SSRF intact against the most sensitive targets of
645
+ # all — services listening only on localhost. The loopback exception is
646
+ # honored here only when the configured MCP server is itself loopback,
647
+ # i.e. the developer is already pointed at a local stack.
648
+ #
649
+ # The rejection is recorded so a later discovery fails closed instead of
650
+ # silently reusing cached authorization-server metadata.
651
+ #
652
+ # NOTE: hostnames are checked literally. This does not resolve DNS, so a
653
+ # public name that resolves to a private address is not caught here;
654
+ # that needs resolution-time checking in the HTTP layer.
655
+ # @param url [String] the peer-advertised URL
656
+ # @param label [String] human-readable name for errors
657
+ # @raise [MCPClient::Errors::ConnectionError] if the URL is not acceptable
658
+ def validate_peer_advertised_url!(url, label)
659
+ uri = URI.parse(url)
660
+ host = uri.hostname.to_s.downcase
661
+
662
+ if uri.scheme != 'https' && !(uri.scheme == 'http' && local_development?)
663
+ reject_challenge!("OAuth #{label} must use HTTPS: #{url}")
664
+ end
665
+ reject_challenge!("OAuth #{label} must not target a loopback or private address: #{url}") if
666
+ local_address?(host) && !local_development?
667
+ rescue URI::InvalidURIError
668
+ reject_challenge!("OAuth #{label} is not a valid URL: #{url}")
669
+ end
670
+
671
+ # @param message [String] why the challenge was refused
672
+ # @raise [MCPClient::Errors::ConnectionError] always
673
+ def reject_challenge!(message)
674
+ # Drop every scrap of the refused challenge so nothing half-applied
675
+ # survives, and remember why for the next discovery attempt.
676
+ @challenge_scope = nil
677
+ @challenge_metadata_url = nil
678
+ @challenge_resource_metadata = nil
679
+ @challenge_error = message
680
+ raise MCPClient::Errors::ConnectionError, message
681
+ end
682
+
683
+ # @return [Boolean] whether the configured MCP server is itself local,
684
+ # in which case local discovery targets are expected
685
+ def local_development?
686
+ local_address?(URI.parse(server_url).hostname.to_s.downcase)
687
+ rescue URI::InvalidURIError
688
+ false
689
+ end
690
+
691
+ # @param host [String] a downcased hostname
692
+ # @return [Boolean] whether it names a loopback, private or link-local address
693
+ def local_address?(host)
694
+ return true if %w[localhost 127.0.0.1 ::1 0.0.0.0].include?(host)
695
+ return true if host.end_with?('.localhost', '.local', '.internal')
696
+ return true if host.start_with?('127.', '10.', '192.168.', '169.254.')
697
+ return true if host.match?(/\A172\.(1[6-9]|2\d|3[01])\./)
698
+ return true if host.match?(/\A\[?(fc|fd|fe80)/)
699
+
700
+ false
701
+ end
702
+
476
703
  # @param url [String, nil] endpoint URL
477
704
  # @param label [String] human-readable endpoint name for errors
478
705
  # @raise [MCPClient::Errors::ConnectionError] if the URL is not HTTPS (non-localhost)
@@ -571,7 +798,11 @@ module MCPClient
571
798
  end
572
799
 
573
800
  data = JSON.parse(response.body)
574
- ResourceMetadata.from_h(data)
801
+ metadata = ResourceMetadata.from_h(data)
802
+ # Retain the most recently fetched PRM so scope resolution can fall
803
+ # back to its scopes_supported (MCP scope selection priority 2).
804
+ @resource_metadata = metadata
805
+ metadata
575
806
  rescue JSON::ParserError => e
576
807
  raise MCPClient::Errors::ConnectionError, "Invalid resource metadata JSON: #{e.message}"
577
808
  rescue Faraday::Error => e
@@ -601,18 +832,28 @@ module MCPClient
601
832
  raise MCPClient::Errors::ConnectionError, "Network error fetching server metadata: #{e.message}"
602
833
  end
603
834
 
604
- # Get or register OAuth client
835
+ # Get or register OAuth client, following the MCP 2025-11-25 client
836
+ # registration priority order: pre-registered/cached client information
837
+ # first, then Client ID Metadata Documents (SEP-991) when the
838
+ # authorization server advertises support and a metadata URL is
839
+ # configured, then Dynamic Client Registration as a fallback.
605
840
  # @param server_metadata [ServerMetadata] Authorization server metadata
606
841
  # @return [ClientInfo] Client information
607
842
  # @raise [MCPClient::Errors::ConnectionError] if registration fails
608
843
  def get_or_register_client(server_metadata)
609
- # Try to get existing client info from storage
844
+ # 1. Pre-registered or previously registered client info from storage
610
845
  if (client_info = storage.get_client_info(server_url)) && !client_info.client_secret_expired?
611
846
  logger.debug("Using cached OAuth client for #{server_url}")
612
847
  return client_info
613
848
  end
614
849
 
615
- # Register new client if server supports it
850
+ # 2. Client ID Metadata Documents (SEP-991): the HTTPS metadata URL is
851
+ # itself the client_id — no registration request is needed.
852
+ if client_id_metadata_url && server_metadata.supports_client_id_metadata_documents?
853
+ return client_info_from_metadata_url
854
+ end
855
+
856
+ # 3. Dynamic Client Registration (RFC 7591) fallback
616
857
  logger.debug('No cached client found, registering new OAuth client...')
617
858
  if server_metadata.supports_registration?
618
859
  register_client(server_metadata)
@@ -622,6 +863,67 @@ module MCPClient
622
863
  end
623
864
  end
624
865
 
866
+ # Build client information for a Client ID Metadata Document client
867
+ # (SEP-991): the configured HTTPS metadata URL is used directly as the
868
+ # client_id in authorization and token requests, without a dynamic
869
+ # registration POST. Serving the metadata JSON at that URL is the
870
+ # application's responsibility, not this library's.
871
+ # @return [ClientInfo] Client information with the metadata URL as client_id
872
+ def client_info_from_metadata_url
873
+ logger.debug("Using Client ID Metadata Document URL as client_id: #{client_id_metadata_url}")
874
+
875
+ metadata = ClientMetadata.new(
876
+ redirect_uris: [redirect_uri],
877
+ token_endpoint_auth_method: 'none', # Public client
878
+ grant_types: %w[authorization_code refresh_token],
879
+ response_types: ['code'],
880
+ scope: resolved_scope,
881
+ **@extra_client_metadata
882
+ )
883
+
884
+ client_info = ClientInfo.new(client_id: client_id_metadata_url, metadata: metadata)
885
+
886
+ # Persist so complete_authorization_flow and token refresh can find it
887
+ storage.set_client_info(server_url, client_info)
888
+
889
+ client_info
890
+ end
891
+
892
+ # Validate a Client ID Metadata Document URL (SEP-991): "The client_id
893
+ # URL MUST use the 'https' scheme and contain a path component, e.g.
894
+ # https://example.com/client.json". Per the Client ID Metadata Document
895
+ # draft the URL must also have a host and must not contain userinfo,
896
+ # a fragment, or single-/double-dot path segments.
897
+ # @param url [String] Candidate metadata URL
898
+ # @return [String] The validated URL
899
+ # @raise [ArgumentError] if the URL is invalid, not HTTPS, lacks a host or path
900
+ # component, or contains userinfo, a fragment, or dot path segments
901
+ def validate_client_id_metadata_url(url)
902
+ uri = URI.parse(url)
903
+ raise ArgumentError, "client_id_metadata_url must be an HTTPS URL: #{url.inspect}" unless uri.is_a?(URI::HTTPS)
904
+
905
+ raise ArgumentError, "client_id_metadata_url must include a host: #{url.inspect}" if uri.host.to_s.empty?
906
+
907
+ raise ArgumentError, "client_id_metadata_url must not contain userinfo: #{url.inspect}" if uri.userinfo
908
+
909
+ raise ArgumentError, "client_id_metadata_url must not contain a fragment: #{url.inspect}" if uri.fragment
910
+
911
+ if uri.path.to_s.empty? || uri.path == '/'
912
+ raise ArgumentError,
913
+ 'client_id_metadata_url must contain a path component ' \
914
+ "(e.g. https://example.com/client.json): #{url.inspect}"
915
+ end
916
+
917
+ if uri.path.split('/').intersect?(['.', '..'])
918
+ raise ArgumentError,
919
+ "client_id_metadata_url must not contain '.' or '..' path segments: #{url.inspect}"
920
+ end
921
+
922
+ url
923
+ rescue URI::InvalidURIError
924
+ raise ArgumentError, "client_id_metadata_url is not a valid URL: #{url.inspect}"
925
+ end
926
+
625
927
  # Register OAuth client dynamically
626
928
  # @param server_metadata [ServerMetadata] Authorization server metadata
627
929
  # @return [ClientInfo] Registered client information
@@ -629,8 +931,6 @@ module MCPClient
629
931
  def register_client(server_metadata)
630
932
  logger.debug("Registering OAuth client at: #{server_metadata.registration_endpoint}")
631
933
 
632
- resolved_scope = scope == :all ? supported_scopes.join(' ') : scope
633
-
634
934
  metadata = ClientMetadata.new(
635
935
  redirect_uris: [redirect_uri],
636
936
  token_endpoint_auth_method: 'none', # Public client
@@ -706,8 +1006,6 @@ module MCPClient
706
1006
  # Use the redirect_uri that was actually registered
707
1007
  registered_redirect_uri = client_info.metadata.redirect_uris.first
708
1008
 
709
- resolved_scope = scope == :all ? supported_scopes.join(' ') : scope
710
-
711
1009
  params = {
712
1010
  response_type: 'code',
713
1011
  client_id: client_info.client_id,
@@ -845,7 +1143,7 @@ module MCPClient
845
1143
  storage.set_token(server_url, new_token)
846
1144
  new_token
847
1145
  rescue JSON::ParserError => e
848
- logger.warn("Invalid token refresh response: #{e.message}")
1146
+ logger.warn("Invalid token refresh response: #{describe_parse_error(e)}")
849
1147
  nil
850
1148
  rescue Faraday::Error => e
851
1149
  logger.warn("Network error during token refresh: #{e.message}")
@@ -100,13 +100,11 @@ module MCPClient
100
100
  # @param tos_uri [String, nil] URL of the client terms of service
101
101
  # @param policy_uri [String, nil] URL of the client privacy policy
102
102
  # @param contacts [Array<String>, nil] List of contact emails for the client
103
- # rubocop:disable Metrics/ParameterLists
104
103
  def initialize(redirect_uris:, token_endpoint_auth_method: 'none',
105
104
  grant_types: %w[authorization_code refresh_token],
106
105
  response_types: ['code'], scope: nil,
107
106
  client_name: nil, client_uri: nil, logo_uri: nil,
108
107
  tos_uri: nil, policy_uri: nil, contacts: nil)
109
- # rubocop:enable Metrics/ParameterLists
110
108
  @redirect_uris = redirect_uris
111
109
  @token_endpoint_auth_method = token_endpoint_auth_method
112
110
  @grant_types = grant_types
@@ -226,7 +224,7 @@ module MCPClient
226
224
  class ServerMetadata
227
225
  attr_reader :issuer, :authorization_endpoint, :token_endpoint, :registration_endpoint,
228
226
  :scopes_supported, :response_types_supported, :grant_types_supported,
229
- :code_challenge_methods_supported
227
+ :code_challenge_methods_supported, :client_id_metadata_document_supported
230
228
 
231
229
  # @param issuer [String] Issuer identifier URL
232
230
  # @param authorization_endpoint [String] Authorization endpoint URL
@@ -236,9 +234,11 @@ module MCPClient
236
234
  # @param response_types_supported [Array<String>, nil] Supported response types
237
235
  # @param grant_types_supported [Array<String>, nil] Supported grant types
238
236
  # @param code_challenge_methods_supported [Array<String>, nil] Supported PKCE code challenge methods (RFC 8414)
237
+ # @param client_id_metadata_document_supported [Boolean, nil] Whether the server accepts
238
+ # Client ID Metadata Document client IDs (MCP 2025-11-25 / SEP-991)
239
239
  def initialize(issuer:, authorization_endpoint:, token_endpoint:, registration_endpoint: nil,
240
240
  scopes_supported: nil, response_types_supported: nil, grant_types_supported: nil,
241
- code_challenge_methods_supported: nil)
241
+ code_challenge_methods_supported: nil, client_id_metadata_document_supported: nil)
242
242
  @issuer = issuer
243
243
  @authorization_endpoint = authorization_endpoint
244
244
  @token_endpoint = token_endpoint
@@ -247,6 +247,7 @@ module MCPClient
247
247
  @response_types_supported = response_types_supported
248
248
  @grant_types_supported = grant_types_supported
249
249
  @code_challenge_methods_supported = code_challenge_methods_supported
250
+ @client_id_metadata_document_supported = client_id_metadata_document_supported
250
251
  end
251
252
 
252
253
  # Check if dynamic client registration is supported
@@ -255,6 +256,13 @@ module MCPClient
255
256
  !@registration_endpoint.nil?
256
257
  end
257
258
 
259
+ # Check if the server accepts clients using Client ID Metadata Documents
260
+ # (MCP 2025-11-25 / SEP-991), i.e. HTTPS URLs as client identifiers
261
+ # @return [Boolean] true if client_id_metadata_document_supported is true
262
+ def supports_client_id_metadata_documents?
263
+ @client_id_metadata_document_supported == true
264
+ end
265
+
258
266
  # Convert to hash
259
267
  # @return [Hash] Hash representation
260
268
  def to_h
@@ -266,7 +274,8 @@ module MCPClient
266
274
  scopes_supported: @scopes_supported,
267
275
  response_types_supported: @response_types_supported,
268
276
  grant_types_supported: @grant_types_supported,
269
- code_challenge_methods_supported: @code_challenge_methods_supported
277
+ code_challenge_methods_supported: @code_challenge_methods_supported,
278
+ client_id_metadata_document_supported: @client_id_metadata_document_supported
270
279
  }.compact
271
280
  end
272
281
 
@@ -283,20 +292,36 @@ module MCPClient
283
292
  response_types_supported: data[:response_types_supported] || data['response_types_supported'],
284
293
  grant_types_supported: data[:grant_types_supported] || data['grant_types_supported'],
285
294
  code_challenge_methods_supported: data[:code_challenge_methods_supported] ||
286
- data['code_challenge_methods_supported']
295
+ data['code_challenge_methods_supported'],
296
+ client_id_metadata_document_supported: fetch_boolean(data, :client_id_metadata_document_supported)
287
297
  )
288
298
  end
299
+
300
+ # Fetch a possibly-false value from a hash by symbol or string key.
301
+ # Unlike the `||` chains above, this preserves an explicit false.
302
+ # @param data [Hash] Source hash
303
+ # @param key [Symbol] Key to fetch
304
+ # @return [Object, nil] The value, or nil when absent under both keys
305
+ def self.fetch_boolean(data, key)
306
+ return data[key] if data.key?(key)
307
+
308
+ data[key.to_s]
309
+ end
310
+ private_class_method :fetch_boolean
289
311
  end
290
312
 
291
313
  # Protected resource metadata for authorization server discovery
292
314
  class ResourceMetadata
293
- attr_reader :resource, :authorization_servers
315
+ attr_reader :resource, :authorization_servers, :scopes_supported
294
316
 
295
317
  # @param resource [String] Resource server identifier
296
318
  # @param authorization_servers [Array<String>] List of authorization server URLs
297
- def initialize(resource:, authorization_servers:)
319
+ # @param scopes_supported [Array<String>, nil] Scopes the resource supports (RFC 9728);
320
+ # per MCP, the default scope set when a challenge provides none
321
+ def initialize(resource:, authorization_servers:, scopes_supported: nil)
298
322
  @resource = resource
299
323
  @authorization_servers = authorization_servers
324
+ @scopes_supported = scopes_supported
300
325
  end
301
326
 
302
327
  # Convert to hash
@@ -304,8 +329,9 @@ module MCPClient
304
329
  def to_h
305
330
  {
306
331
  resource: @resource,
307
- authorization_servers: @authorization_servers
308
- }
332
+ authorization_servers: @authorization_servers,
333
+ scopes_supported: @scopes_supported
334
+ }.compact
309
335
  end
310
336
 
311
337
  # Create resource metadata from hash
@@ -314,7 +340,8 @@ module MCPClient
314
340
  def self.from_h(data)
315
341
  new(
316
342
  resource: data[:resource] || data['resource'],
317
- authorization_servers: data[:authorization_servers] || data['authorization_servers']
343
+ authorization_servers: data[:authorization_servers] || data['authorization_servers'],
344
+ scopes_supported: data[:scopes_supported] || data['scopes_supported']
318
345
  )
319
346
  end
320
347
  end