diff --git a/src/fastmcp/client/auth/oauth.py b/src/fastmcp/client/auth/oauth.py index 349db61b3..2190b9a43 100644 --- a/src/fastmcp/client/auth/oauth.py +++ b/src/fastmcp/client/auth/oauth.py @@ -4,7 +4,6 @@ import time import webbrowser from collections.abc import AsyncGenerator from typing import Any -from urllib.parse import urlparse import anyio import httpx @@ -162,8 +161,8 @@ class OAuth(OAuthClientProvider): additional_client_metadata: Extra fields for OAuthClientMetadata callback_port: Fixed port for OAuth callback (default: random available port) """ - parsed_url = urlparse(mcp_url) - server_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}" + # Normalize the MCP URL (strip trailing slashes for consistency) + mcp_url = mcp_url.rstrip("/") # Setup OAuth client self.httpx_client_factory = httpx_client_factory or httpx.AsyncClient @@ -201,16 +200,17 @@ class OAuth(OAuthClientProvider): stacklevel=2, ) + # Use full URL for token storage to properly separate tokens per MCP endpoint self.token_storage_adapter: TokenStorageAdapter = TokenStorageAdapter( - async_key_value=token_storage, server_url=server_base_url + async_key_value=token_storage, server_url=mcp_url ) - # Store server_base_url for use in callback_handler - self.server_base_url = server_base_url + # Store full MCP URL for use in callback_handler display + self.mcp_url = mcp_url - # Initialize parent class + # Initialize parent class with full URL for proper OAuth metadata discovery super().__init__( - server_url=server_base_url, + server_url=mcp_url, client_metadata=client_metadata, storage=self.token_storage_adapter, redirect_handler=self.redirect_handler, @@ -256,7 +256,7 @@ class OAuth(OAuthClientProvider): # Create server with result tracking server: Server = create_oauth_callback_server( port=self.redirect_port, - server_url=self.server_base_url, + server_url=self.mcp_url, result_container=result, result_ready=result_ready, ) diff --git a/tests/client/auth/test_oauth_client.py b/tests/client/auth/test_oauth_client.py index f9e452503..832d7ce02 100644 --- a/tests/client/auth/test_oauth_client.py +++ b/tests/client/auth/test_oauth_client.py @@ -4,6 +4,7 @@ import httpx import pytest from fastmcp.client import Client +from fastmcp.client.auth import OAuth from fastmcp.client.transports import StreamableHttpTransport from fastmcp.server.auth.auth import ClientRegistrationOptions from fastmcp.server.auth.providers.in_memory import InMemoryOAuthProvider @@ -124,3 +125,49 @@ async def test_oauth_server_metadata_discovery(streamable_http_server: str): # The endpoints should be properly formed URLs assert metadata["authorization_endpoint"].startswith(server_base_url) assert metadata["token_endpoint"].startswith(server_base_url) + + +class TestOAuthClientUrlHandling: + """Tests for OAuth client URL handling (issue #2573).""" + + def test_oauth_preserves_full_url_with_path(self): + """OAuth client should preserve the full MCP URL including path components. + + This is critical for servers hosted under path-based endpoints like + mcp.example.com/server1/v1.0/mcp where OAuth metadata discovery needs + the full path to find the correct .well-known endpoints. + """ + mcp_url = "https://mcp.example.com/server1/v1.0/mcp" + oauth = OAuth(mcp_url=mcp_url) + + # The full URL should be preserved for OAuth discovery + assert oauth.context.server_url == mcp_url + + # The stored mcp_url should match + assert oauth.mcp_url == mcp_url + + def test_oauth_preserves_root_url(self): + """OAuth client should work correctly with root-level URLs.""" + mcp_url = "https://mcp.example.com" + oauth = OAuth(mcp_url=mcp_url) + + assert oauth.context.server_url == mcp_url + assert oauth.mcp_url == mcp_url + + def test_oauth_normalizes_trailing_slash(self): + """OAuth client should normalize trailing slashes for consistency.""" + mcp_url_with_slash = "https://mcp.example.com/api/mcp/" + oauth = OAuth(mcp_url=mcp_url_with_slash) + + # Trailing slash should be stripped + expected = "https://mcp.example.com/api/mcp" + assert oauth.context.server_url == expected + assert oauth.mcp_url == expected + + def test_oauth_token_storage_uses_full_url(self): + """Token storage should use the full URL to separate tokens per endpoint.""" + mcp_url = "https://mcp.example.com/server1/v1.0/mcp" + oauth = OAuth(mcp_url=mcp_url) + + # Token storage should key by the full URL, not just the host + assert oauth.token_storage_adapter._server_url == mcp_url