Skip to content

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

Open

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. scosemicolon commented on May 27, 2026

    @scosemicolon
    Contributor

    The safest fix seems to be canonicalizing the stored redirect URI values at the model boundary, then keeping comparisons boring everywhere else.

    A focused implementation shape:

    • add a validator on OAuthClientInformationFull.redirect_uris that normalizes each item through str(uri) and then back into the declared AnyUrl type
    • keep accepting raw strings, AnyUrl, and AnyHttpUrl inputs
    • add a regression test where redirect_uris=[AnyHttpUrl("https://example.com/cb")] and the authorize/token path checks an incoming AnyUrl("https://example.com/cb")
    • assert both membership and serialized client metadata remain unchanged

    I would avoid changing equality semantics globally. Comparing-by-string inside this one field is much lower blast radius than trying to alter or wrap pydantic URL equality across the SDK.

  2. 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.

  3. added
    triageQueued for automated analysis — bot will process and remove this label
    on May 31, 2026
  4. ghost added
    bugSomething isn't working
    on Jun 1, 2026
  5. ghost added
    ready for workEnough information for someone to start working on
    on Jun 1, 2026
  6. ghost added
    authIssues and PRs related to Authentication / OAuth
    on Jun 1, 2026
  7. ghost added
    P2Moderate issues affecting some users, edge cases, potentially valuable feature
    on Jun 1, 2026
  8. ghost added
    fix proposedBot has a verified fix diff in the comment
    on Jun 1, 2026
  9. ghost removed
    triageQueued for automated analysis — bot will process and remove this label
    on Jun 1, 2026
  10. Ben-Home commented on Jun 12, 2026

    @Ben-Home

    I hit a related MCP OAuth failure in production today that follows this exact pattern — values that serialize identically but fail comparison because of type/subtype mismatch.

    In our case it was parameter names: the OAuth metadata endpoint declared standard redirect_uri, but a custom frontend auth page expected non-standard mcp_callback. Both resolve to the same URL, but they're different keys — same class of bug as the AnyUrl vs AnyHttpUrl problem here.

    The model-boundary normalization approach (field_validator that runs through str()) is the right fix. It's localized, doesn't change pydantic's global equality semantics, and handles the entire family of AnyUrl subtypes.

    One additional consideration: if callers are also constructing redirect_uris from raw strings parsed out of query parameters (which is how the /authorize → /token flow typically works), the validator should handle that case too — str() on a plain string is a no-op, so this should already be covered.

    +1 on the fix approach. Happy to test once a PR is up.

  11. 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