ruby-mcp-client 1.0.1 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8e56ef84106ab19bf3028b31dd7ce362cfb58f244743057271f14a83409153f0
4
- data.tar.gz: 96f0e42875d80032c24056c1dbd9c6417b3db994538838736fa52f303e0d4f85
3
+ metadata.gz: e99574cdccfbab39918f6576fc7c896b5208599313bd69e2f39df5b529c82c22
4
+ data.tar.gz: 823177bc1a531d773514b9d9da8d3d6f0f2aa15f6e8562e8c0123adcdb38911c
5
5
  SHA512:
6
- metadata.gz: 48b7cd77ab406967bc4cd5fe79c5c1511a8eb2c9e4c9ebc27b31b8386e175a435d1a620ffcfa7871044188061b3f0718875d009f2a4ef7d030786ad11bcbde35
7
- data.tar.gz: 11f845c21895b67d0a308267972936fcf07df042e33de6417b69ee486f1739a8768f25b698bbbad92fdbef3acf0e6ec42eaffa525896b9e77e50e86960c7daf8
6
+ metadata.gz: bc352d545dbabb90322ab473eaff7c6d52111ccd6e4812baa8b6507dc720024e23752b4a68481dc5b83722d2c9c4b6b3a36a17821998fc745cd8164e57ac8380
7
+ data.tar.gz: 72b2cdc279b54f2b10cbac22755086edee67792bd18e035d167f2855ad1d5dc5999c87d98e17bef83a70632459003bbd4af516414fe0e7b78b918fc2b2c5a8ce
data/README.md CHANGED
@@ -38,7 +38,7 @@ Implements **MCP 2025-11-25** specification:
38
38
  - **Sampling**: Server-requested LLM completions with modelPreferences
39
39
  - **Completion**: Autocomplete for prompts/resources with context
40
40
  - **Logging**: Server log messages with level filtering
41
- - **Tasks**: Structured task management with progress tracking
41
+ - **Tasks**: Task-augmented `tools/call` create with a `ttl`, poll `tasks/get`, retrieve via `tasks/result`, plus `tasks/list` and `tasks/cancel`
42
42
  - **Audio**: Audio content type support
43
43
  - **OAuth 2.1**: PKCE, server discovery, dynamic registration
44
44
 
@@ -107,6 +107,11 @@ end
107
107
  prompts = client.list_prompts
108
108
  result = client.get_prompt('greeting', { name: 'Alice' })
109
109
 
110
+ # Pagination: list_tools and list_prompts automatically follow the server's
111
+ # nextCursor and return the COMPLETE set across all pages (with a per-call
112
+ # safety bound and an identical-cursor loop guard). No manual cursor handling
113
+ # is required.
114
+
110
115
  # Resources
111
116
  result = client.list_resources
112
117
  contents = client.read_resource('file:///example.txt')
@@ -124,9 +129,12 @@ end
124
129
  tool = client.find_tool('delete_user')
125
130
 
126
131
  # Hint-style annotations (MCP 2025-11-25)
127
- tool.read_only_hint? # Defaults to true; tool does not modify environment
128
- tool.destructive_hint? # Defaults to false; tool may perform destructive updates
129
- tool.idempotent_hint? # Defaults to false; repeated calls have no additional effect
132
+ # Defaults follow the MCP ToolAnnotations schema: when a hint is absent the
133
+ # client assumes the less-safe value, so an un-annotated tool is treated as
134
+ # writable, potentially destructive, and open-world.
135
+ tool.read_only_hint? # Defaults to false; tool may modify its environment
136
+ tool.destructive_hint? # Defaults to true; tool may perform destructive updates
137
+ tool.idempotent_hint? # Defaults to false; repeated calls may have additional effects
130
138
  tool.open_world_hint? # Defaults to true; tool may interact with external entities
131
139
 
132
140
  # Legacy annotations
@@ -200,6 +208,39 @@ client.on_notification do |server, method, params|
200
208
  end
