From 73d1dfa06dc19b00e0501ddf175c08cd8ffc0b10 Mon Sep 17 00:00:00 2001 From: DDDonut <265319989+daleselaji-dev@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:17:15 +0800 Subject: [PATCH] fix(auth): preserve root resource URI path --- src/mcp/shared/auth.py | 17 +++++++++++++++++ tests/shared/test_auth.py | 33 ++++++++++++++++++++++++++++++--- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index 881379d381..29766627fd 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -1,4 +1,5 @@ from typing import Any, Literal, cast +from urllib.parse import urlsplit, urlunsplit from pydantic import AnyHttpUrl, AnyUrl, BaseModel, ConfigDict, Field, field_validator, model_validator @@ -256,3 +257,19 @@ 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_validator("resource", mode="before") + @classmethod + def _preserve_empty_resource_path(cls, value: object) -> object: + """Keep the RFC 9728 root resource URI free of a synthetic slash. + + ``AnyHttpUrl`` normalizes ``https://example.com`` to + ``https://example.com/`` before the model's ``url_preserve_empty_path`` + setting can preserve the distinction. This is especially visible when + the value arrives as an already-validated ``AnyHttpUrl`` from the + server settings. + """ + parsed = urlsplit(str(value)) + if parsed.path == "/": + return urlunsplit(parsed._replace(path="")) + return value diff --git a/tests/shared/test_auth.py b/tests/shared/test_auth.py index 5286b93834..c7782ab14f 100644 --- a/tests/shared/test_auth.py +++ b/tests/shared/test_auth.py @@ -1,9 +1,15 @@ """Tests for OAuth 2.0 shared code.""" import pytest -from pydantic import AnyUrl, ValidationError - -from mcp.shared.auth import InvalidRedirectUriError, OAuthClientInformationFull, OAuthClientMetadata, OAuthMetadata +from pydantic import AnyHttpUrl, AnyUrl, ValidationError + +from mcp.shared.auth import ( + InvalidRedirectUriError, + OAuthClientInformationFull, + OAuthClientMetadata, + OAuthMetadata, + ProtectedResourceMetadata, +) def test_oauth(): @@ -109,6 +115,27 @@ def test_valid_url_passes_through_unchanged(): assert str(metadata.client_uri) == "https://udemy.com/" +def test_protected_resource_metadata_preserves_empty_root_path(): + metadata = ProtectedResourceMetadata.model_validate( + { + "resource": "https://example.com", + "authorization_servers": ["https://auth.example.com"], + } + ) + + assert str(metadata.resource) == "https://example.com" + assert '"resource":"https://example.com"' in metadata.model_dump_json() + + +def test_protected_resource_metadata_strips_normalized_root_path(): + metadata = ProtectedResourceMetadata( + resource=AnyHttpUrl("https://example.com"), + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + ) + + assert str(metadata.resource) == "https://example.com" + + def test_information_full_inherits_coercion(): """OAuthClientInformationFull shares the metadata base, so the same coercion applies to DCR responses parsed via the full model."""