Skip to content

Commit 90299d3

Browse files
author
Jianke LIN
committed
fix(auth): keep root PRM resource URL canonical
1 parent 4472428 commit 90299d3

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, ConfigDict, Field, field_validator
4+
from pydantic import AnyHttpUrl, AnyUrl, BaseModel, ConfigDict, Field, field_serializer, field_validator
45

56

67
class OAuthToken(BaseModel):
@@ -193,3 +194,18 @@ class ProtectedResourceMetadata(BaseModel):
193194
dpop_signing_alg_values_supported: list[str] | None = None
194195
# dpop_bound_access_tokens_required default is False, but omitted here for clarity
195196
dpop_bound_access_tokens_required: bool | None = None
197+
198+
@field_serializer("resource")
199+
def _serialize_resource(self, value: AnyHttpUrl) -> str:
200+
"""Preserve canonical root resources without a trailing slash.
201+
202+
Pydantic normalizes `https://example.com` to `https://example.com/`.
203+
RFC 9728 resource metadata is compared as a canonical resource URL, so
204+
when the resource path is the origin root we serialize it back without
205+
that synthetic slash.
206+
"""
207+
url = str(value)
208+
parsed = urlsplit(url)
209+
if parsed.path != "/":
210+
return url
211+
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)