ruby-mcp-client 1.1.0 → 2.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 +216 -10
- data/lib/mcp_client/auth/oauth_provider.rb +325 -27
- data/lib/mcp_client/auth.rb +38 -11
- data/lib/mcp_client/client.rb +523 -150
- data/lib/mcp_client/elicitation_validator.rb +99 -13
- data/lib/mcp_client/errors.rb +43 -1
- data/lib/mcp_client/http_transport_base.rb +254 -41
- data/lib/mcp_client/json_rpc_common.rb +196 -14
- data/lib/mcp_client/oauth_client.rb +8 -3
- data/lib/mcp_client/prompt.rb +17 -2
- data/lib/mcp_client/resource.rb +13 -2
- data/lib/mcp_client/resource_content.rb +8 -3
- data/lib/mcp_client/resource_link.rb +14 -3
- data/lib/mcp_client/resource_template.rb +13 -2
- data/lib/mcp_client/root.rb +61 -7
- data/lib/mcp_client/schema_validator.rb +329 -0
- data/lib/mcp_client/server_base.rb +66 -0
- data/lib/mcp_client/server_factory.rb +4 -1
- data/lib/mcp_client/server_http/json_rpc_transport.rb +3 -2
- data/lib/mcp_client/server_http.rb +18 -12
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +97 -14
- data/lib/mcp_client/server_sse/origin_policy.rb +57 -0
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +17 -4
- data/lib/mcp_client/server_sse/sse_parser.rb +78 -10
- data/lib/mcp_client/server_sse.rb +132 -35
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +31 -8
- data/lib/mcp_client/server_stdio.rb +98 -20
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +222 -26
- data/lib/mcp_client/server_streamable_http.rb +472 -108
- data/lib/mcp_client/tool.rb +16 -3
- data/lib/mcp_client/version.rb +6 -1
- data/lib/mcp_client.rb +9 -1
- metadata +5 -6
|
@@ -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,30 @@ 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
|
|
71
|
+
# Why a peer-advertised challenge URL was refused, if one was
|
|
72
|
+
@challenge_error = nil
|
|
48
73
|
end
|
|
49
74
|
|
|
50
75
|
# @param url [String] Server URL to normalize
|
|
@@ -52,6 +77,15 @@ module MCPClient
|
|
|
52
77
|
@server_url = normalize_server_url(url)
|
|
53
78
|
end
|
|
54
79
|
|
|
80
|
+
# Set the Client ID Metadata Document URL (SEP-991), validating it per the
|
|
81
|
+
# MCP spec: the client_id URL MUST use the "https" scheme and contain a
|
|
82
|
+
# path component (e.g. https://example.com/client.json).
|
|
83
|
+
# @param url [String, nil] HTTPS URL of this client's metadata document, or nil to clear
|
|
84
|
+
# @raise [ArgumentError] if the URL is not HTTPS or lacks a path component
|
|
85
|
+
def client_id_metadata_url=(url)
|
|
86
|
+
@client_id_metadata_url = url.nil? ? nil : validate_client_id_metadata_url(url)
|
|
87
|
+
end
|
|
88
|
+
|
|
55
89
|
# Get current access token (refresh if needed)
|
|
56
90
|
# @return [Token, nil] Current valid access token or nil
|
|
57
91
|
def access_token
|
|
@@ -146,9 +180,34 @@ module MCPClient
|
|
|
146
180
|
www_authenticate = response.headers['WWW-Authenticate'] || response.headers['www-authenticate']
|
|
147
181
|
return nil unless www_authenticate
|
|
148
182
|
|
|
183
|
+
# Challenge parameters are read from the Bearer challenge's own
|
|
184
|
+
# segment only — never from the whole (possibly multi-challenge)
|
|
185
|
+
# header — so parameters belonging to Basic or another scheme cannot
|
|
186
|
+
# drive Bearer scope selection or resource metadata discovery. A
|
|
187
|
+
# header without a Bearer challenge carries no usable Bearer params.
|
|
188
|
+
bearer_params = bearer_challenge_segment(www_authenticate)
|
|
189
|
+
|
|
190
|
+
# MCP 2025-11-25: "Clients MUST treat the scopes provided in the
|
|
191
|
+
# challenge as authoritative for satisfying the current request" —
|
|
192
|
+
# including resetting a previously challenged scope when the current
|
|
193
|
+
# challenge carries none.
|
|
149
194
|
url = extract_resource_metadata_url(www_authenticate)
|
|
195
|
+
|
|
196
|
+
# The challenge header is peer-controlled input: validate the
|
|
197
|
+
# advertised URL BEFORE storing, fetching, or recording any challenge
|
|
198
|
+
# state, so a malicious challenge cannot pivot this host into requests
|
|
199
|
+
# against internal services (SSRF) and cannot leave the provider
|
|
200
|
+
# holding half of a rejected challenge.
|
|
201
|
+
validate_peer_advertised_url!(url, 'resource metadata URL (from WWW-Authenticate challenge)') if url
|
|
202
|
+
|
|
203
|
+
@challenge_scope = bearer_params && extract_challenge_param(bearer_params, 'scope')
|
|
150
204
|
return nil unless url
|
|
151
205
|
|
|
206
|
+
# Remember the advertised URL even if the fetch below fails, so a
|
|
207
|
+
# later discovery retries it instead of probing well-known URIs the
|
|
208
|
+
# challenge already superseded.
|
|
209
|
+
@challenge_metadata_url = url
|
|
210
|
+
|
|
152
211
|
# This URL was explicitly advertised by the 401 challenge, so a 404 is a
|
|
153
212
|
# misconfiguration to surface (strict), not a speculative miss to skip.
|
|
154
213
|
metadata = fetch_resource_metadata(url, strict: true)
|
|
@@ -160,27 +219,93 @@ module MCPClient
|
|
|
160
219
|
|
|
161
220
|
# Extract the protected-resource-metadata URL from a WWW-Authenticate header.
|
|
162
221
|
# Per RFC 9728 the parameter is `resource_metadata`; a legacy `resource`
|
|
163
|
-
# parameter is accepted as a fallback for older servers.
|
|
222
|
+
# parameter is accepted as a fallback for older servers. Only the Bearer
|
|
223
|
+
# challenge's own segment is consulted, so a parameter belonging to
|
|
224
|
+
# another scheme's challenge can never drive discovery.
|
|
164
225
|
# @param header [String] the WWW-Authenticate header value
|
|
165
226
|
# @return [String, nil] the metadata URL if present
|
|
166
227
|
def extract_resource_metadata_url(header)
|
|
228
|
+
params = bearer_challenge_segment(header)
|
|
229
|
+
return nil unless params
|
|
230
|
+
|
|
167
231
|
# Auth-params may include optional whitespace around '=' (RFC 7235).
|
|
168
232
|
# Quoted form: resource_metadata = "https://..."
|
|
169
|
-
if (m =
|
|
233
|
+
if (m = params.match(/resource_metadata\s*=\s*"([^"]+)"/))
|
|
170
234
|
return m[1]
|
|
171
235
|
end
|
|
172
236
|
|
|
173
237
|
# Unquoted token form: resource_metadata = https://...
|
|
174
|
-
if (m =
|
|
238
|
+
if (m = params.match(/resource_metadata\s*=\s*([^,\s]+)/))
|
|
175
239
|
return m[1]
|
|
176
240
|
end
|
|
177
241
|
|
|
178
242
|
# Legacy fallback: resource="https://..."
|
|
179
|
-
|
|
243
|
+
params.match(/resource\s*=\s*"([^"]+)"/)&.captures&.first
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Extract the Bearer challenge's own parameter segment from a (possibly
|
|
247
|
+
# multi-challenge) WWW-Authenticate header, so params belonging to other
|
|
248
|
+
# schemes (e.g. `Basic resource_metadata="...", Bearer realm="x"`) are
|
|
249
|
+
# never attributed to the Bearer challenge. Mirrors
|
|
250
|
+
# HttpTransportBase#bearer_challenge_segment.
|
|
251
|
+
# @param header [String, nil] the WWW-Authenticate header value
|
|
252
|
+
# @return [String, nil] the Bearer challenge's parameters (possibly
|
|
253
|
+
# empty), or nil when the header has no Bearer challenge
|
|
254
|
+
def bearer_challenge_segment(header)
|
|
255
|
+
return nil unless header
|
|
256
|
+
|
|
257
|
+
# Locate the Bearer scheme token only OUTSIDE quoted strings: a
|
|
258
|
+
# quoted value such as realm="prefix Bearer x" must not anchor the
|
|
259
|
+
# segment.
|
|
260
|
+
masked = header.gsub(/"(?:\\.|[^"\\])*"/) { |q| "\"#{' ' * (q.length - 2)}\"" }
|
|
261
|
+
match = masked.match(/(?:\A|[\s,])Bearer(?=[\s,]|\z)/i)
|
|
262
|
+
return nil unless match
|
|
263
|
+
|
|
264
|
+
header[match.end(0)..][AUTH_PARAMS_RUN]
|
|
180
265
|
end
|
|
181
266
|
|
|
267
|
+
# Extract an auth-param value from a WWW-Authenticate header
|
|
268
|
+
# (quoted or unquoted form, optional whitespace around '=').
|
|
269
|
+
# @param header [String] the WWW-Authenticate header value
|
|
270
|
+
# @param name [String] the auth-param name
|
|
271
|
+
# @return [String, nil] the parameter value if present
|
|
272
|
+
def extract_challenge_param(header, name)
|
|
273
|
+
if (m = header.match(/(?:^|[\s,])#{Regexp.escape(name)}\s*=\s*"([^"]*)"/i))
|
|
274
|
+
return m[1]
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
header.match(/(?:^|[\s,])#{Regexp.escape(name)}\s*=\s*([^,\s]+)/i)&.captures&.first
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Scope requested by the most recent WWW-Authenticate challenge.
|
|
281
|
+
# @return [String, nil]
|
|
282
|
+
attr_reader :challenge_scope
|
|
283
|
+
|
|
182
284
|
private
|
|
183
285
|
|
|
286
|
+
# Resolve the scope for authorization/registration requests using the
|
|
287
|
+
# MCP 2025-11-25 scope selection strategy: the challenge's scope
|
|
288
|
+
# parameter is authoritative; then an explicitly configured scope
|
|
289
|
+
# (:all resolves to the AS-advertised scope list); then the Protected
|
|
290
|
+
# Resource Metadata's scopes_supported; otherwise omit scope entirely.
|
|
291
|
+
# @return [String, nil]
|
|
292
|
+
def resolved_scope
|
|
293
|
+
return @challenge_scope if @challenge_scope && !@challenge_scope.empty?
|
|
294
|
+
|
|
295
|
+
if scope == :all
|
|
296
|
+
all_scopes = supported_scopes
|
|
297
|
+
return all_scopes.join(' ') unless all_scopes.empty?
|
|
298
|
+
elsif scope
|
|
299
|
+
return scope
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
prm = @challenge_resource_metadata || @resource_metadata
|
|
303
|
+
prm_scopes = prm&.scopes_supported
|
|
304
|
+
return prm_scopes.join(' ') if prm_scopes && !prm_scopes.empty?
|
|
305
|
+
|
|
306
|
+
nil
|
|
307
|
+
end
|
|
308
|
+
|
|
184
309
|
# Normalize server URL to canonical form
|
|
185
310
|
# @param url [String] Server URL
|
|
186
311
|
# @return [String] Normalized URL
|
|
@@ -298,9 +423,18 @@ module MCPClient
|
|
|
298
423
|
# @return [ServerMetadata] Authorization server metadata
|
|
299
424
|
# @raise [MCPClient::Errors::ConnectionError] if discovery fails
|
|
300
425
|
def discover_authorization_server
|
|
426
|
+
# A challenge we refused is still authoritative: it says the cached
|
|
427
|
+
# authorization server is no longer the right one. Falling back to
|
|
428
|
+
# that cache (or to speculative well-known probing) would quietly
|
|
429
|
+
# undo the rejection, so surface it instead.
|
|
430
|
+
raise MCPClient::Errors::ConnectionError, @challenge_error if @challenge_error
|
|
431
|
+
|
|
301
432
|
# A fresh 401 challenge is authoritative and overrides any cached
|
|
302
|
-
# (possibly stale or direct-discovered) authorization server metadata
|
|
303
|
-
|
|
433
|
+
# (possibly stale or direct-discovered) authorization server metadata —
|
|
434
|
+
# whether the challenge-advertised PRM was already fetched or only its
|
|
435
|
+
# URL is pending (e.g. the initial fetch failed and must be retried).
|
|
436
|
+
challenge_pending = @challenge_resource_metadata || @challenge_metadata_url
|
|
437
|
+
cached = storage.get_server_metadata(server_url) unless challenge_pending
|
|
304
438
|
if cached
|
|
305
439
|
# Validate the cached entry before use so a persisted/older cache with
|
|
306
440
|
# an HTTP endpoint or without S256 is still rejected.
|
|
@@ -333,6 +467,8 @@ module MCPClient
|
|
|
333
467
|
|
|
334
468
|
storage.set_server_metadata(server_url, server_metadata)
|
|
335
469
|
@challenge_resource_metadata = nil # consumed
|
|
470
|
+
@challenge_metadata_url = nil # consumed
|
|
471
|
+
@challenge_error = nil
|
|
336
472
|
server_metadata
|
|
337
473
|
end
|
|
338
474
|
|
|
@@ -375,8 +511,7 @@ module MCPClient
|
|
|
375
511
|
# once PRM IS found it is authoritative: any subsequent failure must be a
|
|
376
512
|
# hard error, never a silent fallback to an authorization server the PRM
|
|
377
513
|
# did not advertise.
|
|
378
|
-
resource_metadata =
|
|
379
|
-
fetch_first_resource_metadata(protected_resource_metadata_urls(server_url))
|
|
514
|
+
resource_metadata = challenge_or_well_known_resource_metadata
|
|
380
515
|
return nil unless resource_metadata
|
|
381
516
|
|
|
382
517
|
validate_resource_matches!(resource_metadata)
|
|
@@ -387,6 +522,13 @@ module MCPClient
|
|
|
387
522
|
'Protected resource metadata does not advertise any authorization_servers'
|
|
388
523
|
end
|
|
389
524
|
|
|
525
|
+
# authorization_servers is untrusted PRM content: validate the
|
|
526
|
+
# advertised origin BEFORE constructing and fetching well-known URLs
|
|
527
|
+
# on it, so a malicious protected resource cannot drive discovery GETs
|
|
528
|
+
# against internal services (SSRF).
|
|
529
|
+
validate_peer_advertised_url!(auth_server_url,
|
|
530
|
+
'authorization server (advertised by protected resource metadata)')
|
|
531
|
+
|
|
390
532
|
server_metadata = fetch_first_server_metadata(authorization_server_metadata_urls(auth_server_url))
|
|
391
533
|
unless server_metadata
|
|
392
534
|
raise MCPClient::Errors::ConnectionError,
|
|
@@ -397,6 +539,25 @@ module MCPClient
|
|
|
397
539
|
server_metadata
|
|
398
540
|
end
|
|
399
541
|
|
|
542
|
+
# Resolve the Protected Resource Metadata for discovery.
|
|
543
|
+
#
|
|
544
|
+
# MCP 2025-11-25 MUST: use the resource metadata URL from the parsed
|
|
545
|
+
# WWW-Authenticate headers when present; otherwise fall back to the
|
|
546
|
+
# well-known URIs. A challenge-advertised URL is therefore authoritative:
|
|
547
|
+
# it is fetched strictly, and any failure (including a 404) raises rather
|
|
548
|
+
# than falling through to constructed well-known candidates the challenge
|
|
549
|
+
# superseded.
|
|
550
|
+
# @return [ResourceMetadata, nil] metadata, or nil when no PRM exists at
|
|
551
|
+
# the speculative well-known URLs (permitting the direct-AS fallback)
|
|
552
|
+
# @raise [MCPClient::Errors::ConnectionError] if the challenge-advertised
|
|
553
|
+
# URL cannot be fetched, or a well-known candidate exists but is invalid
|
|
554
|
+
def challenge_or_well_known_resource_metadata
|
|
555
|
+
return @challenge_resource_metadata if @challenge_resource_metadata
|
|
556
|
+
return fetch_resource_metadata(@challenge_metadata_url, strict: true) if @challenge_metadata_url
|
|
557
|
+
|
|
558
|
+
fetch_first_resource_metadata(protected_resource_metadata_urls(server_url))
|
|
559
|
+
end
|
|
560
|
+
|
|
400
561
|
# Legacy/self-hosted discovery: treat the MCP server ORIGIN as its own
|
|
401
562
|
# authorization server issuer.
|
|
402
563
|
# @return [ServerMetadata, nil]
|
|
@@ -444,17 +605,18 @@ module MCPClient
|
|
|
444
605
|
end
|
|
445
606
|
|
|
446
607
|
# Verify the authorization server supports PKCE S256 (RFC 8414 / MCP).
|
|
447
|
-
#
|
|
448
|
-
# the
|
|
608
|
+
# Per MCP 2025-11-25, "If code_challenge_methods_supported is absent,
|
|
609
|
+
# the authorization server does not support PKCE and MCP clients MUST
|
|
610
|
+
# refuse to proceed" — absence is a hard stop, not a warning, because
|
|
611
|
+
# proceeding would defeat the authorization-code downgrade protection.
|
|
449
612
|
# @param server_metadata [ServerMetadata]
|
|
450
|
-
# @raise [MCPClient::Errors::ConnectionError] if S256
|
|
613
|
+
# @raise [MCPClient::Errors::ConnectionError] if PKCE S256 support cannot be verified
|
|
451
614
|
def verify_pkce_support!(server_metadata)
|
|
452
615
|
methods = server_metadata.code_challenge_methods_supported
|
|
453
616
|
if methods.nil?
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
)
|
|
617
|
+
raise MCPClient::Errors::ConnectionError,
|
|
618
|
+
'Authorization server metadata omits code_challenge_methods_supported; ' \
|
|
619
|
+
'the server does not support PKCE and the client must refuse to proceed'
|
|
458
620
|
elsif !methods.include?('S256')
|
|
459
621
|
raise MCPClient::Errors::ConnectionError,
|
|
460
622
|
'Authorization server does not support PKCE S256 ' \
|
|
@@ -473,6 +635,71 @@ module MCPClient
|
|
|
473
635
|
enforce_https!(server_metadata.registration_endpoint, 'registration endpoint')
|
|
474
636
|
end
|
|
475
637
|
|
|
638
|
+
# Validate a URL that a peer advertised to us (a 401 challenge's
|
|
639
|
+
# resource_metadata, or PRM authorization_servers).
|
|
640
|
+
#
|
|
641
|
+
# Stricter than enforce_https!, which exists for URLs the OPERATOR
|
|
642
|
+
# configured and therefore tolerates plain-HTTP loopback for local
|
|
643
|
+
# development. Applying that exception to peer-supplied input would
|
|
644
|
+
# leave the reported SSRF intact against the most sensitive targets of
|
|
645
|
+
# all — services listening only on localhost. The loopback exception is
|
|
646
|
+
# honored here only when the configured MCP server is itself loopback,
|
|
647
|
+
# i.e. the developer is already pointed at a local stack.
|
|
648
|
+
#
|
|
649
|
+
# The rejection is recorded so a later discovery fails closed instead of
|
|
650
|
+
# silently reusing cached authorization-server metadata.
|
|
651
|
+
#
|
|
652
|
+
# NOTE: hostnames are checked literally. This does not resolve DNS, so a
|
|
653
|
+
# public name that resolves to a private address is not caught here;
|
|
654
|
+
# that needs resolution-time checking in the HTTP layer.
|
|
655
|
+
# @param url [String] the peer-advertised URL
|
|
656
|
+
# @param label [String] human-readable name for errors
|
|
657
|
+
# @raise [MCPClient::Errors::ConnectionError] if the URL is not acceptable
|
|
658
|
+
def validate_peer_advertised_url!(url, label)
|
|
659
|
+
uri = URI.parse(url)
|
|
660
|
+
host = uri.hostname.to_s.downcase
|
|
661
|
+
|
|
662
|
+
if uri.scheme != 'https' && !(uri.scheme == 'http' && local_development?)
|
|
663
|
+
reject_challenge!("OAuth #{label} must use HTTPS: #{url}")
|
|
664
|
+
end
|
|
665
|
+
reject_challenge!("OAuth #{label} must not target a loopback or private address: #{url}") if
|
|
666
|
+
local_address?(host) && !local_development?
|
|
667
|
+
rescue URI::InvalidURIError
|
|
668
|
+
reject_challenge!("OAuth #{label} is not a valid URL: #{url}")
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
# @param message [String] why the challenge was refused
|
|
672
|
+
# @raise [MCPClient::Errors::ConnectionError] always
|
|
673
|
+
def reject_challenge!(message)
|
|
674
|
+
# Drop every scrap of the refused challenge so nothing half-applied
|
|
675
|
+
# survives, and remember why for the next discovery attempt.
|
|
676
|
+
@challenge_scope = nil
|
|
677
|
+
@challenge_metadata_url = nil
|
|
678
|
+
@challenge_resource_metadata = nil
|
|
679
|
+
@challenge_error = message
|
|
680
|
+
raise MCPClient::Errors::ConnectionError, message
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
# @return [Boolean] whether the configured MCP server is itself local,
|
|
684
|
+
# in which case local discovery targets are expected
|
|
685
|
+
def local_development?
|
|
686
|
+
local_address?(URI.parse(server_url).hostname.to_s.downcase)
|
|
687
|
+
rescue URI::InvalidURIError
|
|
688
|
+
false
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
# @param host [String] a downcased hostname
|
|
692
|
+
# @return [Boolean] whether it names a loopback, private or link-local address
|
|
693
|
+
def local_address?(host)
|
|
694
|
+
return true if %w[localhost 127.0.0.1 ::1 0.0.0.0].include?(host)
|
|
695
|
+
return true if host.end_with?('.localhost', '.local', '.internal')
|
|
696
|
+
return true if host.start_with?('127.', '10.', '192.168.', '169.254.')
|
|
697
|
+
return true if host.match?(/\A172\.(1[6-9]|2\d|3[01])\./)
|
|
698
|
+
return true if host.match?(/\A\[?(fc|fd|fe80)/)
|
|
699
|
+
|
|
700
|
+
false
|
|
701
|
+
end
|
|
702
|
+
|
|
476
703
|
# @param url [String, nil] endpoint URL
|
|
477
704
|
# @param label [String] human-readable endpoint name for errors
|
|
478
705
|
# @raise [MCPClient::Errors::ConnectionError] if the URL is not HTTPS (non-localhost)
|
|
@@ -571,7 +798,11 @@ module MCPClient
|
|
|
571
798
|
end
|
|
572
799
|
|
|
573
800
|
data = JSON.parse(response.body)
|
|
574
|
-
ResourceMetadata.from_h(data)
|
|
801
|
+
metadata = ResourceMetadata.from_h(data)
|
|
802
|
+
# Retain the most recently fetched PRM so scope resolution can fall
|
|
803
|
+
# back to its scopes_supported (MCP scope selection priority 2).
|
|
804
|
+
@resource_metadata = metadata
|
|
805
|
+
metadata
|
|
575
806
|
rescue JSON::ParserError => e
|
|
576
807
|
raise MCPClient::Errors::ConnectionError, "Invalid resource metadata JSON: #{e.message}"
|
|
577
808
|
rescue Faraday::Error => e
|
|
@@ -601,18 +832,28 @@ module MCPClient
|
|
|
601
832
|
raise MCPClient::Errors::ConnectionError, "Network error fetching server metadata: #{e.message}"
|
|
602
833
|
end
|
|
603
834
|
|
|
604
|
-
# Get or register OAuth client
|
|
835
|
+
# Get or register OAuth client, following the MCP 2025-11-25 client
|
|
836
|
+
# registration priority order: pre-registered/cached client information
|
|
837
|
+
# first, then Client ID Metadata Documents (SEP-991) when the
|
|
838
|
+
# authorization server advertises support and a metadata URL is
|
|
839
|
+
# configured, then Dynamic Client Registration as a fallback.
|
|
605
840
|
# @param server_metadata [ServerMetadata] Authorization server metadata
|
|
606
841
|
# @return [ClientInfo] Client information
|
|
607
842
|
# @raise [MCPClient::Errors::ConnectionError] if registration fails
|
|
608
843
|
def get_or_register_client(server_metadata)
|
|
609
|
-
#
|
|
844
|
+
# 1. Pre-registered or previously registered client info from storage
|
|
610
845
|
if (client_info = storage.get_client_info(server_url)) && !client_info.client_secret_expired?
|
|
611
846
|
logger.debug("Using cached OAuth client for #{server_url}")
|
|
612
847
|
return client_info
|
|
613
848
|
end
|
|
614
849
|
|
|
615
|
-
#
|
|
850
|
+
# 2. Client ID Metadata Documents (SEP-991): the HTTPS metadata URL is
|
|
851
|
+
# itself the client_id — no registration request is needed.
|
|
852
|
+
if client_id_metadata_url && server_metadata.supports_client_id_metadata_documents?
|
|
853
|
+
return client_info_from_metadata_url
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
# 3. Dynamic Client Registration (RFC 7591) fallback
|
|
616
857
|
logger.debug('No cached client found, registering new OAuth client...')
|
|
617
858
|
if server_metadata.supports_registration?
|
|
618
859
|
register_client(server_metadata)
|
|
@@ -622,6 +863,67 @@ module MCPClient
|
|
|
622
863
|
end
|
|
623
864
|
end
|
|
624
865
|
|
|
866
|
+
# Build client information for a Client ID Metadata Document client
|
|
867
|
+
# (SEP-991): the configured HTTPS metadata URL is used directly as the
|
|
868
|
+
# client_id in authorization and token requests, without a dynamic
|
|
869
|
+
# registration POST. Serving the metadata JSON at that URL is the
|
|
870
|
+
# application's responsibility, not this library's.
|
|
871
|
+
# @return [ClientInfo] Client information with the metadata URL as client_id
|
|
872
|
+
def client_info_from_metadata_url
|
|
873
|
+
logger.debug("Using Client ID Metadata Document URL as client_id: #{client_id_metadata_url}")
|
|
874
|
+
|
|
875
|
+
metadata = ClientMetadata.new(
|
|
876
|
+
redirect_uris: [redirect_uri],
|
|
877
|
+
token_endpoint_auth_method: 'none', # Public client
|
|
878
|
+
grant_types: %w[authorization_code refresh_token],
|
|
879
|
+
response_types: ['code'],
|
|
880
|
+
scope: resolved_scope,
|
|
881
|
+
**@extra_client_metadata
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
client_info = ClientInfo.new(client_id: client_id_metadata_url, metadata: metadata)
|
|
885
|
+
|
|
886
|
+
# Persist so complete_authorization_flow and token refresh can find it
|
|
887
|
+
storage.set_client_info(server_url, client_info)
|
|
888
|
+
|
|
889
|
+
client_info
|
|
890
|
+
end
|
|
891
|
+
|
|
892
|
+
# Validate a Client ID Metadata Document URL (SEP-991): "The client_id
|
|
893
|
+
# URL MUST use the 'https' scheme and contain a path component, e.g.
|
|
894
|
+
# https://example.com/client.json". Per the Client ID Metadata Document
|
|
895
|
+
# draft the URL must also have a host and must not contain userinfo,
|
|
896
|
+
# a fragment, or single-/double-dot path segments.
|
|
897
|
+
# @param url [String] Candidate metadata URL
|
|
898
|
+
# @return [String] The validated URL
|
|
899
|
+
# @raise [ArgumentError] if the URL is invalid, not HTTPS, lacks a host or path
|
|
900
|
+
# component, or contains userinfo, a fragment, or dot path segments
|
|
901
|
+
def validate_client_id_metadata_url(url)
|
|
902
|
+
uri = URI.parse(url)
|
|
903
|
+
raise ArgumentError, "client_id_metadata_url must be an HTTPS URL: #{url.inspect}" unless uri.is_a?(URI::HTTPS)
|
|
904
|
+
|
|
905
|
+
raise ArgumentError, "client_id_metadata_url must include a host: #{url.inspect}" if uri.host.to_s.empty?
|
|
906
|
+
|
|
907
|
+
raise ArgumentError, "client_id_metadata_url must not contain userinfo: #{url.inspect}" if uri.userinfo
|
|
908
|
+
|
|
909
|
+
raise ArgumentError, "client_id_metadata_url must not contain a fragment: #{url.inspect}" if uri.fragment
|
|
910
|
+
|
|
911
|
+
if uri.path.to_s.empty? || uri.path == '/'
|
|
912
|
+
raise ArgumentError,
|
|
913
|
+
'client_id_metadata_url must contain a path component ' \
|
|
914
|
+
"(e.g. https://example.com/client.json): #{url.inspect}"
|
|
915
|
+
end
|
|
916
|
+
|
|
917
|
+
if uri.path.split('/').intersect?(['.', '..'])
|
|
918
|
+
raise ArgumentError,
|
|
919
|
+
"client_id_metadata_url must not contain '.' or '..' path segments: #{url.inspect}"
|
|
920
|
+
end
|
|
921
|
+
|
|
922
|
+
url
|
|
923
|
+
rescue URI::InvalidURIError
|
|
924
|
+
raise ArgumentError, "client_id_metadata_url is not a valid URL: #{url.inspect}"
|
|
925
|
+
end
|
|
926
|
+
|
|
625
927
|
# Register OAuth client dynamically
|
|
626
928
|
# @param server_metadata [ServerMetadata] Authorization server metadata
|
|
627
929
|
# @return [ClientInfo] Registered client information
|
|
@@ -629,8 +931,6 @@ module MCPClient
|
|
|
629
931
|
def register_client(server_metadata)
|
|
630
932
|
logger.debug("Registering OAuth client at: #{server_metadata.registration_endpoint}")
|
|
631
933
|
|
|
632
|
-
resolved_scope = scope == :all ? supported_scopes.join(' ') : scope
|
|
633
|
-
|
|
634
934
|
metadata = ClientMetadata.new(
|
|
635
935
|
redirect_uris: [redirect_uri],
|
|
636
936
|
token_endpoint_auth_method: 'none', # Public client
|
|
@@ -706,8 +1006,6 @@ module MCPClient
|
|
|
706
1006
|
# Use the redirect_uri that was actually registered
|
|
707
1007
|
registered_redirect_uri = client_info.metadata.redirect_uris.first
|
|
708
1008
|
|
|
709
|
-
resolved_scope = scope == :all ? supported_scopes.join(' ') : scope
|
|
710
|
-
|
|
711
1009
|
params = {
|
|
712
1010
|
response_type: 'code',
|
|
713
1011
|
client_id: client_info.client_id,
|
|
@@ -845,7 +1143,7 @@ module MCPClient
|
|
|
845
1143
|
storage.set_token(server_url, new_token)
|
|
846
1144
|
new_token
|
|
847
1145
|
rescue JSON::ParserError => e
|
|
848
|
-
logger.warn("Invalid token refresh response: #{e
|
|
1146
|
+
logger.warn("Invalid token refresh response: #{describe_parse_error(e)}")
|
|
849
1147
|
nil
|
|
850
1148
|
rescue Faraday::Error => e
|
|
851
1149
|
logger.warn("Network error during token refresh: #{e.message}")
|
data/lib/mcp_client/auth.rb
CHANGED
|
@@ -100,13 +100,11 @@ module MCPClient
|
|
|
100
100
|
# @param tos_uri [String, nil] URL of the client terms of service
|
|
101
101
|
# @param policy_uri [String, nil] URL of the client privacy policy
|
|
102
102
|
# @param contacts [Array<String>, nil] List of contact emails for the client
|
|
103
|
-
# rubocop:disable Metrics/ParameterLists
|
|
104
103
|
def initialize(redirect_uris:, token_endpoint_auth_method: 'none',
|
|
105
104
|
grant_types: %w[authorization_code refresh_token],
|
|
106
105
|
response_types: ['code'], scope: nil,
|
|
107
106
|
client_name: nil, client_uri: nil, logo_uri: nil,
|
|
108
107
|
tos_uri: nil, policy_uri: nil, contacts: nil)
|
|
109
|
-
# rubocop:enable Metrics/ParameterLists
|
|
110
108
|
@redirect_uris = redirect_uris
|
|
111
109
|
@token_endpoint_auth_method = token_endpoint_auth_method
|
|
112
110
|
@grant_types = grant_types
|
|
@@ -226,7 +224,7 @@ module MCPClient
|
|
|
226
224
|
class ServerMetadata
|
|
227
225
|
attr_reader :issuer, :authorization_endpoint, :token_endpoint, :registration_endpoint,
|
|
228
226
|
:scopes_supported, :response_types_supported, :grant_types_supported,
|
|
229
|
-
:code_challenge_methods_supported
|
|
227
|
+
:code_challenge_methods_supported, :client_id_metadata_document_supported
|
|
230
228
|
|
|
231
229
|
# @param issuer [String] Issuer identifier URL
|
|
232
230
|
# @param authorization_endpoint [String] Authorization endpoint URL
|
|
@@ -236,9 +234,11 @@ module MCPClient
|
|
|
236
234
|
# @param response_types_supported [Array<String>, nil] Supported response types
|
|
237
235
|
# @param grant_types_supported [Array<String>, nil] Supported grant types
|
|
238
236
|
# @param code_challenge_methods_supported [Array<String>, nil] Supported PKCE code challenge methods (RFC 8414)
|
|
237
|
+
# @param client_id_metadata_document_supported [Boolean, nil] Whether the server accepts
|
|
238
|
+
# Client ID Metadata Document client IDs (MCP 2025-11-25 / SEP-991)
|
|
239
239
|
def initialize(issuer:, authorization_endpoint:, token_endpoint:, registration_endpoint: nil,
|
|
240
240
|
scopes_supported: nil, response_types_supported: nil, grant_types_supported: nil,
|
|
241
|
-
code_challenge_methods_supported: nil)
|
|
241
|
+
code_challenge_methods_supported: nil, client_id_metadata_document_supported: nil)
|
|
242
242
|
@issuer = issuer
|
|
243
243
|
@authorization_endpoint = authorization_endpoint
|
|
244
244
|
@token_endpoint = token_endpoint
|
|
@@ -247,6 +247,7 @@ module MCPClient
|
|
|
247
247
|
@response_types_supported = response_types_supported
|
|
248
248
|
@grant_types_supported = grant_types_supported
|
|
249
249
|
@code_challenge_methods_supported = code_challenge_methods_supported
|
|
250
|
+
@client_id_metadata_document_supported = client_id_metadata_document_supported
|
|
250
251
|
end
|
|
251
252
|
|
|
252
253
|
# Check if dynamic client registration is supported
|
|
@@ -255,6 +256,13 @@ module MCPClient
|
|
|
255
256
|
!@registration_endpoint.nil?
|
|
256
257
|
end
|
|
257
258
|
|
|
259
|
+
# Check if the server accepts clients using Client ID Metadata Documents
|
|
260
|
+
# (MCP 2025-11-25 / SEP-991), i.e. HTTPS URLs as client identifiers
|
|
261
|
+
# @return [Boolean] true if client_id_metadata_document_supported is true
|
|
262
|
+
def supports_client_id_metadata_documents?
|
|
263
|
+
@client_id_metadata_document_supported == true
|
|
264
|
+
end
|
|
265
|
+
|
|
258
266
|
# Convert to hash
|
|
259
267
|
# @return [Hash] Hash representation
|
|
260
268
|
def to_h
|
|
@@ -266,7 +274,8 @@ module MCPClient
|
|
|
266
274
|
scopes_supported: @scopes_supported,
|
|
267
275
|
response_types_supported: @response_types_supported,
|
|
268
276
|
grant_types_supported: @grant_types_supported,
|
|
269
|
-
code_challenge_methods_supported: @code_challenge_methods_supported
|
|
277
|
+
code_challenge_methods_supported: @code_challenge_methods_supported,
|
|
278
|
+
client_id_metadata_document_supported: @client_id_metadata_document_supported
|
|
270
279
|
}.compact
|
|
271
280
|
end
|
|
272
281
|
|
|
@@ -283,20 +292,36 @@ module MCPClient
|
|
|
283
292
|
response_types_supported: data[:response_types_supported] || data['response_types_supported'],
|
|
284
293
|
grant_types_supported: data[:grant_types_supported] || data['grant_types_supported'],
|
|
285
294
|
code_challenge_methods_supported: data[:code_challenge_methods_supported] ||
|
|
286
|
-
data['code_challenge_methods_supported']
|
|
295
|
+
data['code_challenge_methods_supported'],
|
|
296
|
+
client_id_metadata_document_supported: fetch_boolean(data, :client_id_metadata_document_supported)
|
|
287
297
|
)
|
|
288
298
|
end
|
|
299
|
+
|
|
300
|
+
# Fetch a possibly-false value from a hash by symbol or string key.
|
|
301
|
+
# Unlike the `||` chains above, this preserves an explicit false.
|
|
302
|
+
# @param data [Hash] Source hash
|
|
303
|
+
# @param key [Symbol] Key to fetch
|
|
304
|
+
# @return [Object, nil] The value, or nil when absent under both keys
|
|
305
|
+
def self.fetch_boolean(data, key)
|
|
306
|
+
return data[key] if data.key?(key)
|
|
307
|
+
|
|
308
|
+
data[key.to_s]
|
|
309
|
+
end
|
|
310
|
+
private_class_method :fetch_boolean
|
|
289
311
|
end
|
|
290
312
|
|
|
291
313
|
# Protected resource metadata for authorization server discovery
|
|
292
314
|
class ResourceMetadata
|
|
293
|
-
attr_reader :resource, :authorization_servers
|
|
315
|
+
attr_reader :resource, :authorization_servers, :scopes_supported
|
|
294
316
|
|
|
295
317
|
# @param resource [String] Resource server identifier
|
|
296
318
|
# @param authorization_servers [Array<String>] List of authorization server URLs
|
|
297
|
-
|
|
319
|
+
# @param scopes_supported [Array<String>, nil] Scopes the resource supports (RFC 9728);
|
|
320
|
+
# per MCP, the default scope set when a challenge provides none
|
|
321
|
+
def initialize(resource:, authorization_servers:, scopes_supported: nil)
|
|
298
322
|
@resource = resource
|
|
299
323
|
@authorization_servers = authorization_servers
|
|
324
|
+
@scopes_supported = scopes_supported
|
|
300
325
|
end
|
|
301
326
|
|
|
302
327
|
# Convert to hash
|
|
@@ -304,8 +329,9 @@ module MCPClient
|
|
|
304
329
|
def to_h
|
|
305
330
|
{
|
|
306
331
|
resource: @resource,
|
|
307
|
-
authorization_servers: @authorization_servers
|
|
308
|
-
|
|
332
|
+
authorization_servers: @authorization_servers,
|
|
333
|
+
scopes_supported: @scopes_supported
|
|
334
|
+
}.compact
|
|
309
335
|
end
|
|
310
336
|
|
|
311
337
|
# Create resource metadata from hash
|
|
@@ -314,7 +340,8 @@ module MCPClient
|
|
|
314
340
|
def self.from_h(data)
|
|
315
341
|
new(
|
|
316
342
|
resource: data[:resource] || data['resource'],
|
|
317
|
-
authorization_servers: data[:authorization_servers] || data['authorization_servers']
|
|
343
|
+
authorization_servers: data[:authorization_servers] || data['authorization_servers'],
|
|
344
|
+
scopes_supported: data[:scopes_supported] || data['scopes_supported']
|
|
318
345
|
)
|
|
319
346
|
end
|
|
320
347
|
end
|