ruby-mcp-client 1.0.1 → 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 +234 -7
- data/lib/mcp_client/auth/oauth_provider.rb +553 -50
- data/lib/mcp_client/auth.rb +47 -9
- data/lib/mcp_client/client.rb +613 -172
- data/lib/mcp_client/elicitation_validator.rb +43 -0
- data/lib/mcp_client/errors.rb +44 -1
- data/lib/mcp_client/http_transport_base.rb +232 -38
- data/lib/mcp_client/json_rpc_common.rb +140 -12
- 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 +139 -0
- data/lib/mcp_client/server_http/json_rpc_transport.rb +2 -1
- data/lib/mcp_client/server_http.rb +45 -29
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +67 -9
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +11 -0
- data/lib/mcp_client/server_sse/sse_parser.rb +51 -11
- data/lib/mcp_client/server_sse.rb +98 -57
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +42 -9
- data/lib/mcp_client/server_stdio.rb +216 -37
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +149 -23
- data/lib/mcp_client/server_streamable_http.rb +249 -107
- data/lib/mcp_client/task.rb +93 -61
- data/lib/mcp_client/tool.rb +63 -10
- data/lib/mcp_client/version.rb +6 -1
- data/lib/mcp_client.rb +1 -0
- metadata +19 -4
|
@@ -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,15 +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
|
|
65
|
+
# Protected resource metadata learned from a 401 WWW-Authenticate
|
|
66
|
+
# challenge, reused by discovery so a challenge-advertised metadata URL
|
|
67
|
+
# is not re-derived (and possibly missed). The URL itself is retained
|
|
68
|
+
# separately so a failed fetch is retried authoritatively by discovery.
|
|
69
|
+
@challenge_resource_metadata = nil
|
|
70
|
+
@challenge_metadata_url = nil
|
|
44
71
|
end
|
|
45
72
|
|
|
46
73
|
# @param url [String] Server URL to normalize
|
|
@@ -48,6 +75,15 @@ module MCPClient
|
|
|
48
75
|
@server_url = normalize_server_url(url)
|
|
49
76
|
end
|
|
50
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
|
+
|
|
51
87
|
# Get current access token (refresh if needed)
|
|
52
88
|
# @return [Token, nil] Current valid access token or nil
|
|
53
89
|
def access_token
|
|
@@ -142,16 +178,125 @@ module MCPClient
|
|
|
142
178
|
www_authenticate = response.headers['WWW-Authenticate'] || response.headers['www-authenticate']
|
|
143
179
|
return nil unless www_authenticate
|
|
144
180
|
|
|
145
|
-
#
|
|
146
|
-
#
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
+
|
|
194
|
+
url = extract_resource_metadata_url(www_authenticate)
|
|
195
|
+
return nil unless url
|
|
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
|
+
|
|
202
|
+
# This URL was explicitly advertised by the 401 challenge, so a 404 is a
|
|
203
|
+
# misconfiguration to surface (strict), not a speculative miss to skip.
|
|
204
|
+
metadata = fetch_resource_metadata(url, strict: true)
|
|
205
|
+
# Reuse this challenge-advertised metadata during the subsequent OAuth
|
|
206
|
+
# flow instead of re-deriving (and possibly missing) the well-known URL.
|
|
207
|
+
@challenge_resource_metadata = metadata
|
|
208
|
+
metadata
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Extract the protected-resource-metadata URL from a WWW-Authenticate header.
|
|
212
|
+
# Per RFC 9728 the parameter is `resource_metadata`; a legacy `resource`
|
|
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.
|
|
216
|
+
# @param header [String] the WWW-Authenticate header value
|
|
217
|
+
# @return [String, nil] the metadata URL if present
|
|
218
|
+
def extract_resource_metadata_url(header)
|
|
219
|
+
params = bearer_challenge_segment(header)
|
|
220
|
+
return nil unless params
|
|
221
|
+
|
|
222
|
+
# Auth-params may include optional whitespace around '=' (RFC 7235).
|
|
223
|
+
# Quoted form: resource_metadata = "https://..."
|
|
224
|
+
if (m = params.match(/resource_metadata\s*=\s*"([^"]+)"/))
|
|
225
|
+
return m[1]
|
|
150
226
|
end
|
|
227
|
+
|
|
228
|
+
# Unquoted token form: resource_metadata = https://...
|
|
229
|
+
if (m = params.match(/resource_metadata\s*=\s*([^,\s]+)/))
|
|
230
|
+
return m[1]
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Legacy fallback: resource="https://..."
|
|
234
|
+
params.match(/resource\s*=\s*"([^"]+)"/)&.captures&.first
|
|
151
235
|
end
|
|
152
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]
|
|
256
|
+
end
|
|
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
|
+
|
|
153
275
|
private
|
|
154
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
|
+
|
|
155
300
|
# Normalize server URL to canonical form
|
|
156
301
|
# @param url [String] Server URL
|
|
157
302
|
# @return [String] Normalized URL
|
|
@@ -213,6 +358,55 @@ module MCPClient
|
|
|
213
358
|
(uri.scheme == 'https' && uri.port == 443)
|
|
214
359
|
end
|
|
215
360
|
|
|
361
|
+
# Build the scheme://host[:port] origin for a parsed URI.
|
|
362
|
+
# @param uri [URI] Parsed URI
|
|
363
|
+
# @return [String] origin string
|
|
364
|
+
def origin_of(uri)
|
|
365
|
+
origin = "#{uri.scheme}://#{uri.host}"
|
|
366
|
+
origin += ":#{uri.port}" if uri.port && !default_port?(uri)
|
|
367
|
+
origin
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Protected Resource Metadata well-known URLs to try, in priority order
|
|
371
|
+
# (RFC 9728 §3.1). When the resource identifier has a path, the well-known
|
|
372
|
+
# segment is inserted between the host and the path; a root-level URL is
|
|
373
|
+
# always tried as a fallback.
|
|
374
|
+
# @param server_url [String] the MCP server (protected resource) URL
|
|
375
|
+
# @return [Array<String>] ordered candidate URLs
|
|
376
|
+
def protected_resource_metadata_urls(server_url)
|
|
377
|
+
uri = URI.parse(server_url)
|
|
378
|
+
origin = origin_of(uri)
|
|
379
|
+
path = uri.path.to_s
|
|
380
|
+
|
|
381
|
+
urls = []
|
|
382
|
+
urls << "#{origin}/.well-known/oauth-protected-resource#{path}" unless path.empty? || path == '/'
|
|
383
|
+
urls << "#{origin}/.well-known/oauth-protected-resource"
|
|
384
|
+
urls.uniq
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Authorization Server Metadata well-known URLs to try, in priority order
|
|
388
|
+
# (RFC 8414 §3.1 path-insertion plus OpenID Connect Discovery). When the
|
|
389
|
+
# issuer has a path, the well-known segment is INSERTED between host and
|
|
390
|
+
# path (not appended); the OIDC path-append form is also tried.
|
|
391
|
+
# @param issuer [String] the authorization server issuer URL
|
|
392
|
+
# @return [Array<String>] ordered candidate URLs
|
|
393
|
+
def authorization_server_metadata_urls(issuer)
|
|
394
|
+
uri = URI.parse(issuer)
|
|
395
|
+
origin = origin_of(uri)
|
|
396
|
+
path = uri.path.to_s
|
|
397
|
+
|
|
398
|
+
urls = []
|
|
399
|
+
if path.empty? || path == '/'
|
|
400
|
+
urls << "#{origin}/.well-known/oauth-authorization-server"
|
|
401
|
+
urls << "#{origin}/.well-known/openid-configuration"
|
|
402
|
+
else
|
|
403
|
+
urls << "#{origin}/.well-known/oauth-authorization-server#{path}"
|
|
404
|
+
urls << "#{origin}/.well-known/openid-configuration#{path}"
|
|
405
|
+
urls << "#{origin}#{path}/.well-known/openid-configuration"
|
|
406
|
+
end
|
|
407
|
+
urls.uniq
|
|
408
|
+
end
|
|
409
|
+
|
|
216
410
|
# Discover authorization server metadata
|
|
217
411
|
# Tries multiple discovery patterns:
|
|
218
412
|
# 1. oauth-authorization-server (MCP spec pattern - server is its own auth server)
|
|
@@ -220,69 +414,307 @@ module MCPClient
|
|
|
220
414
|
# @return [ServerMetadata] Authorization server metadata
|
|
221
415
|
# @raise [MCPClient::Errors::ConnectionError] if discovery fails
|
|
222
416
|
def discover_authorization_server
|
|
223
|
-
#
|
|
224
|
-
|
|
417
|
+
# A fresh 401 challenge is authoritative and overrides any cached
|
|
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
|
|
423
|
+
if cached
|
|
424
|
+
# Validate the cached entry before use so a persisted/older cache with
|
|
425
|
+
# an HTTP endpoint or without S256 is still rejected.
|
|
426
|
+
validate_server_metadata!(cached)
|
|
225
427
|
return cached
|
|
226
428
|
end
|
|
227
429
|
|
|
228
|
-
|
|
430
|
+
discover_and_cache_authorization_server
|
|
431
|
+
end
|
|
229
432
|
|
|
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
|
|
433
|
+
# Discover authorization server metadata, validate it, and cache it.
|
|
434
|
+
# @return [ServerMetadata]
|
|
435
|
+
# @raise [MCPClient::Errors::ConnectionError] if discovery or validation fails
|
|
436
|
+
def discover_and_cache_authorization_server
|
|
437
|
+
previous = storage.get_server_metadata(server_url)
|
|
239
438
|
|
|
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
|
|
439
|
+
# RFC 9728: Protected Resource Metadata is authoritative — try it first,
|
|
440
|
+
# then fall back to treating the MCP server origin as its own AS.
|
|
441
|
+
server_metadata = discover_via_protected_resource || discover_via_direct_authorization_server
|
|
257
442
|
|
|
258
443
|
unless server_metadata
|
|
259
444
|
raise MCPClient::Errors::ConnectionError,
|
|
260
|
-
'OAuth discovery failed: no valid
|
|
445
|
+
'OAuth discovery failed: no valid authorization server metadata found'
|
|
261
446
|
end
|
|
262
447
|
|
|
263
|
-
#
|
|
448
|
+
# Validate BEFORE caching so invalid metadata is never persisted.
|
|
449
|
+
validate_server_metadata!(server_metadata)
|
|
450
|
+
|
|
451
|
+
invalidate_client_info_on_as_change(previous, server_metadata)
|
|
452
|
+
|
|
264
453
|
storage.set_server_metadata(server_url, server_metadata)
|
|
454
|
+
@challenge_resource_metadata = nil # consumed
|
|
455
|
+
@challenge_metadata_url = nil # consumed
|
|
456
|
+
server_metadata
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# When a 401 challenge changes the authorization server, per-AS state cached
|
|
460
|
+
# under this server_url becomes invalid: a client_id registered with the
|
|
461
|
+
# previous AS would fail as invalid_client, and memoized scopes belong to
|
|
462
|
+
# the old AS. Discard both so the new flow re-registers and re-discovers.
|
|
463
|
+
# @param previous [ServerMetadata, nil] previously cached AS metadata
|
|
464
|
+
# @param current [ServerMetadata] newly discovered AS metadata
|
|
465
|
+
def invalidate_client_info_on_as_change(previous, current)
|
|
466
|
+
return unless previous && previous.issuer != current.issuer
|
|
467
|
+
|
|
468
|
+
logger.debug('Authorization server changed; discarding client and scopes from the previous AS')
|
|
469
|
+
|
|
470
|
+
# Prefer an explicit delete; fall back to the always-available
|
|
471
|
+
# set_client_info(nil) so custom storage backends are handled too.
|
|
472
|
+
if storage.respond_to?(:delete_client_info)
|
|
473
|
+
storage.delete_client_info(server_url)
|
|
474
|
+
else
|
|
475
|
+
storage.set_client_info(server_url, nil)
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
@supported_scopes = nil
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# Apply the PKCE-support and HTTPS-endpoint checks to server metadata.
|
|
482
|
+
# @param server_metadata [ServerMetadata]
|
|
483
|
+
# @raise [MCPClient::Errors::ConnectionError] if a check fails
|
|
484
|
+
def validate_server_metadata!(server_metadata)
|
|
485
|
+
verify_pkce_support!(server_metadata)
|
|
486
|
+
enforce_https_endpoints!(server_metadata)
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# PRM-first discovery: fetch Protected Resource Metadata and then the
|
|
490
|
+
# authorization server it advertises.
|
|
491
|
+
# @return [ServerMetadata, nil] AS metadata, or nil if no PRM document exists
|
|
492
|
+
# @raise [MCPClient::Errors::ConnectionError] if PRM is malformed or mismatched
|
|
493
|
+
def discover_via_protected_resource
|
|
494
|
+
# A missing PRM document (return nil) permits the direct-AS fallback. But
|
|
495
|
+
# once PRM IS found it is authoritative: any subsequent failure must be a
|
|
496
|
+
# hard error, never a silent fallback to an authorization server the PRM
|
|
497
|
+
# did not advertise.
|
|
498
|
+
resource_metadata = challenge_or_well_known_resource_metadata
|
|
499
|
+
return nil unless resource_metadata
|
|
500
|
+
|
|
501
|
+
validate_resource_matches!(resource_metadata)
|
|
502
|
+
|
|
503
|
+
auth_server_url = Array(resource_metadata.authorization_servers).first
|
|
504
|
+
unless auth_server_url
|
|
505
|
+
raise MCPClient::Errors::ConnectionError,
|
|
506
|
+
'Protected resource metadata does not advertise any authorization_servers'
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
server_metadata = fetch_first_server_metadata(authorization_server_metadata_urls(auth_server_url))
|
|
510
|
+
unless server_metadata
|
|
511
|
+
raise MCPClient::Errors::ConnectionError,
|
|
512
|
+
"Authorization server advertised by protected resource metadata (#{auth_server_url}) " \
|
|
513
|
+
'could not be discovered'
|
|
514
|
+
end
|
|
265
515
|
|
|
266
516
|
server_metadata
|
|
267
517
|
end
|
|
268
518
|
|
|
269
|
-
#
|
|
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
|
+
|
|
538
|
+
# Legacy/self-hosted discovery: treat the MCP server ORIGIN as its own
|
|
539
|
+
# authorization server issuer.
|
|
540
|
+
# @return [ServerMetadata, nil]
|
|
541
|
+
def discover_via_direct_authorization_server
|
|
542
|
+
origin = origin_of(URI.parse(server_url))
|
|
543
|
+
fetch_first_server_metadata(authorization_server_metadata_urls(origin))
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
# Fetch the first Protected Resource Metadata document that resolves.
|
|
547
|
+
#
|
|
548
|
+
# PRM is authoritative: a candidate that genuinely does not exist (HTTP
|
|
549
|
+
# 404) is skipped so the next candidate is tried, but a candidate that
|
|
550
|
+
# exists yet is malformed or errors (bad JSON, 5xx, network failure) is a
|
|
551
|
+
# HARD failure — it must not silently fall through to a root PRM or the
|
|
552
|
+
# direct-AS path, which could point at a different authorization server.
|
|
553
|
+
# @param urls [Array<String>] candidate URLs
|
|
554
|
+
# @return [ResourceMetadata, nil]
|
|
555
|
+
def fetch_first_resource_metadata(urls)
|
|
556
|
+
urls.each do |url|
|
|
557
|
+
md = fetch_resource_metadata(url) # returns nil only for a 404 (absent); raises otherwise
|
|
558
|
+
return md if md
|
|
559
|
+
end
|
|
560
|
+
nil
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# Fetch the first Authorization Server Metadata document that resolves.
|
|
564
|
+
# The oauth-authorization-server and openid-configuration forms are
|
|
565
|
+
# genuine alternatives, so any failing candidate is skipped to try the next.
|
|
566
|
+
# @param urls [Array<String>] candidate URLs
|
|
567
|
+
# @return [ServerMetadata, nil]
|
|
568
|
+
def fetch_first_server_metadata(urls)
|
|
569
|
+
urls.each do |url|
|
|
570
|
+
md = try_fetch_server_metadata(url)
|
|
571
|
+
return md if md
|
|
572
|
+
end
|
|
573
|
+
nil
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
# Non-raising server-metadata fetch used while iterating candidates.
|
|
577
|
+
def try_fetch_server_metadata(url)
|
|
578
|
+
fetch_server_metadata(url)
|
|
579
|
+
rescue MCPClient::Errors::ConnectionError => e
|
|
580
|
+
logger.debug("Authorization server metadata candidate failed (#{url}): #{e.message}")
|
|
581
|
+
nil
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# Verify the authorization server supports PKCE S256 (RFC 8414 / MCP).
|
|
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.
|
|
589
|
+
# @param server_metadata [ServerMetadata]
|
|
590
|
+
# @raise [MCPClient::Errors::ConnectionError] if PKCE S256 support cannot be verified
|
|
591
|
+
def verify_pkce_support!(server_metadata)
|
|
592
|
+
methods = server_metadata.code_challenge_methods_supported
|
|
593
|
+
if methods.nil?
|
|
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'
|
|
597
|
+
elsif !methods.include?('S256')
|
|
598
|
+
raise MCPClient::Errors::ConnectionError,
|
|
599
|
+
'Authorization server does not support PKCE S256 ' \
|
|
600
|
+
'(code_challenge_methods_supported does not include "S256")'
|
|
601
|
+
end
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
# Require HTTPS for all discovered authorization server endpoints, with a
|
|
605
|
+
# localhost exception for local development.
|
|
606
|
+
# @param server_metadata [ServerMetadata]
|
|
607
|
+
def enforce_https_endpoints!(server_metadata)
|
|
608
|
+
enforce_https!(server_metadata.authorization_endpoint, 'authorization endpoint')
|
|
609
|
+
enforce_https!(server_metadata.token_endpoint, 'token endpoint')
|
|
610
|
+
return unless server_metadata.registration_endpoint
|
|
611
|
+
|
|
612
|
+
enforce_https!(server_metadata.registration_endpoint, 'registration endpoint')
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
# @param url [String, nil] endpoint URL
|
|
616
|
+
# @param label [String] human-readable endpoint name for errors
|
|
617
|
+
# @raise [MCPClient::Errors::ConnectionError] if the URL is not HTTPS (non-localhost)
|
|
618
|
+
def enforce_https!(url, label)
|
|
619
|
+
return if url.nil?
|
|
620
|
+
|
|
621
|
+
uri = URI.parse(url)
|
|
622
|
+
return if uri.scheme == 'https'
|
|
623
|
+
# Dev exception is only for plain HTTP on a loopback host — not any other
|
|
624
|
+
# scheme (e.g. ftp://localhost). Use #hostname (not #host) so an IPv6
|
|
625
|
+
# loopback like http://[::1]:9292 matches without the surrounding brackets.
|
|
626
|
+
return if uri.scheme == 'http' && %w[localhost 127.0.0.1 ::1].include?(uri.hostname)
|
|
627
|
+
|
|
628
|
+
raise MCPClient::Errors::ConnectionError, "OAuth #{label} must use HTTPS: #{url}"
|
|
629
|
+
rescue URI::InvalidURIError
|
|
630
|
+
raise MCPClient::Errors::ConnectionError, "OAuth #{label} is not a valid URL: #{url}"
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
# Validate the PRM `resource` identifies this server (RFC 9728 confused
|
|
634
|
+
# deputy protection). Path/canonicalization differences on the same host
|
|
635
|
+
# are tolerated; a different host is rejected.
|
|
636
|
+
# @param resource_metadata [ResourceMetadata]
|
|
637
|
+
# @raise [MCPClient::Errors::ConnectionError] on host mismatch
|
|
638
|
+
def validate_resource_matches!(resource_metadata)
|
|
639
|
+
# RFC 9728 requires `resource`; without it the confused-deputy check is
|
|
640
|
+
# impossible, so a PRM that omits it must be rejected (not trusted).
|
|
641
|
+
if resource_metadata.resource.nil?
|
|
642
|
+
raise MCPClient::Errors::ConnectionError,
|
|
643
|
+
'Protected resource metadata is missing the required "resource" identifier'
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
# Require an EXACT canonical match (scheme/host/port/path). A same-host
|
|
647
|
+
# match is not enough: a host that serves multiple resources/tenants
|
|
648
|
+
# must not have one tenant's PRM trusted for another.
|
|
649
|
+
advertised = resource_identity(resource_metadata.resource)
|
|
650
|
+
expected = resource_identity(server_url)
|
|
651
|
+
return if advertised == expected
|
|
652
|
+
|
|
653
|
+
raise MCPClient::Errors::ConnectionError,
|
|
654
|
+
"Protected resource metadata resource (#{resource_metadata.resource}) " \
|
|
655
|
+
"does not match the server URL (#{server_url})"
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
# Canonical resource identity (scheme, host, port, path, query) used for
|
|
659
|
+
# the confused-deputy comparison. The query is included because the
|
|
660
|
+
# `resource` parameter sent in the authorization and token requests is the
|
|
661
|
+
# full server URL (some servers distinguish resources by query, e.g.
|
|
662
|
+
# ?serverId=a vs ?serverId=b); only the fragment is ignored. Rejects a
|
|
663
|
+
# non-absolute URL, since the value is untrusted server input.
|
|
664
|
+
# @param url [String] a resource URL
|
|
665
|
+
# @return [String] canonical identity string
|
|
666
|
+
# @raise [MCPClient::Errors::ConnectionError] if the URL is not an absolute URI
|
|
667
|
+
def resource_identity(url)
|
|
668
|
+
uri = URI.parse(url)
|
|
669
|
+
unless uri.scheme && uri.host
|
|
670
|
+
raise MCPClient::Errors::ConnectionError, "Invalid resource URL (must be absolute): #{url}"
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
scheme = uri.scheme.downcase
|
|
674
|
+
host = uri.host.downcase
|
|
675
|
+
port = uri.port
|
|
676
|
+
port = nil if (scheme == 'http' && port == 80) || (scheme == 'https' && port == 443)
|
|
677
|
+
path = uri.path.to_s
|
|
678
|
+
path = '' if path == '/'
|
|
679
|
+
path = path.chomp('/') while path.end_with?('/')
|
|
680
|
+
query = uri.query ? "?#{uri.query}" : ''
|
|
681
|
+
|
|
682
|
+
"#{scheme}://#{host}#{":#{port}" if port}#{path}#{query}"
|
|
683
|
+
rescue URI::InvalidURIError
|
|
684
|
+
raise MCPClient::Errors::ConnectionError, "Invalid resource URL: #{url}"
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
# Fetch resource metadata from URL.
|
|
688
|
+
#
|
|
689
|
+
# Returns nil when the document is genuinely absent (HTTP 404) so a
|
|
690
|
+
# discovery loop can try the next candidate. Any other failure — a
|
|
691
|
+
# non-404 error status, malformed JSON, or a network error — raises,
|
|
692
|
+
# because PRM is authoritative and such a response must not be silently
|
|
693
|
+
# skipped in favor of a different authorization server.
|
|
270
694
|
# @param url [String] Resource metadata URL
|
|
271
|
-
# @
|
|
272
|
-
#
|
|
273
|
-
|
|
695
|
+
# @param strict [Boolean] when true, a 404 raises instead of returning nil
|
|
696
|
+
# (used for a URL explicitly advertised by a 401 challenge)
|
|
697
|
+
# @return [ResourceMetadata, nil] metadata, or nil if a speculative URL returns 404
|
|
698
|
+
# @raise [MCPClient::Errors::ConnectionError] on any non-404 failure, or on 404 when strict
|
|
699
|
+
def fetch_resource_metadata(url, strict: false)
|
|
274
700
|
logger.debug("Fetching resource metadata from: #{url}")
|
|
275
701
|
|
|
276
702
|
response = @http_client.get(url) do |req|
|
|
277
703
|
req.headers['Accept'] = 'application/json'
|
|
278
704
|
end
|
|
279
705
|
|
|
706
|
+
return nil if response.status == 404 && !strict
|
|
707
|
+
|
|
280
708
|
unless response.success?
|
|
281
709
|
raise MCPClient::Errors::ConnectionError, "Failed to fetch resource metadata: HTTP #{response.status}"
|
|
282
710
|
end
|
|
283
711
|
|
|
284
712
|
data = JSON.parse(response.body)
|
|
285
|
-
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
|
|
286
718
|
rescue JSON::ParserError => e
|
|
287
719
|
raise MCPClient::Errors::ConnectionError, "Invalid resource metadata JSON: #{e.message}"
|
|
288
720
|
rescue Faraday::Error => e
|
|
@@ -312,18 +744,28 @@ module MCPClient
|
|
|
312
744
|
raise MCPClient::Errors::ConnectionError, "Network error fetching server metadata: #{e.message}"
|
|
313
745
|
end
|
|
314
746
|
|
|
315
|
-
# 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.
|
|
316
752
|
# @param server_metadata [ServerMetadata] Authorization server metadata
|
|
317
753
|
# @return [ClientInfo] Client information
|
|
318
754
|
# @raise [MCPClient::Errors::ConnectionError] if registration fails
|
|
319
755
|
def get_or_register_client(server_metadata)
|
|
320
|
-
#
|
|
756
|
+
# 1. Pre-registered or previously registered client info from storage
|
|
321
757
|
if (client_info = storage.get_client_info(server_url)) && !client_info.client_secret_expired?
|
|
322
758
|
logger.debug("Using cached OAuth client for #{server_url}")
|
|
323
759
|
return client_info
|
|
324
760
|
end
|
|
325
761
|
|
|
326
|
-
#
|
|
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
|
|
327
769
|
logger.debug('No cached client found, registering new OAuth client...')
|
|
328
770
|
if server_metadata.supports_registration?
|
|
329
771
|
register_client(server_metadata)
|
|
@@ -333,6 +775,67 @@ module MCPClient
|
|
|
333
775
|
end
|
|
334
776
|
end
|
|
335
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
|
+
|
|
336
839
|
# Register OAuth client dynamically
|
|
337
840
|
# @param server_metadata [ServerMetadata] Authorization server metadata
|
|
338
841
|
# @return [ClientInfo] Registered client information
|
|
@@ -340,8 +843,6 @@ module MCPClient
|
|
|
340
843
|
def register_client(server_metadata)
|
|
341
844
|
logger.debug("Registering OAuth client at: #{server_metadata.registration_endpoint}")
|
|
342
845
|
|
|
343
|
-
resolved_scope = scope == :all ? supported_scopes.join(' ') : scope
|
|
344
|
-
|
|
345
846
|
metadata = ClientMetadata.new(
|
|
346
847
|
redirect_uris: [redirect_uri],
|
|
347
848
|
token_endpoint_auth_method: 'none', # Public client
|
|
@@ -417,8 +918,6 @@ module MCPClient
|
|
|
417
918
|
# Use the redirect_uri that was actually registered
|
|
418
919
|
registered_redirect_uri = client_info.metadata.redirect_uris.first
|
|
419
920
|
|
|
420
|
-
resolved_scope = scope == :all ? supported_scopes.join(' ') : scope
|
|
421
|
-
|
|
422
921
|
params = {
|
|
423
922
|
response_type: 'code',
|
|
424
923
|
client_id: client_info.client_id,
|
|
@@ -612,6 +1111,10 @@ module MCPClient
|
|
|
612
1111
|
@client_infos[server_url] = client_info
|
|
613
1112
|
end
|
|
614
1113
|
|
|
1114
|
+
def delete_client_info(server_url)
|
|
1115
|
+
@client_infos.delete(server_url)
|
|
1116
|
+
end
|
|
1117
|
+
|
|
615
1118
|
def get_server_metadata(server_url)
|
|
616
1119
|
@server_metadata[server_url]
|
|
617
1120
|
end
|