ruby-mcp-client 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +139 -4
- data/lib/mcp_client/auth/oauth_provider.rb +362 -46
- data/lib/mcp_client/auth.rb +79 -12
- data/lib/mcp_client/client.rb +197 -34
- data/lib/mcp_client/errors.rb +9 -0
- data/lib/mcp_client/http_transport_base.rb +5 -1
- data/lib/mcp_client/json_rpc_common.rb +19 -4
- data/lib/mcp_client/server_base.rb +73 -0
- data/lib/mcp_client/server_http.rb +27 -17
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +4 -1
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +4 -0
- data/lib/mcp_client/server_sse/sse_parser.rb +5 -2
- data/lib/mcp_client/server_sse.rb +49 -36
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +13 -3
- data/lib/mcp_client/server_stdio.rb +124 -20
- data/lib/mcp_client/server_streamable_http.rb +11 -30
- data/lib/mcp_client/task.rb +93 -61
- data/lib/mcp_client/tool.rb +48 -10
- data/lib/mcp_client/version.rb +1 -1
- metadata +18 -4
|
@@ -14,7 +14,7 @@ module MCPClient
|
|
|
14
14
|
# @!attribute [rw] redirect_uri
|
|
15
15
|
# @return [String] OAuth redirect URI
|
|
16
16
|
# @!attribute [rw] scope
|
|
17
|
-
# @return [String, nil] OAuth scope
|
|
17
|
+
# @return [String, Symbol, nil] OAuth scope (use :all for all server-supported scopes)
|
|
18
18
|
# @!attribute [rw] logger
|
|
19
19
|
# @return [Logger] Logger instance
|
|
20
20
|
# @!attribute [rw] storage
|
|
@@ -27,16 +27,24 @@ module MCPClient
|
|
|
27
27
|
# Initialize OAuth provider
|
|
28
28
|
# @param server_url [String] The MCP server URL (used as OAuth resource parameter)
|
|
29
29
|
# @param redirect_uri [String] OAuth redirect URI (default: http://localhost:8080/callback)
|
|
30
|
-
# @param scope [String, nil] OAuth scope
|
|
30
|
+
# @param scope [String, Symbol, nil] OAuth scope (use :all for all server-supported scopes)
|
|
31
31
|
# @param logger [Logger, nil] Optional logger
|
|
32
32
|
# @param storage [Object, nil] Storage backend for tokens and client info
|
|
33
|
-
|
|
33
|
+
# @param client_metadata [Hash] Extra OIDC client metadata fields for DCR registration.
|
|
34
|
+
# Supported keys: :client_name, :client_uri, :logo_uri, :tos_uri, :policy_uri, :contacts
|
|
35
|
+
def initialize(server_url:, redirect_uri: 'http://localhost:8080/callback', scope: nil, logger: nil, storage: nil,
|
|
36
|
+
client_metadata: {})
|
|
34
37
|
self.server_url = server_url
|
|
35
38
|
self.redirect_uri = redirect_uri
|
|
36
39
|
self.scope = scope
|
|
37
40
|
self.logger = logger || Logger.new($stdout, level: Logger::WARN)
|
|
38
41
|
self.storage = storage || MemoryStorage.new
|
|
42
|
+
@extra_client_metadata = client_metadata
|
|
39
43
|
@http_client = create_http_client
|
|
44
|
+
# Protected resource metadata learned from a 401 WWW-Authenticate
|
|
45
|
+
# challenge, reused by discovery so a challenge-advertised metadata URL
|
|
46
|
+
# is not re-derived (and possibly missed).
|
|
47
|
+
@challenge_resource_metadata = nil
|
|
40
48
|
end
|
|
41
49
|
|
|
42
50
|
# @param url [String] Server URL to normalize
|
|
@@ -58,6 +66,14 @@ module MCPClient
|
|
|
58
66
|
refresh_token(token) if token.refresh_token
|
|
59
67
|
end
|
|
60
68
|
|
|
69
|
+
# Return the scopes supported by the authorization server
|
|
70
|
+
# Discovers server metadata and returns the scopes_supported list.
|
|
71
|
+
# @return [Array<String>] supported scopes, or empty array if not advertised
|
|
72
|
+
# @raise [MCPClient::Errors::ConnectionError] if server discovery fails
|
|
73
|
+
def supported_scopes
|
|
74
|
+
@supported_scopes ||= discover_authorization_server.scopes_supported || []
|
|
75
|
+
end
|
|
76
|
+
|
|
61
77
|
# Start OAuth authorization flow
|
|
62
78
|
# @return [String] Authorization URL to redirect user to
|
|
63
79
|
# @raise [MCPClient::Errors::ConnectionError] if server discovery fails
|
|
@@ -130,12 +146,37 @@ module MCPClient
|
|
|
130
146
|
www_authenticate = response.headers['WWW-Authenticate'] || response.headers['www-authenticate']
|
|
131
147
|
return nil unless www_authenticate
|
|
132
148
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
149
|
+
url = extract_resource_metadata_url(www_authenticate)
|
|
150
|
+
return nil unless url
|
|
151
|
+
|
|
152
|
+
# This URL was explicitly advertised by the 401 challenge, so a 404 is a
|
|
153
|
+
# misconfiguration to surface (strict), not a speculative miss to skip.
|
|
154
|
+
metadata = fetch_resource_metadata(url, strict: true)
|
|
155
|
+
# Reuse this challenge-advertised metadata during the subsequent OAuth
|
|
156
|
+
# flow instead of re-deriving (and possibly missing) the well-known URL.
|
|
157
|
+
@challenge_resource_metadata = metadata
|
|
158
|
+
metadata
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Extract the protected-resource-metadata URL from a WWW-Authenticate header.
|
|
162
|
+
# Per RFC 9728 the parameter is `resource_metadata`; a legacy `resource`
|
|
163
|
+
# parameter is accepted as a fallback for older servers.
|
|
164
|
+
# @param header [String] the WWW-Authenticate header value
|
|
165
|
+
# @return [String, nil] the metadata URL if present
|
|
166
|
+
def extract_resource_metadata_url(header)
|
|
167
|
+
# Auth-params may include optional whitespace around '=' (RFC 7235).
|
|
168
|
+
# Quoted form: resource_metadata = "https://..."
|
|
169
|
+
if (m = header.match(/resource_metadata\s*=\s*"([^"]+)"/))
|
|
170
|
+
return m[1]
|
|
138
171
|
end
|
|
172
|
+
|
|
173
|
+
# Unquoted token form: resource_metadata = https://...
|
|
174
|
+
if (m = header.match(/resource_metadata\s*=\s*([^,\s]+)/))
|
|
175
|
+
return m[1]
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Legacy fallback: resource="https://..."
|
|
179
|
+
header.match(/resource\s*=\s*"([^"]+)"/)&.captures&.first
|
|
139
180
|
end
|
|
140
181
|
|
|
141
182
|
private
|
|
@@ -201,6 +242,55 @@ module MCPClient
|
|
|
201
242
|
(uri.scheme == 'https' && uri.port == 443)
|
|
202
243
|
end
|
|
203
244
|
|
|
245
|
+
# Build the scheme://host[:port] origin for a parsed URI.
|
|
246
|
+
# @param uri [URI] Parsed URI
|
|
247
|
+
# @return [String] origin string
|
|
248
|
+
def origin_of(uri)
|
|
249
|
+
origin = "#{uri.scheme}://#{uri.host}"
|
|
250
|
+
origin += ":#{uri.port}" if uri.port && !default_port?(uri)
|
|
251
|
+
origin
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Protected Resource Metadata well-known URLs to try, in priority order
|
|
255
|
+
# (RFC 9728 §3.1). When the resource identifier has a path, the well-known
|
|
256
|
+
# segment is inserted between the host and the path; a root-level URL is
|
|
257
|
+
# always tried as a fallback.
|
|
258
|
+
# @param server_url [String] the MCP server (protected resource) URL
|
|
259
|
+
# @return [Array<String>] ordered candidate URLs
|
|
260
|
+
def protected_resource_metadata_urls(server_url)
|
|
261
|
+
uri = URI.parse(server_url)
|
|
262
|
+
origin = origin_of(uri)
|
|
263
|
+
path = uri.path.to_s
|
|
264
|
+
|
|
265
|
+
urls = []
|
|
266
|
+
urls << "#{origin}/.well-known/oauth-protected-resource#{path}" unless path.empty? || path == '/'
|
|
267
|
+
urls << "#{origin}/.well-known/oauth-protected-resource"
|
|
268
|
+
urls.uniq
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Authorization Server Metadata well-known URLs to try, in priority order
|
|
272
|
+
# (RFC 8414 §3.1 path-insertion plus OpenID Connect Discovery). When the
|
|
273
|
+
# issuer has a path, the well-known segment is INSERTED between host and
|
|
274
|
+
# path (not appended); the OIDC path-append form is also tried.
|
|
275
|
+
# @param issuer [String] the authorization server issuer URL
|
|
276
|
+
# @return [Array<String>] ordered candidate URLs
|
|
277
|
+
def authorization_server_metadata_urls(issuer)
|
|
278
|
+
uri = URI.parse(issuer)
|
|
279
|
+
origin = origin_of(uri)
|
|
280
|
+
path = uri.path.to_s
|
|
281
|
+
|
|
282
|
+
urls = []
|
|
283
|
+
if path.empty? || path == '/'
|
|
284
|
+
urls << "#{origin}/.well-known/oauth-authorization-server"
|
|
285
|
+
urls << "#{origin}/.well-known/openid-configuration"
|
|
286
|
+
else
|
|
287
|
+
urls << "#{origin}/.well-known/oauth-authorization-server#{path}"
|
|
288
|
+
urls << "#{origin}/.well-known/openid-configuration#{path}"
|
|
289
|
+
urls << "#{origin}#{path}/.well-known/openid-configuration"
|
|
290
|
+
end
|
|
291
|
+
urls.uniq
|
|
292
|
+
end
|
|
293
|
+
|
|
204
294
|
# Discover authorization server metadata
|
|
205
295
|
# Tries multiple discovery patterns:
|
|
206
296
|
# 1. oauth-authorization-server (MCP spec pattern - server is its own auth server)
|
|
@@ -208,63 +298,274 @@ module MCPClient
|
|
|
208
298
|
# @return [ServerMetadata] Authorization server metadata
|
|
209
299
|
# @raise [MCPClient::Errors::ConnectionError] if discovery fails
|
|
210
300
|
def discover_authorization_server
|
|
211
|
-
#
|
|
212
|
-
|
|
301
|
+
# A fresh 401 challenge is authoritative and overrides any cached
|
|
302
|
+
# (possibly stale or direct-discovered) authorization server metadata.
|
|
303
|
+
cached = storage.get_server_metadata(server_url) unless @challenge_resource_metadata
|
|
304
|
+
if cached
|
|
305
|
+
# Validate the cached entry before use so a persisted/older cache with
|
|
306
|
+
# an HTTP endpoint or without S256 is still rejected.
|
|
307
|
+
validate_server_metadata!(cached)
|
|
213
308
|
return cached
|
|
214
309
|
end
|
|
215
310
|
|
|
216
|
-
|
|
311
|
+
discover_and_cache_authorization_server
|
|
312
|
+
end
|
|
217
313
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
server_metadata = fetch_server_metadata(discovery_url)
|
|
224
|
-
rescue MCPClient::Errors::ConnectionError => e
|
|
225
|
-
logger.debug("oauth-authorization-server discovery failed: #{e.message}")
|
|
226
|
-
end
|
|
314
|
+
# Discover authorization server metadata, validate it, and cache it.
|
|
315
|
+
# @return [ServerMetadata]
|
|
316
|
+
# @raise [MCPClient::Errors::ConnectionError] if discovery or validation fails
|
|
317
|
+
def discover_and_cache_authorization_server
|
|
318
|
+
previous = storage.get_server_metadata(server_url)
|
|
227
319
|
|
|
228
|
-
#
|
|
229
|
-
#
|
|
230
|
-
|
|
231
|
-
begin
|
|
232
|
-
discovery_url = build_discovery_url(server_url, :protected_resource)
|
|
233
|
-
logger.debug("Attempting OAuth discovery: #{discovery_url}")
|
|
234
|
-
|
|
235
|
-
resource_metadata = fetch_resource_metadata(discovery_url)
|
|
236
|
-
auth_server_url = resource_metadata.authorization_servers.first
|
|
237
|
-
|
|
238
|
-
if auth_server_url
|
|
239
|
-
server_metadata = fetch_server_metadata("#{auth_server_url}/.well-known/oauth-authorization-server")
|
|
240
|
-
end
|
|
241
|
-
rescue MCPClient::Errors::ConnectionError => e
|
|
242
|
-
logger.debug("oauth-protected-resource discovery failed: #{e.message}")
|
|
243
|
-
end
|
|
244
|
-
end
|
|
320
|
+
# RFC 9728: Protected Resource Metadata is authoritative — try it first,
|
|
321
|
+
# then fall back to treating the MCP server origin as its own AS.
|
|
322
|
+
server_metadata = discover_via_protected_resource || discover_via_direct_authorization_server
|
|
245
323
|
|
|
246
324
|
unless server_metadata
|
|
247
325
|
raise MCPClient::Errors::ConnectionError,
|
|
248
|
-
'OAuth discovery failed: no valid
|
|
326
|
+
'OAuth discovery failed: no valid authorization server metadata found'
|
|
249
327
|
end
|
|
250
328
|
|
|
251
|
-
#
|
|
329
|
+
# Validate BEFORE caching so invalid metadata is never persisted.
|
|
330
|
+
validate_server_metadata!(server_metadata)
|
|
331
|
+
|
|
332
|
+
invalidate_client_info_on_as_change(previous, server_metadata)
|
|
333
|
+
|
|
252
334
|
storage.set_server_metadata(server_url, server_metadata)
|
|
335
|
+
@challenge_resource_metadata = nil # consumed
|
|
336
|
+
server_metadata
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# When a 401 challenge changes the authorization server, per-AS state cached
|
|
340
|
+
# under this server_url becomes invalid: a client_id registered with the
|
|
341
|
+
# previous AS would fail as invalid_client, and memoized scopes belong to
|
|
342
|
+
# the old AS. Discard both so the new flow re-registers and re-discovers.
|
|
343
|
+
# @param previous [ServerMetadata, nil] previously cached AS metadata
|
|
344
|
+
# @param current [ServerMetadata] newly discovered AS metadata
|
|
345
|
+
def invalidate_client_info_on_as_change(previous, current)
|
|
346
|
+
return unless previous && previous.issuer != current.issuer
|
|
347
|
+
|
|
348
|
+
logger.debug('Authorization server changed; discarding client and scopes from the previous AS')
|
|
349
|
+
|
|
350
|
+
# Prefer an explicit delete; fall back to the always-available
|
|
351
|
+
# set_client_info(nil) so custom storage backends are handled too.
|
|
352
|
+
if storage.respond_to?(:delete_client_info)
|
|
353
|
+
storage.delete_client_info(server_url)
|
|
354
|
+
else
|
|
355
|
+
storage.set_client_info(server_url, nil)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
@supported_scopes = nil
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Apply the PKCE-support and HTTPS-endpoint checks to server metadata.
|
|
362
|
+
# @param server_metadata [ServerMetadata]
|
|
363
|
+
# @raise [MCPClient::Errors::ConnectionError] if a check fails
|
|
364
|
+
def validate_server_metadata!(server_metadata)
|
|
365
|
+
verify_pkce_support!(server_metadata)
|
|
366
|
+
enforce_https_endpoints!(server_metadata)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# PRM-first discovery: fetch Protected Resource Metadata and then the
|
|
370
|
+
# authorization server it advertises.
|
|
371
|
+
# @return [ServerMetadata, nil] AS metadata, or nil if no PRM document exists
|
|
372
|
+
# @raise [MCPClient::Errors::ConnectionError] if PRM is malformed or mismatched
|
|
373
|
+
def discover_via_protected_resource
|
|
374
|
+
# A missing PRM document (return nil) permits the direct-AS fallback. But
|
|
375
|
+
# once PRM IS found it is authoritative: any subsequent failure must be a
|
|
376
|
+
# hard error, never a silent fallback to an authorization server the PRM
|
|
377
|
+
# did not advertise.
|
|
378
|
+
resource_metadata = @challenge_resource_metadata ||
|
|
379
|
+
fetch_first_resource_metadata(protected_resource_metadata_urls(server_url))
|
|
380
|
+
return nil unless resource_metadata
|
|
381
|
+
|
|
382
|
+
validate_resource_matches!(resource_metadata)
|
|
383
|
+
|
|
384
|
+
auth_server_url = Array(resource_metadata.authorization_servers).first
|
|
385
|
+
unless auth_server_url
|
|
386
|
+
raise MCPClient::Errors::ConnectionError,
|
|
387
|
+
'Protected resource metadata does not advertise any authorization_servers'
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
server_metadata = fetch_first_server_metadata(authorization_server_metadata_urls(auth_server_url))
|
|
391
|
+
unless server_metadata
|
|
392
|
+
raise MCPClient::Errors::ConnectionError,
|
|
393
|
+
"Authorization server advertised by protected resource metadata (#{auth_server_url}) " \
|
|
394
|
+
'could not be discovered'
|
|
395
|
+
end
|
|
253
396
|
|
|
254
397
|
server_metadata
|
|
255
398
|
end
|
|
256
399
|
|
|
257
|
-
#
|
|
400
|
+
# Legacy/self-hosted discovery: treat the MCP server ORIGIN as its own
|
|
401
|
+
# authorization server issuer.
|
|
402
|
+
# @return [ServerMetadata, nil]
|
|
403
|
+
def discover_via_direct_authorization_server
|
|
404
|
+
origin = origin_of(URI.parse(server_url))
|
|
405
|
+
fetch_first_server_metadata(authorization_server_metadata_urls(origin))
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Fetch the first Protected Resource Metadata document that resolves.
|
|
409
|
+
#
|
|
410
|
+
# PRM is authoritative: a candidate that genuinely does not exist (HTTP
|
|
411
|
+
# 404) is skipped so the next candidate is tried, but a candidate that
|
|
412
|
+
# exists yet is malformed or errors (bad JSON, 5xx, network failure) is a
|
|
413
|
+
# HARD failure — it must not silently fall through to a root PRM or the
|
|
414
|
+
# direct-AS path, which could point at a different authorization server.
|
|
415
|
+
# @param urls [Array<String>] candidate URLs
|
|
416
|
+
# @return [ResourceMetadata, nil]
|
|
417
|
+
def fetch_first_resource_metadata(urls)
|
|
418
|
+
urls.each do |url|
|
|
419
|
+
md = fetch_resource_metadata(url) # returns nil only for a 404 (absent); raises otherwise
|
|
420
|
+
return md if md
|
|
421
|
+
end
|
|
422
|
+
nil
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# Fetch the first Authorization Server Metadata document that resolves.
|
|
426
|
+
# The oauth-authorization-server and openid-configuration forms are
|
|
427
|
+
# genuine alternatives, so any failing candidate is skipped to try the next.
|
|
428
|
+
# @param urls [Array<String>] candidate URLs
|
|
429
|
+
# @return [ServerMetadata, nil]
|
|
430
|
+
def fetch_first_server_metadata(urls)
|
|
431
|
+
urls.each do |url|
|
|
432
|
+
md = try_fetch_server_metadata(url)
|
|
433
|
+
return md if md
|
|
434
|
+
end
|
|
435
|
+
nil
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Non-raising server-metadata fetch used while iterating candidates.
|
|
439
|
+
def try_fetch_server_metadata(url)
|
|
440
|
+
fetch_server_metadata(url)
|
|
441
|
+
rescue MCPClient::Errors::ConnectionError => e
|
|
442
|
+
logger.debug("Authorization server metadata candidate failed (#{url}): #{e.message}")
|
|
443
|
+
nil
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Verify the authorization server supports PKCE S256 (RFC 8414 / MCP).
|
|
447
|
+
# Refuses when S256 is explicitly not offered; warns (and proceeds, since
|
|
448
|
+
# the client always uses S256) when the field is not advertised at all.
|
|
449
|
+
# @param server_metadata [ServerMetadata]
|
|
450
|
+
# @raise [MCPClient::Errors::ConnectionError] if S256 is explicitly unsupported
|
|
451
|
+
def verify_pkce_support!(server_metadata)
|
|
452
|
+
methods = server_metadata.code_challenge_methods_supported
|
|
453
|
+
if methods.nil?
|
|
454
|
+
logger.warn(
|
|
455
|
+
'Authorization server metadata omits code_challenge_methods_supported; ' \
|
|
456
|
+
'proceeding with PKCE S256'
|
|
457
|
+
)
|
|
458
|
+
elsif !methods.include?('S256')
|
|
459
|
+
raise MCPClient::Errors::ConnectionError,
|
|
460
|
+
'Authorization server does not support PKCE S256 ' \
|
|
461
|
+
'(code_challenge_methods_supported does not include "S256")'
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# Require HTTPS for all discovered authorization server endpoints, with a
|
|
466
|
+
# localhost exception for local development.
|
|
467
|
+
# @param server_metadata [ServerMetadata]
|
|
468
|
+
def enforce_https_endpoints!(server_metadata)
|
|
469
|
+
enforce_https!(server_metadata.authorization_endpoint, 'authorization endpoint')
|
|
470
|
+
enforce_https!(server_metadata.token_endpoint, 'token endpoint')
|
|
471
|
+
return unless server_metadata.registration_endpoint
|
|
472
|
+
|
|
473
|
+
enforce_https!(server_metadata.registration_endpoint, 'registration endpoint')
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# @param url [String, nil] endpoint URL
|
|
477
|
+
# @param label [String] human-readable endpoint name for errors
|
|
478
|
+
# @raise [MCPClient::Errors::ConnectionError] if the URL is not HTTPS (non-localhost)
|
|
479
|
+
def enforce_https!(url, label)
|
|
480
|
+
return if url.nil?
|
|
481
|
+
|
|
482
|
+
uri = URI.parse(url)
|
|
483
|
+
return if uri.scheme == 'https'
|
|
484
|
+
# Dev exception is only for plain HTTP on a loopback host — not any other
|
|
485
|
+
# scheme (e.g. ftp://localhost). Use #hostname (not #host) so an IPv6
|
|
486
|
+
# loopback like http://[::1]:9292 matches without the surrounding brackets.
|
|
487
|
+
return if uri.scheme == 'http' && %w[localhost 127.0.0.1 ::1].include?(uri.hostname)
|
|
488
|
+
|
|
489
|
+
raise MCPClient::Errors::ConnectionError, "OAuth #{label} must use HTTPS: #{url}"
|
|
490
|
+
rescue URI::InvalidURIError
|
|
491
|
+
raise MCPClient::Errors::ConnectionError, "OAuth #{label} is not a valid URL: #{url}"
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# Validate the PRM `resource` identifies this server (RFC 9728 confused
|
|
495
|
+
# deputy protection). Path/canonicalization differences on the same host
|
|
496
|
+
# are tolerated; a different host is rejected.
|
|
497
|
+
# @param resource_metadata [ResourceMetadata]
|
|
498
|
+
# @raise [MCPClient::Errors::ConnectionError] on host mismatch
|
|
499
|
+
def validate_resource_matches!(resource_metadata)
|
|
500
|
+
# RFC 9728 requires `resource`; without it the confused-deputy check is
|
|
501
|
+
# impossible, so a PRM that omits it must be rejected (not trusted).
|
|
502
|
+
if resource_metadata.resource.nil?
|
|
503
|
+
raise MCPClient::Errors::ConnectionError,
|
|
504
|
+
'Protected resource metadata is missing the required "resource" identifier'
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# Require an EXACT canonical match (scheme/host/port/path). A same-host
|
|
508
|
+
# match is not enough: a host that serves multiple resources/tenants
|
|
509
|
+
# must not have one tenant's PRM trusted for another.
|
|
510
|
+
advertised = resource_identity(resource_metadata.resource)
|
|
511
|
+
expected = resource_identity(server_url)
|
|
512
|
+
return if advertised == expected
|
|
513
|
+
|
|
514
|
+
raise MCPClient::Errors::ConnectionError,
|
|
515
|
+
"Protected resource metadata resource (#{resource_metadata.resource}) " \
|
|
516
|
+
"does not match the server URL (#{server_url})"
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# Canonical resource identity (scheme, host, port, path, query) used for
|
|
520
|
+
# the confused-deputy comparison. The query is included because the
|
|
521
|
+
# `resource` parameter sent in the authorization and token requests is the
|
|
522
|
+
# full server URL (some servers distinguish resources by query, e.g.
|
|
523
|
+
# ?serverId=a vs ?serverId=b); only the fragment is ignored. Rejects a
|
|
524
|
+
# non-absolute URL, since the value is untrusted server input.
|
|
525
|
+
# @param url [String] a resource URL
|
|
526
|
+
# @return [String] canonical identity string
|
|
527
|
+
# @raise [MCPClient::Errors::ConnectionError] if the URL is not an absolute URI
|
|
528
|
+
def resource_identity(url)
|
|
529
|
+
uri = URI.parse(url)
|
|
530
|
+
unless uri.scheme && uri.host
|
|
531
|
+
raise MCPClient::Errors::ConnectionError, "Invalid resource URL (must be absolute): #{url}"
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
scheme = uri.scheme.downcase
|
|
535
|
+
host = uri.host.downcase
|
|
536
|
+
port = uri.port
|
|
537
|
+
port = nil if (scheme == 'http' && port == 80) || (scheme == 'https' && port == 443)
|
|
538
|
+
path = uri.path.to_s
|
|
539
|
+
path = '' if path == '/'
|
|
540
|
+
path = path.chomp('/') while path.end_with?('/')
|
|
541
|
+
query = uri.query ? "?#{uri.query}" : ''
|
|
542
|
+
|
|
543
|
+
"#{scheme}://#{host}#{":#{port}" if port}#{path}#{query}"
|
|
544
|
+
rescue URI::InvalidURIError
|
|
545
|
+
raise MCPClient::Errors::ConnectionError, "Invalid resource URL: #{url}"
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
# Fetch resource metadata from URL.
|
|
549
|
+
#
|
|
550
|
+
# Returns nil when the document is genuinely absent (HTTP 404) so a
|
|
551
|
+
# discovery loop can try the next candidate. Any other failure — a
|
|
552
|
+
# non-404 error status, malformed JSON, or a network error — raises,
|
|
553
|
+
# because PRM is authoritative and such a response must not be silently
|
|
554
|
+
# skipped in favor of a different authorization server.
|
|
258
555
|
# @param url [String] Resource metadata URL
|
|
259
|
-
# @
|
|
260
|
-
#
|
|
261
|
-
|
|
556
|
+
# @param strict [Boolean] when true, a 404 raises instead of returning nil
|
|
557
|
+
# (used for a URL explicitly advertised by a 401 challenge)
|
|
558
|
+
# @return [ResourceMetadata, nil] metadata, or nil if a speculative URL returns 404
|
|
559
|
+
# @raise [MCPClient::Errors::ConnectionError] on any non-404 failure, or on 404 when strict
|
|
560
|
+
def fetch_resource_metadata(url, strict: false)
|
|
262
561
|
logger.debug("Fetching resource metadata from: #{url}")
|
|
263
562
|
|
|
264
563
|
response = @http_client.get(url) do |req|
|
|
265
564
|
req.headers['Accept'] = 'application/json'
|
|
266
565
|
end
|
|
267
566
|
|
|
567
|
+
return nil if response.status == 404 && !strict
|
|
568
|
+
|
|
268
569
|
unless response.success?
|
|
269
570
|
raise MCPClient::Errors::ConnectionError, "Failed to fetch resource metadata: HTTP #{response.status}"
|
|
270
571
|
end
|
|
@@ -328,12 +629,15 @@ module MCPClient
|
|
|
328
629
|
def register_client(server_metadata)
|
|
329
630
|
logger.debug("Registering OAuth client at: #{server_metadata.registration_endpoint}")
|
|
330
631
|
|
|
632
|
+
resolved_scope = scope == :all ? supported_scopes.join(' ') : scope
|
|
633
|
+
|
|
331
634
|
metadata = ClientMetadata.new(
|
|
332
635
|
redirect_uris: [redirect_uri],
|
|
333
636
|
token_endpoint_auth_method: 'none', # Public client
|
|
334
637
|
grant_types: %w[authorization_code refresh_token],
|
|
335
638
|
response_types: ['code'],
|
|
336
|
-
scope:
|
|
639
|
+
scope: resolved_scope,
|
|
640
|
+
**@extra_client_metadata
|
|
337
641
|
)
|
|
338
642
|
|
|
339
643
|
response = @http_client.post(server_metadata.registration_endpoint) do |req|
|
|
@@ -355,7 +659,13 @@ module MCPClient
|
|
|
355
659
|
token_endpoint_auth_method: data['token_endpoint_auth_method'] || 'none',
|
|
356
660
|
grant_types: data['grant_types'] || %w[authorization_code refresh_token],
|
|
357
661
|
response_types: data['response_types'] || ['code'],
|
|
358
|
-
scope: data['scope']
|
|
662
|
+
scope: data['scope'],
|
|
663
|
+
client_name: data['client_name'],
|
|
664
|
+
client_uri: data['client_uri'],
|
|
665
|
+
logo_uri: data['logo_uri'],
|
|
666
|
+
tos_uri: data['tos_uri'],
|
|
667
|
+
policy_uri: data['policy_uri'],
|
|
668
|
+
contacts: data['contacts']
|
|
359
669
|
)
|
|
360
670
|
|
|
361
671
|
# Warn if server changed redirect_uri
|
|
@@ -396,11 +706,13 @@ module MCPClient
|
|
|
396
706
|
# Use the redirect_uri that was actually registered
|
|
397
707
|
registered_redirect_uri = client_info.metadata.redirect_uris.first
|
|
398
708
|
|
|
709
|
+
resolved_scope = scope == :all ? supported_scopes.join(' ') : scope
|
|
710
|
+
|
|
399
711
|
params = {
|
|
400
712
|
response_type: 'code',
|
|
401
713
|
client_id: client_info.client_id,
|
|
402
714
|
redirect_uri: registered_redirect_uri,
|
|
403
|
-
scope:
|
|
715
|
+
scope: resolved_scope,
|
|
404
716
|
state: state,
|
|
405
717
|
code_challenge: pkce.code_challenge,
|
|
406
718
|
code_challenge_method: pkce.code_challenge_method,
|
|
@@ -589,6 +901,10 @@ module MCPClient
|
|
|
589
901
|
@client_infos[server_url] = client_info
|
|
590
902
|
end
|
|
591
903
|
|
|
904
|
+
def delete_client_info(server_url)
|
|
905
|
+
@client_infos.delete(server_url)
|
|
906
|
+
end
|
|
907
|
+
|
|
592
908
|
def get_server_metadata(server_url)
|
|
593
909
|
@server_metadata[server_url]
|
|
594
910
|
end
|