From f43f0b68f1b790148135a0643ec1f077441afe8b Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Sun, 7 Jun 2026 12:18:50 +0800 Subject: [PATCH 1/2] fix(auth): normalize redirect URI URL subclasses --- src/mcp/shared/auth.py | 10 ++++++++++ tests/shared/test_auth.py | 17 +++++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index 3b48152d5b..92d866c189 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -67,6 +67,16 @@ class OAuthClientMetadata(BaseModel): software_id: str | None = None software_version: str | None = None + @field_validator("redirect_uris", mode="before") + @classmethod + def _coerce_redirect_uris_to_any_url(cls, v: object) -> object: + # Pydantic v2 keeps AnyUrl subclasses such as AnyHttpUrl as-is, while + # AnyUrl equality is type-strict. Store the declared base type so later + # redirect_uri membership checks compare URLs, not URL wrapper classes. + if isinstance(v, list | tuple): + return [str(item) if isinstance(item, AnyUrl) else item for item in v] + return v + @field_validator( "client_uri", "logo_uri", diff --git a/tests/shared/test_auth.py b/tests/shared/test_auth.py index 7463bc5a8a..cd05add890 100644 --- a/tests/shared/test_auth.py +++ b/tests/shared/test_auth.py @@ -1,9 +1,9 @@ """Tests for OAuth 2.0 shared code.""" import pytest -from pydantic import ValidationError +from pydantic import AnyHttpUrl, AnyUrl, ValidationError -from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthMetadata +from mcp.shared.auth import InvalidRedirectUriError, OAuthClientInformationFull, OAuthClientMetadata, OAuthMetadata def test_oauth(): @@ -130,6 +130,19 @@ def test_information_full_inherits_coercion(): assert info.jwks_uri is None +def test_redirect_uris_normalize_any_url_subtypes(): + info = OAuthClientInformationFull( + client_id="abc123", + redirect_uris=[AnyHttpUrl("https://example.com/callback")], + ) + + assert info.validate_redirect_uri(AnyUrl("https://example.com/callback")) == AnyUrl("https://example.com/callback") + assert info.model_dump(mode="json")["redirect_uris"] == ["https://example.com/callback"] + + with pytest.raises(InvalidRedirectUriError): + info.validate_redirect_uri(AnyUrl("https://example.com/other")) + + def test_invalid_non_empty_url_still_rejected(): """Coercion must only touch empty strings — garbage URLs still raise.""" data = { From 4d2fac6f18fc2b9f854876b8b78487084972d38e Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Sun, 7 Jun 2026 17:35:22 +0800 Subject: [PATCH 2/2] fix: type redirect URI normalization --- src/mcp/shared/auth.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index 92d866c189..d2bcc32643 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -1,4 +1,4 @@ -from typing import Any, Literal +from typing import Any, Literal, cast from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, field_validator @@ -74,7 +74,8 @@ def _coerce_redirect_uris_to_any_url(cls, v: object) -> object: # AnyUrl equality is type-strict. Store the declared base type so later # redirect_uri membership checks compare URLs, not URL wrapper classes. if isinstance(v, list | tuple): - return [str(item) if isinstance(item, AnyUrl) else item for item in v] + items = cast("list[object] | tuple[object, ...]", v) + return [str(item) if isinstance(item, AnyUrl) else item for item in items] return v @field_validator(