Repository navigation
OAuthClientInformationFull.redirect_uris: pydantic strict-type-equality breaks AnyUrl(x) != AnyHttpUrl(x) round-trip #2687
Description
Activity
I checked this against current
mainand 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 clientThe narrow fix I would propose is model-boundary normalization for
OAuthClientMetadata.redirect_uris/OAuthClientInformationFull.redirect_uris, so callers can pass strings,AnyUrl, orAnyHttpUrl, but stored values are revalidated as the declaredAnyUrltype. That keepsvalidate_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.
- construct
- addedtriageQueued for automated analysis — bot will process and remove this labelQueued for automated analysis — bot will process and remove this label
on May 31, 2026 reproduces on main (616476f) and v1.x (6213787). root cause:
OAuthClientMetadata.redirect_uris: list[AnyUrl] | None(src/mcp/shared/auth.py:40) acceptsAnyHttpUrlinstances without re-coercing to the declared base type, and pydantic v2AnyUrl.__eq__is type-strict, so theredirect_uri not in self.redirect_urischeck at src/mcp/shared/auth.py:101 fails when stored values and incoming value are differentAnyUrlsubclasses with identical string form. workaround: passredirect_urisaslist[str].the fix is a
field_validator("redirect_uris", mode="before")onOAuthClientMetadatathat runs each list entry throughstr(...), forcing pydantic to re-instantiate it as the declaredAnyUrlbase. regression coverage intests/shared/test_auth.pyconstructs withAnyHttpUrl(...), assertsvalidate_redirect_uri(AnyUrl(...))succeeds, asserts an unrelatedAnyUrlis 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 clientsame output on
origin/v1.x(6213787).code path
- src/mcp/shared/auth.py:40 —
redirect_uris: list[AnyUrl] | None; pydantic v2 keepsAnyHttpUrlinstances as-is (subclass passes theAnyUrlcheck 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__callsAnyUrl.__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(...)letsvalidate_redirect_uri(AnyUrl(...))succeed while an unrelated URL is still rejected, andmodel_dump(mode="json")round-trips the original string. full suite (1744 tests) passes.- src/mcp/shared/auth.py:40 —
- addedbugSomething isn't workingSomething isn't workingready for workEnough information for someone to start working onEnough information for someone to start working onauthIssues and PRs related to Authentication / OAuthIssues and PRs related to Authentication / OAuthP2Moderate issues affecting some users, edge cases, potentially valuable featureModerate issues affecting some users, edge cases, potentially valuable featurefix proposedBot has a verified fix diff in the commentBot has a verified fix diff in the commentand removedtriageQueued for automated analysis — bot will process and remove this labelQueued for automated analysis — bot will process and remove this label
on Jun 1, 2026
Summary
When implementing a custom OAuth provider against the MCP Python SDK, callers must construct
OAuthClientInformationFullinstances. The SDK declaresredirect_uris: list[AnyUrl](whereAnyUrlis pydantic's base URL type). Passing pydantic's stricter subtypeAnyHttpUrl(or any other AnyUrl subtype) causes silent equality failures downstream:AnyUrl("https://...") == AnyHttpUrl("https://...")returnsFalsein pydantic v2, even when the two URLs serialize identically. This breaksredirect_urimatching during the/authorize→/tokenexchange.Reproducer
Expected behavior
OAuthClientInformationFull.redirect_urisshould 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_urisfield validator coerce all values toAnyUrl(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 toAnyUrlstrings before pydantic instantiates would do it.Workaround (current PolyBot mitigation)
Pass
redirect_urisas rawlist[str]; pydantic coerces toAnyUrlper the field declaration. This avoids the type mismatch: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—OAuthClientInformationFulldefinition withredirect_uris: list[AnyUrl]mcp/server/auth/handlers/authorize.py— where the membership check happensSeverity
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.