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
2 changed files with 140 additions and 1 deletions

View file

@ -158,6 +158,39 @@ module MCP
)
end
def test_infer_application_type_returns_native_for_loopback_http_redirect_uris
uris = ["http://localhost:0/callback", "http://127.0.0.1:8080/cb", "http://[::1]/cb"]
assert_equal("native", Discovery.infer_application_type(uris))
end
def test_infer_application_type_returns_native_for_custom_scheme_redirect_uris
assert_equal("native", Discovery.infer_application_type(["com.example.app:/oauth/callback"]))
end
def test_infer_application_type_returns_web_for_https_redirect_uris
assert_equal("web", Discovery.infer_application_type(["https://app.example.com/callback"]))
end
def test_infer_application_type_returns_web_when_any_redirect_uri_is_not_native
uris = ["http://localhost:0/callback", "https://app.example.com/callback"]
assert_equal("web", Discovery.infer_application_type(uris))
end
def test_infer_application_type_returns_web_for_localhost_lookalike_host
assert_equal("web", Discovery.infer_application_type(["http://localhost.example.com/cb"]))
end
def test_infer_application_type_returns_web_for_nil_or_empty_redirect_uris
assert_equal("web", Discovery.infer_application_type(nil))
assert_equal("web", Discovery.infer_application_type([]))
end
def test_infer_application_type_returns_web_for_unparseable_redirect_uri
assert_equal("web", Discovery.infer_application_type(["http://[invalid"]))
end
def test_canonicalize_url_normalizes_scheme_host_port_and_path
assert_equal(
"https://srv.example.com/mcp",

View file

@ -200,6 +200,62 @@ module MCP
end
end
# Runs the full authorization flow with a minimal provider so tests can assert on
# the Dynamic Client Registration request body. The default loopback redirect URI
# exercises SEP-837's `"native"` inference; passing an HTTPS `redirect_uri` exercises
# the `"web"` inference.
def run_authorization_flow(redirect_uri: "http://localhost:0/callback", client_metadata_extra: {})
state_holder = {}
provider = Provider.new(
client_metadata: {
redirect_uris: [redirect_uri],
grant_types: ["authorization_code"],
response_types: ["code"],
token_endpoint_auth_method: "none",
}.merge(client_metadata_extra),
redirect_uri: redirect_uri,
redirect_handler: ->(url) { state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") },
callback_handler: -> { ["test-auth-code", state_holder[:state]] },
)
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
end
def test_run_registers_native_application_type_for_loopback_redirect_uri
run_authorization_flow
assert_requested(:post, "#{@auth_base}/register") do |req|
JSON.parse(req.body)["application_type"] == "native"
end
end
def test_run_registers_web_application_type_for_https_redirect_uri
run_authorization_flow(redirect_uri: "https://app.example.com/callback")
assert_requested(:post, "#{@auth_base}/register") do |req|
JSON.parse(req.body)["application_type"] == "web"
end
end
def test_run_does_not_override_explicit_application_type
run_authorization_flow(client_metadata_extra: { application_type: "web" })
assert_requested(:post, "#{@auth_base}/register") do |req|
JSON.parse(req.body)["application_type"] == "web"
end
end
def test_run_does_not_override_explicit_string_keyed_application_type
run_authorization_flow(
redirect_uri: "https://app.example.com/callback",
client_metadata_extra: { "application_type" => "native" },
)
assert_requested(:post, "#{@auth_base}/register") do |req|
JSON.parse(req.body)["application_type"] == "native"
end
end
def test_run_completes_full_authorization_flow
captured_authorization_url = nil
state_value = nil