Skip to content

Commit 77d36d8

Browse files
committed
fix(auth): strip trailing slashes from OAuth metadata URLs
Pydantic AnyHttpUrl adds a trailing slash to bare hostnames, which breaks RFC 8414/9728 exact issuer and resource comparison during OAuth discovery. Pass canonical URL strings into metadata models so served wire JSON omits the synthetic root slash. Fixes #1919 and #1265.
1 parent e942d00 commit 77d36d8

3 files changed

Lines changed: 20 additions & 6 deletions

File tree

src/mcp/server/auth/routes.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from collections.abc import Awaitable, Callable
2-
from typing import Any
2+
from typing import Any, cast
33
from urllib.parse import urlparse
44

55
from pydantic import AnyHttpUrl
@@ -169,7 +169,7 @@ def build_metadata(
169169

170170
# Create metadata
171171
metadata = OAuthMetadata(
172-
issuer=issuer_url,
172+
issuer=cast(AnyHttpUrl, str(issuer_url).rstrip("/")),
173173
authorization_endpoint=authorization_url,
174174
token_endpoint=token_url,
175175
scopes_supported=client_registration_options.valid_scopes,
@@ -237,8 +237,11 @@ def create_protected_resource_routes(
237237
List of Starlette routes for protected resource metadata
238238
"""
239239
metadata = ProtectedResourceMetadata(
240-
resource=resource_url,
241-
authorization_servers=authorization_servers,
240+
resource=cast(AnyHttpUrl, str(resource_url).rstrip("/")),
241+
authorization_servers=cast(
242+
list[AnyHttpUrl],
243+
[str(server).rstrip("/") for server in authorization_servers],
244+
),
242245
scopes_supported=scopes_supported,
243246
resource_name=resource_name,
244247
resource_documentation=resource_documentation,

tests/server/auth/test_protected_resource.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,8 @@ async def test_metadata_endpoint_without_path(root_resource_client: httpx.AsyncC
9696
assert response.status_code == 200
9797
assert response.json() == snapshot(
9898
{
99-
"resource": "https://example.com/",
100-
"authorization_servers": ["https://auth.example.com/"],
99+
"resource": "https://example.com",
100+
"authorization_servers": ["https://auth.example.com"],
101101
"scopes_supported": ["read"],
102102
"resource_name": "Root Resource",
103103
"bearer_methods_supported": ["header"],

tests/server/auth/test_routes.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,14 @@ def test_build_metadata_serves_issuer_without_trailing_slash():
7070
assert served["issuer"] == "https://as.example.com"
7171
assert served["authorization_endpoint"] == "https://as.example.com/authorize"
7272
assert served["token_endpoint"] == "https://as.example.com/token"
73+
74+
75+
def test_build_metadata_strips_trailing_slash_from_anyhttpurl_issuer():
76+
"""AnyHttpUrl adds a trailing slash to bare hostnames; served metadata must not."""
77+
issuer_url = AnyHttpUrl("http://localhost:8000")
78+
assert str(issuer_url).endswith("/")
79+
80+
metadata = build_metadata(issuer_url, None, ClientRegistrationOptions(), RevocationOptions())
81+
82+
served = metadata.model_dump(mode="json", exclude_none=True)
83+
assert served["issuer"] == "http://localhost:8000"

0 commit comments

Comments
 (0)