fastmcp 2.12.4__py3-none-any.whl → 2.13.0__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 (72) hide show
  1. fastmcp/cli/cli.py +7 -6
  2. fastmcp/cli/install/claude_code.py +6 -6
  3. fastmcp/cli/install/claude_desktop.py +3 -3
  4. fastmcp/cli/install/cursor.py +7 -7
  5. fastmcp/cli/install/gemini_cli.py +3 -3
  6. fastmcp/cli/install/mcp_json.py +3 -3
  7. fastmcp/cli/run.py +13 -8
  8. fastmcp/client/auth/oauth.py +100 -208
  9. fastmcp/client/client.py +11 -11
  10. fastmcp/client/logging.py +18 -14
  11. fastmcp/client/oauth_callback.py +85 -171
  12. fastmcp/client/transports.py +77 -22
  13. fastmcp/contrib/component_manager/component_service.py +6 -6
  14. fastmcp/contrib/mcp_mixin/README.md +32 -1
  15. fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -2
  16. fastmcp/experimental/utilities/openapi/json_schema_converter.py +4 -0
  17. fastmcp/experimental/utilities/openapi/parser.py +23 -3
  18. fastmcp/prompts/prompt.py +13 -6
  19. fastmcp/prompts/prompt_manager.py +16 -101
  20. fastmcp/resources/resource.py +13 -6
  21. fastmcp/resources/resource_manager.py +5 -164
  22. fastmcp/resources/template.py +107 -17
  23. fastmcp/resources/types.py +30 -24
  24. fastmcp/server/auth/auth.py +40 -32
  25. fastmcp/server/auth/handlers/authorize.py +324 -0
  26. fastmcp/server/auth/jwt_issuer.py +236 -0
  27. fastmcp/server/auth/middleware.py +96 -0
  28. fastmcp/server/auth/oauth_proxy.py +1256 -242
  29. fastmcp/server/auth/oidc_proxy.py +23 -6
  30. fastmcp/server/auth/providers/auth0.py +40 -21
  31. fastmcp/server/auth/providers/aws.py +29 -3
  32. fastmcp/server/auth/providers/azure.py +178 -127
  33. fastmcp/server/auth/providers/descope.py +4 -6
  34. fastmcp/server/auth/providers/github.py +29 -8
  35. fastmcp/server/auth/providers/google.py +30 -9
  36. fastmcp/server/auth/providers/introspection.py +281 -0
  37. fastmcp/server/auth/providers/jwt.py +8 -2
  38. fastmcp/server/auth/providers/scalekit.py +179 -0
  39. fastmcp/server/auth/providers/supabase.py +172 -0
  40. fastmcp/server/auth/providers/workos.py +32 -14
  41. fastmcp/server/context.py +122 -36
  42. fastmcp/server/http.py +58 -18
  43. fastmcp/server/low_level.py +121 -2
  44. fastmcp/server/middleware/caching.py +469 -0
  45. fastmcp/server/middleware/error_handling.py +6 -2
  46. fastmcp/server/middleware/logging.py +48 -37
  47. fastmcp/server/middleware/middleware.py +28 -15
  48. fastmcp/server/middleware/rate_limiting.py +3 -3
  49. fastmcp/server/middleware/tool_injection.py +116 -0
  50. fastmcp/server/proxy.py +6 -6
  51. fastmcp/server/server.py +683 -207
  52. fastmcp/settings.py +24 -10
  53. fastmcp/tools/tool.py +7 -3
  54. fastmcp/tools/tool_manager.py +30 -112
  55. fastmcp/tools/tool_transform.py +3 -3
  56. fastmcp/utilities/cli.py +62 -22
  57. fastmcp/utilities/components.py +5 -0
  58. fastmcp/utilities/inspect.py +77 -21
  59. fastmcp/utilities/logging.py +118 -8
  60. fastmcp/utilities/mcp_server_config/v1/environments/uv.py +6 -6
  61. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +3 -3
  62. fastmcp/utilities/mcp_server_config/v1/schema.json +3 -0
  63. fastmcp/utilities/tests.py +87 -4
  64. fastmcp/utilities/types.py +1 -1
  65. fastmcp/utilities/ui.py +617 -0
  66. {fastmcp-2.12.4.dist-info → fastmcp-2.13.0.dist-info}/METADATA +10 -6
  67. {fastmcp-2.12.4.dist-info → fastmcp-2.13.0.dist-info}/RECORD +70 -63
  68. fastmcp/cli/claude.py +0 -135
  69. fastmcp/utilities/storage.py +0 -204
  70. {fastmcp-2.12.4.dist-info → fastmcp-2.13.0.dist-info}/WHEEL +0 -0
  71. {fastmcp-2.12.4.dist-info → fastmcp-2.13.0.dist-info}/entry_points.txt +0 -0
  72. {fastmcp-2.12.4.dist-info → fastmcp-2.13.0.dist-info}/licenses/LICENSE +0 -0
