From 37daf553c1b52ceecc1a9ee6dd39780ea9f039ca Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 26 Jun 2025 19:07:05 -0400 Subject: [PATCH 1/3] Clean up extra oauth models, not needed anymore --- src/fastmcp/client/auth/oauth.py | 87 ++------------------------------ 1 file changed, 5 insertions(+), 82 deletions(-) diff --git a/src/fastmcp/client/auth/oauth.py b/src/fastmcp/client/auth/oauth.py index 7984a4299..ca86088d4 100644 --- a/src/fastmcp/client/auth/oauth.py +++ b/src/fastmcp/client/auth/oauth.py @@ -9,14 +9,11 @@ from urllib.parse import urljoin, urlparse import anyio import httpx -from mcp.client.auth import OAuthClientProvider as _MCPOAuthClientProvider -from mcp.client.auth import TokenStorage +from mcp.client.auth import OAuthClientProvider, TokenStorage from mcp.shared.auth import ( OAuthClientInformationFull, OAuthClientMetadata, -) -from mcp.shared.auth import ( - OAuthMetadata as _MCPServerOAuthMetadata, + OAuthMetadata, ) from mcp.shared.auth import ( OAuthToken as OAuthToken, @@ -39,80 +36,6 @@ def default_cache_dir() -> Path: return fastmcp_global_settings.home / "oauth-mcp-client-cache" -# Flexible OAuth models for real-world compatibility -class ServerOAuthMetadata(_MCPServerOAuthMetadata): - """ - More flexible OAuth metadata model that accepts broader ranges of values - than the restrictive MCP standard model. - - This handles real-world OAuth servers like PayPal that may support - additional methods not in the MCP specification. - """ - - # Allow any code challenge methods, not just S256 - code_challenge_methods_supported: list[str] | None = None - - # Allow any token endpoint auth methods - token_endpoint_auth_methods_supported: list[str] | None = None - - # Allow any grant types - grant_types_supported: list[str] | None = None - - # Allow any response types - response_types_supported: list[str] = ["code"] - - # Allow any response modes - response_modes_supported: list[str] | None = None - - -class OAuthClientProvider(_MCPOAuthClientProvider): - """ - OAuth client provider with more flexible OAuth metadata discovery. - """ - - async def _discover_oauth_metadata( - self, server_url: str - ) -> ServerOAuthMetadata | None: - """ - Discover OAuth metadata with flexible validation. - - This is nearly identical to the parent implementation but uses - ServerOAuthMetadata instead of the restrictive MCP OAuthMetadata. - """ - # Extract base URL per MCP spec - auth_base_url = self.context.get_authorization_base_url(server_url) - url = urljoin(auth_base_url, "/.well-known/oauth-authorization-server") - - from mcp.types import LATEST_PROTOCOL_VERSION - - headers = {"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION} - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - if response.status_code == 404: - return None - response.raise_for_status() - metadata_json = response.json() - logger.debug(f"OAuth metadata discovered: {metadata_json}") - return ServerOAuthMetadata.model_validate(metadata_json) - except Exception: - # Retry without MCP header for CORS compatibility - try: - response = await client.get(url) - if response.status_code == 404: - return None - response.raise_for_status() - metadata_json = response.json() - logger.debug( - f"OAuth metadata discovered (no MCP header): {metadata_json}" - ) - return ServerOAuthMetadata.model_validate(metadata_json) - except Exception: - logger.exception("Failed to discover OAuth metadata") - return None - - class FileTokenStorage(TokenStorage): """ File-based token storage implementation for OAuth credentials and tokens. @@ -229,7 +152,7 @@ class FileTokenStorage(TokenStorage): async def discover_oauth_metadata( server_base_url: str, httpx_kwargs: dict[str, Any] | None = None -) -> _MCPServerOAuthMetadata | None: +) -> OAuthMetadata | None: """ Discover OAuth metadata from the server using RFC 8414 well-known endpoint. @@ -248,7 +171,7 @@ async def discover_oauth_metadata( response = await client.get(well_known_url, timeout=10.0) if response.status_code == 200: logger.debug("Successfully discovered OAuth metadata") - return _MCPServerOAuthMetadata.model_validate(response.json()) + return OAuthMetadata.model_validate(response.json()) elif response.status_code == 404: logger.debug( "OAuth metadata not found (404) - server may not require auth" @@ -298,7 +221,7 @@ def OAuth( client_name: str = "FastMCP Client", token_storage_cache_dir: Path | None = None, additional_client_metadata: dict[str, Any] | None = None, -) -> _MCPOAuthClientProvider: +) -> OAuthClientProvider: """ Create an OAuthClientProvider for an MCP server. From 19c1526286567a8bd7623256cb85092908e3013e Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 26 Jun 2025 19:24:57 -0400 Subject: [PATCH 2/3] Fix trailing slash --- src/fastmcp/client/transports.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 68508db7b..cc05669b8 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -9,7 +9,6 @@ import warnings from collections.abc import AsyncIterator, Callable from pathlib import Path from typing import Any, Literal, TypeVar, cast, overload -from urllib.parse import urlparse, urlunparse import anyio import httpx @@ -161,11 +160,8 @@ class SSETransport(ClientTransport): if not isinstance(url, str) or not url.startswith("http"): raise ValueError("Invalid HTTP/S URL provided for SSE.") - # Ensure the URL path ends with a trailing slash to avoid automatic redirects - parsed = urlparse(url) - if not parsed.path.endswith("/"): - parsed = parsed._replace(path=parsed.path + "/") - url = urlunparse(parsed) + # Don't modify the URL path - respect the exact URL provided by the user + # Some servers are strict about trailing slashes (e.g., PayPal MCP) self.url = url self.headers = headers or {} @@ -236,11 +232,8 @@ class StreamableHttpTransport(ClientTransport): if not isinstance(url, str) or not url.startswith("http"): raise ValueError("Invalid HTTP/S URL provided for Streamable HTTP.") - # Ensure the URL path ends with a trailing slash to avoid automatic redirects - parsed = urlparse(url) - if not parsed.path.endswith("/"): - parsed = parsed._replace(path=parsed.path + "/") - url = urlunparse(parsed) + # Don't modify the URL path - respect the exact URL provided by the user + # Some servers are strict about trailing slashes (e.g., PayPal MCP) self.url = url self.headers = headers or {} From 3598e947b2cbbc241991256f6d872fd6b773f209 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 28 Jun 2025 08:15:43 -0400 Subject: [PATCH 3/3] Update test_mcp_config.py --- tests/utilities/test_mcp_config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/utilities/test_mcp_config.py b/tests/utilities/test_mcp_config.py index 7775e12bc..ec6833b0a 100644 --- a/tests/utilities/test_mcp_config.py +++ b/tests/utilities/test_mcp_config.py @@ -39,7 +39,7 @@ def test_parse_single_remote_config(): mcp_config = MCPConfig.from_dict(config) transport = mcp_config.mcpServers["test_server"].to_transport() assert isinstance(transport, StreamableHttpTransport) - assert transport.url == "http://localhost:8000/" + assert transport.url == "http://localhost:8000" def test_parse_remote_config_with_transport(): @@ -54,7 +54,7 @@ def test_parse_remote_config_with_transport(): mcp_config = MCPConfig.from_dict(config) transport = mcp_config.mcpServers["test_server"].to_transport() assert isinstance(transport, SSETransport) - assert transport.url == "http://localhost:8000/" + assert transport.url == "http://localhost:8000" def test_parse_remote_config_with_url_inference():