ruby-mcp-client 1.0.0 → 1.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.
@@ -14,7 +14,7 @@ module MCPClient
14
14
  # @!attribute [rw] redirect_uri
15
15
  # @return [String] OAuth redirect URI
16
16
  # @!attribute [rw] scope
17
- # @return [String, nil] OAuth scope
17
+ # @return [String, Symbol, nil] OAuth scope (use :all for all server-supported scopes)
18
18
  # @!attribute [rw] logger
19
19
  # @return [Logger] Logger instance
20
20
  # @!attribute [rw] storage
@@ -27,16 +27,24 @@ module MCPClient
27
27
  # Initialize OAuth provider
28
28
  # @param server_url [String] The MCP server URL (used as OAuth resource parameter)
29
29
  # @param redirect_uri [String] OAuth redirect URI (default: http://localhost:8080/callback)
30
- # @param scope [String, nil] OAuth scope
30
+ # @param scope [String, Symbol, nil] OAuth scope (use :all for all server-supported scopes)
31
31
  # @param logger [Logger, nil] Optional logger
32
32
  # @param storage [Object, nil] Storage backend for tokens and client info
33
- def initialize(server_url:, redirect_uri: 'http://localhost:8080/callback', scope: nil, logger: nil, storage: nil)
33
+ # @param client_metadata [Hash] Extra OIDC client metadata fields for DCR registration.
34
+ # Supported keys: :client_name, :client_uri, :logo_uri, :tos_uri, :policy_uri, :contacts
35
+ def initialize(server_url:, redirect_uri: 'http://localhost:8080/callback', scope: nil, logger: nil, storage: nil,
36
+ client_metadata: {})
34
37
  self.server_url = server_url
35
38
  self.redirect_uri = redirect_uri
36
39
  self.scope = scope
37
40
  self.logger = logger || Logger.new($stdout, level: Logger::WARN)
38
41
  self.storage = storage || MemoryStorage.new
42
+ @extra_client_metadata = client_metadata
39
43
  @http_client = create_http_client
44
+ # Protected resource metadata learned from a 401 WWW-Authenticate
45
+ # challenge, reused by discovery so a challenge-advertised metadata URL
46
+ # is not re-derived (and possibly missed).
47
+ @challenge_resource_metadata = nil
40
48
  end
41
49
 
42
50
  # @param url [String] Server URL to normalize
@@ -58,6 +66,14 @@ module MCPClient
58
66
  refresh_token(token) if token.refresh_token
59
67
  end
60
68
 
69
+ # Return the scopes supported by the authorization server
70
+ # Discovers server metadata and returns the scopes_supported list.
71
+ # @return [Array<String>] supported scopes, or empty array if not advertised
72
+ # @raise [MCPClient::Errors::ConnectionError] if server discovery fails
73
+ def supported_scopes
74
+ @supported_scopes ||= discover_authorization_server.scopes_supported || []
75
+ end
76
+
61
77
  # Start OAuth authorization flow
62
78
  # @return [String] Authorization URL to redirect user to
63
79
  # @raise [MCPClient::Errors::ConnectionError] if server discovery fails
@@ -130,12 +146,37 @@ module MCPClient
130
146
  www_authenticate = response.headers['WWW-Authenticate'] || response.headers['www-authenticate']
131
147
  return nil unless www_authenticate
132
148
 
133
- # Parse WWW-Authenticate header to extract resource metadata URL
134
- # Format: Bearer resource="https://example.com/.well-known/oauth-protected-resource"
135
- if (match = www_authenticate.match(/resource="([^"]+)"/))
136
- resource_metadata_url = match[1]
137
- fetch_resource_metadata(resource_metadata_url)
149
+ url = extract_resource_metadata_url(www_authenticate)
150
+ return nil unless url
151
+
152
+ # This URL was explicitly advertised by the 401 challenge, so a 404 is a
153
+ # misconfiguration to surface (strict), not a speculative miss to skip.
154
+ metadata = fetch_resource_metadata(url, strict: true)
155
+ # Reuse this challenge-advertised metadata during the subsequent OAuth
156
+ # flow instead of re-deriving (and possibly missing) the well-known URL.
157
+ @challenge_resource_metadata = metadata
158
+ metadata
159
+ end
160
+
161
+ # Extract the protected-resource-metadata URL from a WWW-Authenticate header.
162
+ # Per RFC 9728 the parameter is `resource_metadata`; a legacy `resource`
163
+ # parameter is accepted as a fallback for older servers.
164
+ # @param header [String] the WWW-Authenticate header value
165
+ # @return [String, nil] the metadata URL if present
166
+ def extract_resource_metadata_url(header)
167
+ # Auth-params may include optional whitespace around '=' (RFC 7235).
168
+ # Quoted form: resource_metadata = "https://..."
169
+ if (m = header.match(/resource_metadata\s*=\s*"([^"]+)"/))
170
+ return m[1]
138
171
  end
