Module: MCP::Client::OAuth::Discovery

Defined in:
lib/mcp/client/oauth/discovery.rb

Overview

Stateless helpers that map MCP-authorization spec URLs and headers into something the ‘Flow` orchestrator and `MCP::Client::HTTP` transport can act on. The module bundles five concerns that share no state but are closely related to the spec’s “Discovery” and “Communication Security” sections:

  • **‘WWW-Authenticate` parsing** (`parse_www_authenticate`): pulls the Bearer challenge parameters (`resource_metadata`, `scope`, `error`, …) out of a header that may carry multiple challenges per RFC 7235 and may use `quoted-pair` escapes per RFC 7230 Section 3.2.6.

  • **Discovery URL builders** (‘protected_resource_metadata_urls`, `authorization_server_metadata_urls`): list the candidate well-known URLs to probe when no explicit metadata URL is supplied, in the priority order required by RFC 9728 and RFC 8414.

  • **Communication Security check** (‘secure_url?`): enforces “HTTPS only” for every OAuth-facing URL, with the loopback carve-out described in `secure_url?`’s comment.

  • **URL canonicalization** (‘canonicalize_url`): normalizes scheme, host, port, path, percent-encoded dot segments, and fragments so two URLs that *refer to the same resource* compare as equal, and drops userinfo so credentials never reach the RFC 8707 `resource` claim or any error message.

  • **Resource coverage** (‘resource_covers?`): decides whether a PRM `resource` URI is allowed to govern a given MCP server URL, i.e. whether the MCP endpoint sits “under” the resource per RFC 8707 audience semantics.

Every entry point is a class method so it can be called from initializers and from any thread without synchronization.

Constant Summary collapse

WWW_AUTH_PARAM_PATTERN =

Matches a single ‘key=value` pair inside an HTTP auth-scheme challenge. `value` is either a quoted string (which can contain commas and spaces) or a bare token, per RFC 7235.

/\A([A-Za-z0-9_-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))/

Class Method Summary collapse

Class Method Details

.authorization_server_metadata_urls(issuer_url) ⇒ Object

Returns the candidate Authorization Server metadata URLs to probe, in priority order.

Per SEP-2351, MCP uses the default ‘oauth-authorization-server` well-known URI suffix registered by RFC 8414 Section 7.3 and defines no application-specific suffix of its own. The OAuth candidates below therefore use only that default suffix (plus the `openid-configuration` suffix from OpenID Connect Discovery), both in the RFC 8414 Section 3.1 path-inserted form for issuers with a path component and in the root form for issuers without one.



104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/mcp/client/oauth/discovery.rb', line 104

def (issuer_url)
  uri = URI.parse(issuer_url)
  path = uri.path == "/" ? "" : uri.path.to_s
  base = base_url(uri)

  if path.empty?
    ["#{base}/.well-known/oauth-authorization-server", "#{base}/.well-known/openid-configuration"]
  else
    [
      "#{base}/.well-known/oauth-authorization-server#{path}",
      "#{base}/.well-known/openid-configuration#{path}",
      "#{base}#{path}/.well-known/openid-configuration",
    ]
  end
end

.canonicalize_origin_and_path(url) ⇒ Object

Like ‘canonicalize_url` but also strips query string, fragment, and userinfo. This variant is used for identity comparison against the request URL Faraday actually sends, which differs from the value the caller passed in two ways: `Faraday::Connection#url_prefix` drops query parameters, and Faraday hoists `user:pass@` out of the URL into an `Authorization: Basic` header before the request goes out. Including userinfo here would (a) raise a false-positive `InsecureURLError` on any legitimate URL with credentials in the authority, and (b) leak `user:pass` through the resulting error message - both of which would defeat the bearer-token-protection purpose of the identity check.



251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
# File 'lib/mcp/client/oauth/discovery.rb', line 251

def canonicalize_origin_and_path(url)
  uri = URI.parse(url.to_s)

  uri.fragment = nil
  uri.query = nil
  # `URI::Generic#userinfo=` is a no-op on Ruby 2.7 (the project's minimum supported version),
  # so clear the components individually.
  if uri.respond_to?(:user) && (uri.user || uri.password)
    uri.user = nil
    uri.password = nil
  end
  uri.scheme = uri.scheme.downcase if uri.scheme
  uri.host = uri.host.downcase if uri.host
  uri.port = nil if uri.port == uri.default_port

  path = uri.path.to_s.gsub(/%2[eE]/, ".")
  uri.path = remove_dot_segments(path)
  uri.path = "" if uri.path == "/"

  uri.to_s
end

.canonicalize_url(url) ⇒ Object

