From f998dc7b17ab50cd02741a58e626d5a464237afc Mon Sep 17 00:00:00 2001 From: Jianke LIN Date: Tue, 16 Jun 2026 22:52:54 +0200 Subject: [PATCH 1/2] fix(auth): keep root PRM resource URL canonical --- src/mcp/shared/auth.py | 18 +++++++++++++++++- tests/server/auth/test_protected_resource.py | 12 +++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) 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..5f490d9195 100644 --- a/tests/server/auth/test_protected_resource.py +++ b/tests/server/auth/test_protected_resource.py @@ -8,6 +8,7 @@ from pydantic import AnyHttpUrl from starlette.applications import Starlette +from mcp.shared.auth import ProtectedResourceMetadata from mcp.server.auth.routes import build_resource_metadata_url, create_protected_resource_routes @@ -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 From c29caf8348f9303a3f5f66f476e1d1f2bcc5e679 Mon Sep 17 00:00:00 2001 From: Jianke LIN Date: Tue, 16 Jun 2026 23:53:33 +0200 Subject: [PATCH 2/2] test: order protected resource imports --- tests/server/auth/test_protected_resource.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/server/auth/test_protected_resource.py b/tests/server/auth/test_protected_resource.py index 5f490d9195..aca1209577 100644 --- a/tests/server/auth/test_protected_resource.py +++ b/tests/server/auth/test_protected_resource.py @@ -8,8 +8,8 @@ from pydantic import AnyHttpUrl from starlette.applications import Starlette -from mcp.shared.auth import ProtectedResourceMetadata from mcp.server.auth.routes import build_resource_metadata_url, create_protected_resource_routes +from mcp.shared.auth import ProtectedResourceMetadata @pytest.fixture