Skip to content

Commit 96c7a43

Browse files
committed
fix: strip trailing slashes from OAuth metadata URL fields
Pydantic's AnyHttpUrl automatically appends a trailing slash to bare hostnames (e.g., http://localhost:8000 becomes http://localhost:8000/). This causes OAuth discovery to fail in clients that validate per RFC 8414 §3.3 and RFC 9728 §3, which require the returned issuer/resource URL to be identical to the URL used for discovery. Add field_serializer to OAuthMetadata.issuer, ProtectedResourceMetadata.resource, and ProtectedResourceMetadata.authorization_servers to strip the trailing slash during JSON serialization. Fixes #1919 Fixes #1265 Reported-by: joar Github-Issue: #1919
1 parent 5301298 commit 96c7a43

File tree

4 files changed

+28
-10
lines changed

4 files changed

+28
-10
lines changed

src/mcp/shared/auth.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from typing import Any, Literal
22

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

55

66
class OAuthToken(BaseModel):
@@ -129,6 +129,12 @@ class OAuthMetadata(BaseModel):
129129
code_challenge_methods_supported: list[str] | None = None
130130
client_id_metadata_document_supported: bool | None = None
131131

132+
@field_serializer("issuer")
133+
@classmethod
134+
def _serialize_issuer(cls, v: AnyHttpUrl) -> str:
135+
"""Strip trailing slash added by AnyHttpUrl for RFC 8414 §3.3 compliance."""
136+
return str(v).rstrip("/")
137+
132138

133139
class ProtectedResourceMetadata(BaseModel):
134140
"""RFC 9728 OAuth 2.0 Protected Resource Metadata.
@@ -151,3 +157,15 @@ class ProtectedResourceMetadata(BaseModel):
151157
dpop_signing_alg_values_supported: list[str] | None = None
152158
# dpop_bound_access_tokens_required default is False, but ommited here for clarity
153159
dpop_bound_access_tokens_required: bool | None = None
160+
161+
@field_serializer("resource")
162+
@classmethod
163+
def _serialize_resource(cls, v: AnyHttpUrl) -> str:
164+
"""Strip trailing slash added by AnyHttpUrl for RFC 9728 §3 compliance."""
165+
return str(v).rstrip("/")
166+
167+
@field_serializer("authorization_servers")
168+
@classmethod
169+
def _serialize_authorization_servers(cls, v: list[AnyHttpUrl]) -> list[str]:
170+
"""Strip trailing slashes added by AnyHttpUrl for RFC 9728 §3 compliance."""
171+
return [str(s).rstrip("/") for s in v]

tests/client/test_auth.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1283,29 +1283,27 @@ async def mock_callback() -> tuple[str, str | None]:
12831283
@pytest.mark.parametrize(
12841284
(
12851285
"issuer_url",
1286+
"expected_issuer",
12861287
"service_documentation_url",
12871288
"authorization_endpoint",
12881289
"token_endpoint",
12891290
"registration_endpoint",
12901291
"revocation_endpoint",
12911292
),
12921293
(
1293-
# Pydantic's AnyUrl incorrectly adds trailing slash to base URLs
1294-
# This is being fixed in https://github.com/pydantic/pydantic-core/pull/1719 (Pydantic 2.12+)
12951294
pytest.param(
1295+
"https://auth.example.com",
12961296
"https://auth.example.com",
12971297
"https://auth.example.com/docs",
12981298
"https://auth.example.com/authorize",
12991299
"https://auth.example.com/token",
13001300
"https://auth.example.com/register",
13011301
"https://auth.example.com/revoke",
13021302
id="simple-url",
1303-
marks=pytest.mark.xfail(
1304-
reason="Pydantic AnyUrl adds trailing slash to base URLs - fixed in Pydantic 2.12+"
1305-
),
13061303
),
13071304
pytest.param(
13081305
"https://auth.example.com/",
1306+
"https://auth.example.com",
13091307
"https://auth.example.com/docs",
13101308
"https://auth.example.com/authorize",
13111309
"https://auth.example.com/token",
@@ -1314,6 +1312,7 @@ async def mock_callback() -> tuple[str, str | None]:
13141312
id="with-trailing-slash",
13151313
),
13161314
pytest.param(
1315+
"https://auth.example.com/v1/mcp",
13171316
"https://auth.example.com/v1/mcp",
13181317
"https://auth.example.com/v1/mcp/docs",
13191318
"https://auth.example.com/v1/mcp/authorize",
@@ -1326,6 +1325,7 @@ async def mock_callback() -> tuple[str, str | None]:
13261325
)
13271326
def test_build_metadata(
13281327
issuer_url: str,
1328+
expected_issuer: str,
13291329
service_documentation_url: str,
13301330
authorization_endpoint: str,
13311331
token_endpoint: str,
@@ -1341,7 +1341,7 @@ def test_build_metadata(
13411341

13421342
assert metadata.model_dump(exclude_defaults=True, mode="json") == snapshot(
13431343
{
1344-
"issuer": Is(issuer_url),
1344+
"issuer": Is(expected_issuer),
13451345
"authorization_endpoint": Is(authorization_endpoint),
13461346
"token_endpoint": Is(token_endpoint),
13471347
"registration_endpoint": Is(registration_endpoint),

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"],

tests/server/fastmcp/auth/test_auth_integration.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ async def test_metadata_endpoint(self, test_client: httpx.AsyncClient):
311311
assert response.status_code == 200
312312

313313
metadata = response.json()
314-
assert metadata["issuer"] == "https://auth.example.com/"
314+
assert metadata["issuer"] == "https://auth.example.com"
315315
assert metadata["authorization_endpoint"] == "https://auth.example.com/authorize"
316316
assert metadata["token_endpoint"] == "https://auth.example.com/token"
317317
assert metadata["registration_endpoint"] == "https://auth.example.com/register"

0 commit comments

Comments
 (0)