Skip to content
Open
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
25 changes: 24 additions & 1 deletion src/mcp/shared/auth.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from typing import Any, Literal
from urllib.parse import urlparse

from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, field_validator
from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, field_serializer, field_validator


class OAuthToken(BaseModel):
Expand Down Expand Up @@ -123,6 +124,20 @@ class OAuthClientInformationFull(OAuthClientMetadata):
client_secret_expires_at: int | None = None


def _serialize_canonical_server_uri(url: AnyHttpUrl) -> str:
"""Serialize root server URIs without the implicit trailing slash.

RFC-defined canonical server URIs omit the synthetic "/" path that
``AnyHttpUrl`` adds for host-only URLs. Preserve non-root paths exactly.
"""

serialized = str(url)
parsed = urlparse(serialized)
if parsed.path == "/" and not parsed.params and not parsed.query and not parsed.fragment:
return serialized[:-1]
return serialized


class OAuthMetadata(BaseModel):
"""
RFC 8414 OAuth 2.0 Authorization Server Metadata.
Expand Down Expand Up @@ -175,3 +190,11 @@ class ProtectedResourceMetadata(BaseModel):
dpop_signing_alg_values_supported: list[str] | None = None
# dpop_bound_access_tokens_required default is False, but ommited here for clarity
dpop_bound_access_tokens_required: bool | None = None

@field_serializer("resource", when_used="json")
def _serialize_resource(self, resource: AnyHttpUrl) -> str:
return _serialize_canonical_server_uri(resource)

@field_serializer("authorization_servers", when_used="json")
def _serialize_authorization_servers(self, authorization_servers: list[AnyHttpUrl]) -> list[str]:
return [_serialize_canonical_server_uri(url) for url in authorization_servers]
4 changes: 2 additions & 2 deletions tests/server/auth/test_protected_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ 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/",
"authorization_servers": ["https://auth.example.com/"],
"resource": "https://example.com",
"authorization_servers": ["https://auth.example.com"],
"scopes_supported": ["read"],
"resource_name": "Root Resource",
"bearer_methods_supported": ["header"],
Expand Down
Loading