From db06b5f7b3d26001c1dd08bf13abf57637177dd5 Mon Sep 17 00:00:00 2001 From: Stefano Amorelli Date: Fri, 5 Jun 2026 14:19:06 +0300 Subject: [PATCH] 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]: https://github.com/modelcontextprotocol/python-sdk/issues/2783 Signed-off-by: Stefano Amorelli --- src/mcp/client/auth/utils.py | 45 +++++++++++++++++++++++ src/mcp/shared/auth.py | 6 +++ tests/client/test_auth.py | 57 +++++++++++++++++++++++++++++ tests/interaction/auth/test_flow.py | 1 + tests/shared/test_auth.py | 21 +++++++++++ 5 files changed, 130 insertions(+) diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py index 780a24e859..28be348403 100644 --- a/src/mcp/client/auth/utils.py +++ b/src/mcp/client/auth/utils.py @@ -1,4 +1,6 @@ +import ipaddress import re +from typing import Literal from urllib.parse import urljoin, urlparse from httpx import Request, Response @@ -215,6 +217,41 @@ def create_oauth_metadata_request(url: str) -> Request: return Request("GET", url, headers={MCP_PROTOCOL_VERSION: LATEST_PROTOCOL_VERSION}) +def _is_loopback_host(host: str) -> bool: + """Return True if host is a loopback address (localhost, 127.0.0.0/8, or ::1).""" + if host == "localhost": + return True + try: + # pydantic keeps IPv6 hosts bracketed (e.g. "[::1]"); ipaddress wants them bare. + return ipaddress.ip_address(host.strip("[]")).is_loopback + except ValueError: + return False + + +def infer_application_type(redirect_uris: list[AnyUrl] | None) -> Literal["native", "web"] | None: + """Infer the OIDC application_type from a client's redirect URIs (SEP-837). + + Loopback redirect URIs (localhost, 127.0.0.0/8, ::1) and private-use URI schemes + identify a native application; an http(s) URL with a non-loopback host identifies + a web application. A mix of both is ambiguous, so the type is left unset for the + authorization server to decide. + """ + if not redirect_uris: + return None + + has_native = False + has_web = False + for uri in redirect_uris: + if uri.scheme in ("http", "https") and not _is_loopback_host(uri.host or ""): + has_web = True + else: + has_native = True + + if has_native and has_web: + return None + return "native" if has_native else "web" + + def create_client_registration_request( auth_server_metadata: OAuthMetadata | None, client_metadata: OAuthClientMetadata, auth_base_url: str ) -> Request: @@ -227,6 +264,14 @@ def create_client_registration_request( registration_data = client_metadata.model_dump(by_alias=True, mode="json", exclude_none=True) + # SEP-837: OIDC servers assume application_type "web" when it is omitted, which can + # reject the loopback redirect URIs native clients use. Send a type inferred from + # the redirect URIs when the caller did not set one explicitly. + if "application_type" not in registration_data: + application_type = infer_application_type(client_metadata.redirect_uris) + if application_type is not None: + registration_data["application_type"] = application_type + return Request("POST", registration_url, json=registration_data, headers={"Content-Type": "application/json"}) diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index 3b48152d5b..43644717f6 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -54,6 +54,12 @@ class OAuthClientMetadata(BaseModel): response_types: list[str] = ["code"] scope: str | None = None + # OpenID Connect application type (OIDC Dynamic Client Registration 1.0 ยง2). + # OIDC servers assume "web" when this is omitted (SEP-837), which can reject the + # loopback redirect URIs native clients rely on. Left None here; the client + # infers it from redirect_uris at registration time when no value is set. + application_type: Literal["native", "web"] | None = None + # these fields are currently unused, but we support & store them for potential # future use client_name: str | None = None diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index bb0bce4c92..fdc3478c70 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -1,6 +1,7 @@ """Tests for refactored OAuth client authentication implementation.""" import base64 +import json import time from unittest import mock from urllib.parse import parse_qs, quote, unquote, urlparse @@ -23,6 +24,7 @@ extract_scope_from_www_auth, get_client_metadata_scopes, handle_registration_response, + infer_application_type, is_valid_client_metadata_url, should_use_client_metadata_url, ) @@ -1028,6 +1030,61 @@ def test_falls_back_when_metadata_has_no_registration_endpoint(self): assert request.method == "POST" +@pytest.mark.parametrize( + ("redirect_uris", "expected"), + [ + (["http://localhost:3030/callback"], "native"), + (["http://127.0.0.1:3030/callback"], "native"), + (["http://[::1]:3030/callback"], "native"), + (["com.example.app:/oauth2redirect"], "native"), + (["https://app.example.com/callback"], "web"), + (["http://localhost:3030/callback", "https://app.example.com/callback"], None), + ], +) +def test_infer_application_type(redirect_uris: list[str], expected: str | None): + """SEP-837: native for loopback or private-use redirect URIs, web for remote hosts.""" + assert infer_application_type([AnyUrl(uri) for uri in redirect_uris]) == expected + + +def test_infer_application_type_without_redirect_uris(): + assert infer_application_type([]) is None + assert infer_application_type(None) is None + + +def test_create_client_registration_request_infers_native_application_type(): + """A loopback redirect URI registers the client as a native application (SEP-837).""" + client_metadata = OAuthClientMetadata(redirect_uris=[AnyUrl("http://localhost:3030/callback")]) + + request = create_client_registration_request(None, client_metadata, "https://auth.example.com") + + assert json.loads(request.content)["application_type"] == "native" + + +def test_create_client_registration_request_preserves_explicit_application_type(): + """An explicit application_type is sent as-is, without inference overriding it.""" + client_metadata = OAuthClientMetadata( + redirect_uris=[AnyUrl("http://localhost:3030/callback")], application_type="web" + ) + + request = create_client_registration_request(None, client_metadata, "https://auth.example.com") + + assert json.loads(request.content)["application_type"] == "web" + + +def test_create_client_registration_request_omits_ambiguous_application_type(): + """Redirect URIs that mix native and web styles leave application_type unset.""" + client_metadata = OAuthClientMetadata( + redirect_uris=[ + AnyUrl("http://localhost:3030/callback"), + AnyUrl("https://app.example.com/callback"), + ] + ) + + request = create_client_registration_request(None, client_metadata, "https://auth.example.com") + + assert "application_type" not in json.loads(request.content) + + class TestAuthFlow: """Test the auth flow in httpx.""" diff --git a/tests/interaction/auth/test_flow.py b/tests/interaction/auth/test_flow.py index 968fc5f980..0e3d06a32b 100644 --- a/tests/interaction/auth/test_flow.py +++ b/tests/interaction/auth/test_flow.py @@ -205,6 +205,7 @@ async def test_the_dcr_request_carries_the_client_metadata() -> None: "scope": "mcp", "client_name": "interaction-suite", "software_id": "interaction-test-suite", + "application_type": "native", } ) diff --git a/tests/shared/test_auth.py b/tests/shared/test_auth.py index 7463bc5a8a..7070a3236d 100644 --- a/tests/shared/test_auth.py +++ b/tests/shared/test_auth.py @@ -138,3 +138,24 @@ def test_invalid_non_empty_url_still_rejected(): } with pytest.raises(ValidationError): OAuthClientMetadata.model_validate(data) + + +def test_application_type_defaults_to_none(): + """SEP-837 application_type is optional; the client infers it when unset.""" + metadata = OAuthClientMetadata.model_validate({"redirect_uris": ["http://localhost:3030/callback"]}) + assert metadata.application_type is None + + +@pytest.mark.parametrize("application_type", ["native", "web"]) +def test_application_type_accepts_valid_values(application_type: str): + metadata = OAuthClientMetadata.model_validate( + {"redirect_uris": ["http://localhost:3030/callback"], "application_type": application_type} + ) + assert metadata.application_type == application_type + + +def test_application_type_rejects_invalid_value(): + with pytest.raises(ValidationError): + OAuthClientMetadata.model_validate( + {"redirect_uris": ["http://localhost:3030/callback"], "application_type": "desktop"} + )