ruby-mcp-client 1.1.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e99574cdccfbab39918f6576fc7c896b5208599313bd69e2f39df5b529c82c22
4
- data.tar.gz: 823177bc1a531d773514b9d9da8d3d6f0f2aa15f6e8562e8c0123adcdb38911c
3
+ metadata.gz: 5687a9c54b7e5c4814e80bd2e5e97d2072dd0f994e3bebe680259a1a9db64c25
4
+ data.tar.gz: 05b3bce582f21e57ae07ac862daf6a74812f3ba79f6e4b806868850686c33467
5
5
  SHA512:
6
- metadata.gz: bc352d545dbabb90322ab473eaff7c6d52111ccd6e4812baa8b6507dc720024e23752b4a68481dc5b83722d2c9c4b6b3a36a17821998fc745cd8164e57ac8380
7
- data.tar.gz: 72b2cdc279b54f2b10cbac22755086edee67792bd18e035d167f2855ad1d5dc5999c87d98e17bef83a70632459003bbd4af516414fe0e7b78b918fc2b2c5a8ce
6
+ metadata.gz: 3aef7d0842eab30de7e84a5507298efd30ae78de3ea3223cc8f00bd31892e7665f55f533952df771a2d3ab38f874d3905e029b0d19b8147d07ffd030772106fe
7
+ data.tar.gz: 324f5121352512f827f05fa55f78917b3218b7d031c418cfa884a8718b2b06fda027b8b811df2083361200ff17682dbfea8998505db0e2932db3a545f605c35a
data/README.md CHANGED
@@ -28,7 +28,10 @@ Built-in API conversions: `to_openai_tools()`, `to_anthropic_tools()`, `to_googl
28
28
 
29
29
  ## MCP Protocol Support
30
30
 
31
- Implements **MCP 2025-11-25** specification:
31
+ Implements the **MCP 2025-11-25** specification. The client negotiates the
32
+ protocol version during `initialize` and disconnects if the server answers
33
+ with a revision it cannot speak (supported: `2025-11-25`, `2025-06-18`,
34
+ `2025-03-26`, `2024-11-05`):
32
35
 
33
36
  - **Tools**: list, call, streaming, annotations (hint-style), structured outputs, title
34
37
  - **Prompts**: list, get with parameters
@@ -40,7 +43,9 @@ Implements **MCP 2025-11-25** specification:
40
43
  - **Logging**: Server log messages with level filtering
41
44
  - **Tasks**: Task-augmented `tools/call` — create with a `ttl`, poll `tasks/get`, retrieve via `tasks/result`, plus `tasks/list` and `tasks/cancel`
42
45
  - **Audio**: Audio content type support
43
- - **OAuth 2.1**: PKCE, server discovery, dynamic registration
46
+ - **Progress & Cancellation**: `progressToken` plumbing with per-call callbacks; automatic `notifications/cancelled` for abandoned requests
47
+ - **Metadata**: `icons`, `title` and `_meta` parsed on tools, prompts and resources
48
+ - **OAuth 2.1**: PKCE (S256 required), RFC 8414/9728 discovery, dynamic registration, Client ID Metadata Documents, scope step-up challenges
44
49
 
45
50
  ## Quick Connect API (Recommended)
46
51
 
@@ -152,6 +157,25 @@ tool.output_schema # JSON Schema for output
152
157
 
153
158
  result = client.call_tool('get_weather', { location: 'SF' })
154
159
  data = result['structuredContent'] # Type-safe structured data
160
+
161
+ # Per MCP 2025-11-25, clients SHOULD validate structured results against the
162
+ # tool's output schema, and a tool that declares an outputSchema must return
163
+ # structuredContent in successful results. call_tool checks both automatically
164
+ # for the common JSON Schema keywords (type, properties, required, items, enum,
165
+ # numeric/string bounds). The full 2020-12 vocabulary ($ref/$dynamicRef/$defs,
166
+ # allOf/anyOf/oneOf/not, if/then/else, additionalProperties, patternProperties,
167
+ # propertyNames, prefixItems, contains/minContains/maxContains, uniqueItems,
168
+ # multipleOf, format, dependentRequired/dependentSchemas, minProperties/
169
+ # maxProperties, unevaluated*) is NOT evaluated: when a schema uses any of
170
+ # those keywords, call_tool logs a "validation is partial" warning naming them
171
+ # (in both modes), since data may pass this check that a full validator would
172
+ # reject. By default a violation (mismatch, or missing structuredContent on a
173
+ # successful result) logs a warning; opt in to strict mode to raise instead:
174
+ client = MCPClient::Client.new(
175
+ mcp_server_configs: [...],
176
+ validate_structured_content: :strict # raises MCPClient::Errors::ValidationError on violation
177
+ )
178
+ # Task-delivered results (get_task_result) are not validated yet.
155
179
  ```