201
209
  ```
202
210
 
211
+ ### Tasks (Long-running, task-augmented tools)
212
+
213
+ A task-capable server (one advertising `tasks.requests.tools.call`) can run a tool
214
+ whose `execution.taskSupport` is `optional` or `required` as a background task:
215
+ the call returns immediately with a task handle, and the result is fetched later.
216
+
217
+ ```ruby
218
+ tool = client.find_tool('long_job')
219
+ tool.supports_task? # execution.taskSupport is optional/required?
220
+
221
+ # Create the task (returns immediately); ttl is the requested lifetime in ms
222
+ task = client.call_tool_as_task('long_job', { input: 'data' }, ttl: 60_000)
223
+
224
+ # Poll until the task reaches a terminal (or input-required) status,
225
+ # honoring the server's suggested poll interval
226
+ until task.terminal? || task.input_required?
227
+ sleep((task.poll_interval || 1000) / 1000.0)
228
+ task = client.get_task(task.task_id) # tasks/get
229
+ end
230
+
231
+ # Retrieve the underlying result (e.g. a CallToolResult) via tasks/result
232
+ result = client.get_task_result(task.task_id)
233
+
234
+ # List and cancel tasks
235
+ page = client.list_tasks # { tasks: [...], next_cursor: ... }
236
+ client.cancel_task(task.task_id) # tasks/cancel
237
+
238
+ # React to server-pushed status updates
239
+ client.on_notification do |server, method, params|
240
+ puts "Task #{params['taskId']} -> #{params['status']}" if method == 'notifications/tasks/status'
241
+ end
242
+ ```
243
+
203
244
  ### Elicitation (Server-initiated user interactions)
204
245
 
205
246
  ```ruby
