fastmcp 2.12.1__py3-none-any.whl → 2.13.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. fastmcp/__init__.py +2 -2
  2. fastmcp/cli/cli.py +56 -36
  3. fastmcp/cli/install/__init__.py +2 -0
  4. fastmcp/cli/install/claude_code.py +7 -16
  5. fastmcp/cli/install/claude_desktop.py +4 -12
  6. fastmcp/cli/install/cursor.py +20 -30
  7. fastmcp/cli/install/gemini_cli.py +241 -0
  8. fastmcp/cli/install/mcp_json.py +4 -12
  9. fastmcp/cli/run.py +15 -94
  10. fastmcp/client/__init__.py +9 -9
  11. fastmcp/client/auth/oauth.py +117 -206
  12. fastmcp/client/client.py +123 -47
  13. fastmcp/client/elicitation.py +6 -1
  14. fastmcp/client/logging.py +18 -14
  15. fastmcp/client/oauth_callback.py +85 -171
  16. fastmcp/client/sampling.py +1 -1
  17. fastmcp/client/transports.py +81 -26
  18. fastmcp/contrib/component_manager/__init__.py +1 -1
  19. fastmcp/contrib/component_manager/component_manager.py +2 -2
  20. fastmcp/contrib/component_manager/component_service.py +7 -7
  21. fastmcp/contrib/mcp_mixin/README.md +35 -4
  22. fastmcp/contrib/mcp_mixin/__init__.py +2 -2
  23. fastmcp/contrib/mcp_mixin/mcp_mixin.py +54 -7
  24. fastmcp/experimental/sampling/handlers/openai.py +2 -2
  25. fastmcp/experimental/server/openapi/__init__.py +5 -8
  26. fastmcp/experimental/server/openapi/components.py +11 -7
  27. fastmcp/experimental/server/openapi/routing.py +2 -2
  28. fastmcp/experimental/utilities/openapi/__init__.py +10 -15
  29. fastmcp/experimental/utilities/openapi/director.py +16 -10
  30. fastmcp/experimental/utilities/openapi/json_schema_converter.py +6 -2
  31. fastmcp/experimental/utilities/openapi/models.py +3 -3
  32. fastmcp/experimental/utilities/openapi/parser.py +37 -16
  33. fastmcp/experimental/utilities/openapi/schemas.py +33 -7
  34. fastmcp/mcp_config.py +3 -4
  35. fastmcp/prompts/__init__.py +1 -1
  36. fastmcp/prompts/prompt.py +32 -27
  37. fastmcp/prompts/prompt_manager.py +16 -101
  38. fastmcp/resources/__init__.py +5 -5
  39. fastmcp/resources/resource.py +28 -20
  40. fastmcp/resources/resource_manager.py +9 -168
  41. fastmcp/resources/template.py +119 -27
  42. fastmcp/resources/types.py +30 -24
  43. fastmcp/server/__init__.py +1 -1
  44. fastmcp/server/auth/__init__.py +9 -5
  45. fastmcp/server/auth/auth.py +80 -47
  46. fastmcp/server/auth/handlers/authorize.py +326 -0
  47. fastmcp/server/auth/jwt_issuer.py +236 -0
  48. fastmcp/server/auth/middleware.py +96 -0
  49. fastmcp/server/auth/oauth_proxy.py +1556 -265
  50. fastmcp/server/auth/oidc_proxy.py +412 -0
  51. fastmcp/server/auth/providers/auth0.py +193 -0
  52. fastmcp/server/auth/providers/aws.py +263 -0
  53. fastmcp/server/auth/providers/azure.py +314 -129
  54. fastmcp/server/auth/providers/bearer.py +1 -1
  55. fastmcp/server/auth/providers/debug.py +114 -0
  56. fastmcp/server/auth/providers/descope.py +229 -0
  57. fastmcp/server/auth/providers/discord.py +308 -0
  58. fastmcp/server/auth/providers/github.py +31 -6
  59. fastmcp/server/auth/providers/google.py +50 -7
  60. fastmcp/server/auth/providers/in_memory.py +27 -3
  61. fastmcp/server/auth/providers/introspection.py +281 -0
  62. fastmcp/server/auth/providers/jwt.py +48 -31
  63. fastmcp/server/auth/providers/oci.py +233 -0
  64. fastmcp/server/auth/providers/scalekit.py +238 -0
  65. fastmcp/server/auth/providers/supabase.py +188 -0
  66. fastmcp/server/auth/providers/workos.py +37 -15
  67. fastmcp/server/context.py +194 -67
  68. fastmcp/server/dependencies.py +56 -16
  69. fastmcp/server/elicitation.py +1 -1
  70. fastmcp/server/http.py +57 -18
  71. fastmcp/server/low_level.py +121 -2
  72. fastmcp/server/middleware/__init__.py +1 -1
  73. fastmcp/server/middleware/caching.py +476 -0
  74. fastmcp/server/middleware/error_handling.py +14 -10
  75. fastmcp/server/middleware/logging.py +158 -116
  76. fastmcp/server/middleware/middleware.py +30 -16
  77. fastmcp/server/middleware/rate_limiting.py +3 -3
  78. fastmcp/server/middleware/tool_injection.py +116 -0
  79. fastmcp/server/openapi.py +15 -7
  80. fastmcp/server/proxy.py +22 -11
  81. fastmcp/server/server.py +744 -254
  82. fastmcp/settings.py +65 -15
  83. fastmcp/tools/__init__.py +1 -1
  84. fastmcp/tools/tool.py +173 -108
  85. fastmcp/tools/tool_manager.py +30 -112
  86. fastmcp/tools/tool_transform.py +13 -11
  87. fastmcp/utilities/cli.py +67 -28
  88. fastmcp/utilities/components.py +7 -2
  89. fastmcp/utilities/inspect.py +79 -23
  90. fastmcp/utilities/json_schema.py +21 -4
  91. fastmcp/utilities/json_schema_type.py +4 -4
  92. fastmcp/utilities/logging.py +182 -10
  93. fastmcp/utilities/mcp_server_config/__init__.py +3 -3
  94. fastmcp/utilities/mcp_server_config/v1/environments/base.py +1 -2
  95. fastmcp/utilities/mcp_server_config/v1/environments/uv.py +10 -45
  96. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +8 -7
  97. fastmcp/utilities/mcp_server_config/v1/schema.json +5 -1
  98. fastmcp/utilities/mcp_server_config/v1/sources/base.py +0 -1
  99. fastmcp/utilities/openapi.py +11 -11
  100. fastmcp/utilities/tests.py +93 -10
  101. fastmcp/utilities/types.py +87 -21
  102. fastmcp/utilities/ui.py +626 -0
  103. {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/METADATA +141 -60
  104. fastmcp-2.13.2.dist-info/RECORD +144 -0
  105. {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/WHEEL +1 -1
  106. fastmcp/cli/claude.py +0 -144
  107. fastmcp-2.12.1.dist-info/RECORD +0 -128
  108. {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/entry_points.txt +0 -0
  109. {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/licenses/LICENSE +0 -0
fastmcp/cli/run.py CHANGED
@@ -1,9 +1,7 @@
1
1
  """FastMCP run command implementation with enhanced type hints."""
2
2
 
3
3
  import json
4
- import os
5
4
  import re
6
- import subprocess
7
5
  import sys
8
6
  from pathlib import Path
9
7
  from typing import Any, Literal
@@ -15,7 +13,6 @@ from fastmcp.utilities.logging import get_logger
15
13
  from fastmcp.utilities.mcp_server_config import (
16
14
  MCPServerConfig,
17
15
  )
18
- from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment
19
16
  from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource
20
17
 
21
18
  logger = get_logger("cli.run")
@@ -31,87 +28,6 @@ def is_url(path: str) -> bool:
31
28
  return bool(url_pattern.match(path))
32
29
 
33
30
 
34
- def run_with_uv(
35
- server_spec: str,
36
- python_version: str | None = None,
37
- with_packages: list[str] | None = None,
38
- with_requirements: Path | None = None,
39
- project: Path | None = None,
40
- transport: TransportType | None = None,
41
- host: str | None = None,
42
- port: int | None = None,
43
- path: str | None = None,
44
- log_level: LogLevelType | None = None,
45
- show_banner: bool = True,
46
- editable: str | list[str] | None = None,
47
- ) -> None:
48
- """Run a MCP server using uv run subprocess.
49
-
50
- This function is called when we need to set up a Python environment with specific
51
- dependencies before running the server. The config parsing and merging should already
52
- be done by the caller.
53
-
54
- Args:
55
- server_spec: Python file, object specification (file:obj), config file, or URL
56
- python_version: Python version to use (e.g. "3.10")
57
- with_packages: Additional packages to install
58
- with_requirements: Requirements file to use
59
- project: Run the command within the given project directory
60
- transport: Transport protocol to use
61
- host: Host to bind to when using http transport
62
- port: Port to bind to when using http transport
63
- path: Path to bind to when using http transport
64
- log_level: Log level
65
- show_banner: Whether to show the server banner
66
- editable: Editable package paths
67
- """
68
-
69
- # Build uv command using Environment.build_uv_run_command()
70
- env_config = UVEnvironment(
71
- python=python_version,
72
- dependencies=with_packages if with_packages else None,
73
- requirements=str(with_requirements.resolve()) if with_requirements else None,
74
- project=str(project.resolve()) if project else None,
75
- editable=editable
76
- if isinstance(editable, list)
77
- else ([editable] if editable else None),
78
- )
79
-
80
- # Build the inner fastmcp command (environment variable prevents infinite recursion)
81
- inner_cmd = ["fastmcp", "run", server_spec]
82
-
83
- # Add transport options to the inner command
84
- if transport:
85
- inner_cmd.extend(["--transport", transport])
86
- # Only add HTTP-specific options for non-stdio transports
87
- if transport != "stdio":
88
- if host:
89
- inner_cmd.extend(["--host", host])
90
- if port:
91
- inner_cmd.extend(["--port", str(port)])
92
- if path:
93
- inner_cmd.extend(["--path", path])
94
- if log_level:
95
- inner_cmd.extend(["--log-level", log_level])
96
- if not show_banner:
97
- inner_cmd.append("--no-banner")
98
-
99
- # Build the full uv command
100
- cmd = env_config.build_command(inner_cmd)
101
-
102
- # Set marker to prevent infinite loops when subprocess calls FastMCP again
103
- env = os.environ | {"FASTMCP_UV_SPAWNED": "1"}
104
-
105
- # Run the command
106
- logger.debug(f"Running command: {' '.join(cmd)}")
107
- try:
108
- process = subprocess.run(cmd, check=True, env=env)
109
- sys.exit(process.returncode)
110
- except subprocess.CalledProcessError as e:
111
- logger.error(f"Failed to run server: {e}")
112
- sys.exit(e.returncode)
113
-
114
-
115
31
  def create_client_server(url: str) -> Any:
116
32
  """Create a FastMCP server from a client URL.
117
33
 
@@ -256,7 +172,7 @@ async def run_command(
256
172
 
257
173
  # handle v1 servers
258
174
  if isinstance(server, FastMCP1x):
259
- run_v1_server(server, host=host, port=port, transport=transport)
175
+ await run_v1_server_async(server, host=host, port=port, transport=transport)
260
176
  return
261
177
 
262
178
  kwargs = {}
@@ -268,8 +184,8 @@ async def run_command(
268
184
  kwargs["port"] = port
269
185
  if path:
270
186
  kwargs["path"] = path
271
- # Note: log_level is not currently supported by run_async
272
- # TODO: Add log_level support to server.run_async
187
+ if log_level:
188
+ kwargs["log_level"] = log_level
273
189
 
274
190
  if not show_banner:
275
191
  kwargs["show_banner"] = False
@@ -281,24 +197,29 @@ async def run_command(
281
197
  sys.exit(1)
282
198
 
283
199
 
284
- def run_v1_server(
200
+ async def run_v1_server_async(
285
201
  server: FastMCP1x,
286
202
  host: str | None = None,
287
203
  port: int | None = None,
288
204
  transport: TransportType | None = None,
289
205
  ) -> None:
290
- from functools import partial
206
+ """Run a FastMCP 1.x server using async methods.
291
207
 
208
+ Args:
209
+ server: FastMCP 1.x server instance
210
+ host: Host to bind to
211
+ port: Port to bind to
212
+ transport: Transport protocol to use
213
+ """
292
214
  if host:
293
215
  server.settings.host = host
294
216
  if port:
295
217
  server.settings.port = port
218
+
296
219
  match transport:
297
220
  case "stdio":
298
- runner = partial(server.run)
221
+ await server.run_stdio_async()
299
222
  case "http" | "streamable-http" | None:
300
- runner = partial(server.run, transport="streamable-http")
223
+ await server.run_streamable_http_async()
301
224
  case "sse":
302
- runner = partial(server.run, transport="sse")
303
-
304
- runner()
225
+ await server.run_sse_async()
@@ -15,18 +15,18 @@ from .transports import (
15
15
  from .auth import OAuth, BearerAuth
16
16
 
17
17
  __all__ = [
18
+ "BearerAuth",
18
19
  "Client",
19
20
  "ClientTransport",
20
- "WSTransport",
21
- "SSETransport",
22
- "StdioTransport",
23
- "PythonStdioTransport",
21
+ "FastMCPTransport",
24
22
  "NodeStdioTransport",
25
- "UvxStdioTransport",
26
- "UvStdioTransport",
27
23
  "NpxStdioTransport",
28
- "FastMCPTransport",
29
- "StreamableHttpTransport",
30
24
  "OAuth",
31
- "BearerAuth",
25
+ "PythonStdioTransport",
26
+ "SSETransport",
27
+ "StdioTransport",
28
+ "StreamableHttpTransport",
29
+ "UvStdioTransport",
30
+ "UvxStdioTransport",
31
+ "WSTransport",
32
32
  ]
@@ -1,30 +1,29 @@
1
1
  from __future__ import annotations
2
2
 
3
- import asyncio
4
- import json
3
+ import time
5
4
  import webbrowser
6
- from asyncio import Future
7
5
  from collections.abc import AsyncGenerator
8
- from datetime import datetime, timedelta, timezone
9
- from pathlib import Path
10
- from typing import Any, Literal
6
+ from typing import Any
11
7
  from urllib.parse import urlparse
12
8
 
13
9
  import anyio
14
10
  import httpx
11
+ from key_value.aio.adapters.pydantic import PydanticAdapter
12
+ from key_value.aio.protocols import AsyncKeyValue
13
+ from key_value.aio.stores.memory import MemoryStore
15
14
  from mcp.client.auth import OAuthClientProvider, TokenStorage
15
+ from mcp.shared._httpx_utils import McpHttpClientFactory
16
16
  from mcp.shared.auth import (
17
17
  OAuthClientInformationFull,
18
18
  OAuthClientMetadata,
19
+ OAuthToken,
19
20
  )
20
- from mcp.shared.auth import (
21
- OAuthToken as OAuthToken,
22
- )
23
- from pydantic import AnyHttpUrl, BaseModel, TypeAdapter, ValidationError
21
+ from pydantic import AnyHttpUrl
22
+ from typing_extensions import override
24
23
  from uvicorn.server import Server
25
24
 
26
- from fastmcp import settings as fastmcp_global_settings
27
25
  from fastmcp.client.oauth_callback import (
26
+ OAuthCallbackResult,
28
27
  create_oauth_callback_server,
29
28
  )
30
29
  from fastmcp.utilities.http import find_available_port
@@ -38,163 +37,6 @@ logger = get_logger(__name__)
38
37
  class ClientNotFoundError(Exception):
39
38
  """Raised when OAuth client credentials are not found on the server."""
40
39
 
41
- pass
42
-
43
-
44
- class StoredToken(BaseModel):
45
- """Token storage format with absolute expiry time."""
46
-
47
- token_payload: OAuthToken
48
- expires_at: datetime | None
49
-
50
-
51
- # Create TypeAdapter at module level for efficient parsing
52
- stored_token_adapter = TypeAdapter(StoredToken)
53
-
54
-
55
- def default_cache_dir() -> Path:
56
- return fastmcp_global_settings.home / "oauth-mcp-client-cache"
57
-
58
-
59
- class FileTokenStorage(TokenStorage):
60
- """
61
- File-based token storage implementation for OAuth credentials and tokens.
62
- Implements the mcp.client.auth.TokenStorage protocol.
63
-
64
- Each instance is tied to a specific server URL for proper token isolation.
65
- """
66
-
67
- def __init__(self, server_url: str, cache_dir: Path | None = None):
68
- """Initialize storage for a specific server URL."""
69
- self.server_url = server_url
70
- self.cache_dir = cache_dir or default_cache_dir()
71
- self.cache_dir.mkdir(exist_ok=True, parents=True)
72
-
73
- @staticmethod
74
- def get_base_url(url: str) -> str:
75
- """Extract the base URL (scheme + host) from a URL."""
76
- parsed = urlparse(url)
77
- return f"{parsed.scheme}://{parsed.netloc}"
78
-
79
- def get_cache_key(self) -> str:
80
- """Generate a safe filesystem key from the server's base URL."""
81
- base_url = self.get_base_url(self.server_url)
82
- return (
83
- base_url.replace("://", "_")
84
- .replace(".", "_")
85
- .replace("/", "_")
86
- .replace(":", "_")
87
- )
88
-
89
- def _get_file_path(self, file_type: Literal["client_info", "tokens"]) -> Path:
90
- """Get the file path for the specified cache file type."""
91
- key = self.get_cache_key()
92
- return self.cache_dir / f"{key}_{file_type}.json"
93
-
94
- async def get_tokens(self) -> OAuthToken | None:
95
- """Load tokens from file storage."""
96
- path = self._get_file_path("tokens")
97
-
98
- try:
99
- # Parse JSON and validate as StoredToken
100
- stored = stored_token_adapter.validate_json(path.read_text())
101
-
102
- # Check if token is expired
103
- if stored.expires_at is not None:
104
- now = datetime.now(timezone.utc)
105
- if now >= stored.expires_at:
106
- logger.debug(
107
- f"Token expired for {self.get_base_url(self.server_url)}"
108
- )
109
- return None
110
-
111
- # Recalculate expires_in to be correct relative to now
112
- if stored.token_payload.expires_in is not None:
113
- remaining = stored.expires_at - now
114
- stored.token_payload.expires_in = max(
115
- 0, int(remaining.total_seconds())
116
- )
117
-
118
- return stored.token_payload
119
-
120
- except (FileNotFoundError, ValidationError) as e:
121
- logger.debug(
122
- f"Could not load tokens for {self.get_base_url(self.server_url)}: {e}"
123
- )
124
- return None
125
-
126
- async def set_tokens(self, tokens: OAuthToken) -> None:
127
- """Save tokens to file storage."""
128
- path = self._get_file_path("tokens")
129
-
130
- # Calculate absolute expiry time if expires_in is present
131
- expires_at = None
132
- if tokens.expires_in is not None:
133
- expires_at = datetime.now(timezone.utc) + timedelta(
134
- seconds=tokens.expires_in
135
- )
136
-
137
- # Create StoredToken and save using Pydantic serialization
138
- stored = StoredToken(token_payload=tokens, expires_at=expires_at)
139
-
140
- path.write_text(stored.model_dump_json(indent=2))
141
- logger.debug(f"Saved tokens for {self.get_base_url(self.server_url)}")
142
-
143
- async def get_client_info(self) -> OAuthClientInformationFull | None:
144
- """Load client information from file storage."""
145
- path = self._get_file_path("client_info")
146
- try:
147
- client_info = OAuthClientInformationFull.model_validate_json(
148
- path.read_text()
149
- )
150
- # Check if we have corresponding valid tokens
151
- # If no tokens exist, the OAuth flow was incomplete and we should
152
- # force a fresh client registration
153
- tokens = await self.get_tokens()
154
- if tokens is None:
155
- logger.debug(
156
- f"No tokens found for client info at {self.get_base_url(self.server_url)}. "
157
- "OAuth flow may have been incomplete. Clearing client info to force fresh registration."
158
- )
159
- # Clear the incomplete client info
160
- client_info_path = self._get_file_path("client_info")
161
- client_info_path.unlink(missing_ok=True)
162
- return None
163
-
164
- return client_info
165
- except (FileNotFoundError, json.JSONDecodeError, ValidationError) as e:
166
- logger.debug(
167
- f"Could not load client info for {self.get_base_url(self.server_url)}: {e}"
168
- )
169
- return None
170
-
171
- async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
172
- """Save client information to file storage."""
173
- path = self._get_file_path("client_info")
174
- path.write_text(client_info.model_dump_json(indent=2))
175
- logger.debug(f"Saved client info for {self.get_base_url(self.server_url)}")
176
-
177
- def clear(self) -> None:
178
- """Clear all cached data for this server."""
179
- file_types: list[Literal["client_info", "tokens"]] = ["client_info", "tokens"]
180
- for file_type in file_types:
181
- path = self._get_file_path(file_type)
182
- path.unlink(missing_ok=True)
183
- logger.debug(f"Cleared OAuth cache for {self.get_base_url(self.server_url)}")
184
-
185
- @classmethod
186
- def clear_all(cls, cache_dir: Path | None = None) -> None:
187
- """Clear all cached data for all servers."""
188
- cache_dir = cache_dir or default_cache_dir()
189
- if not cache_dir.exists():
190
- return
191
-
192
- file_types: list[Literal["client_info", "tokens"]] = ["client_info", "tokens"]
193
- for file_type in file_types:
194
- for file in cache_dir.glob(f"*_{file_type}.json"):
195
- file.unlink(missing_ok=True)
196
- logger.info("Cleared all OAuth client cache data.")
197
-
198
40
 
199
41
  async def check_if_auth_required(
200
42
  mcp_url: str, httpx_kwargs: dict[str, Any] | None = None
@@ -215,7 +57,7 @@ async def check_if_auth_required(
215
57
  return True
216
58
 
217
59
  # Check for WWW-Authenticate header
218
- if "WWW-Authenticate" in response.headers:
60
+ if "WWW-Authenticate" in response.headers: # noqa: SIM103
219
61
  return True
220
62
 
221
63
  # If we get a successful response, auth may not be required
@@ -226,6 +68,70 @@ async def check_if_auth_required(
226
68
  return True
227
69
 
228
70
 
71
+ class TokenStorageAdapter(TokenStorage):
72
+ _server_url: str
73
+ _key_value_store: AsyncKeyValue
74
+ _storage_oauth_token: PydanticAdapter[OAuthToken]
75
+ _storage_client_info: PydanticAdapter[OAuthClientInformationFull]
76
+
77
+ def __init__(self, async_key_value: AsyncKeyValue, server_url: str):
78
+ self._server_url = server_url
79
+ self._key_value_store = async_key_value
80
+ self._storage_oauth_token = PydanticAdapter[OAuthToken](
81
+ default_collection="mcp-oauth-token",
82
+ key_value=async_key_value,
83
+ pydantic_model=OAuthToken,
84
+ raise_on_validation_error=True,
85
+ )
86
+ self._storage_client_info = PydanticAdapter[OAuthClientInformationFull](
87
+ default_collection="mcp-oauth-client-info",
88
+ key_value=async_key_value,
89
+ pydantic_model=OAuthClientInformationFull,
90
+ raise_on_validation_error=True,
91
+ )
92
+
93
+ def _get_token_cache_key(self) -> str:
94
+ return f"{self._server_url}/tokens"
95
+
96
+ def _get_client_info_cache_key(self) -> str:
97
+ return f"{self._server_url}/client_info"
98
+
99
+ async def clear(self) -> None:
100
+ await self._storage_oauth_token.delete(key=self._get_token_cache_key())
101
+ await self._storage_client_info.delete(key=self._get_client_info_cache_key())
102
+
103
+ @override
104
+ async def get_tokens(self) -> OAuthToken | None:
105
+ return await self._storage_oauth_token.get(key=self._get_token_cache_key())
106
+
107
+ @override
108
+ async def set_tokens(self, tokens: OAuthToken) -> None:
109
+ await self._storage_oauth_token.put(
110
+ key=self._get_token_cache_key(),
111
+ value=tokens,
112
+ ttl=tokens.expires_in,
113
+ )
114
+
115
+ @override
116
+ async def get_client_info(self) -> OAuthClientInformationFull | None:
117
+ return await self._storage_client_info.get(
118
+ key=self._get_client_info_cache_key()
119
+ )
120
+
121
+ @override
122
+ async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
123
+ ttl: int | None = None
124
+
125
+ if client_info.client_secret_expires_at:
126
+ ttl = client_info.client_secret_expires_at - int(time.time())
127
+
128
+ await self._storage_client_info.put(
129
+ key=self._get_client_info_cache_key(),
130
+ value=client_info,
131
+ ttl=ttl,
132
+ )
133
+
134
+
229
135
  class OAuth(OAuthClientProvider):
230
136
  """
231
137
  OAuth client provider for MCP servers with browser-based authentication.
@@ -239,9 +145,10 @@ class OAuth(OAuthClientProvider):
239
145
  mcp_url: str,
240
146
  scopes: str | list[str] | None = None,
241
147
  client_name: str = "FastMCP Client",
242
- token_storage_cache_dir: Path | None = None,
148
+ token_storage: AsyncKeyValue | None = None,
243
149
  additional_client_metadata: dict[str, Any] | None = None,
244
150
  callback_port: int | None = None,
151
+ httpx_client_factory: McpHttpClientFactory | None = None,
245
152
  ):
246
153
  """
247
154
  Initialize OAuth client provider for an MCP server.
@@ -251,7 +158,7 @@ class OAuth(OAuthClientProvider):
251
158
  scopes: OAuth scopes to request. Can be a
252
159
  space-separated string or a list of strings.
253
160
  client_name: Name for this client during registration
254
- token_storage_cache_dir: Directory for FileTokenStorage
161
+ token_storage: An AsyncKeyValue-compatible token store, tokens are stored in memory if not provided
255
162
  additional_client_metadata: Extra fields for OAuthClientMetadata
256
163
  callback_port: Fixed port for OAuth callback (default: random available port)
257
164
  """
@@ -259,6 +166,7 @@ class OAuth(OAuthClientProvider):
259
166
  server_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
260
167
 
261
168
  # Setup OAuth client
169
+ self.httpx_client_factory = httpx_client_factory or httpx.AsyncClient
262
170
  self.redirect_port = callback_port or find_available_port()
263
171
  redirect_uri = f"http://localhost:{self.redirect_port}/callback"
264
172
 
@@ -281,8 +189,20 @@ class OAuth(OAuthClientProvider):
281
189
  )
282
190
 
283
191
  # Create server-specific token storage
284
- storage = FileTokenStorage(
285
- server_url=server_base_url, cache_dir=token_storage_cache_dir
192
+ token_storage = token_storage or MemoryStore()
193
+
194
+ if isinstance(token_storage, MemoryStore):
195
+ from warnings import warn
196
+
197
+ warn(
198
+ message="Using in-memory token storage -- tokens will be lost when the client restarts. "
199
+ + "For persistent storage across multiple MCP servers, provide an encrypted AsyncKeyValue backend. "
200
+ + "See https://gofastmcp.com/clients/auth/oauth#token-storage for details.",
201
+ stacklevel=2,
202
+ )
203
+
204
+ self.token_storage_adapter: TokenStorageAdapter = TokenStorageAdapter(
205
+ async_key_value=token_storage, server_url=server_base_url
286
206
  )
287
207
 
288
208
  # Store server_base_url for use in callback_handler
@@ -292,7 +212,7 @@ class OAuth(OAuthClientProvider):
292
212
  super().__init__(
293
213
  server_url=server_base_url,
294
214
  client_metadata=client_metadata,
295
- storage=storage,
215
+ storage=self.token_storage_adapter,
296
216
  redirect_handler=self.redirect_handler,
297
217
  callback_handler=self.callback_handler,
298
218
  )
@@ -309,7 +229,7 @@ class OAuth(OAuthClientProvider):
309
229
  async def redirect_handler(self, authorization_url: str) -> None:
310
230
  """Open browser for authorization, with pre-flight check for invalid client."""
311
231
  # Pre-flight check to detect invalid client_id before opening browser
312
- async with httpx.AsyncClient() as client:
232
+ async with self.httpx_client_factory() as client:
313
233
  response = await client.get(authorization_url, follow_redirects=False)
314
234
 
315
235
  # Check for client not found error (400 typically means bad client_id)
@@ -318,8 +238,8 @@ class OAuth(OAuthClientProvider):
318
238
  "OAuth client not found - cached credentials may be stale"
319
239
  )
320
240
 
321
- # For any non-redirect response, something is wrong
322
- if response.status_code not in (302, 303, 307, 308):
241
+ # OAuth typically returns redirects, but some providers return 200 with HTML login pages
242
+ if response.status_code not in (200, 302, 303, 307, 308):
323
243
  raise RuntimeError(
324
244
  f"Unexpected authorization response: {response.status_code}"
325
245
  )
@@ -329,14 +249,16 @@ class OAuth(OAuthClientProvider):
329
249
 
330
250
  async def callback_handler(self) -> tuple[str, str | None]:
331
251
  """Handle OAuth callback and return (auth_code, state)."""
332
- # Create a future to capture the OAuth response
333
- response_future: Future[Any] = asyncio.get_running_loop().create_future()
252
+ # Create result container and event to capture the OAuth response
253
+ result = OAuthCallbackResult()
254
+ result_ready = anyio.Event()
334
255
 
335
- # Create server with the future
256
+ # Create server with result tracking
336
257
  server: Server = create_oauth_callback_server(
337
258
  port=self.redirect_port,
338
259
  server_url=self.server_base_url,
339
- response_future=response_future,
260
+ result_container=result,
261
+ result_ready=result_ready,
340
262
  )
341
263
 
342
264
  # Run server until response is received with timeout logic
@@ -349,13 +271,17 @@ class OAuth(OAuthClientProvider):
349
271
  TIMEOUT = 300.0 # 5 minute timeout
350
272
  try:
351
273
  with anyio.fail_after(TIMEOUT):
352
- auth_code, state = await response_future
353
- return auth_code, state
354
- except TimeoutError:
355
- raise TimeoutError(f"OAuth callback timed out after {TIMEOUT} seconds")
274
+ await result_ready.wait()
275
+ if result.error:
276
+ raise result.error
277
+ return result.code, result.state # type: ignore
278
+ except TimeoutError as e:
279
+ raise TimeoutError(
280
+ f"OAuth callback timed out after {TIMEOUT} seconds"
281
+ ) from e
356
282
  finally:
357
283
  server.should_exit = True
358
- await asyncio.sleep(0.1) # Allow server to shutdown gracefully
284
+ await anyio.sleep(0.1) # Allow server to shut down gracefully
359
285
  tg.cancel_scope.cancel()
360
286
 
361
287
  raise RuntimeError("OAuth callback handler could not be started")
@@ -374,7 +300,8 @@ class OAuth(OAuthClientProvider):
374
300
  response = None
375
301
  while True:
376
302
  try:
377
- yielded_request = await gen.asend(response)
303
+ # First iteration sends None, subsequent iterations send response
304
+ yielded_request = await gen.asend(response) # ty: ignore[invalid-argument-type]
378
305
  response = yield yielded_request
379
306
  except StopAsyncIteration:
380
307
  break
@@ -383,32 +310,16 @@ class OAuth(OAuthClientProvider):
383
310
  logger.debug(
384
311
  "OAuth client not found on server, clearing cache and retrying..."
385
312
  )
386
-
387
313
  # Clear cached state and retry once
388
314
  self._initialized = False
315
+ await self.token_storage_adapter.clear()
389
316
 
390
- # Try to clear storage if it supports it
391
- if hasattr(self.context.storage, "clear"):
392
- try:
393
- self.context.storage.clear()
394
- except Exception as e:
395
- logger.warning(f"Failed to clear OAuth storage cache: {e}")
396
- # Can't retry without clearing cache, re-raise original error
397
- raise ClientNotFoundError(
398
- "OAuth client not found and cache could not be cleared"
399
- ) from e
400
- else:
401
- logger.warning(
402
- "Storage does not support clear() - cannot retry with fresh credentials"
403
- )
404
- # Can't retry without clearing cache, re-raise original error
405
- raise
406
-
317
+ # Retry with fresh registration
407
318
  gen = super().async_auth_flow(request)
408
319
  response = None
409
320
  while True:
410
321
  try:
411
- yielded_request = await gen.asend(response)
322
+ yielded_request = await gen.asend(response) # ty: ignore[invalid-argument-type]
412
323
  response = yield yielded_request
413
324
  except StopAsyncIteration:
414
325
  break