Jeremiah Lowin commited on
Commit
2efc46e
·
unverified ·
2 Parent(s): 42909f034a5c49

Merge pull request #967 from jlowin/auth

Browse files
src/fastmcp/client/auth/oauth.py CHANGED
@@ -9,14 +9,11 @@ from urllib.parse import urljoin, urlparse
9
 
10
  import anyio
11
  import httpx
12
- from mcp.client.auth import OAuthClientProvider as _MCPOAuthClientProvider
13
- from mcp.client.auth import TokenStorage
14
  from mcp.shared.auth import (
15
  OAuthClientInformationFull,
16
  OAuthClientMetadata,
17
- )
18
- from mcp.shared.auth import (
19
- OAuthMetadata as _MCPServerOAuthMetadata,
20
  )
21
  from mcp.shared.auth import (
22
  OAuthToken as OAuthToken,
@@ -39,80 +36,6 @@ def default_cache_dir() -> Path:
39
  return fastmcp_global_settings.home / "oauth-mcp-client-cache"
40
 
41
 
42
- # Flexible OAuth models for real-world compatibility
43
- class ServerOAuthMetadata(_MCPServerOAuthMetadata):
44
- """
45
- More flexible OAuth metadata model that accepts broader ranges of values
46
- than the restrictive MCP standard model.
47
-
48
- This handles real-world OAuth servers like PayPal that may support
49
- additional methods not in the MCP specification.
50
- """
51
-
52
- # Allow any code challenge methods, not just S256
53
- code_challenge_methods_supported: list[str] | None = None
54
-
55
- # Allow any token endpoint auth methods
56
- token_endpoint_auth_methods_supported: list[str] | None = None
57
-
58
- # Allow any grant types
59
- grant_types_supported: list[str] | None = None
60
-
61
- # Allow any response types
62
- response_types_supported: list[str] = ["code"]
63
-
64
- # Allow any response modes
65
- response_modes_supported: list[str] | None = None
66
-
67
-
68
- class OAuthClientProvider(_MCPOAuthClientProvider):
69
- """
70
- OAuth client provider with more flexible OAuth metadata discovery.
71
- """
72
-
73
- async def _discover_oauth_metadata(
74
- self, server_url: str
75
- ) -> ServerOAuthMetadata | None:
76
- """
77
- Discover OAuth metadata with flexible validation.
78
-
79
- This is nearly identical to the parent implementation but uses
80
- ServerOAuthMetadata instead of the restrictive MCP OAuthMetadata.
81
- """
82
- # Extract base URL per MCP spec
83
- auth_base_url = self.context.get_authorization_base_url(server_url)
84
- url = urljoin(auth_base_url, "/.well-known/oauth-authorization-server")
85
-
86
- from mcp.types import LATEST_PROTOCOL_VERSION
87
-
88
- headers = {"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION}
89
-
90
- async with httpx.AsyncClient() as client:
91
- try:
92
- response = await client.get(url, headers=headers)
93
- if response.status_code == 404:
94
- return None
95
- response.raise_for_status()
96
- metadata_json = response.json()
97
- logger.debug(f"OAuth metadata discovered: {metadata_json}")
98
- return ServerOAuthMetadata.model_validate(metadata_json)
99
- except Exception:
100
- # Retry without MCP header for CORS compatibility
101
- try:
102
- response = await client.get(url)
103
- if response.status_code == 404:
104
- return None
105
- response.raise_for_status()
106
- metadata_json = response.json()
107
- logger.debug(
108
- f"OAuth metadata discovered (no MCP header): {metadata_json}"
109
- )
110
- return ServerOAuthMetadata.model_validate(metadata_json)
111
- except Exception:
112
- logger.exception("Failed to discover OAuth metadata")
113
- return None
114
-
115
-
116
  class FileTokenStorage(TokenStorage):
117
  """
118
  File-based token storage implementation for OAuth credentials and tokens.
@@ -229,7 +152,7 @@ class FileTokenStorage(TokenStorage):
229
 
230
  async def discover_oauth_metadata(
231
  server_base_url: str, httpx_kwargs: dict[str, Any] | None = None
232
- ) -> _MCPServerOAuthMetadata | None:
233
  """
234
  Discover OAuth metadata from the server using RFC 8414 well-known endpoint.
235
 
@@ -248,7 +171,7 @@ async def discover_oauth_metadata(
248
  response = await client.get(well_known_url, timeout=10.0)
249
  if response.status_code == 200:
250
  logger.debug("Successfully discovered OAuth metadata")
251
- return _MCPServerOAuthMetadata.model_validate(response.json())
252
  elif response.status_code == 404:
253
  logger.debug(
254
  "OAuth metadata not found (404) - server may not require auth"
@@ -298,7 +221,7 @@ def OAuth(
298
  client_name: str = "FastMCP Client",
299
  token_storage_cache_dir: Path | None = None,
300
  additional_client_metadata: dict[str, Any] | None = None,
301
- ) -> _MCPOAuthClientProvider:
302
  """
303
  Create an OAuthClientProvider for an MCP server.
304
 
 
9
 
10
  import anyio
11
  import httpx
12
+ from mcp.client.auth import OAuthClientProvider, TokenStorage
 
13
  from mcp.shared.auth import (
14
  OAuthClientInformationFull,
15
  OAuthClientMetadata,
16
+ OAuthMetadata,
 
 
17
  )
18
  from mcp.shared.auth import (
19
  OAuthToken as OAuthToken,
 
36
  return fastmcp_global_settings.home / "oauth-mcp-client-cache"
37
 
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  class FileTokenStorage(TokenStorage):
40
  """
41
  File-based token storage implementation for OAuth credentials and tokens.
 
152
 
153
  async def discover_oauth_metadata(
154
  server_base_url: str, httpx_kwargs: dict[str, Any] | None = None
155
+ ) -> OAuthMetadata | None:
156
  """
157
  Discover OAuth metadata from the server using RFC 8414 well-known endpoint.
158
 
 
171
  response = await client.get(well_known_url, timeout=10.0)
172
  if response.status_code == 200:
173
  logger.debug("Successfully discovered OAuth metadata")
174
+ return OAuthMetadata.model_validate(response.json())
175
  elif response.status_code == 404:
176
  logger.debug(
177
  "OAuth metadata not found (404) - server may not require auth"
 
221
  client_name: str = "FastMCP Client",
222
  token_storage_cache_dir: Path | None = None,
223
  additional_client_metadata: dict[str, Any] | None = None,
224
+ ) -> OAuthClientProvider:
225
  """
226
  Create an OAuthClientProvider for an MCP server.
227
 
src/fastmcp/client/transports.py CHANGED
@@ -9,7 +9,6 @@ import warnings
9
  from collections.abc import AsyncIterator, Callable
10
  from pathlib import Path
11
  from typing import Any, Literal, TypeVar, cast, overload
12
- from urllib.parse import urlparse, urlunparse
13
 
14
  import anyio
15
  import httpx
@@ -161,11 +160,8 @@ class SSETransport(ClientTransport):
161
  if not isinstance(url, str) or not url.startswith("http"):
162
  raise ValueError("Invalid HTTP/S URL provided for SSE.")
163
 
164
- # Ensure the URL path ends with a trailing slash to avoid automatic redirects
165
- parsed = urlparse(url)
166
- if not parsed.path.endswith("/"):
167
- parsed = parsed._replace(path=parsed.path + "/")
168
- url = urlunparse(parsed)
169
 
170
  self.url = url
171
  self.headers = headers or {}
@@ -236,11 +232,8 @@ class StreamableHttpTransport(ClientTransport):
236
  if not isinstance(url, str) or not url.startswith("http"):
237
  raise ValueError("Invalid HTTP/S URL provided for Streamable HTTP.")
238
 
239
- # Ensure the URL path ends with a trailing slash to avoid automatic redirects
240
- parsed = urlparse(url)
241
- if not parsed.path.endswith("/"):
242
- parsed = parsed._replace(path=parsed.path + "/")
243
- url = urlunparse(parsed)
244
 
245
  self.url = url
246
  self.headers = headers or {}
 
9
  from collections.abc import AsyncIterator, Callable
10
  from pathlib import Path
11
  from typing import Any, Literal, TypeVar, cast, overload
 
12
 
13
  import anyio
14
  import httpx
 
160
  if not isinstance(url, str) or not url.startswith("http"):
161
  raise ValueError("Invalid HTTP/S URL provided for SSE.")
162
 
163
+ # Don't modify the URL path - respect the exact URL provided by the user
164
+ # Some servers are strict about trailing slashes (e.g., PayPal MCP)
 
 
 
165
 
166
  self.url = url
167
  self.headers = headers or {}
 
232
  if not isinstance(url, str) or not url.startswith("http"):
233
  raise ValueError("Invalid HTTP/S URL provided for Streamable HTTP.")
234
 
235
+ # Don't modify the URL path - respect the exact URL provided by the user
236
+ # Some servers are strict about trailing slashes (e.g., PayPal MCP)
 
 
 
237
 
238
  self.url = url
239
  self.headers = headers or {}
tests/utilities/test_mcp_config.py CHANGED
@@ -39,7 +39,7 @@ def test_parse_single_remote_config():
39
  mcp_config = MCPConfig.from_dict(config)
40
  transport = mcp_config.mcpServers["test_server"].to_transport()
41
  assert isinstance(transport, StreamableHttpTransport)
42
- assert transport.url == "http://localhost:8000/"
43
 
44
 
45
  def test_parse_remote_config_with_transport():
@@ -54,7 +54,7 @@ def test_parse_remote_config_with_transport():
54
  mcp_config = MCPConfig.from_dict(config)
55
  transport = mcp_config.mcpServers["test_server"].to_transport()
56
  assert isinstance(transport, SSETransport)
57
- assert transport.url == "http://localhost:8000/"
58
 
59
 
60
  def test_parse_remote_config_with_url_inference():
 
39
  mcp_config = MCPConfig.from_dict(config)
40
  transport = mcp_config.mcpServers["test_server"].to_transport()
41
  assert isinstance(transport, StreamableHttpTransport)
42
+ assert transport.url == "http://localhost:8000"
43
 
44
 
45
  def test_parse_remote_config_with_transport():
 
54
  mcp_config = MCPConfig.from_dict(config)
55
  transport = mcp_config.mcpServers["test_server"].to_transport()
56
  assert isinstance(transport, SSETransport)
57
+ assert transport.url == "http://localhost:8000"
58
 
59
 
60
  def test_parse_remote_config_with_url_inference():