156
180
 
157
181
  ### Roots
@@ -184,6 +208,81 @@ client = MCPClient.connect('http://server/mcp',
184
208
  )
185
209
  ```
186
210
 
211
+ Sampling tool calling (SEP-1577) is opt-in: pass `sampling_supports_tools: true`
212
+ to declare the `sampling.tools` capability. The handler then receives the full
213
+ request params (including `tools`/`toolChoice`) as an optional fifth argument;
214
+ without the opt-in, tool-enabled sampling requests are rejected with `-32602`
215
+ as the spec requires:
216
+
217
+ ```ruby
218
+ client = MCPClient::Client.new(
219
+ mcp_server_configs: [...],
220
+ sampling_supports_tools: true,
221
+ sampling_handler: ->(messages, prefs, system_prompt, max_tokens, params = nil) {
222
+ tools = params && params['tools'] # ToolUseContent may be returned in content
223
+ # ...
224
+ }
225
+ )
226
+ ```
227
+
228
+ ### Progress Tracking
229
+
230
+ Attach a per-call progress callback — the client generates a unique
231
+ `progressToken`, places it in the request `_meta`, and routes matching
232
+ `notifications/progress` to your block while the request is active (stale
233
+ tokens after completion are dropped):
234
+
235
+ ```ruby
236
+ client.call_tool('long_running', args, progress: ->(progress, total, message) {
237
+ puts "#{message}: #{progress}/#{total}"
238
+ })
239
+ ```
240
+
241
+ A request-level `_meta` (e.g. a hand-picked `progressToken`) can also be passed
242
+ inside the arguments under the `'_meta'` key on every transport — it is hoisted
243
+ to the JSON-RPC params level on the wire, never sent as a tool argument.
244
+
245
+ ### Timeouts and Cancellation
246
+
247
+ Timeouts are configurable per request in addition to the per-server
248
+ `read_timeout`. A timed-out request raises
249
+ `MCPClient::Errors::RequestTimeoutError` (a `TransportError` subclass), is
250
+ **never** silently re-sent by the retry layer, and a best-effort
251
+ `notifications/cancelled` is sent for the abandoned request (never for
252
+ `initialize`, and task-augmented calls use `tasks/cancel` instead):
253
+
254
+ ```ruby
255
+ client.send_rpc('tools/call', params: { name: 'slow', arguments: {} }, timeout: 300)
256
+ server.rpc_request('tools/list', {}, timeout: 5)
257
+ ```
258
+
259
+ ### Client Identity and Server Instructions
260
+
261
+ Hosts can present their own `Implementation` info (sent as `clientInfo` during
262
+ initialize; `name` and `version` required — `title`, `description`,
263
+ `websiteUrl`, `icons` optional), and read the server's `instructions` hint
264
+ after connecting:
265
+
266
+ ```ruby
267
+ client = MCPClient::Client.new(
268
+ mcp_server_configs: [...],
269
+ client_info: { 'name' => 'my-ide', 'version' => '2.0.0', 'description' => 'An MCP-powered IDE' }
270
+ )
271
+ client.servers.first.connect
272
+ puts client.servers.first.instructions # e.g. "Use the search tool before answering."
273
+ ```
274
+
275
+ ### Capability Gating
276
+
277
+ Optional server features (`logging/setLevel`, `resources/subscribe`,
278
+ `completion/complete`, `tasks/list`, `tasks/cancel`) are only sent to servers
279
+ that negotiated the corresponding capability; otherwise
280
+ `MCPClient::Errors::CapabilityError` is raised (the lifecycle forbids using
281
+ capabilities that were not negotiated). `Client#log_level=` skips
282
+ non-logging servers instead of failing. Declared *client* capabilities are
283
+ derived from what the host actually registered (handlers, roots), never
284
+ hardcoded.
285
+
187
286
  ### Completion (Autocomplete)
188
287
 
189
288
  ```ruby
@@ -213,6 +312,9 @@ end
213
312
  A task-capable server (one advertising `tasks.requests.tools.call`) can run a tool
214
313
  whose `execution.taskSupport` is `optional` or `required` as a background task:
215
314
  the call returns immediately with a task handle, and the result is fetched later.
315
+ Try it locally: `python3 examples/echo_server_streamable.py &` then
316
+ `./examples/tasks_example.rb` runs the full lifecycle against a task-capable
317
+ demo server.
216
318
 
217
319
  ```ruby
218
320
  tool = client.find_tool('long_job')
@@ -470,6 +572,20 @@ Features: PKCE, server discovery (`.well-known`), dynamic registration, token re
470
572
 
471
573
  See [OAUTH.md](OAUTH.md) for full documentation.
