diff --git a/docs/client/oauth-clients.md b/docs/client/oauth-clients.md index 545784393d..2a34abf88a 100644 --- a/docs/client/oauth-clients.md +++ b/docs/client/oauth-clients.md @@ -25,6 +25,8 @@ Nothing else in the file mentions OAuth. `main()` never sees a token. `OAuthClientMetadata` is the real [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591) registration document, as a Pydantic model. +You can pass URL strings or Pydantic URL subclasses in `redirect_uris`. The SDK stores them as `AnyUrl` values without changing their serialized URI. + You set three fields. The defaults fill in the rest: `grant_types` is already `["authorization_code", "refresh_token"]` and `response_types` is already `["code"]`, which is exactly the flow this provider runs. !!! check diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index 881379d381..b2d44dbafb 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -1,6 +1,6 @@ -from typing import Any, Literal, cast +from typing import Annotated, Any, Literal, cast -from pydantic import AnyHttpUrl, AnyUrl, BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import AfterValidator, AnyHttpUrl, AnyUrl, BaseModel, ConfigDict, Field, field_validator, model_validator # RFC 7523 JWT bearer grant; SEP-990 leg 2 uses this to present the ID-JAG. JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer" @@ -115,7 +115,8 @@ class OAuthClientMetadata(OAuthClientMetadataBase): job. See https://datatracker.ietf.org/doc/html/rfc7591#section-2 """ - redirect_uris: list[AnyUrl] | None = Field(..., min_length=1) + # Pydantic retains URL subclasses, whose equality differs from AnyUrl. + redirect_uris: list[Annotated[AnyUrl, AfterValidator(AnyUrl)]] | None = Field(..., min_length=1) # supported auth methods for the token endpoint token_endpoint_auth_method: TokenEndpointAuthMethod | None = None # supported grant_types of this implementation @@ -143,7 +144,7 @@ class OAuthClientInformationFull(OAuthClientMetadataBase): them against a client's requested `redirect_uri`. """ - redirect_uris: list[AnyUrl] | None = None + redirect_uris: list[Annotated[AnyUrl, AfterValidator(AnyUrl)]] | None = None # RFC 7591 ยง3.2.1: the server may assign an auth method other than the one requested, # including methods this SDK does not implement, or omit it. token_endpoint_auth_method: str | None = None diff --git a/tests/shared/test_auth.py b/tests/shared/test_auth.py index 5286b93834..810f7614b5 100644 --- a/tests/shared/test_auth.py +++ b/tests/shared/test_auth.py @@ -1,7 +1,7 @@ """Tests for OAuth 2.0 shared code.""" import pytest -from pydantic import AnyUrl, ValidationError +from pydantic import AnyHttpUrl, AnyUrl, ValidationError, create_model from mcp.shared.auth import InvalidRedirectUriError, OAuthClientInformationFull, OAuthClientMetadata, OAuthMetadata @@ -218,6 +218,51 @@ def test_client_with_no_registered_redirect_uris_cannot_resolve_a_redirect( info.validate_redirect_uri(redirect_uri) +@pytest.mark.parametrize("model", [OAuthClientMetadata, OAuthClientInformationFull]) +@pytest.mark.parametrize("container", [list, tuple]) +def test_redirect_uri_subtypes_are_stored_as_any_url( + model: type[OAuthClientMetadata] | type[OAuthClientInformationFull], + container: type[list[AnyHttpUrl]] | type[tuple[AnyHttpUrl, ...]], +) -> None: + """SDK-defined registration models compare a URL subclass with the incoming base URL.""" + url = "https://example.com/callback" + data: dict[str, object] = {"redirect_uris": container([AnyHttpUrl(url)])} + if model is OAuthClientInformationFull: + data["client_id"] = "abc123" + info = model.model_validate(data, strict=container is list) + + assert info.redirect_uris == [AnyUrl(url)] + assert info.model_dump(mode="json")["redirect_uris"] == [url] + if isinstance(info, OAuthClientInformationFull): + assert info.validate_redirect_uri(AnyUrl(url)) == AnyUrl(url) + with pytest.raises(InvalidRedirectUriError): + info.validate_redirect_uri(AnyUrl("https://example.com/other")) + + +@pytest.mark.parametrize("model", [OAuthClientMetadata, OAuthClientInformationFull]) +def test_redirect_uri_normalization_preserves_strict_list_validation( + model: type[OAuthClientMetadata] | type[OAuthClientInformationFull], +) -> None: + """SDK-defined strict parsing still rejects non-list redirect URI containers.""" + data: dict[str, object] = {"redirect_uris": (AnyHttpUrl("https://example.com/callback"),)} + if model is OAuthClientInformationFull: + data["client_id"] = "abc123" + + with pytest.raises(ValidationError): + model.model_validate(data, strict=True) + + +def test_redeclared_redirect_uri_field_keeps_its_own_validation() -> None: + """SDK-defined URL normalization does not override a subclass's replacement field.""" + + CustomClientInformation = create_model( + "CustomClientInformation", __base__=OAuthClientInformationFull, redirect_uris=(list[str] | None, None) + ) + info = CustomClientInformation.model_validate({"client_id": "abc123", "redirect_uris": ["custom redirect"]}) + + assert info.redirect_uris == ["custom redirect"] + + def test_request_metadata_restricts_application_type_to_the_values_the_sdk_sends(): """What the SDK sends stays narrow even though what it accepts back is wide.""" with pytest.raises(ValidationError):