From 928fb9831ca5932f44a9cfa6719409f72e30b6af Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 20 Jun 2026 17:27:21 +0200 Subject: [PATCH 1/2] Preserve empty URL paths on OAuth metadata models Set url_preserve_empty_path=True (Pydantic 2.12+) on OAuthMetadata, ProtectedResourceMetadata, and OAuthClientMetadata so a path-less URL parsed from the wire keeps its empty path instead of acquiring a trailing slash. RFC 9207 / RFC 8414 issuer comparisons require simple string comparison (RFC 3986 6.2.1), which a spurious trailing slash defeats: an issuer of https://as.example.com now round-trips unchanged rather than as https://as.example.com/. Only values parsed from strings/JSON change; URLs built from an already normalized AnyHttpUrl object are unaffected. --- docs/migration.md | 10 ++++++++++ src/mcp/shared/auth.py | 14 +++++++++++++- tests/client/test_auth.py | 4 ++-- 3 files changed, 25 insertions(+), 3 deletions(-) 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..8662b4a689 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,10 @@ class OAuthClientMetadata(BaseModel): See https://datatracker.ietf.org/doc/html/rfc7591#section-2 """ + # Preserve empty URL paths so identifiers are compared as transmitted (RFC 3986 6.2.1) + # instead of acquiring a trailing slash; defaults in Pydantic v3. + 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 +127,10 @@ class OAuthMetadata(BaseModel): See https://datatracker.ietf.org/doc/html/rfc8414#section-2 """ + # Preserve empty URL paths so the issuer is compared as transmitted (RFC 3986 6.2.1) + # instead of acquiring a trailing slash; defaults in Pydantic v3. + model_config = ConfigDict(url_preserve_empty_path=True) + issuer: AnyHttpUrl authorization_endpoint: AnyHttpUrl token_endpoint: AnyHttpUrl @@ -152,6 +160,10 @@ class ProtectedResourceMetadata(BaseModel): See https://datatracker.ietf.org/doc/html/rfc9728#section-2 """ + # Preserve empty URL paths so the resource and authorization servers are compared as + # transmitted (RFC 3986 6.2.1) instead of acquiring a trailing slash; defaults in Pydantic v3. + 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( From bc348b64b6ab61d0fc2d0dbd9aac3ddb01e1e60f Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 20 Jun 2026 17:28:56 +0200 Subject: [PATCH 2/2] Drop explanatory comments on url_preserve_empty_path config --- src/mcp/shared/auth.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index 8662b4a689..02273954ea 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -37,8 +37,6 @@ class OAuthClientMetadata(BaseModel): See https://datatracker.ietf.org/doc/html/rfc7591#section-2 """ - # Preserve empty URL paths so identifiers are compared as transmitted (RFC 3986 6.2.1) - # instead of acquiring a trailing slash; defaults in Pydantic v3. model_config = ConfigDict(url_preserve_empty_path=True) redirect_uris: list[AnyUrl] | None = Field(..., min_length=1) @@ -127,8 +125,6 @@ class OAuthMetadata(BaseModel): See https://datatracker.ietf.org/doc/html/rfc8414#section-2 """ - # Preserve empty URL paths so the issuer is compared as transmitted (RFC 3986 6.2.1) - # instead of acquiring a trailing slash; defaults in Pydantic v3. model_config = ConfigDict(url_preserve_empty_path=True) issuer: AnyHttpUrl @@ -160,8 +156,6 @@ class ProtectedResourceMetadata(BaseModel): See https://datatracker.ietf.org/doc/html/rfc9728#section-2 """ - # Preserve empty URL paths so the resource and authorization servers are compared as - # transmitted (RFC 3986 6.2.1) instead of acquiring a trailing slash; defaults in Pydantic v3. model_config = ConfigDict(url_preserve_empty_path=True) resource: AnyHttpUrl