fastmcp/cli/cli.py CHANGED
@@ -46,9 +46,7 @@ def _get_npx_command():
46
46
  # Try both npx.cmd and npx.exe on Windows
47
47
  for cmd in ["npx.cmd", "npx.exe", "npx"]:
48
48
  try:
49
- subprocess.run(
50
- [cmd, "--version"], check=True, capture_output=True, shell=True
51
- )
49
+ subprocess.run([cmd, "--version"], check=True, capture_output=True)
52
50
  return cmd
53
51
  except subprocess.CalledProcessError:
54
52
  continue
@@ -277,12 +275,10 @@ async def dev(
277
275
  # Set marker to prevent infinite loops when subprocess calls FastMCP
278
276
  env = dict(os.environ.items()) | env_vars | {"FASTMCP_UV_SPAWNED": "1"}
279
277
 
280
- # Run the MCP Inspector command with shell=True on Windows
281
- shell = sys.platform == "win32"
278
+ # Run the MCP Inspector command
282
279
  process = subprocess.run(
283
280
  [npx_cmd, inspector_cmd] + uv_cmd,
284
281
  check=True,
285
- shell=shell,
286
282
  env=env,
287
283
  )
288
284
  sys.exit(process.returncode)
@@ -717,6 +713,10 @@ async def inspect(
717
713
  console.print(f" Name: {info.name}")
718
714
  if info.version:
719
715
  console.print(f" Version: {info.version}")
716
+ if info.website_url:
717
+ console.print(f" Website: {info.website_url}")
718
+ if info.icons:
719
+ console.print(f" Icons: {len(info.icons)}")
720
720
  console.print(f" Generation: {info.server_generation}")
721
721
  if info.instructions:
722
722
  console.print(f" Instructions: {info.instructions}")
@@ -772,6 +772,7 @@ async def inspect(
772
772
  "server_spec": server_spec,
773
773
  "error": str(e),
774
774
  },
775
+ exc_info=True,
775
776
  )
776
777
  console.print(f"[bold red]✗[/bold red] Failed to inspect server: {e}")
777
778
  sys.exit(1)
@@ -110,9 +110,9 @@ def install_claude_code(
110
110
  env_config = UVEnvironment(
111
111
  python=python_version,
112
112
  dependencies=(with_packages or []) + ["fastmcp"],
113
- requirements=str(with_requirements) if with_requirements else None,
114
- project=str(project) if project else None,
115
- editable=[str(p) for p in with_editable] if with_editable else None,
113
+ requirements=with_requirements,
114
+ project=project,
115
+ editable=with_editable,
116
116
  )
117
117
 
118
118
  # Build server spec from parsed components
@@ -125,15 +125,15 @@ def install_claude_code(
125
125
  full_command = env_config.build_command(["fastmcp", "run", server_spec])
126
126
 
127
127
  # Build claude mcp add command
128
- cmd_parts = [claude_cmd, "mcp", "add"]
128
+ cmd_parts = [claude_cmd, "mcp", "add", name]
129
129
 
130
- # Add environment variables if specified (before the name and command)
130
+ # Add environment variables if specified
131
131
  if env_vars:
132
132
  for key, value in env_vars.items():
133
133
  cmd_parts.extend(["-e", f"{key}={value}"])
134
134
 
135
135
  # Add server name and command
136
- cmd_parts.extend([name, "--"])
136
+ cmd_parts.append("--")
137
137
  cmd_parts.extend(full_command)
138
138
 
139
139
  try:
@@ -76,9 +76,9 @@ def install_claude_desktop(
76
76
  env_config = UVEnvironment(
77
77
  python=python_version,
78
78
  dependencies=(with_packages or []) + ["fastmcp"],
79
- requirements=str(with_requirements) if with_requirements else None,
80
- project=str(project) if project else None,
81
- editable=[str(p) for p in with_editable] if with_editable else None,
79
+ requirements=with_requirements,
80
+ project=project,
81
+ editable=with_editable,
82
82
  )
83
83
  # Build server spec from parsed components
84
84
  if server_object:
@@ -56,7 +56,7 @@ def open_deeplink(deeplink: str) -> bool:
56
56
  subprocess.run(["open", deeplink], check=True, capture_output=True)
57
57
  elif sys.platform == "win32": # Windows
58
58
  subprocess.run(
59
- ["start", deeplink], shell=True, check=True, capture_output=True
59
+ ["cmd", "/c", "start", deeplink], check=True, capture_output=True
60
60
  )
61
61
  else: # Linux and others
62
62
  subprocess.run(["xdg-open", deeplink], check=True, capture_output=True)
@@ -110,9 +110,9 @@ def install_cursor_workspace(
110
110
  env_config = UVEnvironment(
111
111
  python=python_version,
112
112
  dependencies=(with_packages or []) + ["fastmcp"],
113
- requirements=str(with_requirements.resolve()) if with_requirements else None,
114
- project=str(project.resolve()) if project else None,
115
- editable=[str(p.resolve()) for p in with_editable] if with_editable else None,
113
+ requirements=with_requirements,
114
+ project=project,
115
+ editable=with_editable,
116
116
  )
117
117
  # Build server spec from parsed components
118
118
  if server_object:
@@ -180,9 +180,9 @@ def install_cursor(
180
180
  env_config = UVEnvironment(
181
181
  python=python_version,
182
182
  dependencies=(with_packages or []) + ["fastmcp"],
183
- requirements=str(with_requirements.resolve()) if with_requirements else None,
184
- project=str(project.resolve()) if project else None,
185
- editable=[str(p.resolve()) for p in with_editable] if with_editable else None,
183
+ requirements=with_requirements,
184
+ project=project,
185
+ editable=with_editable,
186
186
  )
187
187
  # Build server spec from parsed components
188
188
  if server_object:
@@ -107,9 +107,9 @@ def install_gemini_cli(
107
107
  env_config = UVEnvironment(
108
108
  python=python_version,
109
109
  dependencies=(with_packages or []) + ["fastmcp"],
110
- requirements=str(with_requirements) if with_requirements else None,
111
- project=str(project) if project else None,
112
- editable=[str(p) for p in with_editable] if with_editable else None,
110
+ requirements=with_requirements,
111
+ project=project,
112
+ editable=with_editable,
113
113
  )
114
114
 
115
115
  # Build server spec from parsed components
@@ -51,9 +51,9 @@ def install_mcp_json(
51
51
  env_config = UVEnvironment(
52
52
  python=python_version,
53
53
  dependencies=(with_packages or []) + ["fastmcp"],
54
- requirements=str(with_requirements) if with_requirements else None,
55
- project=str(project) if project else None,
56
- editable=[str(p) for p in with_editable] if with_editable else None,
54
+ requirements=with_requirements,
55
+ project=project,
56
+ editable=with_editable,
57
57
  )
58
58
  # Build server spec from parsed components
59
59
  if server_object:
fastmcp/cli/run.py CHANGED
@@ -172,7 +172,7 @@ async def run_command(
172
172
 
173
173
  # handle v1 servers
174
174
  if isinstance(server, FastMCP1x):
175
- run_v1_server(server, host=host, port=port, transport=transport)
175
+ await run_v1_server_async(server, host=host, port=port, transport=transport)
176
176
  return
177
177
 
178
178
  kwargs = {}
@@ -197,24 +197,29 @@ async def run_command(
197
197
  sys.exit(1)
198
198
 
199
199
 
200
- def run_v1_server(
200
+ async def run_v1_server_async(
201
201
  server: FastMCP1x,
202
202
  host: str | None = None,
203
203
  port: int | None = None,
204
204
  transport: TransportType | None = None,
205
205
  ) -> None:
206
- from functools import partial
206
+ """Run a FastMCP 1.x server using async methods.
207
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
+ """
208
214
  if host:
209
215
  server.settings.host = host
210
216
  if port:
211
217
  server.settings.port = port
218
+
212
219
  match transport:
213
220
  case "stdio":
214
- runner = partial(server.run)
221
+ await server.run_stdio_async()
215
222
  case "http" | "streamable-http" | None:
216
- runner = partial(server.run, transport="streamable-http")
223
+ await server.run_streamable_http_async()
217
224
  case "sse":
218
- runner = partial(server.run, transport="sse")
219
-
220
- runner()
225
+ await server.run_sse_async()
@@ -1,34 +1,32 @@
1
1
  from __future__ import annotations
2
2
 
3
- import asyncio
3
+ import time
4
4
  import webbrowser
5
- from asyncio import Future
6
5
  from collections.abc import AsyncGenerator
7
- from datetime import datetime, timedelta, timezone
8
- from pathlib import Path
9
- from typing import Any, Literal
6
+ from typing import Any
10
7
  from urllib.parse import urlparse
11
8
 
12
9
  import anyio
13
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
14
14
  from mcp.client.auth import OAuthClientProvider, TokenStorage
15
15
  from mcp.shared.auth import (
16
16
  OAuthClientInformationFull,
17
17
  OAuthClientMetadata,
18
+ OAuthToken,
18
19
  )
19
- from mcp.shared.auth import (
20
- OAuthToken as OAuthToken,
21
- )
22
- from pydantic import AnyHttpUrl, BaseModel, TypeAdapter, ValidationError
20
+ from pydantic import AnyHttpUrl
21
+ from typing_extensions import override
23
22
  from uvicorn.server import Server
24
23
 
25
- from fastmcp import settings as fastmcp_global_settings
26
24
  from fastmcp.client.oauth_callback import (
25
+ OAuthCallbackResult,
27
26
  create_oauth_callback_server,
28
27
  )
29
28
  from fastmcp.utilities.http import find_available_port
30
29
  from fastmcp.utilities.logging import get_logger
31
- from fastmcp.utilities.storage import JSONFileStorage
32
30
 
33
31
  __all__ = ["OAuth"]
34
32
 
@@ -41,174 +39,6 @@ class ClientNotFoundError(Exception):
41
39
  pass
42
40
 
43
41
 
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
- Uses JSONFileStorage internally for consistent file handling.
66
- """
67
-
68
- def __init__(self, server_url: str, cache_dir: Path | None = None):
69
- """Initialize storage for a specific server URL."""
70
- self.server_url = server_url
71
- # Use JSONFileStorage for actual file operations
72
- self._storage = JSONFileStorage(cache_dir or default_cache_dir())
73
-
74
- @staticmethod
75
- def get_base_url(url: str) -> str:
76
- """Extract the base URL (scheme + host) from a URL."""
77
- parsed = urlparse(url)
78
- return f"{parsed.scheme}://{parsed.netloc}"
79
-
80
- def _get_storage_key(self, file_type: Literal["client_info", "tokens"]) -> str:
81
- """Get the storage key for the specified data type.
82
-
83
- JSONFileStorage will handle making the key filesystem-safe.
84
- """
85
- base_url = self.get_base_url(self.server_url)
86
- return f"{base_url}_{file_type}"
87
-
88
- def _get_file_path(self, file_type: Literal["client_info", "tokens"]) -> Path:
89
- """Get the file path for the specified cache file type.
90
-
91
- This method is kept for backward compatibility with tests that access _get_file_path.
92
- """
93
- key = self._get_storage_key(file_type)
94
- return self._storage._get_file_path(key)
95
-
96
- async def get_tokens(self) -> OAuthToken | None:
97
- """Load tokens from file storage."""
98
- key = self._get_storage_key("tokens")
99
- data = await self._storage.get(key)
100
-
101
- if data is None:
102
- return None
103
-
104
- try:
105
- # Parse and validate as StoredToken
106
- stored = stored_token_adapter.validate_python(data)
107
-
108
- # Check if token is expired
109
- if stored.expires_at is not None:
110
- now = datetime.now(timezone.utc)
111
- if now >= stored.expires_at:
112
- logger.debug(
113
- f"Token expired for {self.get_base_url(self.server_url)}"
114
- )
115
- return None
116
-
117
- # Recalculate expires_in to be correct relative to now
118
- if stored.token_payload.expires_in is not None:
119
- remaining = stored.expires_at - now
120
- stored.token_payload.expires_in = max(
121
- 0, int(remaining.total_seconds())
122
- )
123
-
124
- return stored.token_payload
125
-
126
- except ValidationError as e:
127
- logger.debug(
128
- f"Could not validate tokens for {self.get_base_url(self.server_url)}: {e}"
129
- )
130
- return None
131
-
132
- async def set_tokens(self, tokens: OAuthToken) -> None:
133
- """Save tokens to file storage."""
134
- key = self._get_storage_key("tokens")
135
-
136
- # Calculate absolute expiry time if expires_in is present
137
- expires_at = None
138
- if tokens.expires_in is not None:
139
- expires_at = datetime.now(timezone.utc) + timedelta(
140
- seconds=tokens.expires_in
141
- )
142
-
143
- # Create StoredToken and save using storage
144
- # Note: JSONFileStorage will wrap this in {"data": ..., "timestamp": ...}
145
- stored = StoredToken(token_payload=tokens, expires_at=expires_at)
146
- await self._storage.set(key, stored.model_dump(mode="json"))
147
- logger.debug(f"Saved tokens for {self.get_base_url(self.server_url)}")
148
-
149
- async def get_client_info(self) -> OAuthClientInformationFull | None:
150
- """Load client information from file storage."""
151
- key = self._get_storage_key("client_info")
152
- data = await self._storage.get(key)
153
-
154
- if data is None:
155
- return None
156
-
157
- try:
158
- client_info = OAuthClientInformationFull.model_validate(data)
159
- # Check if we have corresponding valid tokens
160
- # If no tokens exist, the OAuth flow was incomplete and we should
161
- # force a fresh client registration
162
- tokens = await self.get_tokens()
163
- if tokens is None:
164
- logger.debug(
165
- f"No tokens found for client info at {self.get_base_url(self.server_url)}. "
166
- "OAuth flow may have been incomplete. Clearing client info to force fresh registration."
167
- )
168
- # Clear the incomplete client info
169
- await self._storage.delete(key)
170
- return None
171
-
172
- return client_info
173
- except ValidationError as e:
174
- logger.debug(
175
- f"Could not validate client info for {self.get_base_url(self.server_url)}: {e}"
176
- )
177
- return None
178
-
179
- async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
180
- """Save client information to file storage."""
181
- key = self._get_storage_key("client_info")
182
- await self._storage.set(key, client_info.model_dump(mode="json"))
183
- logger.debug(f"Saved client info for {self.get_base_url(self.server_url)}")
184
-
185
- def clear(self) -> None:
186
- """Clear all cached data for this server.
187
-
188
- Note: This is a synchronous method for backward compatibility.
189
- Uses direct file operations instead of async storage methods.
190
- """
191
- file_types: list[Literal["client_info", "tokens"]] = ["client_info", "tokens"]
192
- for file_type in file_types:
193
- # Use the file path directly for synchronous deletion
194
- path = self._get_file_path(file_type)
195
- path.unlink(missing_ok=True)
196
- logger.debug(f"Cleared OAuth cache for {self.get_base_url(self.server_url)}")
197
-
198
- @classmethod
199
- def clear_all(cls, cache_dir: Path | None = None) -> None:
200
- """Clear all cached data for all servers."""
201
- cache_dir = cache_dir or default_cache_dir()
202
- if not cache_dir.exists():
203
- return
204
-
205
- file_types: list[Literal["client_info", "tokens"]] = ["client_info", "tokens"]
206
- for file_type in file_types:
207
- for file in cache_dir.glob(f"*_{file_type}.json"):
208
- file.unlink(missing_ok=True)
209
- logger.info("Cleared all OAuth client cache data.")
210
-
211
-
212
42
  async def check_if_auth_required(
213
43
  mcp_url: str, httpx_kwargs: dict[str, Any] | None = None
214
44
  ) -> bool:
@@ -239,6 +69,70 @@ async def check_if_auth_required(
239
69
  return True
240
70
 
241
71
 
72
+ class TokenStorageAdapter(TokenStorage):
73
+ _server_url: str
74
+ _key_value_store: AsyncKeyValue
75
+ _storage_oauth_token: PydanticAdapter[OAuthToken]
76
+ _storage_client_info: PydanticAdapter[OAuthClientInformationFull]
77
+
78
+ def __init__(self, async_key_value: AsyncKeyValue, server_url: str):
79
+ self._server_url = server_url
80
+ self._key_value_store = async_key_value
81
+ self._storage_oauth_token = PydanticAdapter[OAuthToken](
82
+ default_collection="mcp-oauth-token",
83
+ key_value=async_key_value,
84
+ pydantic_model=OAuthToken,
85
+ raise_on_validation_error=True,
86
+ )
87
+ self._storage_client_info = PydanticAdapter[OAuthClientInformationFull](
88
+ default_collection="mcp-oauth-client-info",
89
+ key_value=async_key_value,
90
+ pydantic_model=OAuthClientInformationFull,
91
+ raise_on_validation_error=True,
92
+ )
93
+
94
+ def _get_token_cache_key(self) -> str:
95
+ return f"{self._server_url}/tokens"
96
+
97
+ def _get_client_info_cache_key(self) -> str:
98
+ return f"{self._server_url}/client_info"
99
+
100
+ async def clear(self) -> None:
101
+ await self._storage_oauth_token.delete(key=self._get_token_cache_key())
102
+ await self._storage_client_info.delete(key=self._get_client_info_cache_key())
103
+
104
+ @override
105
+ async def get_tokens(self) -> OAuthToken | None:
106
+ return await self._storage_oauth_token.get(key=self._get_token_cache_key())
107
+
108
+ @override
109
+ async def set_tokens(self, tokens: OAuthToken) -> None:
110
+ await self._storage_oauth_token.put(
111
+ key=self._get_token_cache_key(),
112
+ value=tokens,
113
+ ttl=tokens.expires_in,
114
+ )
115
+
116
+ @override
117
+ async def get_client_info(self) -> OAuthClientInformationFull | None:
118
+ return await self._storage_client_info.get(
119
+ key=self._get_client_info_cache_key()
120
+ )
121
+
122
+ @override
123
+ async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
124
+ ttl: int | None = None
125
+
126
+ if client_info.client_secret_expires_at:
127
+ ttl = client_info.client_secret_expires_at - int(time.time())
128
+
129
+ await self._storage_client_info.put(
130
+ key=self._get_client_info_cache_key(),
131
+ value=client_info,
132
+ ttl=ttl,
133
+ )
134
+
135
+
242
136
  class OAuth(OAuthClientProvider):
243
137
  """
244
138
  OAuth client provider for MCP servers with browser-based authentication.
@@ -252,7 +146,7 @@ class OAuth(OAuthClientProvider):
252
146
  mcp_url: str,
253
147
  scopes: str | list[str] | None = None,
254
148
  client_name: str = "FastMCP Client",
255
- token_storage_cache_dir: Path | None = None,
149
+ token_storage: AsyncKeyValue | None = None,
256
150
  additional_client_metadata: dict[str, Any] | None = None,
257
151
  callback_port: int | None = None,
258
152
  ):
@@ -264,7 +158,7 @@ class OAuth(OAuthClientProvider):
264
158
  scopes: OAuth scopes to request. Can be a
265
159
  space-separated string or a list of strings.
266
160
  client_name: Name for this client during registration
267
- token_storage_cache_dir: Directory for FileTokenStorage
161
+ token_storage: An AsyncKeyValue-compatible token store, tokens are stored in memory if not provided
268
162
  additional_client_metadata: Extra fields for OAuthClientMetadata
269
163
  callback_port: Fixed port for OAuth callback (default: random available port)
270
164
  """
@@ -294,8 +188,18 @@ class OAuth(OAuthClientProvider):
294
188
  )
295
189
 
296
190
  # Create server-specific token storage
297
- storage = FileTokenStorage(
298
- server_url=server_base_url, cache_dir=token_storage_cache_dir
191
+ token_storage = token_storage or MemoryStore()
192
+
193
+ if isinstance(token_storage, MemoryStore):
194
+ from warnings import warn
195
+
196
+ warn(
197
+ message="Using in-memory token storage is not recommended for production use -- "
198
+ + "tokens will be lost on server restart."
199
+ )
200
+
201
+ self.token_storage_adapter: TokenStorageAdapter = TokenStorageAdapter(
202
+ async_key_value=token_storage, server_url=server_base_url
299
203
  )
300
204
 
301
205
  # Store server_base_url for use in callback_handler
@@ -305,7 +209,7 @@ class OAuth(OAuthClientProvider):
305
209
  super().__init__(
306
210
  server_url=server_base_url,
307
211
  client_metadata=client_metadata,
308
- storage=storage,
212
+ storage=self.token_storage_adapter,
309
213
  redirect_handler=self.redirect_handler,
310
214
  callback_handler=self.callback_handler,
311
215
  )
@@ -342,14 +246,16 @@ class OAuth(OAuthClientProvider):
342
246
 
343
247
  async def callback_handler(self) -> tuple[str, str | None]:
344
248
  """Handle OAuth callback and return (auth_code, state)."""
345
- # Create a future to capture the OAuth response
346
- response_future: Future[Any] = asyncio.get_running_loop().create_future()
249
+ # Create result container and event to capture the OAuth response
250
+ result = OAuthCallbackResult()
251
+ result_ready = anyio.Event()
347
252
 
348
- # Create server with the future
253
+ # Create server with result tracking
349
254
  server: Server = create_oauth_callback_server(
350
255
  port=self.redirect_port,
351
256
  server_url=self.server_base_url,
352
- response_future=response_future,
257
+ result_container=result,
258
+ result_ready=result_ready,
353
259
  )
354
260
 
355
261
  # Run server until response is received with timeout logic
@@ -362,13 +268,15 @@ class OAuth(OAuthClientProvider):
362
268
  TIMEOUT = 300.0 # 5 minute timeout
363
269
  try:
364
270
  with anyio.fail_after(TIMEOUT):
365
- auth_code, state = await response_future
366
- return auth_code, state
271
+ await result_ready.wait()
272
+ if result.error:
273
+ raise result.error
274
+ return result.code, result.state # type: ignore
367
275
  except TimeoutError:
368
276
  raise TimeoutError(f"OAuth callback timed out after {TIMEOUT} seconds")
369
277
  finally:
370
278
  server.should_exit = True
371
- await asyncio.sleep(0.1) # Allow server to shut down gracefully
279
+ await anyio.sleep(0.1) # Allow server to shut down gracefully
372
280
  tg.cancel_scope.cancel()
373
281
 
374
282
  raise RuntimeError("OAuth callback handler could not be started")
@@ -399,23 +307,7 @@ class OAuth(OAuthClientProvider):
399
307
 
400
308
  # Clear cached state and retry once
401
309
  self._initialized = False
402
-
403
- # Try to clear storage if it supports it
404
- if hasattr(self.context.storage, "clear"):
405
- try:
406
- self.context.storage.clear()
407
- except Exception as e:
408
- logger.warning(f"Failed to clear OAuth storage cache: {e}")
409
- # Can't retry without clearing cache, re-raise original error
410
- raise ClientNotFoundError(
411
- "OAuth client not found and cache could not be cleared"
412
- ) from e
413
- else:
414
- logger.warning(
415
- "Storage does not support clear() - cannot retry with fresh credentials"
416
- )
417
- # Can't retry without clearing cache, re-raise original error
418
- raise
310
+ await self.token_storage_adapter.clear()
419
311
 
420
312
  gen = super().async_auth_flow(request)
421
313
  response = None
fastmcp/client/client.py CHANGED
@@ -155,38 +155,38 @@ class Client(Generic[ClientTransportT]):
155
155
  """
156
156
 
157
157
  @overload
158
- def __init__(self: Client[T], transport: T, *args, **kwargs) -> None: ...
158
+ def __init__(self: Client[T], transport: T, *args: Any, **kwargs: Any) -> None: ...
159
159
 
160
160
  @overload
161
161
  def __init__(
162
162
  self: Client[SSETransport | StreamableHttpTransport],
163
163
  transport: AnyUrl,
164
- *args,
165
- **kwargs,
164
+ *args: Any,
165
+ **kwargs: Any,
166
166
  ) -> None: ...
167
167
 
168
168
  @overload
169
169
  def __init__(
170
170
  self: Client[FastMCPTransport],
171
171
  transport: FastMCP | FastMCP1Server,
172
- *args,
173
- **kwargs,
172
+ *args: Any,
173
+ **kwargs: Any,
174
174
  ) -> None: ...
175
175
 
176
176
  @overload
177
177
  def __init__(
178
178
  self: Client[PythonStdioTransport | NodeStdioTransport],
179
179
  transport: Path,
180
- *args,
181
- **kwargs,
180
+ *args: Any,
181
+ **kwargs: Any,
182
182
  ) -> None: ...
183
183
 
184
184
  @overload
185
185
  def __init__(
186
186
  self: Client[MCPConfigTransport],
187
187
  transport: MCPConfig | dict[str, Any],
188
- *args,
189
- **kwargs,
188
+ *args: Any,
189
+ **kwargs: Any,
190
190
  ) -> None: ...
191
191
 
192
192
  @overload
@@ -198,8 +198,8 @@ class Client(Generic[ClientTransportT]):
198
198
  | StreamableHttpTransport
199
199
  ],
200
200
  transport: str,
201
- *args,
202
- **kwargs,
201
+ *args: Any,
202
+ **kwargs: Any,
203
203
  ) -> None: ...
204
204
 
205
205
  def __init__(