Skip to content

Commit 928fb98

Browse files
committed
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.
1 parent fda4c54 commit 928fb98

3 files changed

Lines changed: 25 additions & 3 deletions

File tree

docs/migration.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1220,6 +1220,16 @@ Tasks are expected to return as a separate MCP extension in a future release.
12201220

12211221
## Bug Fixes
12221222

1223+
### OAuth metadata URLs no longer gain a trailing slash
1224+
1225+
`OAuthMetadata`, `ProtectedResourceMetadata`, and `OAuthClientMetadata` now set
1226+
`url_preserve_empty_path=True` (Pydantic 2.12+). A path-less URL parsed from the wire keeps its
1227+
empty path instead of acquiring a trailing slash, so e.g. an `issuer` of `https://as.example.com`
1228+
round-trips as `https://as.example.com` rather than `https://as.example.com/`. This matters for
1229+
RFC 9207 / RFC 8414 issuer comparisons, which require simple string comparison (RFC 3986 §6.2.1).
1230+
URLs constructed in Python from an already-built `AnyHttpUrl` object are unaffected (they were
1231+
normalized at construction); only values parsed from strings/JSON change.
1232+
12231233
### Lowlevel `Server`: `subscribe` capability now correctly reported
12241234

12251235
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.

src/mcp/shared/auth.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from typing import Any, Literal
22

3-
from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, field_validator
3+
from pydantic import AnyHttpUrl, AnyUrl, BaseModel, ConfigDict, Field, field_validator
44

55

66
class OAuthToken(BaseModel):
@@ -37,6 +37,10 @@ class OAuthClientMetadata(BaseModel):
3737
See https://datatracker.ietf.org/doc/html/rfc7591#section-2
3838
"""
3939

40+
# Preserve empty URL paths so identifiers are compared as transmitted (RFC 3986 6.2.1)
41+
# instead of acquiring a trailing slash; defaults in Pydantic v3.
42+
model_config = ConfigDict(url_preserve_empty_path=True)
43+
4044
redirect_uris: list[AnyUrl] | None = Field(..., min_length=1)
4145
# supported auth methods for the token endpoint
4246
token_endpoint_auth_method: (
@@ -123,6 +127,10 @@ class OAuthMetadata(BaseModel):
123127
See https://datatracker.ietf.org/doc/html/rfc8414#section-2
124128
"""
125129

130+
# Preserve empty URL paths so the issuer is compared as transmitted (RFC 3986 6.2.1)
131+
# instead of acquiring a trailing slash; defaults in Pydantic v3.
132+
model_config = ConfigDict(url_preserve_empty_path=True)
133+
126134
issuer: AnyHttpUrl
127135
authorization_endpoint: AnyHttpUrl
128136
token_endpoint: AnyHttpUrl
@@ -152,6 +160,10 @@ class ProtectedResourceMetadata(BaseModel):
152160
See https://datatracker.ietf.org/doc/html/rfc9728#section-2
153161
"""
154162

163+
# Preserve empty URL paths so the resource and authorization servers are compared as
164+
# transmitted (RFC 3986 6.2.1) instead of acquiring a trailing slash; defaults in Pydantic v3.
165+
model_config = ConfigDict(url_preserve_empty_path=True)
166+
155167
resource: AnyHttpUrl
156168
authorization_servers: list[AnyHttpUrl] = Field(..., min_length=1)
157169
jwks_uri: AnyHttpUrl | None = None

tests/client/test_auth.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -517,10 +517,10 @@ async def test_handle_metadata_response_success(self, oauth_provider: OAuthClien
517517
}"""
518518
response = httpx.Response(200, content=content)
519519

520-
# Should set metadata
520+
# Should set metadata; the empty path is preserved (no trailing slash added)
521521
await oauth_provider._handle_oauth_metadata_response(response)
522522
assert oauth_provider.context.oauth_metadata is not None
523-
assert str(oauth_provider.context.oauth_metadata.issuer) == "https://auth.example.com/"
523+
assert str(oauth_provider.context.oauth_metadata.issuer) == "https://auth.example.com"
524524

525525
@pytest.mark.anyio
526526
async def test_prioritize_www_auth_scope_over_prm(

0 commit comments

Comments
 (0)