Skip to content

Commit 3093b3e

Browse files
committed
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.
1 parent 7f266d4 commit 3093b3e

6 files changed

Lines changed: 140 additions & 1 deletion

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2029,6 +2029,8 @@ Required keyword arguments to `Provider.new`:
20292029

20302030
- `client_metadata`: Hash sent to the authorization server's Dynamic Client Registration endpoint. Must include `redirect_uris`, `grant_types`, `response_types`,
20312031
`token_endpoint_auth_method`. `redirect_uri` (below) must appear in this list, otherwise the constructor raises `Provider::UnregisteredRedirectURIError`.
2032+
When `application_type` is omitted, the SDK infers `"native"` or `"web"` from `redirect_uris` per SEP-837 before registering (loopback or custom-scheme URIs are native);
2033+
an explicit value always wins.
20322034
- `redirect_uri`: String. Must use HTTPS or be a loopback URL (`localhost`, `127.0.0.0/8`, `::1`); other values raise `Provider::InsecureRedirectURIError`.
20332035
- `redirect_handler`: Callable invoked with the fully-built authorization `URI`. Typically opens the user's browser.
20342036
- `callback_handler`: Callable that returns `[code, state]` after the user is redirected back to `redirect_uri`.

lib/mcp/client/oauth/discovery.rb

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,23 @@ def client_id_metadata_document_url?(url)
237237
false
238238
end
239239

240+
# Infers the OIDC Dynamic Client Registration `application_type` for a client from its `redirect_uris`.
241+
# Per SEP-837, MCP clients MUST specify an appropriate application type during Dynamic Client Registration
242+
# so the authorization server can apply the matching redirect URI policy.
243+
#
244+
# Returns `"native"` when every redirect URI is a native-app URI: a custom non-http(s) scheme (RFC 8252 Section 7.1)
245+
# or an http(s) URI whose host is a loopback address (`localhost`, `127.0.0.0/8`, or `::1`, RFC 8252 Section 7.3).
246+
# Returns `"web"` otherwise, including when `redirect_uris` is nil, empty, or contains an unparseable URI.
247+
#
248+
# - https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837
249+
# - https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata
250+
def infer_application_type(redirect_uris)
251+
uris = Array(redirect_uris)
252+
return "web" if uris.empty?
253+
254+
uris.all? { |uri| native_redirect_uri?(uri) } ? "native" : "web"
255+
end
256+
240257
# Like `canonicalize_url` but also strips query string, fragment, and
241258
# userinfo. This variant is used for identity comparison against
242259
# the request URL Faraday actually sends, which differs from the value
@@ -345,6 +362,20 @@ def parse_ip_address(candidate)
345362
nil
346363
end
347364

365+
# A redirect URI counts as native when it uses a custom non-http(s) scheme
366+
# (e.g. `com.example.app:/callback`) or when it is an http(s) URI whose host is
367+
# a loopback address. A URI without a scheme or one that fails to parse is not native.
368+
def native_redirect_uri?(url)
369+
uri = URI.parse(url.to_s)
370+
scheme = uri.scheme&.downcase
371+
return false if scheme.nil?
372+
return loopback_host?(uri.host) if ["http", "https"].include?(scheme)
373+
374+
true
375+
rescue URI::InvalidURIError
376+
false
377+
end
378+
348379
def base_url(uri)
349380
port_part = uri.port && uri.port != uri.default_port ? ":#{uri.port}" : ""
350381
"#{uri.scheme}://#{uri.host}#{port_part}"

lib/mcp/client/oauth/flow.rb

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -367,7 +367,7 @@ def ensure_client_registered(as_metadata:)
367367
end
368368

369369
response = begin
370-
http_post_json(registration_endpoint, @provider.client_metadata)
370+
http_post_json(registration_endpoint, registration_client_metadata)
371371
rescue Faraday::Error => e
372372
raise AuthorizationError,
373373
"Dynamic client registration failed: #{e.class}: #{e.message}."
@@ -393,6 +393,20 @@ def ensure_client_registered(as_metadata:)
393393
info
394394
end
395395

396+
# Returns the client metadata to submit on Dynamic Client Registration.
397+
# Per SEP-837, MCP clients MUST specify an appropriate OIDC `application_type`
398+
# so the authorization server can apply the matching redirect URI policy.
399+
# When the user did not set one explicitly, infer `"native"` vs `"web"` from
400+
# the registered `redirect_uris`; an explicit value always wins.
401+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837
402+
def registration_client_metadata
403+
metadata = @provider.client_metadata
404+
return metadata if metadata[:application_type] || metadata["application_type"]
405+
406+
redirect_uris = metadata[:redirect_uris] || metadata["redirect_uris"]
407+
metadata.merge("application_type" => Discovery.infer_application_type(redirect_uris))
408+
end
409+
396410
# Reads `key` from a `client_information` hash that may use either string or
397411
# symbol keys, so users can persist the result of `JSON.parse` *or* a hand-built
398412
# `{ client_id:, client_secret: }` and have both work.

