Jeremiah Lowin commited on
Commit
7218a9e
·
1 Parent(s): f16268d

Update token cache

Browse files
src/fastmcp/client/auth.py CHANGED
@@ -1,24 +1,29 @@
1
  from __future__ import annotations
2
 
3
  import asyncio
 
4
  import json
5
  import webbrowser
6
  from pathlib import Path
7
  from typing import Any, Literal
8
  from urllib.parse import urljoin, urlparse
9
 
 
10
  import httpx
11
  from mcp.client.auth import OAuthClientProvider as _MCPOAuthClientProvider
12
  from mcp.client.auth import TokenStorage
13
  from mcp.shared.auth import (
14
  OAuthClientInformationFull,
15
  OAuthClientMetadata,
16
- OAuthToken,
17
  )
18
  from mcp.shared.auth import (
19
  OAuthMetadata as _MCPServerOAuthMetadata,
20
  )
21
- from pydantic import AnyHttpUrl, ValidationError
 
 
 
 
22
 
23
  from fastmcp.client.oauth_callback import (
24
  create_oauth_callback_server,
@@ -32,6 +37,21 @@ __all__ = ["OAuth"]
32
  logger = get_logger(__name__)
33
 
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  # Flexible OAuth models for real-world compatibility
36
  class ServerOAuthMetadata(_MCPServerOAuthMetadata):
37
  """
@@ -149,17 +169,24 @@ class FileTokenStorage(TokenStorage):
149
  async def get_tokens(self) -> OAuthToken | None:
150
  """Load tokens from file storage."""
151
  path = self._get_file_path("tokens")
 
152
  try:
153
- data = json.loads(path.read_text())
154
- return OAuthToken.model_validate(data)
 
 
 
 
155
  except (FileNotFoundError, json.JSONDecodeError, ValidationError) as e:
156
  logger.debug(
157
  f"Could not load tokens for {self.get_base_url(self.server_url)}: {e}"
158
  )
159
  return None
160
 
161
- async def set_tokens(self, tokens: OAuthToken) -> None:
162
  """Save tokens to file storage."""
 
 
163
  path = self._get_file_path("tokens")
164
  path.write_text(tokens.model_dump_json(indent=2))
165
  logger.debug(f"Saved tokens for {self.get_base_url(self.server_url)}")
@@ -168,8 +195,7 @@ class FileTokenStorage(TokenStorage):
168
  """Load client information from file storage."""
169
  path = self._get_file_path("client_info")
170
  try:
171
- data = json.loads(path.read_text())
172
- return OAuthClientInformationFull.model_validate(data)
173
  except (FileNotFoundError, json.JSONDecodeError, ValidationError) as e:
174
  logger.debug(
175
  f"Could not load client info for {self.get_base_url(self.server_url)}: {e}"
@@ -190,59 +216,8 @@ class FileTokenStorage(TokenStorage):
190
  path.unlink(missing_ok=True)
191
  logger.info(f"Cleared OAuth cache for {self.get_base_url(self.server_url)}")
192
 
193
- def has_valid_token(self) -> bool:
194
- """Check if there's a valid non-expired token (synchronous check)."""
195
- path = self._get_file_path("tokens")
196
- try:
197
- data = json.loads(path.read_text())
198
- token = OAuthToken.model_validate(data)
199
-
200
- # Check if token has expiration info
201
- if not token.expires_in:
202
- return True # Assume valid if no expiration
203
-
204
- # We need to check when the token was saved vs current time
205
- # For simplicity, we'll assume the token is fresh enough for now
206
- # A more robust implementation would store the timestamp when saved
207
- return True
208
-
209
- except (FileNotFoundError, json.JSONDecodeError, ValidationError):
210
- return False
211
-
212
- @classmethod
213
- def list_cached_servers(cls, cache_dir: Path | None = None) -> list[str]:
214
- """List all servers with cached data."""
215
- cache_dir = cache_dir or fastmcp_global_settings.home / "oauth-mcp-client-cache"
216
- if not cache_dir.exists():
217
- return []
218
-
219
- servers = set()
220
- for file in cache_dir.glob("*_tokens.json"):
221
- # Extract server info from filename
222
- key_part = file.stem.replace("_tokens", "")
223
- # Attempt to reconstruct URL (best effort)
224
- if "_" in key_part:
225
- try:
226
- # Handle common patterns like "https_example_com_8080"
227
- parts = key_part.split("_")
228
- if len(parts) >= 3:
229
- scheme = parts[0]
230
- host_parts = parts[1:-1] if parts[-1].isdigit() else parts[1:]
231
- port = parts[-1] if parts[-1].isdigit() else None
232
-
233
- host = ".".join(host_parts)
234
- url = f"{scheme}://{host}"
235
- if port:
236
- url += f":{port}"
237
- servers.add(url)
238
- except Exception:
239
- # If reconstruction fails, at least show the key
240
- servers.add(key_part)
241
-
242
- return sorted(list(servers))
243
-
244
  @classmethod
245
- def clear_all_cache(cls, cache_dir: Path | None = None) -> None:
246
  """Clear all cached data for all servers."""
247
  cache_dir = cache_dir or fastmcp_global_settings.home / "oauth-mcp-client-cache"
248
  if not cache_dir.exists():
@@ -388,7 +363,6 @@ def OAuth(
388
  )
389
 
390
  # Run server until response is received with timeout logic
391
- import anyio
392
 
393
  async with anyio.create_task_group() as tg:
394
  tg.start_soon(server.serve)
 
1
  from __future__ import annotations
2
 
3
  import asyncio
4
+ import datetime
5
  import json
6
  import webbrowser
7
  from pathlib import Path
8
  from typing import Any, Literal
9
  from urllib.parse import urljoin, urlparse
10
 
11
+ import anyio
12
  import httpx
13
  from mcp.client.auth import OAuthClientProvider as _MCPOAuthClientProvider
14
  from mcp.client.auth import TokenStorage
15
  from mcp.shared.auth import (
16
  OAuthClientInformationFull,
17
  OAuthClientMetadata,
 
18
  )
19
  from mcp.shared.auth import (
20
  OAuthMetadata as _MCPServerOAuthMetadata,
21
  )
22
+ from mcp.shared.auth import (
23
+ OAuthToken as _MCPOAuthToken,
24
+ )
25
+ from pydantic import AnyHttpUrl, ValidationError, model_validator
26
+ from typing_extensions import Self
27
 
28
  from fastmcp.client.oauth_callback import (
29
  create_oauth_callback_server,
 
37
  logger = get_logger(__name__)
38
 
39
 
40
+ class OAuthToken(_MCPOAuthToken):
41
+ """
42
+ OAuth token that stores expiration as a datetime object
43
+ """
44
+
45
+ expires_at: datetime.datetime | None = None
46
+
47
+ @model_validator(mode="after")
48
+ def set_expires_at(self) -> Self:
49
+ if self.expires_in is not None and self.expires_at is None:
50
+ now = datetime.datetime.now(datetime.timezone.utc)
51
+ self.expires_at = now + datetime.timedelta(seconds=self.expires_in)
52
+ return self
53
+
54
+
55
  # Flexible OAuth models for real-world compatibility
56
  class ServerOAuthMetadata(_MCPServerOAuthMetadata):
57
  """
 
169
  async def get_tokens(self) -> OAuthToken | None:
170
  """Load tokens from file storage."""
171
  path = self._get_file_path("tokens")
172
+
173
  try:
174
+ tokens = OAuthToken.model_validate_json(path.read_text())
175
+ now = datetime.datetime.now(datetime.timezone.utc)
176
+ if tokens.expires_at is not None and tokens.expires_at <= now:
177
+ logger.debug(f"Token expired for {self.get_base_url(self.server_url)}")
178
+ return None
179
+ return tokens
180
  except (FileNotFoundError, json.JSONDecodeError, ValidationError) as e:
181
  logger.debug(
182
  f"Could not load tokens for {self.get_base_url(self.server_url)}: {e}"
183
  )
184
  return None
185
 
186
+ async def set_tokens(self, tokens: _MCPOAuthToken) -> None:
187
  """Save tokens to file storage."""
188
+ # Convert to custom model with expiration datetime
189
+ tokens = OAuthToken.model_validate(tokens)
190
  path = self._get_file_path("tokens")
191
  path.write_text(tokens.model_dump_json(indent=2))
192
  logger.debug(f"Saved tokens for {self.get_base_url(self.server_url)}")
 
195
  """Load client information from file storage."""
196
  path = self._get_file_path("client_info")
197
  try:
198
+ return OAuthClientInformationFull.model_validate_json(path.read_text())
 
199
  except (FileNotFoundError, json.JSONDecodeError, ValidationError) as e:
200
  logger.debug(
201
  f"Could not load client info for {self.get_base_url(self.server_url)}: {e}"
 
216
  path.unlink(missing_ok=True)
217
  logger.info(f"Cleared OAuth cache for {self.get_base_url(self.server_url)}")
218
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  @classmethod
220
+ def clear_all(cls, cache_dir: Path | None = None) -> None:
221
  """Clear all cached data for all servers."""
222
  cache_dir = cache_dir or fastmcp_global_settings.home / "oauth-mcp-client-cache"
223
  if not cache_dir.exists():
 
363
  )
364
 
365
  # Run server until response is received with timeout logic
 
366
 
367
  async with anyio.create_task_group() as tg:
368
  tg.start_soon(server.serve)
src/fastmcp/client/client.py CHANGED
@@ -44,7 +44,6 @@ from .transports import (
44
 
45
  __all__ = [
46
  "Client",
47
- "ClientTransport",
48
  "SessionKwargs",
49
  "RootsHandler",
50
  "RootsList",
 
44
 
45
  __all__ = [
46
  "Client",
 
47
  "SessionKwargs",
48
  "RootsHandler",
49
  "RootsList",
src/fastmcp/client/oauth_callback.py CHANGED
@@ -221,7 +221,7 @@ def create_oauth_callback_server(
221
 
222
  return HTMLResponse(
223
  create_callback_html(
224
- f"OAuth Error: {error}<br>{error_desc}", is_success=False
225
  ),
226
  status_code=400,
227
  )
@@ -235,7 +235,8 @@ def create_oauth_callback_server(
235
 
236
  return HTMLResponse(
237
  create_callback_html(
238
- "OAuth Error: No authorization code received", is_success=False
 
239
  ),
240
  status_code=400,
241
  )
@@ -245,7 +246,7 @@ def create_oauth_callback_server(
245
  response_future.set_result((auth_code, state))
246
 
247
  return HTMLResponse(
248
- create_callback_html("OAuth login complete!", server_url=server_url)
249
  )
250
 
251
  app = Starlette(routes=[Route(callback_path, callback_handler)])
 
221
 
222
  return HTMLResponse(
223
  create_callback_html(
224
+ f"FastMCP OAuth Error: {error}<br>{error_desc}", is_success=False
225
  ),
226
  status_code=400,
227
  )
 
235
 
236
  return HTMLResponse(
237
  create_callback_html(
238
+ "FastMCP OAuth Error: No authorization code received",
239
+ is_success=False,
240
  ),
241
  status_code=400,
242
  )
 
246
  response_future.set_result((auth_code, state))
247
 
248
  return HTMLResponse(
249
+ create_callback_html("FastMCP OAuth login complete!", server_url=server_url)
250
  )
251
 
252
  app = Starlette(routes=[Route(callback_path, callback_handler)])