@@ -243,6 +284,18 @@ client = MCPClient.create_client(
243
284
  client = MCPClient.create_client(server_definition_file: 'servers.json')
244
285
  ```
245
286
 
287
+ ### Retries
288
+
289
+ The `retries:` option controls automatic retry with exponential backoff. Only
290
+ failures where the request most likely did **not** complete at the server are
291
+ retried: transport/network errors and HTTP **5xx** responses. Application-level
292
+ failures — a JSON-RPC error response or an HTTP **4xx** — are **never** retried,
293
+ because the server already processed or rejected the request and re-sending
294
+ would risk re-executing a non-idempotent `tools/call`. Retryable server failures
295
+ raise `MCPClient::Errors::TransientServerError`, a subclass of
296
+ `MCPClient::Errors::ServerError`, so existing `rescue ServerError` handlers are
297
+ unaffected.
298
+
246
299
  ### Faraday Customization
247
300
 
248
301
  ```ruby
@@ -334,9 +387,65 @@ See `examples/` for complete implementations:
334
387
  - `gemini_ai_mcp.rb` - Google Vertex AI integration
335
388
  - `ruby_llm_mcp.rb` - RubyLLM integration (OpenAI provider)
336
389
 
390
+ ## Running the Examples
391
+
392
+ The `examples/run_all_examples.sh` harness runs every example that can run on the current machine — self-contained stdio servers, the Python/Flask/FastMCP echo and elicitation servers, `npx`-based MCP servers, and (optionally) the paid LLM integrations. It starts and tears down each server automatically and prints a `PASS`/`FAIL`/`SKIP` summary. `tasks_example.rb` is always skipped (it needs a task-capable remote server); `oauth_browser_auth.rb` is interactive and only runs when you opt in with `RUN_OAUTH=1`.
393
+
394
+ ### Prerequisites
395
+
396
+ Run `bundle install` first. The script preflight-checks the following and prints a warning (it does **not** abort) for anything missing; affected examples are then skipped or fail:
397
+
398
+ - `ruby`, `bundle`, `curl`, `lsof` - on `PATH`
399
+ - `python3` (or `$PYTHON`) plus a separate `python` binary - on `PATH`
400
+ - Python packages `flask`, `fastmcp`, `mcp` - importable by `$PYTHON`
401
+ - `npx` (Node) - needed by the `npx`-based example (`json_input`) and by every LLM example, which spawn `npx` filesystem/Playwright servers
402
+
403
+ ### Usage
404
+
405
+ ```bash
406
+ examples/run_all_examples.sh # run everything runnable on this machine
407
+ RUN_AI=0 examples/run_all_examples.sh # skip the paid-LLM examples
408
+ RUN_NPX=0 examples/run_all_examples.sh # skip the npx-based example (json_input)
409
+ LOG_DIR=/path examples/run_all_examples.sh # write logs to a chosen dir
410
+ PYTHON=python3.12 TIMEOUT=180 examples/run_all_examples.sh # override interpreter and per-example timeout
411
+ ```
412
+
413
+ ### Environment Knobs
414
+
415
+ | Variable | Default | Effect |
416
+ |----------|---------|--------|
417
+ | `RUN_AI` | `1` | Set to `0` to skip the LLM integrations, which make **real, paid** API calls. |
418
+ | `RUN_NPX` | `1` | Set to `0` (or leave `npx` off `PATH`) to skip the `npx`-based example (`json_input`). The LLM examples spawn `npx` servers too, but are gated by `RUN_AI` and their API keys instead. |
419
+ | `PYTHON` | `python3` | Interpreter used to launch the Python/Flask/FastMCP servers and run the import preflight checks. |
420
+ | `TIMEOUT` | `120` | Per-example wall-clock timeout in seconds; a timeout is reported as a `FAIL`. |
421
+ | `LOG_DIR` | fresh `mktemp` dir | Directory for per-example and per-server logs; the path is printed after preflight and in the summary. |
422
+
423
+ ### Secrets and API Keys
424
+
425
+ Real secrets live in `examples/secrets.env`, which is **gitignored** and sourced automatically (every `KEY=value` line is exported) when present. Copy the tracked template to get started:
426
+
427
+ ```bash
428
+ cp examples/secrets.env.example examples/secrets.env
429
+ # then set ZAPIER_MCP_TOKEN=... to enable the Zapier streamable-HTTP example
430
+ ```
431
+
432
+ Set `ZAPIER_MCP_TOKEN` (from the Zapier MCP setup page, "Option 1: Authorization header") to run `streamable_http_example.rb` and `oauth_example.rb` against Zapier; override `ZAPIER_MCP_URL` if your connect URL differs. To run the interactive `oauth_browser_auth.rb`, set `MCP_SERVER_URL` (e.g. an ngrok tunnel to your OAuth-protected MCP server) in `secrets.env` and pass `RUN_OAUTH=1`. The LLM examples each need their own credentials in the environment and are skipped without them:
433
+
434
+ - `ruby_anthropic_mcp.rb` - `ANTHROPIC_API_KEY` (+ `npx`)
435
+ - `openai_ruby_mcp.rb` - `OPENAI_API_KEY` (+ `npx`)
436
+ - `ruby_openai_mcp.rb`, `ruby_llm_mcp.rb` - `OPENAI_API_KEY` (+ `npx`, plus a Playwright MCP server on `:8931`)
437
+ - `gemini_ai_mcp.rb` - a Vertex service-account JSON at `VERTEX_CREDENTIALS_FILE` (default `examples/google-credentials.json`, + `npx`)
438
+
439
+ ### How Pass/Fail Is Judged
440
+
441
+ Most examples print their own success/failure marks but exit `0` regardless, so the harness combines the exit code with a scan of the output rather than trusting the exit status alone. An example `FAIL`s when it exits nonzero, times out (exit `124`), prints a hard-error signature (a Ruby/Python traceback, `Connection refused`, `uninitialized constant`, and similar), prints a `❌` mark, or is missing its expected success marker; otherwise it `PASS`es. (The `❌` check is suppressed with `IGNORE_XMARK=1` for the interactive elicitation demos, where `❌` can be legitimate "declined" output.) The script exits `0` only if zero examples failed — `SKIP`s do not affect the exit status.
442
+
443
+ For deeper, per-topic walkthroughs see [`examples/README.md`](examples/README.md), [`examples/README_ECHO_SERVER.md`](examples/README_ECHO_SERVER.md), [`examples/STREAMABLE_HTTP_TESTING.md`](examples/STREAMABLE_HTTP_TESTING.md), and [`examples/elicitation/README.md`](examples/elicitation/README.md).
444
+
337
445
  ## OAuth 2.1 Authentication
338
446
 
339
447
  ```ruby
448
+ require 'mcp_client'
340
449
  require 'mcp_client/auth/browser_oauth'
341
450
 
342
451
  oauth = MCPClient::Auth::OAuthProvider.new(
@@ -41,6 +41,10 @@ module MCPClient
41
41
  self.storage = storage || MemoryStorage.new
42
42
  @extra_client_metadata = client_metadata
43
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
44
48
  end
45
49
 
46
50
  # @param url [String] Server URL to normalize
@@ -142,12 +146,37 @@ module MCPClient
142
146
  www_authenticate = response.headers['WWW-Authenticate'] || response.headers['www-authenticate']
143
147
  return nil unless www_authenticate
144
148
 
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)
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]
171
+ end
172
+
173
+ # Unquoted token form: resource_metadata = https://...
174
+ if (m = header.match(/resource_metadata\s*=\s*([^,\s]+)/))
175
+ return m[1]
150
176
  end
177
+
178
+ # Legacy fallback: resource="https://..."
179
+ header.match(/resource\s*=\s*"([^"]+)"/)&.captures&.first
151
180
  end
152
181
 
153
182
  private
@@ -213,6 +242,55 @@ module MCPClient
213
242
  (uri.scheme == 'https' && uri.port == 443)
214
243
  end
215
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
+
216
294
  # Discover authorization server metadata
217
295
  # Tries multiple discovery patterns:
218
296
  # 1. oauth-authorization-server (MCP spec pattern - server is its own auth server)
@@ -220,63 +298,274 @@ module MCPClient
220
298
  # @return [ServerMetadata] Authorization server metadata
221
299
  # @raise [MCPClient::Errors::ConnectionError] if discovery fails
222
300
  def discover_authorization_server
223
- # Try to get from storage first
224
- 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)
225
308
  return cached
226
309
  end
227
310
 
228
- server_metadata = nil
311
+ discover_and_cache_authorization_server
312
+ end
229
313
 
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
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)
239
319
 
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
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
257
323
 
258
324
  unless server_metadata
259
325
  raise MCPClient::Errors::ConnectionError,
260
- 'OAuth discovery failed: no valid endpoints found'
326
+ 'OAuth discovery failed: no valid authorization server metadata found'
261
327
  end
262
328
 
263
- # 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
+
264
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
265
396
 
266
397
  server_metadata
267
398
  end
268
399
 
269
- # 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.
270
555
  # @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)
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)
274
561
  logger.debug("Fetching resource metadata from: #{url}")
275
562
 
276
563
  response = @http_client.get(url) do |req|
277
564
  req.headers['Accept'] = 'application/json'
278
565
  end
279
566
 
567
+ return nil if response.status == 404 && !strict
568
+
280
569
  unless response.success?
281
570
  raise MCPClient::Errors::ConnectionError, "Failed to fetch resource metadata: HTTP #{response.status}"
282
571
  end
@@ -612,6 +901,10 @@ module MCPClient
612
901
  @client_infos[server_url] = client_info
613
902
  end
614
903
 
904
+ def delete_client_info(server_url)
905
+ @client_infos.delete(server_url)
906
+ end
907
+
615
908
  def get_server_metadata(server_url)
616
909
  @server_metadata[server_url]
617
910
  end
@@ -225,7 +225,8 @@ module MCPClient
225
225
  # OAuth authorization server metadata
226
226
  class ServerMetadata
227
227
  attr_reader :issuer, :authorization_endpoint, :token_endpoint, :registration_endpoint,
228
- :scopes_supported, :response_types_supported, :grant_types_supported
228
+ :scopes_supported, :response_types_supported, :grant_types_supported,
229
+ :code_challenge_methods_supported
229
230
 
230
231
  # @param issuer [String] Issuer identifier URL
231
232
  # @param authorization_endpoint [String] Authorization endpoint URL
@@ -234,8 +235,10 @@ module MCPClient
234
235
  # @param scopes_supported [Array<String>, nil] Supported OAuth scopes
235
236
  # @param response_types_supported [Array<String>, nil] Supported response types
236
237
  # @param grant_types_supported [Array<String>, nil] Supported grant types
238
+ # @param code_challenge_methods_supported [Array<String>, nil] Supported PKCE code challenge methods (RFC 8414)
237
239
  def initialize(issuer:, authorization_endpoint:, token_endpoint:, registration_endpoint: nil,
238
- scopes_supported: nil, response_types_supported: nil, grant_types_supported: nil)
240
+ scopes_supported: nil, response_types_supported: nil, grant_types_supported: nil,
241
+ code_challenge_methods_supported: nil)
239
242
  @issuer = issuer
240
243
  @authorization_endpoint = authorization_endpoint
241
244
  @token_endpoint = token_endpoint
@@ -243,6 +246,7 @@ module MCPClient
243
246
  @scopes_supported = scopes_supported
244
247
  @response_types_supported = response_types_supported
245
248
  @grant_types_supported = grant_types_supported
249
+ @code_challenge_methods_supported = code_challenge_methods_supported
246
250
  end
247
251
 
248
252
  # Check if dynamic client registration is supported
@@ -261,7 +265,8 @@ module MCPClient
261
265
  registration_endpoint: @registration_endpoint,
262
266
  scopes_supported: @scopes_supported,
263
267
  response_types_supported: @response_types_supported,
264
- grant_types_supported: @grant_types_supported
268
+ grant_types_supported: @grant_types_supported,
269
+ code_challenge_methods_supported: @code_challenge_methods_supported
265
270
  }.compact
266
271
  end
267
272
 
@@ -276,7 +281,9 @@ module MCPClient
276
281
  registration_endpoint: data[:registration_endpoint] || data['registration_endpoint'],
277
282
  scopes_supported: data[:scopes_supported] || data['scopes_supported'],
278
283
  response_types_supported: data[:response_types_supported] || data['response_types_supported'],
279
- grant_types_supported: data[:grant_types_supported] || data['grant_types_supported']
284
+ grant_types_supported: data[:grant_types_supported] || data['grant_types_supported'],
285
+ code_challenge_methods_supported: data[:code_challenge_methods_supported] ||
286
+ data['code_challenge_methods_supported']
280
287
  )
281
288
  end
282
289
  end