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 +4 -4
- data/README.md +121 -3
- data/lib/mcp_client/auth/oauth_provider.rb +236 -26
- data/lib/mcp_client/auth.rb +40 -9
- data/lib/mcp_client/client.rb +417 -139
- data/lib/mcp_client/elicitation_validator.rb +43 -0
- data/lib/mcp_client/errors.rb +35 -1
- data/lib/mcp_client/http_transport_base.rb +227 -37
- data/lib/mcp_client/json_rpc_common.rb +126 -13
- data/lib/mcp_client/oauth_client.rb +8 -3
- data/lib/mcp_client/prompt.rb +17 -2
- data/lib/mcp_client/resource.rb +15 -2
- data/lib/mcp_client/resource_content.rb +8 -3
- data/lib/mcp_client/resource_link.rb +16 -3
- data/lib/mcp_client/resource_template.rb +15 -2
- data/lib/mcp_client/root.rb +61 -7
- data/lib/mcp_client/schema_validator.rb +285 -0
- data/lib/mcp_client/server_base.rb +66 -0
- data/lib/mcp_client/server_http/json_rpc_transport.rb +2 -1
- data/lib/mcp_client/server_http.rb +18 -12
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +63 -8
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +10 -3
- data/lib/mcp_client/server_sse/sse_parser.rb +46 -9
- data/lib/mcp_client/server_sse.rb +49 -21
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +29 -6
- data/lib/mcp_client/server_stdio.rb +92 -17
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +149 -23
- data/lib/mcp_client/server_streamable_http.rb +238 -77
- data/lib/mcp_client/tool.rb +18 -3
- data/lib/mcp_client/version.rb +6 -1
- data/lib/mcp_client.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5687a9c54b7e5c4814e80bd2e5e97d2072dd0f994e3bebe680259a1a9db64c25
|
|
4
|
+
data.tar.gz: 05b3bce582f21e57ae07ac862daf6a74812f3ba79f6e4b806868850686c33467
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
- **
|
|
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):
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
#
|
|
448
|
-
# the
|
|
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
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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,
|