Skip to content

Commit ecad2fb

Browse files
feat(auth): send OIDC application_type during registration (SEP-837)
SEP-837 requires an MCP client to specify an application_type during OIDC Dynamic Client Registration [1]. When it is omitted, OIDC servers default the client to "web", which conflicts with the loopback redirect URIs that CLI and desktop clients use, so the registration can be rejected. OAuthClientMetadata had no such field and the registration request never sent one, so the SDK hit exactly that default. I add an optional application_type to OAuthClientMetadata and infer it from the redirect URIs when the caller does not set one: loopback and private-use scheme URIs register as "native", a remote https host as "web", and a mix is left unset for the server to decide. An explicit value is always sent as-is. Non-OIDC servers ignore the parameter. Implements [2]. [1]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/7df71535a0c9b2057d73966bb6b123e684a940cd/docs/specification/draft/basic/authorization/client-registration.mdx#L152-L179 [2]: #2783 Signed-off-by: Stefano Amorelli <stefano@amorelli.tech>
1 parent b478bff commit ecad2fb

5 files changed

Lines changed: 130 additions & 0 deletions

File tree

src/mcp/client/auth/utils.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import ipaddress
12
import re
3+
from typing import Literal
24
from urllib.parse import urljoin, urlparse
35

46
from httpx import Request, Response
@@ -215,6 +217,41 @@ def create_oauth_metadata_request(url: str) -> Request:
215217
return Request("GET", url, headers={MCP_PROTOCOL_VERSION: LATEST_PROTOCOL_VERSION})
216218

217219

