Skip to content
Merged
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
10 changes: 10 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion src/mcp/shared/auth.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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: (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions tests/client/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading