Skip to content

Commit 9801940

Browse files
committed
fix(auth): only strip trailing slash on root OAuth identifiers
Keep path-based issuer/resource/authorization_server identifiers intact so exact-string matching still works. Root URLs still drop the synthetic slash added by AnyHttpUrl.
1 parent df380ed commit 9801940

3 files changed

Lines changed: 46 additions & 3 deletions

File tree

src/mcp/server/auth/routes.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,19 @@ def validate_issuer_url(url: AnyHttpUrl):
4747
REGISTRATION_PATH = "/register"
4848
REVOCATION_PATH = "/revoke"
4949

50+
51+
def _metadata_identifier_url(url: AnyHttpUrl) -> str:
52+
"""Canonical wire form for issuer/resource identifiers in OAuth metadata.
53+
54+
Bare ``AnyHttpUrl("https://host")`` synthesizes a trailing slash. Strip that
55+
only for root URLs (path ``""`` or ``"/"``) so RFC 8414/9728 exact string
56+
comparison works. Path-based identifiers keep a trailing slash if present.
57+
"""
58+
s = str(url)
59+
if urlparse(s).path in ("", "/"):
60+
return s.rstrip("/")
61+
return s
62+
5063
# SEP-990: leg 2 uses the RFC 7523 jwt-bearer grant; support is advertised as the ID-JAG profile.
5164
ID_JAG_GRANT_PROFILE = "urn:ietf:params:oauth:grant-profile:id-jag"
5265

@@ -169,7 +182,7 @@ def build_metadata(
169182

170183
# Create metadata
171184
metadata = OAuthMetadata(
172-
issuer=cast(AnyHttpUrl, str(issuer_url).rstrip("/")),
185+
issuer=cast(AnyHttpUrl, _metadata_identifier_url(issuer_url)),
173186
authorization_endpoint=authorization_url,
174187
token_endpoint=token_url,
175188
scopes_supported=client_registration_options.valid_scopes,
@@ -237,10 +250,10 @@ def create_protected_resource_routes(
237250
List of Starlette routes for protected resource metadata
238251
"""
239252
metadata = ProtectedResourceMetadata(
240-
resource=cast(AnyHttpUrl, str(resource_url).rstrip("/")),
253+
resource=cast(AnyHttpUrl, _metadata_identifier_url(resource_url)),
241254
authorization_servers=cast(
242255
list[AnyHttpUrl],
243-
[str(server).rstrip("/") for server in authorization_servers],
256+
[_metadata_identifier_url(server) for server in authorization_servers],
244257
),
245258
scopes_supported=scopes_supported,
246259
resource_name=resource_name,

tests/server/auth/test_protected_resource.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,24 @@ def test_metadata_url_construction_various_resource_configurations(resource_url:
150150
# Tests for consistency between URL generation and route registration
151151

152152

153+
@pytest.mark.anyio
154+
async def test_path_based_identifiers_keep_trailing_slash():
155+
"""Path-based resource/AS identifiers keep a configured trailing slash."""
156+
routes = create_protected_resource_routes(
157+
resource_url=AnyHttpUrl("https://rs.example.com/mcp/"),
158+
authorization_servers=[AnyHttpUrl("https://as.example.com/realms/foo/")],
159+
)
160+
app = Starlette(routes=routes)
161+
async with httpx2.AsyncClient(
162+
transport=httpx2.ASGITransport(app=app), base_url="https://rs.example.com"
163+
) as client:
164+
response = await client.get("/.well-known/oauth-protected-resource/mcp/")
165+
assert response.status_code == 200
166+
body = response.json()
167+
assert body["resource"] == "https://rs.example.com/mcp/"
168+
assert body["authorization_servers"] == ["https://as.example.com/realms/foo/"]
169+
170+
153171
def test_route_consistency_route_path_matches_metadata_url():
154172
"""Test that route path matches the generated metadata URL."""
155173
resource_url = AnyHttpUrl("https://example.com/mcp")

tests/server/auth/test_routes.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,15 @@ def test_build_metadata_strips_trailing_slash_from_anyhttpurl_issuer():
8181

8282
served = metadata.model_dump(mode="json", exclude_none=True)
8383
assert served["issuer"] == "http://localhost:8000"
84+
85+
86+
def test_build_metadata_preserves_trailing_slash_on_path_issuer():
87+
"""Path-based issuer identifiers keep a configured trailing slash (exact-string match)."""
88+
issuer_url = AnyHttpUrl("https://as.example.com/tenant/")
89+
assert str(issuer_url).endswith("/tenant/")
90+
91+
metadata = build_metadata(issuer_url, None, ClientRegistrationOptions(), RevocationOptions())
92+
93+
served = metadata.model_dump(mode="json", exclude_none=True)
94+
assert served["issuer"] == "https://as.example.com/tenant/"
95+
assert served["authorization_endpoint"] == "https://as.example.com/tenant/authorize"

0 commit comments

Comments
 (0)