172
+
173
+ # Unquoted token form: resource_metadata = https://...
174
+ if (m = header.match(/resource_metadata\s*=\s*([^,\s]+)/))
175
+ return m[1]
176
+ end
177
+
178
+ # Legacy fallback: resource="https://..."
179
+ header.match(/resource\s*=\s*"([^"]+)"/)&.captures&.first
139
180
  end
140
181
 
141
182
  private
@@ -201,6 +242,55 @@ module MCPClient
201
242
  (uri.scheme == 'https' && uri.port == 443)
202
243
  end
203
244
 
245
+ # Build the scheme://host[:port] origin for a parsed URI.
246
+ # @param uri [URI] Parsed URI
247
+ # @return [String] origin string
248
+ def origin_of(uri)
249
+ origin = "#{uri.scheme}://#{uri.host}"
250
+ origin += ":#{uri.port}" if uri.port && !default_port?(uri)
251
+ origin
252
+ end
253
+
254
+ # Protected Resource Metadata well-known URLs to try, in priority order
255
+ # (RFC 9728 §3.1). When the resource identifier has a path, the well-known
256
+ # segment is inserted between the host and the path; a root-level URL is
257
+ # always tried as a fallback.
258
+ # @param server_url [String] the MCP server (protected resource) URL
259
+ # @return [Array<String>] ordered candidate URLs
260
+ def protected_resource_metadata_urls(server_url)
261
+ uri = URI.parse(server_url)
262
+ origin = origin_of(uri)
263
+ path = uri.path.to_s
264
+
265
+ urls = []
266
+ urls << "#{origin}/.well-known/oauth-protected-resource#{path}" unless path.empty? || path == '/'
267
+ urls << "#{origin}/.well-known/oauth-protected-resource"
268
+ urls.uniq
269
+ end
270
+
271
+ # Authorization Server Metadata well-known URLs to try, in priority order
272
+ # (RFC 8414 §3.1 path-insertion plus OpenID Connect Discovery). When the
273
+ # issuer has a path, the well-known segment is INSERTED between host and
274
+ # path (not appended); the OIDC path-append form is also tried.
275
+ # @param issuer [String] the authorization server issuer URL
276
+ # @return [Array<String>] ordered candidate URLs
277
+ def authorization_server_metadata_urls(issuer)
278
+ uri = URI.parse(issuer)
279
+ origin = origin_of(uri)
280
+ path = uri.path.to_s
281
+
282
+ urls = []
283
+ if path.empty? || path == '/'
284
+ urls << "#{origin}/.well-known/oauth-authorization-server"
285
+ urls << "#{origin}/.well-known/openid-configuration"
286
+ else
287
+ urls << "#{origin}/.well-known/oauth-authorization-server#{path}"
288
+ urls << "#{origin}/.well-known/openid-configuration#{path}"
289
+ urls << "#{origin}#{path}/.well-known/openid-configuration"
290
+ end
291
+ urls.uniq
292
+ end
293
+
204
294
  # Discover authorization server metadata
205
295
  # Tries multiple discovery patterns:
206
296
  # 1. oauth-authorization-server (MCP spec pattern - server is its own auth server)
@@ -208,63 +298,274 @@ module MCPClient
208
298
  # @return [ServerMetadata] Authorization server metadata
209
299
  # @raise [MCPClient::Errors::ConnectionError] if discovery fails
210
300
  def discover_authorization_server
211
- # Try to get from storage first
212
- if (cached = storage.get_server_metadata(server_url))
301
+ # 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
304
+ if cached
305
+ # Validate the cached entry before use so a persisted/older cache with
306
+ # an HTTP endpoint or without S256 is still rejected.
307
+ validate_server_metadata!(cached)
213
308
  return cached
