diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index 3b48152d5b..474f125a92 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -1,6 +1,7 @@ from typing import Any, Literal +from urllib.parse import urlsplit, urlunsplit -from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, field_validator +from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, field_serializer, field_validator class OAuthToken(BaseModel): @@ -168,3 +169,18 @@ class ProtectedResourceMetadata(BaseModel): dpop_signing_alg_values_supported: list[str] | None = None # dpop_bound_access_tokens_required default is False, but omitted here for clarity dpop_bound_access_tokens_required: bool | None = None + + @field_serializer("resource") + def _serialize_resource(self, value: AnyHttpUrl) -> str: + """Preserve canonical root resources without a trailing slash. + + Pydantic normalizes `https://example.com` to `https://example.com/`. + RFC 9728 resource metadata is compared as a canonical resource URL, so + when the resource path is the origin root we serialize it back without + that synthetic slash. + """ + url = str(value) + parsed = urlsplit(url) + if parsed.path != "/": + return url + return urlunsplit((parsed.scheme, parsed.netloc, "", parsed.query, parsed.fragment)) diff --git a/tests/server/auth/test_protected_resource.py b/tests/server/auth/test_protected_resource.py index 413a80276e..aca1209577 100644 --- a/tests/server/auth/test_protected_resource.py +++ b/tests/server/auth/test_protected_resource.py @@ -9,6 +9,7 @@ from starlette.applications import Starlette from mcp.server.auth.routes import build_resource_metadata_url, create_protected_resource_routes +from mcp.shared.auth import ProtectedResourceMetadata @pytest.fixture @@ -96,7 +97,7 @@ async def test_metadata_endpoint_without_path(root_resource_client: httpx.AsyncC assert response.status_code == 200 assert response.json() == snapshot( { - "resource": "https://example.com/", + "resource": "https://example.com", "authorization_servers": ["https://auth.example.com/"], "scopes_supported": ["read"], "resource_name": "Root Resource", @@ -105,6 +106,15 @@ async def test_metadata_endpoint_without_path(root_resource_client: httpx.AsyncC ) +def test_root_resource_serializes_without_trailing_slash(): + metadata = ProtectedResourceMetadata( + resource=AnyHttpUrl("https://example.com"), + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + ) + + assert metadata.model_dump(mode="json")["resource"] == "https://example.com" + + # Tests for URL construction utility function