From 77d36d89986996cae8ef714907c1dc532bbf828f Mon Sep 17 00:00:00 2001 From: Piyush Jagadish Bag Date: Sun, 28 Jun 2026 03:16:16 -0700 Subject: [PATCH 1/2] fix(auth): strip trailing slashes from OAuth metadata URLs Pydantic AnyHttpUrl adds a trailing slash to bare hostnames, which breaks RFC 8414/9728 exact issuer and resource comparison during OAuth discovery. Pass canonical URL strings into metadata models so served wire JSON omits the synthetic root slash. Fixes #1919 and #1265. --- src/mcp/server/auth/routes.py | 11 +++++++---- tests/server/auth/test_protected_resource.py | 4 ++-- tests/server/auth/test_routes.py | 11 +++++++++++ 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/mcp/server/auth/routes.py b/src/mcp/server/auth/routes.py index fa88dddcf4..f6fd7e1687 100644 --- a/src/mcp/server/auth/routes.py +++ b/src/mcp/server/auth/routes.py @@ -1,5 +1,5 @@ from collections.abc import Awaitable, Callable -from typing import Any +from typing import Any, cast from urllib.parse import urlparse from pydantic import AnyHttpUrl @@ -169,7 +169,7 @@ def build_metadata( # Create metadata metadata = OAuthMetadata( - issuer=issuer_url, + issuer=cast(AnyHttpUrl, str(issuer_url).rstrip("/")), authorization_endpoint=authorization_url, token_endpoint=token_url, scopes_supported=client_registration_options.valid_scopes, @@ -237,8 +237,11 @@ def create_protected_resource_routes( List of Starlette routes for protected resource metadata """ metadata = ProtectedResourceMetadata( - resource=resource_url, - authorization_servers=authorization_servers, + resource=cast(AnyHttpUrl, str(resource_url).rstrip("/")), + authorization_servers=cast( + list[AnyHttpUrl], + [str(server).rstrip("/") for server in authorization_servers], + ), scopes_supported=scopes_supported, resource_name=resource_name, resource_documentation=resource_documentation, diff --git a/tests/server/auth/test_protected_resource.py b/tests/server/auth/test_protected_resource.py index 413a80276e..ca2a6e15fe 100644 --- a/tests/server/auth/test_protected_resource.py +++ b/tests/server/auth/test_protected_resource.py @@ -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"], diff --git a/tests/server/auth/test_routes.py b/tests/server/auth/test_routes.py index 58685c64c7..00d3058892 100644 --- a/tests/server/auth/test_routes.py +++ b/tests/server/auth/test_routes.py @@ -70,3 +70,14 @@ def test_build_metadata_serves_issuer_without_trailing_slash(): assert served["issuer"] == "https://as.example.com" assert served["authorization_endpoint"] == "https://as.example.com/authorize" assert served["token_endpoint"] == "https://as.example.com/token" + + +def test_build_metadata_strips_trailing_slash_from_anyhttpurl_issuer(): + """AnyHttpUrl adds a trailing slash to bare hostnames; served metadata must not.""" + issuer_url = AnyHttpUrl("http://localhost:8000") + assert str(issuer_url).endswith("/") + + metadata = build_metadata(issuer_url, None, ClientRegistrationOptions(), RevocationOptions()) + + served = metadata.model_dump(mode="json", exclude_none=True) + assert served["issuer"] == "http://localhost:8000" From a5917571d8ae3a9e3d802b247ec294063fa2e0b4 Mon Sep 17 00:00:00 2001 From: Piyush Jagadish Bag Date: Sun, 28 Jun 2026 03:25:21 -0700 Subject: [PATCH 2/2] test(auth): align OAuth metadata expectations with canonical URLs Update docs, stories, and tests that asserted trailing slashes on root issuer and authorization_server metadata fields after the routes.py fix. --- docs_src/identity_assertion/tutorial001.py | 2 +- docs_src/identity_assertion/tutorial002.py | 2 +- examples/stories/_shared/auth.py | 5 ++--- examples/stories/identity_assertion/client.py | 2 +- examples/stories/identity_assertion/server.py | 3 +-- examples/stories/oauth_client_credentials/server.py | 2 +- examples/stories/oauth_client_credentials/server_lowlevel.py | 2 +- tests/docs_src/test_authorization.py | 2 +- tests/docs_src/test_identity_assertion.py | 2 +- tests/server/mcpserver/auth/test_auth_integration.py | 2 +- 10 files changed, 11 insertions(+), 13 deletions(-) diff --git a/docs_src/identity_assertion/tutorial001.py b/docs_src/identity_assertion/tutorial001.py index 8a7e9a050b..8b562b979a 100644 --- a/docs_src/identity_assertion/tutorial001.py +++ b/docs_src/identity_assertion/tutorial001.py @@ -55,7 +55,7 @@ async def fetch_id_jag(audience: str, resource: str) -> str: storage=InMemoryTokenStorage(), client_id="finance-agent", client_secret="finance-agent-secret", - issuer="https://auth.example.com/", + issuer="https://auth.example.com", assertion_provider=fetch_id_jag, scope="notes:read", ) diff --git a/docs_src/identity_assertion/tutorial002.py b/docs_src/identity_assertion/tutorial002.py index d537069f18..308cac7149 100644 --- a/docs_src/identity_assertion/tutorial002.py +++ b/docs_src/identity_assertion/tutorial002.py @@ -18,7 +18,7 @@ from mcp.server.auth.routes import create_auth_routes from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthToken -ISSUER = "https://auth.example.com/" +ISSUER = "https://auth.example.com" MCP_SERVER = "http://localhost:8001/mcp" IDP_ISSUER = "https://idp.example.com" IDP_SIGNING_KEY = "the-enterprise-idp-signing-key" diff --git a/examples/stories/_shared/auth.py b/examples/stories/_shared/auth.py index 3bedcd3ab9..bc56ce2502 100644 --- a/examples/stories/_shared/auth.py +++ b/examples/stories/_shared/auth.py @@ -11,7 +11,6 @@ from urllib.parse import parse_qs, urlsplit import httpx -from pydantic import AnyHttpUrl from mcp.server.auth.provider import ( AccessToken, @@ -164,8 +163,8 @@ def auth_settings( """ scopes = required_scopes or ["mcp"] return AuthSettings( - issuer_url=AnyHttpUrl(BASE_URL), - resource_server_url=AnyHttpUrl(MCP_URL), + issuer_url=BASE_URL, # type: ignore[arg-type] + resource_server_url=MCP_URL, # type: ignore[arg-type] required_scopes=scopes, client_registration_options=ClientRegistrationOptions(enabled=True, valid_scopes=scopes, default_scopes=scopes), identity_assertion_enabled=identity_assertion_enabled, diff --git a/examples/stories/identity_assertion/client.py b/examples/stories/identity_assertion/client.py index bd13909801..1d7b04b353 100644 --- a/examples/stories/identity_assertion/client.py +++ b/examples/stories/identity_assertion/client.py @@ -32,7 +32,7 @@ def build_auth(_http: httpx.AsyncClient) -> httpx.Auth: `issuer` is configuration, not discovery: the provider fetches metadata from this issuer's well-known and never asks the MCP server which authorization server to use. The string must - equal the `issuer` its metadata serves byte for byte (note the trailing slash). + equal the `issuer` its metadata serves byte for byte. `Client(url, auth=...)` doesn't exist yet, so the harness threads this onto the underlying `httpx.AsyncClient` and hands `main` a target that is already routed through it. """ diff --git a/examples/stories/identity_assertion/server.py b/examples/stories/identity_assertion/server.py index 8b0c8f4019..803171c48c 100644 --- a/examples/stories/identity_assertion/server.py +++ b/examples/stories/identity_assertion/server.py @@ -23,8 +23,7 @@ DEMO_CLIENT_SECRET = "demo-finance-agent-secret" DEMO_SCOPE = "mcp" # The exact `issuer` string this authorization server's metadata serves. The client must configure -# the byte-identical string: RFC 8414 issuer comparison is character for character, and the -# settings' `AnyHttpUrl` renders the path-less loopback origin with a trailing slash. +# the byte-identical string: RFC 8414 issuer comparison is character for character. ISSUER = str(auth_settings().issuer_url) diff --git a/examples/stories/oauth_client_credentials/server.py b/examples/stories/oauth_client_credentials/server.py index 7e3d910e8f..cd7a33ebe8 100644 --- a/examples/stories/oauth_client_credentials/server.py +++ b/examples/stories/oauth_client_credentials/server.py @@ -48,7 +48,7 @@ def whoami() -> Whoami: @mcp.custom_route("/.well-known/oauth-authorization-server", methods=["GET"]) async def as_metadata(request: Request) -> JSONResponse: meta = OAuthMetadata( - issuer=AnyHttpUrl(BASE_URL), + issuer=BASE_URL, # type: ignore[arg-type] authorization_endpoint=AnyHttpUrl(f"{BASE_URL}/authorize"), # unused; required token_endpoint=AnyHttpUrl(f"{BASE_URL}/token"), grant_types_supported=["client_credentials"], diff --git a/examples/stories/oauth_client_credentials/server_lowlevel.py b/examples/stories/oauth_client_credentials/server_lowlevel.py index ba2003dedf..4e2d2a6602 100644 --- a/examples/stories/oauth_client_credentials/server_lowlevel.py +++ b/examples/stories/oauth_client_credentials/server_lowlevel.py @@ -46,7 +46,7 @@ async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolReques async def as_metadata(request: Request) -> JSONResponse: meta = OAuthMetadata( - issuer=AnyHttpUrl(BASE_URL), + issuer=BASE_URL, # type: ignore[arg-type] authorization_endpoint=AnyHttpUrl(f"{BASE_URL}/authorize"), # unused; required token_endpoint=AnyHttpUrl(f"{BASE_URL}/token"), grant_types_supported=["client_credentials"], diff --git a/tests/docs_src/test_authorization.py b/tests/docs_src/test_authorization.py index 4c7554ed75..eb6b906052 100644 --- a/tests/docs_src/test_authorization.py +++ b/tests/docs_src/test_authorization.py @@ -47,7 +47,7 @@ async def test_the_metadata_document_is_built_from_auth_settings() -> None: assert response.json() == snapshot( { "resource": "http://127.0.0.1:8000/mcp", - "authorization_servers": ["https://auth.example.com/"], + "authorization_servers": ["https://auth.example.com"], "scopes_supported": ["notes:read"], "bearer_methods_supported": ["header"], } diff --git a/tests/docs_src/test_identity_assertion.py b/tests/docs_src/test_identity_assertion.py index afcfd83290..58f03f6ad5 100644 --- a/tests/docs_src/test_identity_assertion.py +++ b/tests/docs_src/test_identity_assertion.py @@ -137,7 +137,7 @@ async def test_the_metadata_advertises_the_grant_type_and_the_id_jag_profile() - response = await http_client.get("/.well-known/oauth-authorization-server") assert response.status_code == 200 metadata = response.json() - assert metadata["issuer"] == "https://auth.example.com/" + assert metadata["issuer"] == "https://auth.example.com" assert "urn:ietf:params:oauth:grant-type:jwt-bearer" in metadata["grant_types_supported"] assert metadata["authorization_grant_profiles_supported"] == ["urn:ietf:params:oauth:grant-profile:id-jag"] diff --git a/tests/server/mcpserver/auth/test_auth_integration.py b/tests/server/mcpserver/auth/test_auth_integration.py index 35fec1c57e..cb8ca39dac 100644 --- a/tests/server/mcpserver/auth/test_auth_integration.py +++ b/tests/server/mcpserver/auth/test_auth_integration.py @@ -317,7 +317,7 @@ async def test_metadata_endpoint(self, test_client: httpx.AsyncClient): assert response.status_code == 200 metadata = response.json() - assert metadata["issuer"] == "https://auth.example.com/" + assert metadata["issuer"] == "https://auth.example.com" assert metadata["authorization_endpoint"] == "https://auth.example.com/authorize" assert metadata["token_endpoint"] == "https://auth.example.com/token" assert metadata["registration_endpoint"] == "https://auth.example.com/register"