lib/mcp/client/oauth/provider.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ module OAuth
1313
# - `client_metadata` - Hash sent to the authorization server's Dynamic Client
1414
# Registration endpoint. Must include at minimum `redirect_uris`,
1515
# `grant_types`, `response_types`, and `token_endpoint_auth_method`.
16+
# When `application_type` is omitted, the SDK infers `"native"` or `"web"`
17+
# from `redirect_uris` per SEP-837 before registering; an explicit value
18+
# always wins.
1619
# - `redirect_uri` - String: the redirect URI used for the authorization
1720
# request. Must be one of `redirect_uris` in `client_metadata`.
1821
# - `redirect_handler` - Callable invoked with the fully-built authorization

test/mcp/client/oauth/discovery_test.rb

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,39 @@ def test_authorization_server_metadata_urls_treat_trailing_slash_issuer_as_root
158158
)
159159
end
160160

161+
def test_infer_application_type_returns_native_for_loopback_http_redirect_uris
162+
uris = ["http://localhost:0/callback", "http://127.0.0.1:8080/cb", "http://[::1]/cb"]
163+
164+
assert_equal("native", Discovery.infer_application_type(uris))
165+
end
166+
167+
def test_infer_application_type_returns_native_for_custom_scheme_redirect_uris
168+
assert_equal("native", Discovery.infer_application_type(["com.example.app:/oauth/callback"]))
169+
end
170+
171+
def test_infer_application_type_returns_web_for_https_redirect_uris
172+
assert_equal("web", Discovery.infer_application_type(["https://app.example.com/callback"]))
173+
end
174+
175+
def test_infer_application_type_returns_web_when_any_redirect_uri_is_not_native
176+
uris = ["http://localhost:0/callback", "https://app.example.com/callback"]
177+
178+
assert_equal("web", Discovery.infer_application_type(uris))
179+
end
180+
181+
def test_infer_application_type_returns_web_for_localhost_lookalike_host
182+
assert_equal("web", Discovery.infer_application_type(["http://localhost.example.com/cb"]))
183+
end
184+
185+
def test_infer_application_type_returns_web_for_nil_or_empty_redirect_uris
186+
assert_equal("web", Discovery.infer_application_type(nil))
187+
assert_equal("web", Discovery.infer_application_type([]))
188+
end
189+
190+
def test_infer_application_type_returns_web_for_unparseable_redirect_uri
191+
assert_equal("web", Discovery.infer_application_type(["http://[invalid"]))
192+
end
193+
161194
def test_canonicalize_url_normalizes_scheme_host_port_and_path
162195
assert_equal(
163196
"https://srv.example.com/mcp",

test/mcp/client/oauth/flow_test.rb

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,62 @@ def test_run_uses_authorization_code_grant_for_default_provider
200200
end
201201
end
202202

203+
# Runs the full authorization flow with a minimal provider so tests can assert on
204+
# the Dynamic Client Registration request body. The default loopback redirect URI
205+
# exercises SEP-837's `"native"` inference; passing an HTTPS `redirect_uri` exercises
206+
# the `"web"` inference.
207+
def run_authorization_flow(redirect_uri: "http://localhost:0/callback", client_metadata_extra: {})
208+
state_holder = {}
209+
provider = Provider.new(
210+
client_metadata: {
211+
redirect_uris: [redirect_uri],
212+
grant_types: ["authorization_code"],
213+
response_types: ["code"],
214+
token_endpoint_auth_method: "none",
215+
}.merge(client_metadata_extra),
216+
redirect_uri: redirect_uri,
217+
redirect_handler: ->(url) { state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") },
218+
callback_handler: -> { ["test-auth-code", state_holder[:state]] },
219+
)
220+
221+
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
222+
end
223+
224+
def test_run_registers_native_application_type_for_loopback_redirect_uri
225+
run_authorization_flow
226+
227+
assert_requested(:post, "#{@auth_base}/register") do |req|
228+
JSON.parse(req.body)["application_type"] == "native"
229+
end
230+
end
231+
232+
def test_run_registers_web_application_type_for_https_redirect_uri
233+
run_authorization_flow(redirect_uri: "https://app.example.com/callback")
234+
235+
assert_requested(:post, "#{@auth_base}/register") do |req|
236+
JSON.parse(req.body)["application_type"] == "web"
237+
end
238+
end
239+
240+
def test_run_does_not_override_explicit_application_type
241+
run_authorization_flow(client_metadata_extra: { application_type: "web" })
242+
243+
assert_requested(:post, "#{@auth_base}/register") do |req|
244+
JSON.parse(req.body)["application_type"] == "web"
245+
end
246+
end
247+
248+
def test_run_does_not_override_explicit_string_keyed_application_type
249+
run_authorization_flow(
250+
redirect_uri: "https://app.example.com/callback",
251+
client_metadata_extra: { "application_type" => "native" },
252+
)
253+
254+
assert_requested(:post, "#{@auth_base}/register") do |req|
255+
JSON.parse(req.body)["application_type"] == "native"
256+
end
257+
end
258+
203259
def test_run_completes_full_authorization_flow
204260
captured_authorization_url = nil
205261
state_value = nil

0 commit comments

Comments
 (0)