From aa66c24e2efc2e626aa9eecbb39f9245cc101bf8 Mon Sep 17 00:00:00 2001 From: kaXianc2-gom Date: Sun, 21 Jun 2026 21:49:22 +0800 Subject: [PATCH] fix: coerce redirect_uris AnyUrl subtypes to plain AnyUrl for correct equality pydantic v2 strict-type equality means AnyUrl(x) != AnyHttpUrl(x) even when the URLs are identical. This breaks validate_redirect_uri membership checks when callers pass AnyHttpUrl instances in redirect_uris. Add a field_validator to OAuthClientMetadata.redirect_uris that converts each element to AnyUrl(str(...)), stripping the subtype while preserving the URL value. Closes #2687 Co-Authored-By: Claude --- src/mcp/shared/auth.py | 15 ++++++++++ tests/shared/test_auth.py | 62 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index 3b48152d5b..acbb625578 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -67,6 +67,21 @@ class OAuthClientMetadata(BaseModel): software_id: str | None = None software_version: str | None = None + @field_validator("redirect_uris") + @classmethod + def _coerce_redirect_uris_to_any_url(cls, v: list[AnyUrl] | None) -> list[AnyUrl] | None: + """Coerce AnyUrl subtypes to plain AnyUrl for correct equality comparison. + + pydantic v2 uses strict-type equality: ``AnyUrl(x) != AnyHttpUrl(x)`` + even when the URLs are identical. This breaks ``validate_redirect_uri`` + membership checks when callers pass AnyHttpUrl instances. + Converting each element to ``AnyUrl(str(...))`` strips the subtype + while preserving the URL value. + """ + if v is None: + return v + return [AnyUrl(str(url)) for url in v] + @field_validator( "client_uri", "logo_uri", diff --git a/tests/shared/test_auth.py b/tests/shared/test_auth.py index 7463bc5a8a..61e355fc28 100644 --- a/tests/shared/test_auth.py +++ b/tests/shared/test_auth.py @@ -1,9 +1,14 @@ """Tests for OAuth 2.0 shared code.""" import pytest -from pydantic import ValidationError +from pydantic import AnyUrl, ValidationError -from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthMetadata +from mcp.shared.auth import ( + InvalidRedirectUriError, + OAuthClientInformationFull, + OAuthClientMetadata, + OAuthMetadata, +) def test_oauth(): @@ -138,3 +143,56 @@ def test_invalid_non_empty_url_still_rejected(): } with pytest.raises(ValidationError): OAuthClientMetadata.model_validate(data) + + +# ─── redirect_uris AnyUrl subtype coercion (#2687) ──────────────────────────── + + +def test_redirect_uris_coerces_any_http_url_to_any_url(): + """When a caller passes AnyHttpUrl instances in redirect_uris, they + should be coerced to plain AnyUrl so that equality checks work.""" + from pydantic import AnyHttpUrl + + data = { + "redirect_uris": [AnyHttpUrl("https://example.com/callback")], + } + metadata = OAuthClientMetadata.model_validate(data) + for url in metadata.redirect_uris: + assert type(url) is AnyUrl, f"Expected AnyUrl, got {type(url)}" + + +def test_redirect_uris_validate_matches_coerced_url(): + """validate_redirect_uri should succeed when the incoming redirect_uri + matches a coerced AnyUrl in redirect_uris.""" + from pydantic import AnyHttpUrl + + metadata = OAuthClientMetadata( + redirect_uris=[AnyHttpUrl("https://example.com/callback")], + ) + # This must not raise InvalidRedirectUriError + result = metadata.validate_redirect_uri(AnyUrl("https://example.com/callback")) + assert str(result) == "https://example.com/callback" + + +def test_redirect_uris_validate_rejects_mismatched_url(): + """validate_redirect_uri should still reject genuinely different URLs.""" + from pydantic import AnyHttpUrl + + metadata = OAuthClientMetadata( + redirect_uris=[AnyHttpUrl("https://example.com/callback")], + ) + with pytest.raises(InvalidRedirectUriError): + metadata.validate_redirect_uri(AnyUrl("https://evil.example/callback")) + + +def test_redirect_uris_none_untouched(): + """None redirect_uris should not be affected by coercion.""" + metadata = OAuthClientMetadata(redirect_uris=None) + assert metadata.redirect_uris is None + + +def test_redirect_uris_string_list_still_works(): + """Passing raw strings should still work (and coerce to AnyUrl).""" + metadata = OAuthClientMetadata(redirect_uris=["https://example.com/callback"]) + assert len(metadata.redirect_uris) == 1 + assert type(metadata.redirect_uris[0]) is AnyUrl