220+
def _is_loopback_host(host: str) -> bool:
221+
"""Return True if host is a loopback address (localhost, 127.0.0.0/8, or ::1)."""
222+
if host == "localhost":
223+
return True
224+
try:
225+
# pydantic keeps IPv6 hosts bracketed (e.g. "[::1]"); ipaddress wants them bare.
226+
return ipaddress.ip_address(host.strip("[]")).is_loopback
227+
except ValueError:
228+
return False
229+
230+
231+
def infer_application_type(redirect_uris: list[AnyUrl] | None) -> Literal["native", "web"] | None:
232+
"""Infer the OIDC application_type from a client's redirect URIs (SEP-837).
233+
234+
Loopback redirect URIs (localhost, 127.0.0.0/8, ::1) and private-use URI schemes
235+
identify a native application; an http(s) URL with a non-loopback host identifies
236+
a web application. A mix of both is ambiguous, so the type is left unset for the
237+
authorization server to decide.
238+
"""
239+
if not redirect_uris:
240+
return None
241+
242+
has_native = False
243+
has_web = False
244+
for uri in redirect_uris:
245+
if uri.scheme in ("http", "https") and not _is_loopback_host(uri.host or ""):
246+
has_web = True
247+
else:
248+
has_native = True
249+
250+
if has_native and has_web:
251+
return None
252+
return "native" if has_native else "web"
253+
254+
218255
def create_client_registration_request(
219256
auth_server_metadata: OAuthMetadata | None, client_metadata: OAuthClientMetadata, auth_base_url: str
220257
) -> Request:
@@ -227,6 +264,14 @@ def create_client_registration_request(
227264

228265
registration_data = client_metadata.model_dump(by_alias=True, mode="json", exclude_none=True)
229266

267+
# SEP-837: OIDC servers assume application_type "web" when it is omitted, which can
268+
# reject the loopback redirect URIs native clients use. Send a type inferred from
269+
# the redirect URIs when the caller did not set one explicitly.
270+
if "application_type" not in registration_data:
271+
application_type = infer_application_type(client_metadata.redirect_uris)
272+
if application_type is not None:
273+
registration_data["application_type"] = application_type
274+
230275
return Request("POST", registration_url, json=registration_data, headers={"Content-Type": "application/json"})
231276

232277

src/mcp/shared/auth.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ class OAuthClientMetadata(BaseModel):
5454
response_types: list[str] = ["code"]
5555
scope: str | None = None
5656

57+
# OpenID Connect application type (OIDC Dynamic Client Registration 1.0 §2).
58+
# OIDC servers assume "web" when this is omitted (SEP-837), which can reject the
59+
# loopback redirect URIs native clients rely on. Left None here; the client
60+
# infers it from redirect_uris at registration time when no value is set.
61+
application_type: Literal["native", "web"] | None = None
62+
5763
# these fields are currently unused, but we support & store them for potential
5864
# future use
5965
client_name: str | None = None

tests/client/test_auth.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Tests for refactored OAuth client authentication implementation."""
22

33
import base64
4+
import json
45
import time
56
from unittest import mock
67
from urllib.parse import parse_qs, quote, unquote, urlparse
@@ -23,6 +24,7 @@
2324
extract_scope_from_www_auth,
2425
get_client_metadata_scopes,
2526
handle_registration_response,
27+
infer_application_type,
2628
is_valid_client_metadata_url,
2729
should_use_client_metadata_url,
2830
)
@@ -1028,6 +1030,61 @@ def test_falls_back_when_metadata_has_no_registration_endpoint(self):
10281030
assert request.method == "POST"
10291031

10301032

1033+
@pytest.mark.parametrize(
1034+
("redirect_uris", "expected"),
1035+
[
1036+
(["http://localhost:3030/callback"], "native"),
1037+
(["http://127.0.0.1:3030/callback"], "native"),
1038+
(["http://[::1]:3030/callback"], "native"),
1039+
(["com.example.app:/oauth2redirect"], "native"),
1040+
(["https://app.example.com/callback"], "web"),
1041+
(["http://localhost:3030/callback", "https://app.example.com/callback"], None),
1042+
],
1043+
)
1044+
def test_infer_application_type(redirect_uris: list[str], expected: str | None):
1045+
"""SEP-837: native for loopback or private-use redirect URIs, web for remote hosts."""
1046+
assert infer_application_type([AnyUrl(uri) for uri in redirect_uris]) == expected
1047+
1048+
1049+
def test_infer_application_type_without_redirect_uris():
1050+
assert infer_application_type([]) is None
1051+
assert infer_application_type(None) is None
1052+
1053+
1054+
def test_create_client_registration_request_infers_native_application_type():
1055+
"""A loopback redirect URI registers the client as a native application (SEP-837)."""
1056+
client_metadata = OAuthClientMetadata(redirect_uris=[AnyUrl("http://localhost:3030/callback")])
1057+
1058+
request = create_client_registration_request(None, client_metadata, "https://auth.example.com")
1059+
1060+
assert json.loads(request.content)["application_type"] == "native"
1061+
1062+
1063+
def test_create_client_registration_request_preserves_explicit_application_type():
1064+
"""An explicit application_type is sent as-is, without inference overriding it."""
1065+
client_metadata = OAuthClientMetadata(
1066+
redirect_uris=[AnyUrl("http://localhost:3030/callback")], application_type="web"
1067+
)
1068+
1069+
request = create_client_registration_request(None, client_metadata, "https://auth.example.com")
1070+
1071+
assert json.loads(request.content)["application_type"] == "web"
1072+
1073+
1074+
def test_create_client_registration_request_omits_ambiguous_application_type():
1075+
"""Redirect URIs that mix native and web styles leave application_type unset."""
1076+
client_metadata = OAuthClientMetadata(
1077+
redirect_uris=[
1078+
AnyUrl("http://localhost:3030/callback"),
1079+
AnyUrl("https://app.example.com/callback"),
1080+
]
1081+
)
1082+
1083+
request = create_client_registration_request(None, client_metadata, "https://auth.example.com")
1084+
1085+
assert "application_type" not in json.loads(request.content)
1086+
1087+
10311088
class TestAuthFlow:
10321089
"""Test the auth flow in httpx."""
10331090

tests/interaction/auth/test_flow.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ async def test_the_dcr_request_carries_the_client_metadata() -> None:
205205
"scope": "mcp",
206206
"client_name": "interaction-suite",
207207
"software_id": "interaction-test-suite",
208+
"application_type": "native",
208209
}
209210
)
210211

tests/shared/test_auth.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,24 @@ def test_invalid_non_empty_url_still_rejected():
138138
}
139139
with pytest.raises(ValidationError):
140140
OAuthClientMetadata.model_validate(data)
141+
142+
143+
def test_application_type_defaults_to_none():
144+
"""SEP-837 application_type is optional; the client infers it when unset."""
145+
metadata = OAuthClientMetadata.model_validate({"redirect_uris": ["http://localhost:3030/callback"]})
146+
assert metadata.application_type is None
147+
148+
149+
@pytest.mark.parametrize("application_type", ["native", "web"])
150+
def test_application_type_accepts_valid_values(application_type: str):
151+
metadata = OAuthClientMetadata.model_validate(
152+
{"redirect_uris": ["http://localhost:3030/callback"], "application_type": application_type}
153+
)
154+
assert metadata.application_type == application_type
155+
156+
157+
def test_application_type_rejects_invalid_value():
158+
with pytest.raises(ValidationError):
159+
OAuthClientMetadata.model_validate(
160+
{"redirect_uris": ["http://localhost:3030/callback"], "application_type": "desktop"}
161+
)

0 commit comments

Comments
 (0)