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-Authenticateparsing (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 usequoted-pairescapes 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 insecure_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 8707resourceclaim or any error message. - Resource coverage (
resource_covers?): decides whether a PRMresourceURI 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=valuepair inside an HTTP auth-scheme challenge.valueis 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
-
.authorization_server_metadata_urls(issuer_url) ⇒ Object
Returns the candidate Authorization Server metadata URLs to probe, in priority order.
-
.canonicalize_origin_and_path(url) ⇒ Object
Like
canonicalize_urlbut also strips query string, fragment, and userinfo. -
.canonicalize_url(url) ⇒ Object
Returns a canonical form of
urlsuitable 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. -
.client_id_metadata_document_url?(url) ⇒ Boolean
Returns true when
urlsatisfies the structural requirements for a Client ID Metadata Document URL per the MCP 2025-11-25 authorization specification anddraft-ietf-oauth-client-id-metadata-document-00. -
.infer_application_type(redirect_uris) ⇒ Object
Infers the OIDC Dynamic Client Registration
application_typefor a client from itsredirect_uris. -
.parse_www_authenticate(header) ⇒ Object
Parses a
WWW-Authenticateheader and returns the parameters of theBearerchallenge as a hash with lower-cased keys (e.g.resource_metadata,scope,error). -
.protected_resource_metadata_urls(server_url:, resource_metadata_url: nil) ⇒ Object
Returns the candidate Protected Resource Metadata URLs to probe, in priority order.
-
.resource_covers?(prm:, server:) ⇒ Boolean
Returns true when
prm(a PRMresourceURL) coversserver(the MCP endpoint URL): same scheme/host/port, with PRM's path being a prefix of the server's path. -
.secure_url?(url) ⇒ Boolean
Returns true when
urlis safe to use for OAuth communication per the MCP authorization spec's "Communication Security" requirement:httpsis always allowed,httpis permitted only when the host is a loopback address (localhost,127.0.0.0/8, or::1).
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.
268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 |
# File 'lib/mcp/client/oauth/discovery.rb', line 268 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 https://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-httpcarve-out used for discovery does not apply: the document URL is sent verbatim to the authorization server as the OAuthclient_idand 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_idlog line - path MUST be already free of
./..dot segments after percent-decoding, so two URLs with the same effective path do not produce differentclient_idstrings
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=2vs?b=2&a=1) would yield distinctclient_idvalues for the same logical document.
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 |
.infer_application_type(redirect_uris) ⇒ Object
Infers the OIDC Dynamic Client Registration application_type for a client from its redirect_uris.
Per SEP-837, MCP clients MUST specify an appropriate application type during Dynamic Client Registration
so the authorization server can apply the matching redirect URI policy.
Returns "native" when every redirect URI is a native-app URI: a custom non-http(s) scheme (RFC 8252 Section 7.1)
or an http(s) URI whose host is a loopback address (localhost, 127.0.0.0/8, or ::1, RFC 8252 Section 7.3).
Returns "web" otherwise, including when redirect_uris is nil, empty, or contains an unparseable URI.
250 251 252 253 254 255 |
# File 'lib/mcp/client/oauth/discovery.rb', line 250 def infer_application_type(redirect_uris) uris = Array(redirect_uris) return "web" if uris.empty? uris.all? { |uri| native_redirect_uri?(uri) } ? "native" : "web" 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. https://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.
306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 |
# File 'lib/mcp/client/oauth/discovery.rb', line 306 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 http://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.
https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#communication-security
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 |