214
309
  end
215
310
 
216
- server_metadata = nil
311
+ discover_and_cache_authorization_server
312
+ end
217
313
 
218
- # Primary discovery: oauth-authorization-server (MCP spec pattern)
219
- # Used by servers that are both resource and authorization server
220
- begin
221
- discovery_url = build_discovery_url(server_url, :authorization_server)
222
- logger.debug("Attempting OAuth discovery: #{discovery_url}")
223
- server_metadata = fetch_server_metadata(discovery_url)
224
- rescue MCPClient::Errors::ConnectionError => e
225
- logger.debug("oauth-authorization-server discovery failed: #{e.message}")
226
- end
314
+ # Discover authorization server metadata, validate it, and cache it.
315
+ # @return [ServerMetadata]
316
+ # @raise [MCPClient::Errors::ConnectionError] if discovery or validation fails
317
+ def discover_and_cache_authorization_server
318
+ previous = storage.get_server_metadata(server_url)
227
319
 
228
- # Fallback discovery: oauth-protected-resource (delegation pattern)
229
- # Used by resource servers that delegate to external authorization servers
230
- unless server_metadata
231
- begin
232
- discovery_url = build_discovery_url(server_url, :protected_resource)
233
- logger.debug("Attempting OAuth discovery: #{discovery_url}")
234
-
235
- resource_metadata = fetch_resource_metadata(discovery_url)
236
- auth_server_url = resource_metadata.authorization_servers.first
237
-
238
- if auth_server_url
239
- server_metadata = fetch_server_metadata("#{auth_server_url}/.well-known/oauth-authorization-server")
240
- end
241
- rescue MCPClient::Errors::ConnectionError => e
242
- logger.debug("oauth-protected-resource discovery failed: #{e.message}")
243
- end
244
- end
320
+ # RFC 9728: Protected Resource Metadata is authoritative — try it first,
321
+ # then fall back to treating the MCP server origin as its own AS.
322
+ server_metadata = discover_via_protected_resource || discover_via_direct_authorization_server
245
323
 
246
324
  unless server_metadata
247
325
  raise MCPClient::Errors::ConnectionError,
248
- 'OAuth discovery failed: no valid endpoints found'
326
+ 'OAuth discovery failed: no valid authorization server metadata found'
249
327
  end
250
328
 
251
- # Cache the metadata
329
+ # Validate BEFORE caching so invalid metadata is never persisted.
330
+ validate_server_metadata!(server_metadata)
331
+
332
+ invalidate_client_info_on_as_change(previous, server_metadata)
333
+
252
334
  storage.set_server_metadata(server_url, server_metadata)
335
+ @challenge_resource_metadata = nil # consumed
336
+ server_metadata
337
+ end
338
+
339
+ # When a 401 challenge changes the authorization server, per-AS state cached
340
+ # under this server_url becomes invalid: a client_id registered with the
341
+ # previous AS would fail as invalid_client, and memoized scopes belong to
342
+ # the old AS. Discard both so the new flow re-registers and re-discovers.
343
+ # @param previous [ServerMetadata, nil] previously cached AS metadata
344
+ # @param current [ServerMetadata] newly discovered AS metadata
345
+ def invalidate_client_info_on_as_change(previous, current)
346
+ return unless previous && previous.issuer != current.issuer
347
+
348
+ logger.debug('Authorization server changed; discarding client and scopes from the previous AS')
349
+
350
+ # Prefer an explicit delete; fall back to the always-available
351
+ # set_client_info(nil) so custom storage backends are handled too.
352
+ if storage.respond_to?(:delete_client_info)
353
+ storage.delete_client_info(server_url)
354
+ else
355
+ storage.set_client_info(server_url, nil)
356
+ end
357
+
358
+ @supported_scopes = nil
359
+ end
360
+
361
+ # Apply the PKCE-support and HTTPS-endpoint checks to server metadata.
362
+ # @param server_metadata [ServerMetadata]
363
+ # @raise [MCPClient::Errors::ConnectionError] if a check fails
364
+ def validate_server_metadata!(server_metadata)
365
+ verify_pkce_support!(server_metadata)
366
+ enforce_https_endpoints!(server_metadata)
367
+ end
368
+
369
+ # PRM-first discovery: fetch Protected Resource Metadata and then the
370
+ # authorization server it advertises.
371
+ # @return [ServerMetadata, nil] AS metadata, or nil if no PRM document exists
372
+ # @raise [MCPClient::Errors::ConnectionError] if PRM is malformed or mismatched
373
+ def discover_via_protected_resource
374
+ # A missing PRM document (return nil) permits the direct-AS fallback. But
375
+ # once PRM IS found it is authoritative: any subsequent failure must be a
376
+ # hard error, never a silent fallback to an authorization server the PRM
377
+ # did not advertise.
378
+ resource_metadata = @challenge_resource_metadata ||
379
+ fetch_first_resource_metadata(protected_resource_metadata_urls(server_url))
380
+ return nil unless resource_metadata
381
+
382
+ validate_resource_matches!(resource_metadata)
383
+
384
+ auth_server_url = Array(resource_metadata.authorization_servers).first
385
+ unless auth_server_url
386
+ raise MCPClient::Errors::ConnectionError,
387
+ 'Protected resource metadata does not advertise any authorization_servers'
388
+ end
389
+
390
+ server_metadata = fetch_first_server_metadata(authorization_server_metadata_urls(auth_server_url))
391
+ unless server_metadata
392
+ raise MCPClient::Errors::ConnectionError,
393
+ "Authorization server advertised by protected resource metadata (#{auth_server_url}) " \
394
+ 'could not be discovered'
395
+ end
253
396
 
