Skip to content

Commit 73d1dfa

Browse files
fix(auth): preserve root resource URI path
1 parent a4f4ccd commit 73d1dfa

2 files changed

Lines changed: 47 additions & 3 deletions

File tree

src/mcp/shared/auth.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from typing import Any, Literal, cast
2+
from urllib.parse import urlsplit, urlunsplit
23

34
from pydantic import AnyHttpUrl, AnyUrl, BaseModel, ConfigDict, Field, field_validator, model_validator
45

@@ -256,3 +257,19 @@ class ProtectedResourceMetadata(BaseModel):
256257
dpop_signing_alg_values_supported: list[str] | None = None
257258
# dpop_bound_access_tokens_required default is False, but omitted here for clarity
258259
dpop_bound_access_tokens_required: bool | None = None
260+
261+
@field_validator("resource", mode="before")
262+
@classmethod
263+
def _preserve_empty_resource_path(cls, value: object) -> object:
264+
"""Keep the RFC 9728 root resource URI free of a synthetic slash.
265+
266+
``AnyHttpUrl`` normalizes ``https://example.com`` to
267+
``https://example.com/`` before the model's ``url_preserve_empty_path``
268+
setting can preserve the distinction. This is especially visible when
269+
the value arrives as an already-validated ``AnyHttpUrl`` from the
270+
server settings.
271+
"""
272+
parsed = urlsplit(str(value))
273+
if parsed.path == "/":
274+
return urlunsplit(parsed._replace(path=""))
275+
return value

tests/shared/test_auth.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
"""Tests for OAuth 2.0 shared code."""
22

33
import pytest
4-
from pydantic import AnyUrl, ValidationError
5-
6-
from mcp.shared.auth import InvalidRedirectUriError, OAuthClientInformationFull, OAuthClientMetadata, OAuthMetadata
4+
from pydantic import AnyHttpUrl, AnyUrl, ValidationError
5+
6+
from mcp.shared.auth import (
7+
InvalidRedirectUriError,
8+
OAuthClientInformationFull,
9+
OAuthClientMetadata,
10+
OAuthMetadata,
11+
ProtectedResourceMetadata,
12+
)
713

814

915
def test_oauth():
@@ -109,6 +115,27 @@ def test_valid_url_passes_through_unchanged():
109115
assert str(metadata.client_uri) == "https://udemy.com/"
110116

111117

118+
def test_protected_resource_metadata_preserves_empty_root_path():
119+
metadata = ProtectedResourceMetadata.model_validate(
120+
{
121+
"resource": "https://example.com",
122+
"authorization_servers": ["https://auth.example.com"],
123+
}
124+
)
125+
126+
assert str(metadata.resource) == "https://example.com"
127+
assert '"resource":"https://example.com"' in metadata.model_dump_json()
128+
129+
130+
def test_protected_resource_metadata_strips_normalized_root_path():
131+
metadata = ProtectedResourceMetadata(
132+
resource=AnyHttpUrl("https://example.com"),
133+
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
134+
)
135+
136+
assert str(metadata.resource) == "https://example.com"
137+
138+
112139
def test_information_full_inherits_coercion():
113140
"""OAuthClientInformationFull shares the metadata base, so the same
114141
coercion applies to DCR responses parsed via the full model."""

0 commit comments

Comments
 (0)