Skip to content

Commit b7a5bff

Browse files
authored
Preserve empty URL paths on OAuth metadata models (#2925)
1 parent 5a3412d commit b7a5bff

3 files changed

Lines changed: 19 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: 7 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,8 @@ class OAuthClientMetadata(BaseModel):
3737
See https://datatracker.ietf.org/doc/html/rfc7591#section-2
3838
"""
3939

40+
model_config = ConfigDict(url_preserve_empty_path=True)
41+
4042
redirect_uris: list[AnyUrl] | None = Field(..., min_length=1)
4143
# supported auth methods for the token endpoint
4244
token_endpoint_auth_method: (
@@ -123,6 +125,8 @@ class OAuthMetadata(BaseModel):
123125
See https://datatracker.ietf.org/doc/html/rfc8414#section-2
124126
"""
125127

128+
model_config = ConfigDict(url_preserve_empty_path=True)
129+
126130
issuer: AnyHttpUrl
127131
authorization_endpoint: AnyHttpUrl
128132
token_endpoint: AnyHttpUrl
@@ -152,6 +156,8 @@ class ProtectedResourceMetadata(BaseModel):
152156
See https://datatracker.ietf.org/doc/html/rfc9728#section-2
153157
"""
154158

159+
model_config = ConfigDict(url_preserve_empty_path=True)
160+
155161
resource: AnyHttpUrl
156162
authorization_servers: list[AnyHttpUrl] = Field(..., min_length=1)
157163
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)