Returns a canonical form of ‘url` suitable for comparing two URIs that are meant to identify the same protected resource: lowercased scheme/host, default port stripped, fragment removed, percent-encoded dot octets normalized to `.` per RFC 3986 Section 6.2.2.2, dot-segments in the path resolved per RFC 3986 Section 5.2.4, and a single trailing `/` on the root path normalized away.

Userinfo is dropped. The MCP authorization spec sends the canonicalized URL on the wire as the RFC 8707 ‘resource` claim and surfaces it in error messages;

both paths would leak `user:pass@` credentials to the authorization server and

to log destinations if we preserved them. The MCP server URI does not legitimately carry userinfo, so dropping it is also a no-op for normal traffic.

Decoding ‘%2e`/`%2E` before dot-segment resolution is what prevents an attacker-supplied URL like `srv.example.com/api/%2e%2e/mcp` from sneaking past the PRM `resource` check in `resource_covers?`.



135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
# File 'lib/mcp/client/oauth/discovery.rb', line 135

def canonicalize_url(url)
  uri = URI.parse(url.to_s)

  uri.fragment = nil
  # `URI::Generic#userinfo=` is a no-op on Ruby 2.7 (the project's minimum supported version),
  # so clear the components individually.
  if uri.respond_to?(:user) && (uri.user || uri.password)
    uri.user = nil
    uri.password = nil
  end
  uri.scheme = uri.scheme.downcase if uri.scheme
  uri.host = uri.host.downcase if uri.host
  uri.port = nil if uri.port == uri.default_port

  path = uri.path.to_s.gsub(/%2[eE]/, ".")
  uri.path = remove_dot_segments(path)
  uri.path = "" if uri.path == "/"

  uri.query = normalize_query(uri.query)

  uri.to_s
end

.client_id_metadata_document_url?(url) ⇒ Boolean

Returns true when ‘url` satisfies the structural requirements for a Client ID Metadata Document URL per the MCP 2025-11-25 authorization specification and `draft-ietf-oauth-client-id-metadata-document-00`.

Spec-required:

  • scheme MUST be ‘https` (the loopback-`http` carve-out used for discovery does not apply: the document URL is sent verbatim to the authorization server as the OAuth `client_id` and travels off-loopback)

  • host MUST be present

  • path MUST be non-empty and MUST NOT be the root (‘/`); the document is a discrete resource, not the origin

  • URL MUST NOT carry a fragment or userinfo: a fragment is not sent to the server, and userinfo would leak credentials into every ‘client_id` log line

  • path MUST be already free of ‘.` / `..` dot segments after percent-decoding, so two URLs with the same effective path do not produce different `client_id` strings

SDK policy (stricter than the draft):

  • URL MUST NOT carry a query string. The draft marks query components only SHOULD NOT include, but different encodings of the same query (‘?a=1&b=2` vs `?b=2&a=1`) would yield distinct `client_id` values for the same logical document.

Returns:

  • (Boolean)


218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/mcp/client/oauth/discovery.rb', line 218

def (url)
  return false if url.nil? || url.to_s.empty?

  uri = URI.parse(url.to_s)
  return false unless uri.scheme&.downcase == "https"
  return false if uri.host.nil? || uri.host.empty?
  return false unless uri.fragment.nil?
  return false unless uri.query.nil?
  return false if uri.respond_to?(:user) && (uri.user || uri.password)

  path = uri.path.to_s
  return false if path.empty? || path == "/"

  decoded = path.gsub(/%2[eE]/, ".")
  segments = decoded.split("/", -1)
  return false if segments.any? { |segment| segment == "." || segment == ".." }

  true
rescue URI::InvalidURIError
  false
end

.parse_www_authenticate(header) ⇒ Object

