Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/mcp/shared/auth.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
33 changes: 30 additions & 3 deletions tests/shared/test_auth.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down Expand Up @@ -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."""
Expand Down
Loading