fastmcp 2.12.5__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.
- fastmcp/cli/cli.py +7 -6
- fastmcp/cli/install/claude_code.py +6 -6
- fastmcp/cli/install/claude_desktop.py +3 -3
- fastmcp/cli/install/cursor.py +7 -7
- fastmcp/cli/install/gemini_cli.py +3 -3
- fastmcp/cli/install/mcp_json.py +3 -3
- fastmcp/cli/run.py +13 -8
- fastmcp/client/auth/oauth.py +100 -208
- fastmcp/client/client.py +11 -11
- fastmcp/client/logging.py +18 -14
- fastmcp/client/oauth_callback.py +85 -171
- fastmcp/client/transports.py +77 -22
- fastmcp/contrib/component_manager/component_service.py +6 -6
- fastmcp/contrib/mcp_mixin/README.md +32 -1
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -2
- fastmcp/experimental/utilities/openapi/json_schema_converter.py +4 -0
- fastmcp/experimental/utilities/openapi/parser.py +23 -3
- fastmcp/prompts/prompt.py +13 -6
- fastmcp/prompts/prompt_manager.py +16 -101
- fastmcp/resources/resource.py +13 -6
- fastmcp/resources/resource_manager.py +5 -164
- fastmcp/resources/template.py +107 -17
- fastmcp/resources/types.py +30 -24
- fastmcp/server/auth/auth.py +40 -32
- fastmcp/server/auth/handlers/authorize.py +324 -0
- fastmcp/server/auth/jwt_issuer.py +236 -0
- fastmcp/server/auth/middleware.py +96 -0
- fastmcp/server/auth/oauth_proxy.py +1256 -242
- fastmcp/server/auth/oidc_proxy.py +23 -6
- fastmcp/server/auth/providers/auth0.py +40 -21
- fastmcp/server/auth/providers/aws.py +29 -3
- fastmcp/server/auth/providers/azure.py +178 -127
- fastmcp/server/auth/providers/descope.py +4 -6
- fastmcp/server/auth/providers/github.py +29 -8
- fastmcp/server/auth/providers/google.py +30 -9
- fastmcp/server/auth/providers/introspection.py +281 -0
- fastmcp/server/auth/providers/jwt.py +8 -2
- fastmcp/server/auth/providers/scalekit.py +179 -0
- fastmcp/server/auth/providers/supabase.py +172 -0
- fastmcp/server/auth/providers/workos.py +32 -14
- fastmcp/server/context.py +122 -36
- fastmcp/server/http.py +58 -18
- fastmcp/server/low_level.py +121 -2
- fastmcp/server/middleware/caching.py +469 -0
- fastmcp/server/middleware/error_handling.py +6 -2
- fastmcp/server/middleware/logging.py +48 -37
- fastmcp/server/middleware/middleware.py +28 -15
- fastmcp/server/middleware/rate_limiting.py +3 -3
- fastmcp/server/middleware/tool_injection.py +116 -0
- fastmcp/server/proxy.py +6 -6
- fastmcp/server/server.py +683 -207
- fastmcp/settings.py +24 -10
- fastmcp/tools/tool.py +7 -3
- fastmcp/tools/tool_manager.py +30 -112
- fastmcp/tools/tool_transform.py +3 -3
- fastmcp/utilities/cli.py +62 -22
- fastmcp/utilities/components.py +5 -0
- fastmcp/utilities/inspect.py +77 -21
- fastmcp/utilities/logging.py +118 -8
- fastmcp/utilities/mcp_server_config/v1/environments/uv.py +6 -6
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +3 -3
- fastmcp/utilities/mcp_server_config/v1/schema.json +3 -0
- fastmcp/utilities/tests.py +87 -4
- fastmcp/utilities/types.py +1 -1
- fastmcp/utilities/ui.py +617 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0.dist-info}/METADATA +10 -6
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0.dist-info}/RECORD +70 -63
- fastmcp/cli/claude.py +0 -135
- fastmcp/utilities/storage.py +0 -204
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0.dist-info}/WHEEL +0 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.12.5.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
|
|
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=
|
|
114
|
-
project=
|
|
115
|
-
editable=
|
|
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
|
|
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.
|
|
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=
|
|
80
|
-
project=
|
|
81
|
-
editable=
|
|
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:
|
fastmcp/cli/install/cursor.py
CHANGED
|
@@ -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],
|
|
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=
|
|
114
|
-
project=
|
|
115
|
-
editable=
|
|
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=
|
|
184
|
-
project=
|
|
185
|
-
editable=
|
|
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=
|
|
111
|
-
project=
|
|
112
|
-
editable=
|
|
110
|
+
requirements=with_requirements,
|
|
111
|
+
project=project,
|
|
112
|
+
editable=with_editable,
|
|
113
113
|
)
|
|
114
114
|
|
|
115
115
|
# Build server spec from parsed components
|
fastmcp/cli/install/mcp_json.py
CHANGED
|
@@ -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=
|
|
55
|
-
project=
|
|
56
|
-
editable=
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
221
|
+
await server.run_stdio_async()
|
|
215
222
|
case "http" | "streamable-http" | None:
|
|
216
|
-
|
|
223
|
+
await server.run_streamable_http_async()
|
|
217
224
|
case "sse":
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
runner()
|
|
225
|
+
await server.run_sse_async()
|
fastmcp/client/auth/oauth.py
CHANGED
|
@@ -1,34 +1,32 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import time
|
|
4
4
|
import webbrowser
|
|
5
|
-
from asyncio import Future
|
|
6
5
|
from collections.abc import AsyncGenerator
|
|
7
|
-
from
|
|
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
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
298
|
-
|
|
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=
|
|
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
|
|
346
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
366
|
-
|
|
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
|
|
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__(
|