Skip to content

Commit b288e05

Browse files
BabyChrist666claude
andcommitted
fix: strip trailing slash from issuer URL in OAuth metadata serialization
Fixes #1919 RFC 8414 examples show issuer URLs without trailing slashes (e.g., `https://example.com` rather than `https://example.com/`). Some OAuth clients (Google ADK, IBM MCP Context Forge) require exact match between the discovery URL and the returned issuer per RFC 8414 Section 3.3. Pydantic's AnyHttpUrl automatically adds a trailing slash when serializing URLs without a path component. This breaks clients that perform exact URL matching during the OAuth discovery flow. This fix adds field_serializer decorators to: - OAuthMetadata.issuer - ProtectedResourceMetadata.resource - ProtectedResourceMetadata.authorization_servers These serializers strip the trailing slash only during JSON serialization, preserving the internal AnyHttpUrl type while ensuring spec-compliant output. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent dda845a commit b288e05

File tree

3 files changed

+102
-4
lines changed

3 files changed

+102
-4
lines changed

src/mcp/shared/auth.py

Lines changed: 27 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,18 @@ 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+
def serialize_issuer_without_trailing_slash(self, v: AnyHttpUrl) -> str:
134+
"""Strip trailing slash from issuer URL during serialization.
135+
136+
RFC 8414 examples show issuer URLs without trailing slashes, and some
137+
OAuth clients (Google ADK, IBM MCP Context Forge) require exact match
138+
between discovery URL and returned issuer per RFC 8414 Section 3.3.
139+
Pydantic's AnyHttpUrl automatically adds a trailing slash, which breaks
140+
these clients. See: https://github.com/modelcontextprotocol/python-sdk/issues/1919
141+
"""
142+
return str(v).rstrip("/")
143+
132144

133145
class ProtectedResourceMetadata(BaseModel):
134146
"""RFC 9728 OAuth 2.0 Protected Resource Metadata.
@@ -151,3 +163,17 @@ class ProtectedResourceMetadata(BaseModel):
151163
dpop_signing_alg_values_supported: list[str] | None = None
152164
# dpop_bound_access_tokens_required default is False, but ommited here for clarity
153165
dpop_bound_access_tokens_required: bool | None = None
166+
167+
@field_serializer("resource")
168+
def serialize_resource_without_trailing_slash(self, v: AnyHttpUrl) -> str:
169+
"""Strip trailing slash from resource URL during serialization.
170+
171+
Same rationale as OAuthMetadata.issuer - RFC specs show URLs without
172+
trailing slashes, and clients may require exact URL matching.
173+
"""
174+
return str(v).rstrip("/")
175+
176+
@field_serializer("authorization_servers")
177+
def serialize_auth_servers_without_trailing_slash(self, v: list[AnyHttpUrl]) -> list[str]:
178+
"""Strip trailing slashes from authorization server URLs during serialization."""
179+
return [str(url).rstrip("/") for url in v]

tests/server/auth/test_protected_resource.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,11 @@ async def test_metadata_endpoint_without_path(root_resource_client: httpx.AsyncC
9494
# For root resource, metadata should be at standard location
9595
response = await root_resource_client.get("/.well-known/oauth-protected-resource")
9696
assert response.status_code == 200
97+
# Note: URLs should NOT have trailing slashes per RFC 8414/9728 (see issue #1919)
9798
assert response.json() == snapshot(
9899
{
99-
"resource": "https://example.com/",
100-
"authorization_servers": ["https://auth.example.com/"],
100+
"resource": "https://example.com",
101+
"authorization_servers": ["https://auth.example.com"],
101102
"scopes_supported": ["read"],
102103
"resource_name": "Root Resource",
103104
"bearer_methods_supported": ["header"],

tests/shared/test_auth.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Tests for OAuth 2.0 shared code."""
22

3-
from mcp.shared.auth import OAuthMetadata
3+
import json
4+
5+
from mcp.shared.auth import OAuthMetadata, ProtectedResourceMetadata
46

57

68
def test_oauth():
@@ -58,3 +60,72 @@ def test_oauth_with_jarm():
5860
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
5961
}
6062
)
63+
64+
65+
class TestIssuerTrailingSlash:
66+
"""Tests for issue #1919: trailing slash in issuer URL.
67+
68+
RFC 8414 examples show issuer URLs without trailing slashes, and some
69+
OAuth clients require exact match between discovery URL and returned issuer.
70+
Pydantic's AnyHttpUrl automatically adds a trailing slash, so we strip it
71+
during serialization.
72+
"""
73+
74+
def test_oauth_metadata_issuer_no_trailing_slash_in_json(self):
75+
"""Serialized issuer should not have trailing slash."""
76+
metadata = OAuthMetadata(
77+
issuer="https://example.com",
78+
authorization_endpoint="https://example.com/oauth2/authorize",
79+
token_endpoint="https://example.com/oauth2/token",
80+
)
81+
serialized = json.loads(metadata.model_dump_json())
82+
assert serialized["issuer"] == "https://example.com"
83+
assert not serialized["issuer"].endswith("/")
84+
85+
def test_oauth_metadata_issuer_with_path_preserves_path(self):
86+
"""Issuer with path should preserve the path, only strip trailing slash."""
87+
metadata = OAuthMetadata(
88+
issuer="https://example.com/auth",
89+
authorization_endpoint="https://example.com/oauth2/authorize",
90+
token_endpoint="https://example.com/oauth2/token",
91+
)
92+
serialized = json.loads(metadata.model_dump_json())
93+
assert serialized["issuer"] == "https://example.com/auth"
94+
assert not serialized["issuer"].endswith("/")
95+
96+
def test_oauth_metadata_issuer_with_path_and_trailing_slash(self):
97+
"""Issuer with path and trailing slash should only strip the trailing slash."""
98+
metadata = OAuthMetadata(
99+
issuer="https://example.com/auth/",
100+
authorization_endpoint="https://example.com/oauth2/authorize",
101+
token_endpoint="https://example.com/oauth2/token",
102+
)
103+
serialized = json.loads(metadata.model_dump_json())
104+
assert serialized["issuer"] == "https://example.com/auth"
105+
106+
def test_protected_resource_metadata_no_trailing_slash(self):
107+
"""ProtectedResourceMetadata.resource should not have trailing slash."""
108+
metadata = ProtectedResourceMetadata(
109+
resource="https://example.com",
110+
authorization_servers=["https://auth.example.com"],
111+
)
112+
serialized = json.loads(metadata.model_dump_json())
113+
assert serialized["resource"] == "https://example.com"
114+
assert not serialized["resource"].endswith("/")
115+
116+
def test_protected_resource_metadata_auth_servers_no_trailing_slash(self):
117+
"""ProtectedResourceMetadata.authorization_servers should not have trailing slashes."""
118+
metadata = ProtectedResourceMetadata(
119+
resource="https://example.com",
120+
authorization_servers=[
121+
"https://auth1.example.com",
122+
"https://auth2.example.com/path",
123+
],
124+
)
125+
serialized = json.loads(metadata.model_dump_json())
126+
assert serialized["authorization_servers"] == [
127+
"https://auth1.example.com",
128+
"https://auth2.example.com/path",
129+
]
130+
for url in serialized["authorization_servers"]:
131+
assert not url.endswith("/")

0 commit comments

Comments
 (0)