diff --git a/docs/migration.md b/docs/migration.md index 675c5b747a..386bc9551c 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1220,6 +1220,16 @@ Tasks are expected to return as a separate MCP extension in a future release. ## Bug Fixes +### OAuth metadata URLs no longer gain a trailing slash + +`OAuthMetadata`, `ProtectedResourceMetadata`, and `OAuthClientMetadata` now set +`url_preserve_empty_path=True` (Pydantic 2.12+). A path-less URL parsed from the wire keeps its +empty path instead of acquiring a trailing slash, so e.g. an `issuer` of `https://as.example.com` +round-trips as `https://as.example.com` rather than `https://as.example.com/`. This matters for +RFC 9207 / RFC 8414 issuer comparisons, which require simple string comparison (RFC 3986 ยง6.2.1). +URLs constructed in Python from an already-built `AnyHttpUrl` object are unaffected (they were +normalized at construction); only values parsed from strings/JSON change. + ### Lowlevel `Server`: `subscribe` capability now correctly reported Previously, the lowlevel `Server` hardcoded `subscribe=False` in resource capabilities even when a `subscribe_resource()` handler was registered. The `subscribe` capability is now dynamically set to `True` when an `on_subscribe_resource` handler is provided. Clients that previously didn't see `subscribe: true` in capabilities will now see it when a handler is registered, which may change client behavior. diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index 3b48152d5b..02273954ea 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -1,6 +1,6 @@ from typing import Any, Literal -from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, field_validator +from pydantic import AnyHttpUrl, AnyUrl, BaseModel, ConfigDict, Field, field_validator class OAuthToken(BaseModel): @@ -37,6 +37,8 @@ class OAuthClientMetadata(BaseModel): See https://datatracker.ietf.org/doc/html/rfc7591#section-2 """ + model_config = ConfigDict(url_preserve_empty_path=True) + redirect_uris: list[AnyUrl] | None = Field(..., min_length=1) # supported auth methods for the token endpoint token_endpoint_auth_method: ( @@ -123,6 +125,8 @@ class OAuthMetadata(BaseModel): See https://datatracker.ietf.org/doc/html/rfc8414#section-2 """ + model_config = ConfigDict(url_preserve_empty_path=True) + issuer: AnyHttpUrl authorization_endpoint: AnyHttpUrl token_endpoint: AnyHttpUrl @@ -152,6 +156,8 @@ class ProtectedResourceMetadata(BaseModel): See https://datatracker.ietf.org/doc/html/rfc9728#section-2 """ + model_config = ConfigDict(url_preserve_empty_path=True) + resource: AnyHttpUrl authorization_servers: list[AnyHttpUrl] = Field(..., min_length=1) jwks_uri: AnyHttpUrl | None = None diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index ca7a495e6c..a51830bf72 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -517,10 +517,10 @@ async def test_handle_metadata_response_success(self, oauth_provider: OAuthClien }""" response = httpx.Response(200, content=content) - # Should set metadata + # Should set metadata; the empty path is preserved (no trailing slash added) await oauth_provider._handle_oauth_metadata_response(response) assert oauth_provider.context.oauth_metadata is not None - assert str(oauth_provider.context.oauth_metadata.issuer) == "https://auth.example.com/" + assert str(oauth_provider.context.oauth_metadata.issuer) == "https://auth.example.com" @pytest.mark.anyio async def test_prioritize_www_auth_scope_over_prm(