254
397
  server_metadata
255
398
  end
256
399
 
257
- # Fetch resource metadata from URL
400
+ # Legacy/self-hosted discovery: treat the MCP server ORIGIN as its own
401
+ # authorization server issuer.
402
+ # @return [ServerMetadata, nil]
403
+ def discover_via_direct_authorization_server
404
+ origin = origin_of(URI.parse(server_url))
405
+ fetch_first_server_metadata(authorization_server_metadata_urls(origin))
406
+ end
407
+
408
+ # Fetch the first Protected Resource Metadata document that resolves.
409
+ #
410
+ # PRM is authoritative: a candidate that genuinely does not exist (HTTP
411
+ # 404) is skipped so the next candidate is tried, but a candidate that
412
+ # exists yet is malformed or errors (bad JSON, 5xx, network failure) is a
413
+ # HARD failure — it must not silently fall through to a root PRM or the
414
+ # direct-AS path, which could point at a different authorization server.
415
+ # @param urls [Array<String>] candidate URLs
416
+ # @return [ResourceMetadata, nil]
417
+ def fetch_first_resource_metadata(urls)
418
+ urls.each do |url|
419
+ md = fetch_resource_metadata(url) # returns nil only for a 404 (absent); raises otherwise
420
+ return md if md
421
+ end
422
+ nil
423
+ end
424
+
425
+ # Fetch the first Authorization Server Metadata document that resolves.
426
+ # The oauth-authorization-server and openid-configuration forms are
427
+ # genuine alternatives, so any failing candidate is skipped to try the next.
428
+ # @param urls [Array<String>] candidate URLs
429
+ # @return [ServerMetadata, nil]
430
+ def fetch_first_server_metadata(urls)
431
+ urls.each do |url|
432
+ md = try_fetch_server_metadata(url)
433
+ return md if md
434
+ end
435
+ nil
436
+ end
437
+
438
+ # Non-raising server-metadata fetch used while iterating candidates.
439
+ def try_fetch_server_metadata(url)
440
+ fetch_server_metadata(url)
441
+ rescue MCPClient::Errors::ConnectionError => e
442
+ logger.debug("Authorization server metadata candidate failed (#{url}): #{e.message}")
443
+ nil
444
+ end
445
+
446
+ # 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.
449
+ # @param server_metadata [ServerMetadata]
450
+ # @raise [MCPClient::Errors::ConnectionError] if S256 is explicitly unsupported
451
+ def verify_pkce_support!(server_metadata)
452
+ methods = server_metadata.code_challenge_methods_supported
453
+ if methods.nil?
454
+ logger.warn(
455
+ 'Authorization server metadata omits code_challenge_methods_supported; ' \
456
+ 'proceeding with PKCE S256'
457
+ )
458
+ elsif !methods.include?('S256')
459
+ raise MCPClient::Errors::ConnectionError,
460
+ 'Authorization server does not support PKCE S256 ' \
461
+ '(code_challenge_methods_supported does not include "S256")'
462
+ end
463
+ end
464
+
465
+ # Require HTTPS for all discovered authorization server endpoints, with a
466
+ # localhost exception for local development.
467
+ # @param server_metadata [ServerMetadata]
468
+ def enforce_https_endpoints!(server_metadata)
469
+ enforce_https!(server_metadata.authorization_endpoint, 'authorization endpoint')
470
+ enforce_https!(server_metadata.token_endpoint, 'token endpoint')
471
+ return unless server_metadata.registration_endpoint
472
+
473
+ enforce_https!(server_metadata.registration_endpoint, 'registration endpoint')
474
+ end
475
+
476
+ # @param url [String, nil] endpoint URL
477
+ # @param label [String] human-readable endpoint name for errors
478
+ # @raise [MCPClient::Errors::ConnectionError] if the URL is not HTTPS (non-localhost)
479
+ def enforce_https!(url, label)
480
+ return if url.nil?
481
+
482
+ uri = URI.parse(url)
483
+ return if uri.scheme == 'https'
484
+ # Dev exception is only for plain HTTP on a loopback host — not any other
485
+ # scheme (e.g. ftp://localhost). Use #hostname (not #host) so an IPv6
486
+ # loopback like http://[::1]:9292 matches without the surrounding brackets.
487
+ return if uri.scheme == 'http' && %w[localhost 127.0.0.1 ::1].include?(uri.hostname)
488
+
489
+ raise MCPClient::Errors::ConnectionError, "OAuth #{label} must use HTTPS: #{url}"
490
+ rescue URI::InvalidURIError
491
+ raise MCPClient::Errors::ConnectionError, "OAuth #{label} is not a valid URL: #{url}"
492
+ end
493
+
494
+ # Validate the PRM `resource` identifies this server (RFC 9728 confused
495
+ # deputy protection). Path/canonicalization differences on the same host
496
+ # are tolerated; a different host is rejected.
497
+ # @param resource_metadata [ResourceMetadata]
498
+ # @raise [MCPClient::Errors::ConnectionError] on host mismatch
499
+ def validate_resource_matches!(resource_metadata)
500
+ # RFC 9728 requires `resource`; without it the confused-deputy check is
501
+ # impossible, so a PRM that omits it must be rejected (not trusted).
502
+ if resource_metadata.resource.nil?
503
+ raise MCPClient::Errors::ConnectionError,
504
+ 'Protected resource metadata is missing the required "resource" identifier'
505
+ end
506
+
507
+ # Require an EXACT canonical match (scheme/host/port/path). A same-host
508
+ # match is not enough: a host that serves multiple resources/tenants
509
+ # must not have one tenant's PRM trusted for another.
510
+ advertised = resource_identity(resource_metadata.resource)
511
+ expected = resource_identity(server_url)
512
+ return if advertised == expected
513
+
514
+ raise MCPClient::Errors::ConnectionError,
515
+ "Protected resource metadata resource (#{resource_metadata.resource}) " \
516
+ "does not match the server URL (#{server_url})"
517
+ end
518
+
519
+ # Canonical resource identity (scheme, host, port, path, query) used for
520
+ # the confused-deputy comparison. The query is included because the
521
+ # `resource` parameter sent in the authorization and token requests is the
522
+ # full server URL (some servers distinguish resources by query, e.g.
523
+ # ?serverId=a vs ?serverId=b); only the fragment is ignored. Rejects a
524
+ # non-absolute URL, since the value is untrusted server input.
525
+ # @param url [String] a resource URL
526
+ # @return [String] canonical identity string
527
+ # @raise [MCPClient::Errors::ConnectionError] if the URL is not an absolute URI
528
+ def resource_identity(url)
529
+ uri = URI.parse(url)
530
+ unless uri.scheme && uri.host
531
+ raise MCPClient::Errors::ConnectionError, "Invalid resource URL (must be absolute): #{url}"
532
+ end
533
+
534
+ scheme = uri.scheme.downcase
535
+ host = uri.host.downcase
536
+ port = uri.port
537
+ port = nil if (scheme == 'http' && port == 80) || (scheme == 'https' && port == 443)
538
+ path = uri.path.to_s
539
+ path = '' if path == '/'
540
+ path = path.chomp('/') while path.end_with?('/')
541
+ query = uri.query ? "?#{uri.query}" : ''
542
+
543
+ "#{scheme}://#{host}#{":#{port}" if port}#{path}#{query}"
544
+ rescue URI::InvalidURIError
545
+ raise MCPClient::Errors::ConnectionError, "Invalid resource URL: #{url}"
546
+ end
547
+
548
+ # Fetch resource metadata from URL.
549
+ #
550
+ # Returns nil when the document is genuinely absent (HTTP 404) so a
551
+ # discovery loop can try the next candidate. Any other failure — a
552
+ # non-404 error status, malformed JSON, or a network error — raises,
553
+ # because PRM is authoritative and such a response must not be silently
554
+ # skipped in favor of a different authorization server.
258
555
  # @param url [String] Resource metadata URL
