Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/client/oauth-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions src/mcp/shared/auth.py
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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)
Comment on lines +118 to +119

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) MCP client authors who built OAuthClientMetadata with AnyHttpUrl elements and compare them in their callback handler now get False where the base branch gave True. src/mcp/shared/auth.py:119 rewrites each element to a plain AnyUrl at construction. Pydantic URL equality checks the exact class, so client_metadata.redirect_uris[0] == AnyHttpUrl(url) or AnyHttpUrl(url) in client_metadata.redirect_uris fails after the upgrade. Fix: compare both sides after normalising to AnyUrl or to str, and call out in the docs that stored elements no longer equal the subclass instance the caller passed in; the :191 finding only covers the server-side validate_redirect_uri input and does not fix client-side comparisons. [also at: src/mcp/shared/auth.py:147 - Authorization-server providers that compare params.redirect_uri against their own AnyHttpUrl values now get False where the base branch gave True. src/mcp/shared/auth.py:147 coerces every registered element to plain AnyUrl, and src/mcp/shared/auth.py:195 returns self.redirect_uris[0] when the client omits redirect_uri, so the AuthorizationParams.redirect_uri handed to authorize() changes class.]

Why this was flagged

A client application constructs OAuthClientMetadata(redirect_uris=[AnyHttpUrl("http://localhost:3000/callback")]). On the base branch the model stores the AnyHttpUrl instance unchanged (list[AnyUrl] with pydantic's isinstance pass-through). After src/mcp/shared/auth.py:119 the AfterValidator(AnyUrl) replaces it with a plain AnyUrl. In the application's redirect callback handler a check such as if received_url == self.client_metadata.redirect_uris[0] or if AnyHttpUrl(received) in self.client_metadata.redirect_uris returns False because pydantic's _BaseUrl.__eq__ requires identical classes. The application rejects its own callback or falls into an error path. docs/client/oauth-clients.md:28 states elements are stored as AnyUrl but does not warn that equality with the passed subclass instance is lost. Remedy: document the equality change explicitly, or avoid rewriting the element class and instead normalise on the comparison side.

Verification: Base declared redirect_uris: list[AnyUrl] | None, so an AnyHttpUrl element was retained as AnyHttpUrl. After the change, src/mcp/shared/auth.py:119 and :147 wrap each element in AfterValidator(AnyUrl), producing a plain AnyUrl. Pydantic implements _BaseUrl.__eq__ as class-exact. Hence AnyHttpUrl(u) in m.redirect_uris is True on base and False on HEAD.

Comment on lines +118 to +119

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): AGENTS.md says v2's public API is a compatibility contract and any change to an existing API's observable behaviour is an explicit maintainer design decision that should generally be avoided. src/mcp/shared/auth.py changes the declared type of redirect_uris on the public OAuthClientMetadata and OAuthClientInformationFull models (visible in model_fields/pyright) and changes runtime behaviour: an AnyHttpUrl (or other AnyUrl subclass) element is now coerced to plain AnyUrl instead of being retained. Fix: confirm this is the intended, maintainer-sanctioned 2.x behaviour change (it fixes #2687) and record it as such; otherwise keep the field type and normalize only inside validate_redirect_uri when comparing. …

Why this was flagged

…Same instruction at 2 sites (src/mcp/shared/auth.py:119, src/mcp/shared/auth.py:147).

Nothing fails at runtime; this is a compatibility-contract note. Guarded against: unannounced changes to a released 2.x public API. Here, any caller that stored AnyHttpUrl instances in redirect_uris and later relied on isinstance(uri, AnyHttpUrl), on type(uri), or on the field's static type will see plain AnyUrl instead after this change; the serialized URI string is unchanged, and the PR author (a maintainer) states the change is intentional and links Fixes #2687 for the mismatch in validate_redirect_uri. Consequence is small and arguably a bug fix, but the instruction asks that such a behaviour change be an explicit maintainer decision rather than incidental.

Verification: AGENTS.md (base commit) "Branching Model": "v2 is released; its public API is a compatibility contract for the 2.x line. Removals, renames, or any change to an existing API's signature or observable behaviour ... is a design decision a maintainer makes explicitly, and should generally be avoided."

# supported auth methods for the token endpoint
token_endpoint_auth_method: TokenEndpointAuthMethod | None = None
# supported grant_types of this implementation
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Normalize the URI passed to redirect validation

When a caller constructs this model with an AnyHttpUrl and later passes that same AnyHttpUrl to validate_redirect_uri(), this validator stores the registered value as an exact AnyUrl while the method compares the argument unchanged. Because Pydantic URL equality requires matching concrete classes, the identical URI is now rejected; before this change both values remained AnyHttpUrl and validation succeeded. Normalize the supplied URI before the membership check, or compare canonical URI values.

AGENTS.md reference: AGENTS.md:L14-L18

Useful? React with 👍 / 👎.

# 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
Expand Down
47 changes: 46 additions & 1 deletion tests/shared/test_auth.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)])}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): the new tests type the validation payload as dict[str, object], which the repo's test-quality bar forbids when a real type exists. The values are a list/tuple of AnyHttpUrl plus a str client_id, so a concrete value type is available. Fix: give data its real value type (e.g. dict[str, Sequence[AnyHttpUrl] | str]) or build the dict inline per model so no widened annotation is needed, which covers the 2 sites listed. Same instruction at 2 sites (tests/shared/test_auth.py:229, tests/shared/test_auth.py:247).

Why this was flagged

AGENTS.md:68 directs test code to conform to .claude/skills/test-quality/SKILL.md, whose Hygiene section says "In tests, narrow types with assert isinstance; never Any/object when a real type exists." tests/shared/test_auth.py:229 and tests/shared/test_auth.py:247 declare data: dict[str, object] and then insert a container of AnyHttpUrl and a str client_id, all of which have real static types. Nothing fails at runtime; the base branch has no such annotation because these tests are new. The instruction guards against widened annotations hiding type mistakes in tests that pyright strict would otherwise catch.

Verification: nit. SKILL.md:101-102 reads "never Any/object when a real type exists." The diff adds two annotations that use object where a concrete type is available: tests/shared/test_auth.py:229 and tests/shared/test_auth.py:247. A real type such as dict[str, Sequence[AnyHttpUrl] | str] exists. Nothing fails at runtime: model_validate accepts Any.

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):
Expand Down
Loading