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/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/client/test_auth.py b/tests/client/test_auth.py index 1ec38ccf6f..7c4f23db76 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -1529,9 +1529,6 @@ async def mock_callback() -> AuthorizationCodeResult: "https://auth.example.com/register", "https://auth.example.com/revoke", id="simple-url", - marks=pytest.mark.xfail( - reason="Pydantic AnyUrl adds trailing slash to base URLs - fixed in Pydantic 2.12+" - ), ), pytest.param( "https://auth.example.com/", @@ -1570,7 +1567,7 @@ def test_build_metadata( assert metadata.model_dump(exclude_defaults=True, mode="json") == snapshot( { - "issuer": Is(issuer_url), + "issuer": Is(issuer_url.rstrip("/")), "authorization_endpoint": Is(authorization_endpoint), "token_endpoint": Is(token_endpoint), "registration_endpoint": Is(registration_endpoint), diff --git a/tests/docs_src/test_authorization.py b/tests/docs_src/test_authorization.py index cde0cea5fd..c8814a3563 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 adfe23bad8..a432cb3772 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/interaction/auth/_provider.py b/tests/interaction/auth/_provider.py index 0c54d4fd37..61cef456df 100644 --- a/tests/interaction/auth/_provider.py +++ b/tests/interaction/auth/_provider.py @@ -63,10 +63,10 @@ def __init__( ) -> None: self._default_scopes = list(default_scopes) if default_scopes is not None else ["mcp"] # The authorization-response iss must equal the AS metadata issuer the client recorded - # (RFC 9207 simple string comparison). `real_asm` builds the issuer from an AnyHttpUrl - # object, so it carries the trailing slash; the redirect iss matches it. Path-issuer - # tests pass the recorded issuer explicitly. - self._issuer = issuer if issuer is not None else f"{BASE_URL}/" + # (RFC 9207 simple string comparison). `build_metadata` serves path-less issuers without a + # trailing slash, so the redirect iss matches that canonical form. Path-issuer tests pass + # the recorded issuer explicitly. + self._issuer = issuer if issuer is not None else BASE_URL self._deny_authorize = deny_authorize self._issue_expired_first = issue_expired_first self._fail_next_refresh = fail_next_refresh diff --git a/tests/interaction/auth/test_authorize_token.py b/tests/interaction/auth/test_authorize_token.py index d4eb591b59..5b43e94feb 100644 --- a/tests/interaction/auth/test_authorize_token.py +++ b/tests/interaction/auth/test_authorize_token.py @@ -17,6 +17,7 @@ import re from collections.abc import AsyncIterator from dataclasses import dataclass +from typing import cast from urllib.parse import parse_qsl, quote, urlsplit import anyio @@ -297,7 +298,7 @@ async def test_the_registered_auth_method_is_used_regardless_of_as_metadata_adve server = Server("guarded", on_list_tools=list_tools) override = OAuthMetadata( - issuer=AnyHttpUrl(f"{BASE_URL}/"), + issuer=cast(AnyHttpUrl, BASE_URL), authorization_endpoint=AnyHttpUrl(f"{BASE_URL}/authorize"), token_endpoint=AnyHttpUrl(f"{BASE_URL}/token"), registration_endpoint=AnyHttpUrl(f"{BASE_URL}/register"), @@ -367,7 +368,7 @@ async def test_pkce_is_still_sent_when_as_metadata_omits_code_challenge_methods_ completes. See the divergence on the requirement. """ override = OAuthMetadata( - issuer=AnyHttpUrl(f"{BASE_URL}/"), + issuer=cast(AnyHttpUrl, BASE_URL), authorization_endpoint=AnyHttpUrl(f"{BASE_URL}/authorize"), token_endpoint=AnyHttpUrl(f"{BASE_URL}/token"), registration_endpoint=AnyHttpUrl(f"{BASE_URL}/register"), diff --git a/tests/interaction/auth/test_discovery.py b/tests/interaction/auth/test_discovery.py index 1317fd19de..6afb4b2706 100644 --- a/tests/interaction/auth/test_discovery.py +++ b/tests/interaction/auth/test_discovery.py @@ -11,6 +11,7 @@ """ import json +from typing import cast import anyio import mcp_types as types @@ -54,7 +55,7 @@ def discovery_gets(recorded: list[RecordedRequest]) -> list[str]: def real_asm() -> OAuthMetadata: """Build an authorization-server metadata document pointing at the real co-hosted endpoints.""" return OAuthMetadata( - issuer=AnyHttpUrl(BASE_URL), + issuer=cast(AnyHttpUrl, BASE_URL), authorization_endpoint=AnyHttpUrl(f"{BASE_URL}/authorize"), token_endpoint=AnyHttpUrl(f"{BASE_URL}/token"), registration_endpoint=AnyHttpUrl(f"{BASE_URL}/register"), @@ -100,7 +101,7 @@ async def test_prm_discovery_falls_back_from_path_well_known_to_root_on_404() -> server = Server("guarded", on_list_tools=list_tools) prm = ProtectedResourceMetadata( - resource=AnyHttpUrl(f"{BASE_URL}/mcp"), authorization_servers=[AnyHttpUrl(BASE_URL)] + resource=AnyHttpUrl(f"{BASE_URL}/mcp"), authorization_servers=[cast(AnyHttpUrl, BASE_URL)] ) app_shim = shim( not_found=frozenset({PRM_PATH_SUFFIXED}), @@ -188,7 +189,7 @@ async def test_prm_with_a_mismatched_resource_aborts_the_flow_before_authorize() server = Server("guarded", on_list_tools=list_tools) prm = ProtectedResourceMetadata( - resource=AnyHttpUrl(f"{BASE_URL}/other"), authorization_servers=[AnyHttpUrl(BASE_URL)] + resource=AnyHttpUrl(f"{BASE_URL}/other"), authorization_servers=[cast(AnyHttpUrl, BASE_URL)] ) app_shim = shim(serve={PRM_PATH_SUFFIXED: metadata_body(prm)}) diff --git a/tests/interaction/auth/test_flow.py b/tests/interaction/auth/test_flow.py index e98735abf8..0411a4e6fb 100644 --- a/tests/interaction/auth/test_flow.py +++ b/tests/interaction/auth/test_flow.py @@ -237,4 +237,4 @@ async def test_shimmed_app_serves_overrides_404s_and_otherwise_forwards_to_the_w forwarded = await http.get("/.well-known/oauth-authorization-server") assert forwarded.status_code == 200 - assert forwarded.json()["issuer"] == "http://127.0.0.1:8000/" + assert forwarded.json()["issuer"] == "http://127.0.0.1:8000" diff --git a/tests/interaction/auth/test_identity_assertion.py b/tests/interaction/auth/test_identity_assertion.py index e03859274e..152eeda405 100644 --- a/tests/interaction/auth/test_identity_assertion.py +++ b/tests/interaction/auth/test_identity_assertion.py @@ -40,9 +40,9 @@ ID_JAG_GRANT_PROFILE = "urn:ietf:params:oauth:grant-profile:id-jag" CLIENT_ID = "enterprise-mcp-client" CLIENT_SECRET = "enterprise-secret" -# The AS metadata issuer carries a trailing slash (built from an AnyHttpUrl object); the client +# The AS metadata issuer is path-less and slash-free (matches build_metadata); the client # pins against exactly that. -EXPECTED_ISSUER = f"{BASE_URL}/" +EXPECTED_ISSUER = BASE_URL async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: @@ -215,7 +215,7 @@ async def test_unexpected_issuer_aborts_before_sending_credentials() -> None: provider = InMemoryAuthorizationServerProvider() preregister_confidential_client(provider) server = Server("guarded", on_list_tools=list_tools) - # The served AS metadata has issuer BASE_URL/, but the client is configured for a different one. + # The served AS metadata has issuer BASE_URL, but the client is configured for a different one. auth = identity_assertion_provider(InMemoryTokenStorage(), issuer="https://corp-as.example/", record=record) with anyio.fail_after(5): diff --git a/tests/interaction/auth/test_lifecycle.py b/tests/interaction/auth/test_lifecycle.py index c810f8c449..60b4657678 100644 --- a/tests/interaction/auth/test_lifecycle.py +++ b/tests/interaction/auth/test_lifecycle.py @@ -8,6 +8,7 @@ import base64 from collections import Counter +from typing import cast from urllib.parse import parse_qsl, urlsplit import anyio @@ -69,7 +70,7 @@ def path_counts(recorded: list[RecordedRequest]) -> Counter[tuple[str, str]]: def cimd_supported_metadata() -> bytes: """AS metadata advertising `client_id_metadata_document_supported: true` (the SDK server never sets it).""" metadata = OAuthMetadata( - issuer=AnyHttpUrl(f"{BASE_URL}/"), + issuer=cast(AnyHttpUrl, BASE_URL), authorization_endpoint=AnyHttpUrl(f"{BASE_URL}/authorize"), token_endpoint=AnyHttpUrl(f"{BASE_URL}/token"), registration_endpoint=AnyHttpUrl(f"{BASE_URL}/register"), @@ -239,7 +240,7 @@ async def test_credentials_bound_to_a_different_issuer_are_discarded_and_the_cli # The persisted client is now bound to the current AS. assert storage.client_info is not None assert storage.client_info.client_id != "stale-as-client" - assert storage.client_info.issuer == f"{BASE_URL}/" + assert storage.client_info.issuer == BASE_URL @requirement("client-auth:401-after-auth-throws") @@ -437,7 +438,7 @@ async def assertion_provider(audience: str) -> str: result = await client.list_tools() assert result.tools[0].name == "echo" - assert audiences == [f"{BASE_URL}/"] + assert audiences == [BASE_URL] [token_req] = find(recorded, "POST", "/token") body = form_body(token_req) 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" 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"