Skip to content

Commit 00f75ac

Browse files
committed
[v1.x] fix: canonicalize root protected resource URIs
1 parent 73d458b commit 00f75ac

File tree

2 files changed

+26
-3
lines changed

2 files changed

+26
-3
lines changed

src/mcp/shared/auth.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from typing import Any, Literal
2+
from urllib.parse import urlparse
23

3-
from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, field_validator
4+
from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, field_serializer, field_validator
45

56

67
class OAuthToken(BaseModel):
@@ -123,6 +124,20 @@ class OAuthClientInformationFull(OAuthClientMetadata):
123124
client_secret_expires_at: int | None = None
124125

125126

127+
def _serialize_canonical_server_uri(url: AnyHttpUrl) -> str:
128+
"""Serialize root server URIs without the implicit trailing slash.
129+
130+
RFC-defined canonical server URIs omit the synthetic "/" path that
131+
``AnyHttpUrl`` adds for host-only URLs. Preserve non-root paths exactly.
132+
"""
133+
134+
serialized = str(url)
135+
parsed = urlparse(serialized)
136+
if parsed.path == "/" and not parsed.params and not parsed.query and not parsed.fragment:
137+
return serialized[:-1]
138+
return serialized
139+
140+
126141
class OAuthMetadata(BaseModel):
127142
"""
128143
RFC 8414 OAuth 2.0 Authorization Server Metadata.
@@ -175,3 +190,11 @@ class ProtectedResourceMetadata(BaseModel):
175190
dpop_signing_alg_values_supported: list[str] | None = None
176191
# dpop_bound_access_tokens_required default is False, but ommited here for clarity
177192
dpop_bound_access_tokens_required: bool | None = None
193+
194+
@field_serializer("resource", when_used="json")
195+
def _serialize_resource(self, resource: AnyHttpUrl) -> str:
196+
return _serialize_canonical_server_uri(resource)
197+
198+
@field_serializer("authorization_servers", when_used="json")
199+
def _serialize_authorization_servers(self, authorization_servers: list[AnyHttpUrl]) -> list[str]:
200+
return [_serialize_canonical_server_uri(url) for url in authorization_servers]

tests/server/auth/test_protected_resource.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,8 @@ async def test_metadata_endpoint_without_path(root_resource_client: httpx.AsyncC
9696
assert response.status_code == 200
9797
assert response.json() == snapshot(
9898
{
99-
"resource": "https://example.com/",
100-
"authorization_servers": ["https://auth.example.com/"],
99+
"resource": "https://example.com",
100+
"authorization_servers": ["https://auth.example.com"],
101101
"scopes_supported": ["read"],
102102
"resource_name": "Root Resource",
103103
"bearer_methods_supported": ["header"],

0 commit comments

Comments
 (0)