Skip to content

Commit aac41cd

Browse files
authored
Merge pull request #408 from koic/dcr_application_type
Set OIDC `application_type` on Dynamic Client Registration per SEP-837
2 parents 60f0088 + 3093b3e commit aac41cd

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
@@ -2036,6 +2036,8 @@ Required keyword arguments to `Provider.new`:
20362036

20372037
- `client_metadata`: Hash sent to the authorization server's Dynamic Client Registration endpoint. Must include `redirect_uris`, `grant_types`, `response_types`,
20382038
`token_endpoint_auth_method`. `redirect_uri` (below) must appear in this list, otherwise the constructor raises `Provider::UnregisteredRedirectURIError`.
2039+
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);
2040+
an explicit value always wins.
20392041
- `redirect_uri`: String. Must use HTTPS or be a loopback URL (`localhost`, `127.0.0.0/8`, `::1`); other values raise `Provider::InsecureRedirectURIError`.
20402042
- `redirect_handler`: Callable invoked with the fully-built authorization `URI`. Typically opens the user's browser.
20412043
- `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
@@ -440,7 +440,7 @@ def ensure_client_registered(as_metadata:)
440440
end
441441

442442
response = begin
443-
http_post_json(registration_endpoint, @provider.client_metadata)
443+
http_post_json(registration_endpoint, registration_client_metadata)
444444
rescue Faraday::Error => e
445445
raise AuthorizationError,
446446
"Dynamic client registration failed: #{e.class}: #{e.message}."
@@ -466,6 +466,20 @@ def ensure_client_registered(as_metadata:)
466466
info
467467
end
468468

469+
# Returns the client metadata to submit on Dynamic Client Registration.
470+
# Per SEP-837, MCP clients MUST specify an appropriate OIDC `application_type`
471+
# so the authorization server can apply the matching redirect URI policy.
472+
# When the user did not set one explicitly, infer `"native"` vs `"web"` from
473+
# the registered `redirect_uris`; an explicit value always wins.
474+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837
475+
def registration_client_metadata
476+
metadata = @provider.client_metadata
477+
return metadata if metadata[:application_type] || metadata["application_type"]
478+
479+
redirect_uris = metadata[:redirect_uris] || metadata["redirect_uris"]
480+
metadata.merge("application_type" => Discovery.infer_application_type(redirect_uris))
481+
end
482+
469483
# Reads `key` from a `client_information` hash that may use either string or
470484
# symbol keys, so users can persist the result of `JSON.parse` *or* a hand-built
471485
# `{ 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)