Set OIDC application_type on Dynamic Client Registration per SEP-837

## 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.
This commit is contained in:
Koichi ITO 2026-06-14 23:32:13 +09:00
commit 3093b3ee3f
3 changed files with 140 additions and 1 deletions

View file

@ -237,6 +237,23 @@ module MCP
false
end
# 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.
#
# - https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837
# - https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata
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
# 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
@ -345,6 +362,20 @@ module MCP
nil
end
# A redirect URI counts as native when it uses a custom non-http(s) scheme
# (e.g. `com.example.app:/callback`) or when it is an http(s) URI whose host is
# a loopback address. A URI without a scheme or one that fails to parse is not native.
def native_redirect_uri?(url)
uri = URI.parse(url.to_s)
scheme = uri.scheme&.downcase
return false if scheme.nil?
return loopback_host?(uri.host) if ["http", "https"].include?(scheme)
true
rescue URI::InvalidURIError
false
end
def base_url(uri)
port_part = uri.port && uri.port != uri.default_port ? ":#{uri.port}" : ""
"#{uri.scheme}://#{uri.host}#{port_part}"

View file

@ -367,7 +367,7 @@ module MCP
end
response = begin
http_post_json(registration_endpoint, @provider.client_metadata)
http_post_json(registration_endpoint, registration_client_metadata)
rescue Faraday::Error => e
raise AuthorizationError,
"Dynamic client registration failed: #{e.class}: #{e.message}."
@ -393,6 +393,20 @@ module MCP
info
end
# Returns the client metadata to submit on Dynamic Client Registration.
# Per SEP-837, MCP clients MUST specify an appropriate OIDC `application_type`
# so the authorization server can apply the matching redirect URI policy.
# When the user did not set one explicitly, infer `"native"` vs `"web"` from
# the registered `redirect_uris`; an explicit value always wins.
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837
def registration_client_metadata
metadata = @provider.client_metadata
return metadata if metadata[:application_type] || metadata["application_type"]
redirect_uris = metadata[:redirect_uris] || metadata["redirect_uris"]
metadata.merge("application_type" => Discovery.infer_application_type(redirect_uris))
end
# Reads `key` from a `client_information` hash that may use either string or
# symbol keys, so users can persist the result of `JSON.parse` *or* a hand-built
# `{ client_id:, client_secret: }` and have both work.

View file

@ -13,6 +13,9 @@ module MCP
# - `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