Skip to content

Commit 92fae82

Browse files
committed
Preserve empty issuer/resource paths on AuthSettings
AuthSettings.issuer_url and resource_server_url are typed AnyHttpUrl, which normalized a path-less URL with a trailing slash before the model's config could apply. The authorization server therefore advertised issuer as https://as.example.com/ instead of https://as.example.com, inconsistent with the exact string comparison RFC 8414/9207 require. Apply url_preserve_empty_path=True to AuthSettings (matching #2925 for the metadata models) so a string issuer_url/resource_server_url keeps its canonical form end to end.
1 parent e9cd169 commit 92fae82

3 files changed

Lines changed: 41 additions & 2 deletions

File tree

docs/migration.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1373,6 +1373,14 @@ match redirect URIs by exact string comparison, so if you registered such a URI
13731373
release (with the trailing slash) and the registration is persisted in `TokenStorage`, re-register
13741374
the client so the stored value matches what the SDK now transmits.
13751375

1376+
`AuthSettings` now sets `url_preserve_empty_path=True` for the same reason: a path-less
1377+
`issuer_url` (or `resource_server_url`) passed as a string keeps its empty path, so the authorization
1378+
server advertises `issuer` as `https://as.example.com` rather than `https://as.example.com/` in its
1379+
metadata. Previously the trailing slash was added before the model saw the value, leaving the served
1380+
issuer inconsistent with what clients compare against under RFC 8414 / RFC 9207. Passing an
1381+
already-built `AnyHttpUrl` object still normalizes at construction; pass a string to get the
1382+
preserved form.
1383+
13761384
### Lowlevel `Server`: `subscribe` capability now correctly reported
13771385

13781386
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/server/auth/settings.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from pydantic import AnyHttpUrl, BaseModel, Field
1+
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field
22

33

44
class ClientRegistrationOptions(BaseModel):
@@ -13,6 +13,12 @@ class RevocationOptions(BaseModel):
1313

1414

1515
class AuthSettings(BaseModel):
16+
# Preserve empty URL paths so a path-less issuer/resource passed as a string keeps its
17+
# canonical form (no trailing slash). RFC 8414/9207 issuer comparison is exact string
18+
# comparison, so a spurious trailing slash would break it. See PR #2925 for the metadata
19+
# models; this applies the same to the server's own configured URLs.
20+
model_config = ConfigDict(url_preserve_empty_path=True)
21+
1622
issuer_url: AnyHttpUrl = Field(
1723
...,
1824
description="OAuth authorization server URL that issues tokens for this resource server.",

tests/server/auth/test_routes.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import pytest
22
from pydantic import AnyHttpUrl
33

4-
from mcp.server.auth.routes import validate_issuer_url
4+
from mcp.server.auth.routes import build_metadata, validate_issuer_url
5+
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions
56

67

78
def test_validate_issuer_url_https_allowed():
@@ -45,3 +46,27 @@ def test_validate_issuer_url_fragment_rejected():
4546
def test_validate_issuer_url_query_rejected():
4647
with pytest.raises(ValueError, match="query"):
4748
validate_issuer_url(AnyHttpUrl("https://example.com/path?q=1"))
49+
50+
51+
def test_auth_settings_preserves_path_less_issuer():
52+
"""A path-less issuer passed as a string keeps its canonical form (no trailing slash)."""
53+
settings = AuthSettings(
54+
issuer_url="https://as.example.com", # type: ignore[arg-type]
55+
resource_server_url="https://rs.example.com", # type: ignore[arg-type]
56+
)
57+
assert str(settings.issuer_url) == "https://as.example.com"
58+
assert str(settings.resource_server_url) == "https://rs.example.com"
59+
60+
61+
def test_build_metadata_serves_issuer_without_trailing_slash():
62+
"""The served issuer matches the configured one exactly (RFC 8414/9207 string comparison)."""
63+
settings = AuthSettings(
64+
issuer_url="https://as.example.com", # type: ignore[arg-type]
65+
resource_server_url="https://rs.example.com", # type: ignore[arg-type]
66+
)
67+
metadata = build_metadata(settings.issuer_url, None, ClientRegistrationOptions(), RevocationOptions())
68+
69+
served = metadata.model_dump(mode="json", exclude_none=True)
70+
assert served["issuer"] == "https://as.example.com"
71+
assert served["authorization_endpoint"] == "https://as.example.com/authorize"
72+
assert served["token_endpoint"] == "https://as.example.com/token"

0 commit comments

Comments
 (0)