259
- # @return [ResourceMetadata] Resource metadata
260
- # @raise [MCPClient::Errors::ConnectionError] if fetch fails
261
- def fetch_resource_metadata(url)
556
+ # @param strict [Boolean] when true, a 404 raises instead of returning nil
557
+ # (used for a URL explicitly advertised by a 401 challenge)
558
+ # @return [ResourceMetadata, nil] metadata, or nil if a speculative URL returns 404
559
+ # @raise [MCPClient::Errors::ConnectionError] on any non-404 failure, or on 404 when strict
560
+ def fetch_resource_metadata(url, strict: false)
262
561
  logger.debug("Fetching resource metadata from: #{url}")
263
562
 
264
563
  response = @http_client.get(url) do |req|
265
564
  req.headers['Accept'] = 'application/json'
266
565
  end
267
566
 
567
+ return nil if response.status == 404 && !strict
568
+
268
569
  unless response.success?
269
570
  raise MCPClient::Errors::ConnectionError, "Failed to fetch resource metadata: HTTP #{response.status}"
270
571
  end
@@ -328,12 +629,15 @@ module MCPClient
328
629
  def register_client(server_metadata)
329
630
  logger.debug("Registering OAuth client at: #{server_metadata.registration_endpoint}")
330
631
 
632
+ resolved_scope = scope == :all ? supported_scopes.join(' ') : scope
633
+
331
634
  metadata = ClientMetadata.new(
332
635
  redirect_uris: [redirect_uri],
333
636
  token_endpoint_auth_method: 'none', # Public client
334
637
  grant_types: %w[authorization_code refresh_token],
335
638
  response_types: ['code'],
336
- scope: scope
639
+ scope: resolved_scope,
640
+ **@extra_client_metadata
337
641
  )
338
642
 
339
643
  response = @http_client.post(server_metadata.registration_endpoint) do |req|
@@ -355,7 +659,13 @@ module MCPClient
355
659
  token_endpoint_auth_method: data['token_endpoint_auth_method'] || 'none',
356
660
  grant_types: data['grant_types'] || %w[authorization_code refresh_token],
357
661
  response_types: data['response_types'] || ['code'],
358
- scope: data['scope']
662
+ scope: data['scope'],
663
+ client_name: data['client_name'],
664
+ client_uri: data['client_uri'],
665
+ logo_uri: data['logo_uri'],
666
+ tos_uri: data['tos_uri'],
667
+ policy_uri: data['policy_uri'],
668
+ contacts: data['contacts']
359
669
  )
360
670
 
361
671
  # Warn if server changed redirect_uri
@@ -396,11 +706,13 @@ module MCPClient
396
706
  # Use the redirect_uri that was actually registered
397
707
  registered_redirect_uri = client_info.metadata.redirect_uris.first
398
708
 
709
+ resolved_scope = scope == :all ? supported_scopes.join(' ') : scope
710
+
399
711
  params = {
400
712
  response_type: 'code',
401
713
  client_id: client_info.client_id,
402
714
  redirect_uri: registered_redirect_uri,
403
- scope: scope,
715
+ scope: resolved_scope,
404
716
  state: state,
405
717
  code_challenge: pkce.code_challenge,
406
718
  code_challenge_method: pkce.code_challenge_method,
@@ -589,6 +901,10 @@ module MCPClient
589
901
  @client_infos[server_url] = client_info
590
902
  end
591
903
 
904
+ def delete_client_info(server_url)
905
+ @client_infos.delete(server_url)
906
+ end
907
+
592
908
  def get_server_metadata(server_url)
593
909
  @server_metadata[server_url]
594
910
  end