472
574
 
575
+ ### OAuth Extras (2025-11-25)
576
+
577
+ - **Client ID Metadata Documents (SEP-991)** — pass
578
+ `client_id_metadata_url: 'https://myapp.example/oauth-client.json'` (an HTTPS
579
+ URL with a path, which doubles as the `client_id`); when the authorization
580
+ server advertises `client_id_metadata_document_supported`, dynamic client
581
+ registration is skipped entirely.
582
+ - **Scope challenges (SEP-835)** — an HTTP 403 `insufficient_scope` challenge
583
+ raises `MCPClient::Errors::InsufficientScopeError` (a `ConnectionError`
584
+ subclass) exposing `#scope` and `#error_description`; the challenged scopes
585
+ are treated as authoritative for the next authorization flow.
586
+ - **PKCE** — authorization refuses to proceed when the authorization server
587
+ does not advertise `code_challenge_methods_supported` including `S256`.
588
+
473
589
  ## Server Notifications
474
590
 
475
591
  ```ruby
@@ -492,7 +608,9 @@ Both HTTP and Streamable HTTP transports automatically handle session-based serv
492
608
  - **Session capture**: Extracts `Mcp-Session-Id` from initialize response
493
609
  - **Session persistence**: Includes session header in subsequent requests
494
610
  - **Session termination**: Sends DELETE request during cleanup
495
- - **Resumability** (Streamable HTTP): Tracks event IDs for message replay
611
+ - **Resumability** (Streamable HTTP, SEP-1699): tracks SSE event IDs and, when a
612
+ response stream is interrupted, resumes via GET with `Last-Event-ID` so the
613
+ server can replay missed messages — honoring the server's `retry:` directive
496
614
 
497
615
  No configuration required - works automatically.
498
616
 
@@ -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,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
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
48
71
  end
49
72
 
50
73
  # @param url [String] Server URL to normalize
@@ -52,6 +75,15 @@ module MCPClient
52
75
  @server_url = normalize_server_url(url)
53
76
  end
54
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
+
55
87
  # Get current access token (refresh if needed)
56
88
  # @return [Token, nil] Current valid access token or nil
57
89
  def access_token
@@ -146,9 +178,27 @@ module MCPClient
146
178
  www_authenticate = response.headers['WWW-Authenticate'] || response.headers['www-authenticate']
147
179
  return nil unless www_authenticate
148
180
 
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
+
149
194
  url = extract_resource_metadata_url(www_authenticate)
150
195
  return nil unless url
151
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
+
152
202
  # This URL was explicitly advertised by the 401 challenge, so a 404 is a
153
203
  # misconfiguration to surface (strict), not a speculative miss to skip.
154
204
  metadata = fetch_resource_metadata(url, strict: true)
@@ -160,27 +210,93 @@ module MCPClient
160
210
 
161
211
  # Extract the protected-resource-metadata URL from a WWW-Authenticate header.
162
212
  # Per RFC 9728 the parameter is `resource_metadata`; a legacy `resource`
163
- # parameter is accepted as a fallback for older servers.
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.
164
216
  # @param header [String] the WWW-Authenticate header value
165
217
  # @return [String, nil] the metadata URL if present
166
218
  def extract_resource_metadata_url(header)
219
+ params = bearer_challenge_segment(header)
220
+ return nil unless params
221
+
167
222
  # Auth-params may include optional whitespace around '=' (RFC 7235).
168
223
  # Quoted form: resource_metadata = "https://..."
169
- if (m = header.match(/resource_metadata\s*=\s*"([^"]+)"/))
224
+ if (m = params.match(/resource_metadata\s*=\s*"([^"]+)"/))
170
225
  return m[1]
171
226
  end
172
227
 
173
228
  # Unquoted token form: resource_metadata = https://...
174
- if (m = header.match(/resource_metadata\s*=\s*([^,\s]+)/))
229
+ if (m = params.match(/resource_metadata\s*=\s*([^,\s]+)/))
175
230
  return m[1]
176
231
  end
177
232
 
178
233
  # Legacy fallback: resource="https://..."
179
- header.match(/resource\s*=\s*"([^"]+)"/)&.captures&.first
234
+ params.match(/resource\s*=\s*"([^"]+)"/)&.captures&.first
235
+ end
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]
180
256
  end
181
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
+
182
275
  private
183
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
+
184
300
  # Normalize server URL to canonical form
185
301
  # @param url [String] Server URL
186
302
  # @return [String] Normalized URL
@@ -299,8 +415,11 @@ module MCPClient
299
415
  # @raise [MCPClient::Errors::ConnectionError] if discovery fails
300
416
  def discover_authorization_server
301
417
  # 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
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
304
423
  if cached
305
424
  # Validate the cached entry before use so a persisted/older cache with
306
425
  # an HTTP endpoint or without S256 is still rejected.
@@ -333,6 +452,7 @@ module MCPClient
333
452
 
334
453
  storage.set_server_metadata(server_url, server_metadata)
335
454
  @challenge_resource_metadata = nil # consumed
455
+ @challenge_metadata_url = nil # consumed
336
456
  server_metadata
337
457
  end
338
458
 
@@ -375,8 +495,7 @@ module MCPClient
375
495
  # once PRM IS found it is authoritative: any subsequent failure must be a
376
496
  # hard error, never a silent fallback to an authorization server the PRM
377
497
  # did not advertise.
378
- resource_metadata = @challenge_resource_metadata ||
379
- fetch_first_resource_metadata(protected_resource_metadata_urls(server_url))
498
+ resource_metadata = challenge_or_well_known_resource_metadata
380
499
  return nil unless resource_metadata
381
500
 
382
501
  validate_resource_matches!(resource_metadata)
@@ -397,6 +516,25 @@ module MCPClient
397
516
  server_metadata
398
517
  end
399
518
 
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
+
400
538
  # Legacy/self-hosted discovery: treat the MCP server ORIGIN as its own
401
539
  # authorization server issuer.
402
540
  # @return [ServerMetadata, nil]
@@ -444,17 +582,18 @@ module MCPClient
444
582
  end
445
583
 
446
584
  # 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.
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.
449
589
  # @param server_metadata [ServerMetadata]
450
- # @raise [MCPClient::Errors::ConnectionError] if S256 is explicitly unsupported
590
+ # @raise [MCPClient::Errors::ConnectionError] if PKCE S256 support cannot be verified
451
591
  def verify_pkce_support!(server_metadata)
452
592
  methods = server_metadata.code_challenge_methods_supported
453
593
  if methods.nil?
454
- logger.warn(
455
- 'Authorization server metadata omits code_challenge_methods_supported; ' \
456
- 'proceeding with PKCE S256'
457
- )
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'
458
597
  elsif !methods.include?('S256')
459
598
  raise MCPClient::Errors::ConnectionError,
460
599
  'Authorization server does not support PKCE S256 ' \
@@ -571,7 +710,11 @@ module MCPClient
571
710
  end
572
711
 
573
712
  data = JSON.parse(response.body)
574
- 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
575
718
  rescue JSON::ParserError => e
576
719
  raise MCPClient::Errors::ConnectionError, "Invalid resource metadata JSON: #{e.message}"
577
720
  rescue Faraday::Error => e
@@ -601,18 +744,28 @@ module MCPClient
601
744
  raise MCPClient::Errors::ConnectionError, "Network error fetching server metadata: #{e.message}"
602
745
  end
603
746
 
604
- # 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.
605
752
  # @param server_metadata [ServerMetadata] Authorization server metadata
606
753
  # @return [ClientInfo] Client information
607
754
  # @raise [MCPClient::Errors::ConnectionError] if registration fails
608
755
  def get_or_register_client(server_metadata)
609
- # Try to get existing client info from storage
756
+ # 1. Pre-registered or previously registered client info from storage
610
757
  if (client_info = storage.get_client_info(server_url)) && !client_info.client_secret_expired?
611
758
  logger.debug("Using cached OAuth client for #{server_url}")
612
759
  return client_info
613
760
  end
614
761
 
615
- # 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
616
769
  logger.debug('No cached client found, registering new OAuth client...')
617
770
  if server_metadata.supports_registration?
618
771
  register_client(server_metadata)
@@ -622,6 +775,67 @@ module MCPClient
622
775
  end
623
776
  end
624
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
+
625
839
  # Register OAuth client dynamically
626
840
  # @param server_metadata [ServerMetadata] Authorization server metadata
627
841
  # @return [ClientInfo] Registered client information
@@ -629,8 +843,6 @@ module MCPClient
629
843
  def register_client(server_metadata)
630
844
  logger.debug("Registering OAuth client at: #{server_metadata.registration_endpoint}")
631
845
 
632
- resolved_scope = scope == :all ? supported_scopes.join(' ') : scope
633
-
634
846
  metadata = ClientMetadata.new(
635
847
  redirect_uris: [redirect_uri],
636
848
  token_endpoint_auth_method: 'none', # Public client
@@ -706,8 +918,6 @@ module MCPClient
706
918
  # Use the redirect_uri that was actually registered
707
919
  registered_redirect_uri = client_info.metadata.redirect_uris.first
708
920
 
709
- resolved_scope = scope == :all ? supported_scopes.join(' ') : scope
710
-
711
921
  params = {
712
922
  response_type: 'code',
713
923
  client_id: client_info.client_id,