Parses a ‘WWW-Authenticate` header and returns the parameters of the `Bearer` challenge as a hash with lower-cased keys (e.g. `resource_metadata`, `scope`, `error`). Returns `{}` when no Bearer challenge is present. Handles multiple challenges (e.g. `Basic …, Bearer …` or `Bearer …, DPoP …`) by extracting only the Bearer parameters.



51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# File 'lib/mcp/client/oauth/discovery.rb', line 51

def parse_www_authenticate(header)
  return {} unless header

  # Locate the Bearer challenge: at the start of the header or after a comma.
  bearer = header.match(/(?:\A|,)\s*Bearer(?:\s+|\z)/i)
  return {} unless bearer

  # Walk key=value pairs starting where Bearer's parameters begin.
  # The loop stops at the first token that is not a key=value pair,
  # which marks the next challenge (e.g. `, DPoP algs="..."`).
  cursor = bearer.end(0)
  params = {}
  while cursor < header.length
    prefix = header[cursor..]
    prefix = prefix.sub(/\A\s*,?\s*/, "")
    break if prefix.empty?

    match = prefix.match(WWW_AUTH_PARAM_PATTERN)
    break unless match

    params[match[1].downcase] = match[2] ? unescape_quoted_pair(match[2]) : match[3]
    cursor = header.length - prefix.length + match.end(0)
  end
  params
end

.protected_resource_metadata_urls(server_url:, resource_metadata_url: nil) ⇒ Object

Returns the candidate Protected Resource Metadata URLs to probe, in priority order. modelcontextprotocol.io/specification/2025-11-25/basic/authorization#protected-resource-metadata-discovery-requirements



79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/mcp/client/oauth/discovery.rb', line 79

def (server_url:, resource_metadata_url: nil)
  urls = []
  urls <<  if 

  uri = URI.parse(server_url)
  path = uri.path == "/" ? "" : uri.path.to_s
  base = base_url(uri)

  urls << "#{base}/.well-known/oauth-protected-resource#{path}"
  urls << "#{base}/.well-known/oauth-protected-resource"
  urls.uniq
end

.resource_covers?(prm:, server:) ⇒ Boolean

Returns true when ‘prm` (a PRM `resource` URL) covers `server` (the MCP endpoint URL): same scheme/host/port, with PRM’s path being a prefix of the server’s path. When PRM also advertises a query string, the server’s query MUST be identical to it (otherwise a hijacked PRM that advertises ‘?tenant=evil` would cover an MCP server at `?tenant=victim` and let the attacker mint a different tenant’s token for the same origin + path). PRM with no query (URI#query returns ‘nil`) acts as a generic identifier over the origin + path prefix and covers any server query.

An empty query (‘prm_url?` – URI#query returns `“”`) is NOT treated as wildcard: it represents the URI literally `<…>?`, which is distinct from “no query at all” and from any non-empty query, so it must match exactly.

Both arguments must already be canonicalized.

Returns:

  • (Boolean)


289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
# File 'lib/mcp/client/oauth/discovery.rb', line 289

def resource_covers?(prm:, server:)
  prm_uri = URI.parse(prm)
  server_uri = URI.parse(server)
  return false unless prm_uri.scheme == server_uri.scheme &&
    prm_uri.host == server_uri.host &&
    prm_uri.port == server_uri.port

  prm_path = prm_uri.path.to_s
  server_path = server_uri.path.to_s
  prm_path = "" if prm_path == "/"
  server_path = "" if server_path == "/"
  path_covers = server_path == prm_path || server_path.start_with?("#{prm_path}/")
  return false unless path_covers

  prm_query = prm_uri.query
  return true if prm_query.nil?

  prm_query == server_uri.query
end

.secure_url?(url) ⇒ Boolean

Returns true when ‘url` is safe to use for OAuth communication per the MCP authorization spec’s “Communication Security” requirement: ‘https` is always allowed, `http` is permitted only when the host is a loopback address (`localhost`, `127.0.0.0/8`, or `::1`).

The loopback exception applies uniformly to every OAuth-related URL the SDK consumes (PRM URL, AS metadata URL, ‘authorization_servers` entries, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`, the `redirect_uri`, and the MCP transport URL when `oauth:` is set). A strict reading of OAuth 2.1 reserves the loopback carve-out for `redirect_uri` only (per RFC 8252), but neither the Python nor the TypeScript MCP SDK enforces HTTPS on those endpoints either - and the official MCP conformance test suite drives its fixtures over `localhost` auth servers, so enforcing HTTPS for everything except `redirect_uri` would break local development out of the box and regress 16 conformance scenarios. Operators who run in production are expected to deploy real HTTPS endpoints; this helper does not enforce that at the SDK boundary.

Rejects URLs that fail to parse, lack a host, or whose ‘http://` host is something like `127.attacker.com` or `foo.localhost`, which would otherwise pass a naive `start_with?(“127.”)` check. modelcontextprotocol.io/specification/2025-11-25/basic/authorization#communication-security

Returns:

  • (Boolean)


181
182
183
184
185
186
187
188
189
190
191
192
193
194
# File 'lib/mcp/client/oauth/discovery.rb', line 181

def secure_url?(url)
  return false if url.nil? || url.to_s.empty?

  uri = URI.parse(url.to_s)
  return false if uri.host.nil? || uri.host.empty?

  scheme = uri.scheme&.downcase
  return true if scheme == "https"
  return loopback_host?(uri.host) if scheme == "http"

  false
rescue URI::InvalidURIError
  false
end