## Motivation and Context SEP-837 (modelcontextprotocol/modelcontextprotocol#837, merged for the 2026-07-28 spec release) updates the authorization spec to require MCP clients to specify an appropriate OIDC `application_type` (`"native"` vs `"web"`) during Dynamic Client Registration, so the authorization server can apply the matching redirect URI policy and native and web clients do not collide on redirect URI validation rules. This mirrors the approach taken by the TypeScript SDK (typescript-sdk#2266) and the Python SDK (python-sdk#2784): - `MCP::Client::OAuth::Discovery.infer_application_type(redirect_uris)` returns `"native"` when every redirect URI is a native-app URI (a custom non-http(s) scheme per RFC 8252 Section 7.1, or an http(s) URI with a loopback host per RFC 8252 Section 7.3) and `"web"` otherwise, including for nil, empty, or unparseable input. Loopback detection reuses the existing `loopback_host?` helper, so lookalike hosts such as `localhost.example.com` or `127.attacker.com` are not treated as native. - `Flow#ensure_client_registered` now posts the metadata through the new `registration_client_metadata` helper, which merges the inferred `application_type` into the DCR request body only when the user did not set one explicitly (symbol or string key); an explicit value always wins. Resolves #375. ## How Has This Been Tested? New tests in `test/mcp/client/oauth/discovery_test.rb` cover the inference: loopback http URIs (IPv4, IPv6, `localhost`), custom schemes, https URIs, mixed lists, the `localhost.example.com` lookalike, nil/empty input, and unparseable URIs. New tests in `test/mcp/client/oauth/flow_test.rb` assert on the actual DCR request body via WebMock `assert_requested`: loopback redirect URI registers `"application_type": "native"`, an HTTPS redirect URI registers `"web"`, and an explicit `application_type` (symbol- or string-keyed) is never overridden. ## Breaking Changes None. The DCR request body gains an `application_type` member only when the user has not specified one; RFC 7591 requires authorization servers to accept and use known metadata fields, and OIDC-registration servers already default this field to `"web"` when absent.
121 lines
6.5 KiB
Ruby
121 lines
6.5 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module MCP
|
|
class Client
|
|
module OAuth
|
|
# Pluggable OAuth client configuration for the OAuth 2.1 Authorization Code + PKCE flow,
|
|
# handed to `MCP::Client::HTTP` via the `oauth:` keyword.
|
|
# Inspired by the OAuthClientProvider in the TypeScript SDK and the httpx.Auth-based provider
|
|
# in the Python SDK. For the non-interactive machine-to-machine `client_credentials` grant,
|
|
# use `ClientCredentialsProvider` instead.
|
|
#
|
|
# Required keyword arguments:
|
|
# - `client_metadata` - Hash sent to the authorization server's Dynamic Client
|
|
# Registration endpoint. Must include at minimum `redirect_uris`,
|
|
# `grant_types`, `response_types`, and `token_endpoint_auth_method`.
|
|
# When `application_type` is omitted, the SDK infers `"native"` or `"web"`
|
|
# from `redirect_uris` per SEP-837 before registering; an explicit value
|
|
# always wins.
|
|
# - `redirect_uri` - String: the redirect URI used for the authorization
|
|
# request. Must be one of `redirect_uris` in `client_metadata`.
|
|
# - `redirect_handler` - Callable invoked with the fully-built authorization
|
|
# URL (a `URI`). Implementations typically open the user's browser.
|
|
# - `callback_handler` - Callable invoked after `redirect_handler`. Returns
|
|
# `[code, state]` where `code` is the authorization code and `state` is
|
|
# the `state` parameter received on the redirect URI.
|
|
#
|
|
# Optional keyword arguments:
|
|
# - `scope` - String of space-separated scopes to request when the server's
|
|
# `WWW-Authenticate` does not specify one.
|
|
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
|
|
# `client_information`, and `save_client_information(info)`. Defaults to
|
|
# an `InMemoryStorage`.
|
|
# - `client_id_metadata_document_url` - URL where the client publishes its Client ID Metadata Document
|
|
# (`draft-ietf-oauth-client-id-metadata-document-00` and the MCP authorization specification).
|
|
# When the authorization server advertises `client_id_metadata_document_supported: true`,
|
|
# the SDK uses this URL as the OAuth `client_id` and skips Dynamic Client Registration.
|
|
# Spec-required: `https://` scheme, a non-root path, and no fragment, userinfo, or `.`/`..` segments.
|
|
# The SDK additionally refuses to send query strings (the draft marks them only SHOULD NOT include,
|
|
# but different encodings of the same query would yield different `client_id` strings for the same document).
|
|
# The document served at the URL is a separate JSON artifact from the `client_metadata` keyword:
|
|
# DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include `client_id` set
|
|
# to the URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
|
|
class Provider
|
|
include StorageBackedProvider
|
|
|
|
# Raised when `Provider#initialize` is called with a `redirect_uri` that
|
|
# is neither HTTPS nor a loopback `http://` URL, per the MCP
|
|
# authorization spec's Communication Security requirement.
|
|
class InsecureRedirectURIError < ArgumentError; end
|
|
|
|
# Raised when the `redirect_uri` argument is not listed in
|
|
# `client_metadata[:redirect_uris]` / `["redirect_uris"]`. Registering
|
|
# the URI with the authorization server but then sending a different
|
|
# one with the authorization request would be rejected by the AS at
|
|
# runtime; failing at construction surfaces the bug earlier.
|
|
class UnregisteredRedirectURIError < ArgumentError; end
|
|
|
|
# Raised when `client_id_metadata_document_url` is provided but does not meet
|
|
# the structural requirements for a Client ID Metadata Document URL:
|
|
# HTTPS, non-root path, and no fragment, query, userinfo, or `.`/`..` segments.
|
|
# The CIMD URL is sent to the authorization server as the OAuth `client_id`,
|
|
# so the same Communication Security guarantee that protects the redirect URI
|
|
# applies and the value must unambiguously identify the document.
|
|
class InvalidClientIDMetadataDocumentURLError < ArgumentError; end
|
|
|
|
attr_reader :client_metadata,
|
|
:redirect_uri,
|
|
:scope,
|
|
:storage,
|
|
:redirect_handler,
|
|
:callback_handler,
|
|
:client_id_metadata_document_url
|
|
|
|
def initialize(
|
|
client_metadata:,
|
|
redirect_uri:,
|
|
redirect_handler:,
|
|
callback_handler:,
|
|
scope: nil,
|
|
storage: nil,
|
|
client_id_metadata_document_url: nil
|
|
)
|
|
unless Discovery.secure_url?(redirect_uri)
|
|
raise InsecureRedirectURIError,
|
|
"redirect_uri #{redirect_uri.inspect} must use https or be a loopback http URL " \
|
|
"(localhost, 127.0.0.0/8, or ::1) per the MCP authorization Communication Security requirement."
|
|
end
|
|
|
|
registered = Array(client_metadata[:redirect_uris] || client_metadata["redirect_uris"])
|
|
unless registered.include?(redirect_uri)
|
|
raise UnregisteredRedirectURIError,
|
|
"redirect_uri #{redirect_uri.inspect} must be listed in client_metadata[:redirect_uris] " \
|
|
"(got #{registered.inspect}); otherwise the authorization server will reject the authorization request."
|
|
end
|
|
|
|
if client_id_metadata_document_url && !Discovery.client_id_metadata_document_url?(client_id_metadata_document_url)
|
|
raise InvalidClientIDMetadataDocumentURLError,
|
|
"client_id_metadata_document_url #{client_id_metadata_document_url.inspect} must be an https URL " \
|
|
"with a non-root path and no fragment, query, userinfo, or `.`/`..` segments, " \
|
|
"per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`."
|
|
end
|
|
|
|
@client_metadata = client_metadata
|
|
@redirect_uri = redirect_uri
|
|
@redirect_handler = redirect_handler
|
|
@callback_handler = callback_handler
|
|
@scope = scope
|
|
@storage = storage || InMemoryStorage.new
|
|
@client_id_metadata_document_url = client_id_metadata_document_url
|
|
end
|
|
|
|
# Identifies the OAuth flow this provider drives.
|
|
# `Flow` dispatches on this rather than inspecting `client_metadata[:grant_types]`,
|
|
# which is protocol metadata for the authorization server, not an SDK control signal.
|
|
def authorization_flow
|
|
:authorization_code
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|