Skip to content

OAuthClientInformationFull.redirect_uris: pydantic strict-type-equality breaks AnyUrl(x) != AnyHttpUrl(x) round-trip #2687

Description

@ptrhrsch-arch

Summary
When implementing a custom OAuth provider against the MCP Python SDK, callers must construct OAuthClientInformationFull instances. The SDK declares redirect_uris: list[AnyUrl] (where AnyUrl is pydantic's base URL type). Passing pydantic's stricter subtype AnyHttpUrl (or any other AnyUrl subtype) causes silent equality failures downstream: AnyUrl("https://...") == AnyHttpUrl("https://...") returns False in pydantic v2, even when the two URLs serialize identically. This breaks redirect_uri matching during the /authorize → /token exchange.
Reproducer

from pydantic import AnyUrl, AnyHttpUrl
from mcp.server.auth.provider import OAuthClientInformationFull

# pydantic v2 strict-type equality
u1 = AnyUrl("https://example.com/callback")
u2 = AnyHttpUrl("https://example.com/callback")
assert str(u1) == str(u2)   # True (both render the same)
assert u1 == u2              # FAILS in pydantic v2 — different runtime types

# Concrete impact in OAuth flow:
client_info = OAuthClientInformationFull(
    client_id="test",
    redirect_uris=[AnyHttpUrl("https://example.com/cb")],
    # ...other required fields
)
# When the /authorize request arrives with redirect_uri parameter, the SDK
# constructs an AnyUrl from the query string and checks membership:
incoming = AnyUrl("https://example.com/cb")
assert incoming in client_info.redirect_uris   # FAILS — type mismatch

Expected behavior
OAuthClientInformationFull.redirect_uris should accept and compare-equal across AnyUrl and AnyUrl subtypes (AnyHttpUrl, AnyHttpsUrl, etc.) when the underlying URL is identical.
Actual behavior
Strict-type equality causes the membership check to fail. The OAuth flow returns a generic redirect-mismatch error to the client; the underlying cause (type vs URL mismatch) is invisible without instrumenting the SDK.
Suggested fix
Two options:
Coerce on assignment. Have OAuthClientInformationFull.redirect_uris field validator coerce all values to AnyUrl (the declared base type), regardless of what the caller passes. This is the cleanest fix and matches the field declaration.
Compare-by-string. Override __eq__ on the AnyUrl chain to compare-by-str() rather than by runtime type. Broader-impact change; probably not desirable.
Option 1 is preferred. A short field_validator with mode="before" converting to AnyUrl strings before pydantic instantiates would do it.
Workaround (current PolyBot mitigation)
Pass redirect_uris as raw list[str]; pydantic coerces to AnyUrl per the field declaration. This avoids the type mismatch:

client_info = OAuthClientInformationFull(
    client_id="test",
    redirect_uris=["https://example.com/cb"],   # raw strings, not AnyHttpUrl
    # ...
)

Works at runtime; loses some IDE type hints in the caller code.
Environment
mcp Python SDK version: 1.27.1
pydantic version: 2.x
Python: 3.11+
Related code locations
In the MCP SDK:
mcp/server/auth/provider.py — OAuthClientInformationFull definition with redirect_uris: list[AnyUrl]
mcp/server/auth/handlers/authorize.py — where the membership check happens
Severity
Medium — silently breaks OAuth flows in custom-provider setups; reproducer is simple; workaround is trivial once known but the failure mode is hard to diagnose from the user-facing error.

Activity

  1. serejaris commented on May 27, 2026

    @serejaris

    I checked this against current main and can reproduce the model-boundary failure. The URL values serialize the same, but Pydantic keeps different URL classes and the current membership check rejects the incoming redirect URI:

    str(AnyHttpUrl("https://example.com/cb")) == str(AnyUrl("https://example.com/cb"))  # True
    AnyHttpUrl("https://example.com/cb") == AnyUrl("https://example.com/cb")            # False
    OAuthClientInformationFull(... redirect_uris=[AnyHttpUrl(...)])
      .validate_redirect_uri(AnyUrl(...))
    # InvalidRedirectUriError: Redirect URI ... not registered for client
    

    The narrow fix I would propose is model-boundary normalization for OAuthClientMetadata.redirect_uris / OAuthClientInformationFull.redirect_uris, so callers can pass strings, AnyUrl, or AnyHttpUrl, but stored values are revalidated as the declared AnyUrl type. That keeps validate_redirect_uri() boring and keeps JSON serialization unchanged.

    Regression target would be tests/shared/test_auth.py:

    • construct OAuthClientInformationFull(redirect_uris=[AnyHttpUrl("https://example.com/cb")]);
    • validate incoming AnyUrl("https://example.com/cb");
    • assert model_dump(mode="json") still returns ["https://example.com/cb"].

    I also checked adjacent PRs #2630/#2638/#1934; they look related to redirect URI validation, but not this exact Pydantic URL-subtype equality path.

    I can prepare a small focused PR for this if maintainers agree this should be handled in the model validator.

  2. added
    triageQueued for automated analysis — bot will process and remove this label
    on May 31, 2026
  3. mcp-claude commented on Jun 1, 2026

    @mcp-claude

    reproduces on main (616476f) and v1.x (6213787). root cause: OAuthClientMetadata.redirect_uris: list[AnyUrl] | None (src/mcp/shared/auth.py:40) accepts AnyHttpUrl instances without re-coercing to the declared base type, and pydantic v2 AnyUrl.__eq__ is type-strict, so the redirect_uri not in self.redirect_uris check at src/mcp/shared/auth.py:101 fails when stored values and incoming value are different AnyUrl subclasses with identical string form. workaround: pass redirect_uris as list[str].

    the fix is a field_validator("redirect_uris", mode="before") on OAuthClientMetadata that runs each list entry through str(...), forcing pydantic to re-instantiate it as the declared AnyUrl base. regression coverage in tests/shared/test_auth.py constructs with AnyHttpUrl(...), asserts validate_redirect_uri(AnyUrl(...)) succeeds, asserts an unrelated AnyUrl is still rejected, and asserts the JSON dump round-trips to the original string.

    repro

    repro.py:

    """Reproduce issue #2687: AnyHttpUrl in redirect_uris breaks membership check."""
    
    from pydantic import AnyHttpUrl, AnyUrl
    
    from mcp.shared.auth import InvalidRedirectUriError, OAuthClientInformationFull
    
    
    def main() -> None:
        u_any = AnyUrl("https://example.com/cb")
        u_http = AnyHttpUrl("https://example.com/cb")
    
        print(f"str(u_any)  = {str(u_any)!r}")
        print(f"str(u_http) = {str(u_http)!r}")
        print(f"str equal:  {str(u_any) == str(u_http)}")
        print(f"obj equal:  {u_any == u_http}")
        print(f"types: AnyUrl={type(u_any).__name__}, AnyHttpUrl={type(u_http).__name__}")
        print()
    
        client = OAuthClientInformationFull(
            client_id="test-client",
            redirect_uris=[AnyHttpUrl("https://example.com/cb")],
        )
        print(f"Stored redirect_uris: {client.redirect_uris}")
        print(f"Stored types: {[type(u).__name__ for u in client.redirect_uris]}")
        print()
    
        incoming = AnyUrl("https://example.com/cb")
        print(f"Incoming: {incoming!r} (type={type(incoming).__name__})")
        print(f"membership 'in': {incoming in client.redirect_uris}")
    
        try:
            result = client.validate_redirect_uri(incoming)
            print(f"validate_redirect_uri OK: {result}")
        except InvalidRedirectUriError as e:
            print(f"validate_redirect_uri FAILED: {e.message}")
    
    
    if __name__ == "__main__":
        main()

    uv run python repro.py:

    str(u_any)  = 'https://example.com/cb'
    str(u_http) = 'https://example.com/cb'
    str equal:  True
    obj equal:  False
    types: AnyUrl=AnyUrl, AnyHttpUrl=AnyHttpUrl
    
    Stored redirect_uris: [AnyHttpUrl('https://example.com/cb')]
    Stored types: ['AnyHttpUrl']
    
    Incoming: AnyUrl('https://example.com/cb') (type=AnyUrl)
    membership 'in': False
    validate_redirect_uri FAILED: Redirect URI 'https://example.com/cb' not registered for client
    

    same output on origin/v1.x (6213787).

    code path
    • src/mcp/shared/auth.py:40 — redirect_uris: list[AnyUrl] | None; pydantic v2 keeps AnyHttpUrl instances as-is (subclass passes the AnyUrl check without re-coercion).
    • src/mcp/shared/auth.py:101 — if self.redirect_uris is None or redirect_uri not in self.redirect_uris: — list __contains__ calls AnyUrl.__eq__, which is type-strict in pydantic v2.
    • result: identical-string URLs with different runtime classes compare non-equal → InvalidRedirectUriError.
    suggested fix
    // src/mcp/shared/auth.py
    +    @field_validator("redirect_uris", mode="before")
    +    @classmethod
    +    def _coerce_redirect_uris_to_anyurl(cls, v: object) -> object:
    +        # pydantic v2 keeps AnyUrl subclass instances (e.g. AnyHttpUrl) as-is
    +        # when the declared type is AnyUrl, and AnyUrl.__eq__ is type-strict.
    +        # Re-stringify each entry so pydantic instantiates the declared base
    +        # AnyUrl, making membership checks in validate_redirect_uri work.
    +        if isinstance(v, list):
    +            return [str(item) if isinstance(item, AnyUrl) else item for item in v]
    +        return v

    test to verify: constructing with AnyHttpUrl(...) lets validate_redirect_uri(AnyUrl(...)) succeed while an unrelated URL is still rejected, and model_dump(mode="json") round-trips the original string. full suite (1744 tests) passes.

  4. added
    bugSomething isn't working
    ready for workEnough information for someone to start working on
    authIssues and PRs related to Authentication / OAuth
    P2Moderate issues affecting some users, edge cases, potentially valuable feature
    fix proposedBot has a verified fix diff in the comment
    and removed
    triageQueued for automated analysis — bot will process and remove this label
    on Jun 1, 2026
  5. syf2211 commented on Jun 26, 2026

    @syf2211
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Moderate issues affecting some users, edge cases, potentially valuable featureauthIssues and PRs related to Authentication / OAuthbugSomething isn't workingfix proposedBot has a verified fix diff in the commentready for workEnough information for someone to start working on

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions