Skip to content

Commit f998dc7

Browse files
committed
fix(auth): keep root PRM resource URL canonical
1 parent 1e21814 commit f998dc7

2 files changed

Lines changed: 28 additions & 2 deletions

File tree

src/mcp/shared/auth.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from typing import Any, Literal
2+
from urllib.parse import urlsplit, urlunsplit
23

3-
from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, field_validator
4+
from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, field_serializer, field_validator
45

56

67
class OAuthToken(BaseModel):
@@ -168,3 +169,18 @@ class ProtectedResourceMetadata(BaseModel):
168169
dpop_signing_alg_values_supported: list[str] | None = None
169170
# dpop_bound_access_tokens_required default is False, but omitted here for clarity
170171
dpop_bound_access_tokens_required: bool | None = None
172+
173+
@field_serializer("resource")
174+
def _serialize_resource(self, value: AnyHttpUrl) -> str:
175+
"""Preserve canonical root resources without a trailing slash.
176+
177+
Pydantic normalizes `https://example.com` to `https://example.com/`.
178+
RFC 9728 resource metadata is compared as a canonical resource URL, so
179+
when the resource path is the origin root we serialize it back without
180+
that synthetic slash.
181+
"""
182+
url = str(value)
183+
parsed = urlsplit(url)
184+
if parsed.path != "/":
185+
return url
186+
return urlunsplit((parsed.scheme, parsed.netloc, "", parsed.query, parsed.fragment))

tests/server/auth/test_protected_resource.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from pydantic import AnyHttpUrl
99
from starlette.applications import Starlette
1010

11+
from mcp.shared.auth import ProtectedResourceMetadata
1112
from mcp.server.auth.routes import build_resource_metadata_url, create_protected_resource_routes
1213

1314

@@ -96,7 +97,7 @@ async def test_metadata_endpoint_without_path(root_resource_client: httpx.AsyncC
9697
assert response.status_code == 200
9798
assert response.json() == snapshot(
9899
{
99-
"resource": "https://example.com/",
100+
"resource": "https://example.com",
100101
"authorization_servers": ["https://auth.example.com/"],
101102
"scopes_supported": ["read"],
102103
"resource_name": "Root Resource",
@@ -105,6 +106,15 @@ async def test_metadata_endpoint_without_path(root_resource_client: httpx.AsyncC
105106
)
106107

107108

109+
def test_root_resource_serializes_without_trailing_slash():
110+
metadata = ProtectedResourceMetadata(
111+
resource=AnyHttpUrl("https://example.com"),
112+
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
113+
)
114+
115+
assert metadata.model_dump(mode="json")["resource"] == "https://example.com"
116+
117+
108118
# Tests for URL construction utility function
109119

110120

0 commit comments

Comments
 (0)