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 +4 -4
- data/README.md +113 -4
- data/lib/mcp_client/auth/oauth_provider.rb +333 -40
- data/lib/mcp_client/auth.rb +11 -4
- data/lib/mcp_client/client.rb +197 -34
- data/lib/mcp_client/errors.rb +9 -0
- data/lib/mcp_client/http_transport_base.rb +5 -1
- data/lib/mcp_client/json_rpc_common.rb +19 -4
- data/lib/mcp_client/server_base.rb +73 -0
- data/lib/mcp_client/server_http.rb +27 -17
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +4 -1
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +4 -0
- data/lib/mcp_client/server_sse/sse_parser.rb +5 -2
- data/lib/mcp_client/server_sse.rb +49 -36
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +13 -3
- data/lib/mcp_client/server_stdio.rb +124 -20
- data/lib/mcp_client/server_streamable_http.rb +11 -30
- data/lib/mcp_client/task.rb +93 -61
- data/lib/mcp_client/tool.rb +48 -10
- data/lib/mcp_client/version.rb +1 -1
- metadata +18 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e99574cdccfbab39918f6576fc7c896b5208599313bd69e2f39df5b529c82c22
|
|
4
|
+
data.tar.gz: 823177bc1a531d773514b9d9da8d3d6f0f2aa15f6e8562e8c0123adcdb38911c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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**:
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
#
|
|
224
|
-
|
|
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
|
-
|
|
311
|
+
discover_and_cache_authorization_server
|
|
312
|
+
end
|
|
229
313
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
#
|
|
241
|
-
#
|
|
242
|
-
|
|
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
|
|
326
|
+
'OAuth discovery failed: no valid authorization server metadata found'
|
|
261
327
|
end
|
|
262
328
|
|
|
263
|
-
#
|
|
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
|
-
#
|
|
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
|
-
# @
|
|
272
|
-
#
|
|
273
|
-
|
|
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
|
data/lib/mcp_client/auth.rb
CHANGED
|
@@ -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
|