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.
- fastmcp/__init__.py +2 -2
- fastmcp/cli/cli.py +56 -36
- fastmcp/cli/install/__init__.py +2 -0
- fastmcp/cli/install/claude_code.py +7 -16
- fastmcp/cli/install/claude_desktop.py +4 -12
- fastmcp/cli/install/cursor.py +20 -30
- fastmcp/cli/install/gemini_cli.py +241 -0
- fastmcp/cli/install/mcp_json.py +4 -12
- fastmcp/cli/run.py +15 -94
- fastmcp/client/__init__.py +9 -9
- fastmcp/client/auth/oauth.py +117 -206
- fastmcp/client/client.py +123 -47
- fastmcp/client/elicitation.py +6 -1
- fastmcp/client/logging.py +18 -14
- fastmcp/client/oauth_callback.py +85 -171
- fastmcp/client/sampling.py +1 -1
- fastmcp/client/transports.py +81 -26
- fastmcp/contrib/component_manager/__init__.py +1 -1
- fastmcp/contrib/component_manager/component_manager.py +2 -2
- fastmcp/contrib/component_manager/component_service.py +7 -7
- fastmcp/contrib/mcp_mixin/README.md +35 -4
- fastmcp/contrib/mcp_mixin/__init__.py +2 -2
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +54 -7
- fastmcp/experimental/sampling/handlers/openai.py +2 -2
- fastmcp/experimental/server/openapi/__init__.py +5 -8
- fastmcp/experimental/server/openapi/components.py +11 -7
- fastmcp/experimental/server/openapi/routing.py +2 -2
- fastmcp/experimental/utilities/openapi/__init__.py +10 -15
- fastmcp/experimental/utilities/openapi/director.py +16 -10
- fastmcp/experimental/utilities/openapi/json_schema_converter.py +6 -2
- fastmcp/experimental/utilities/openapi/models.py +3 -3
- fastmcp/experimental/utilities/openapi/parser.py +37 -16
- fastmcp/experimental/utilities/openapi/schemas.py +33 -7
- fastmcp/mcp_config.py +3 -4
- fastmcp/prompts/__init__.py +1 -1
- fastmcp/prompts/prompt.py +32 -27
- fastmcp/prompts/prompt_manager.py +16 -101
- fastmcp/resources/__init__.py +5 -5
- fastmcp/resources/resource.py +28 -20
- fastmcp/resources/resource_manager.py +9 -168
- fastmcp/resources/template.py +119 -27
- fastmcp/resources/types.py +30 -24
- fastmcp/server/__init__.py +1 -1
- fastmcp/server/auth/__init__.py +9 -5
- fastmcp/server/auth/auth.py +80 -47
- fastmcp/server/auth/handlers/authorize.py +326 -0
- fastmcp/server/auth/jwt_issuer.py +236 -0
- fastmcp/server/auth/middleware.py +96 -0
- fastmcp/server/auth/oauth_proxy.py +1556 -265
- fastmcp/server/auth/oidc_proxy.py +412 -0
- fastmcp/server/auth/providers/auth0.py +193 -0
- fastmcp/server/auth/providers/aws.py +263 -0
- fastmcp/server/auth/providers/azure.py +314 -129
- fastmcp/server/auth/providers/bearer.py +1 -1
- fastmcp/server/auth/providers/debug.py +114 -0
- fastmcp/server/auth/providers/descope.py +229 -0
- fastmcp/server/auth/providers/discord.py +308 -0
- fastmcp/server/auth/providers/github.py +31 -6
- fastmcp/server/auth/providers/google.py +50 -7
- fastmcp/server/auth/providers/in_memory.py +27 -3
- fastmcp/server/auth/providers/introspection.py +281 -0
- fastmcp/server/auth/providers/jwt.py +48 -31
- fastmcp/server/auth/providers/oci.py +233 -0
- fastmcp/server/auth/providers/scalekit.py +238 -0
- fastmcp/server/auth/providers/supabase.py +188 -0
- fastmcp/server/auth/providers/workos.py +37 -15
- fastmcp/server/context.py +194 -67
- fastmcp/server/dependencies.py +56 -16
- fastmcp/server/elicitation.py +1 -1
- fastmcp/server/http.py +57 -18
- fastmcp/server/low_level.py +121 -2
- fastmcp/server/middleware/__init__.py +1 -1
- fastmcp/server/middleware/caching.py +476 -0
- fastmcp/server/middleware/error_handling.py +14 -10
- fastmcp/server/middleware/logging.py +158 -116
- fastmcp/server/middleware/middleware.py +30 -16
- fastmcp/server/middleware/rate_limiting.py +3 -3
- fastmcp/server/middleware/tool_injection.py +116 -0
- fastmcp/server/openapi.py +15 -7
- fastmcp/server/proxy.py +22 -11
- fastmcp/server/server.py +744 -254
- fastmcp/settings.py +65 -15
- fastmcp/tools/__init__.py +1 -1
- fastmcp/tools/tool.py +173 -108
- fastmcp/tools/tool_manager.py +30 -112
- fastmcp/tools/tool_transform.py +13 -11
- fastmcp/utilities/cli.py +67 -28
- fastmcp/utilities/components.py +7 -2
- fastmcp/utilities/inspect.py +79 -23
- fastmcp/utilities/json_schema.py +21 -4
- fastmcp/utilities/json_schema_type.py +4 -4
- fastmcp/utilities/logging.py +182 -10
- fastmcp/utilities/mcp_server_config/__init__.py +3 -3
- fastmcp/utilities/mcp_server_config/v1/environments/base.py +1 -2
- fastmcp/utilities/mcp_server_config/v1/environments/uv.py +10 -45
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +8 -7
- fastmcp/utilities/mcp_server_config/v1/schema.json +5 -1
- fastmcp/utilities/mcp_server_config/v1/sources/base.py +0 -1
- fastmcp/utilities/openapi.py +11 -11
- fastmcp/utilities/tests.py +93 -10
- fastmcp/utilities/types.py +87 -21
- fastmcp/utilities/ui.py +626 -0
- {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/METADATA +141 -60
- fastmcp-2.13.2.dist-info/RECORD +144 -0
- {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/WHEEL +1 -1
- fastmcp/cli/claude.py +0 -144
- fastmcp-2.12.1.dist-info/RECORD +0 -128
- {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
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
|
-
|
|
272
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
221
|
+
await server.run_stdio_async()
|
|
299
222
|
case "http" | "streamable-http" | None:
|
|
300
|
-
|
|
223
|
+
await server.run_streamable_http_async()
|
|
301
224
|
case "sse":
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
runner()
|
|
225
|
+
await server.run_sse_async()
|
fastmcp/client/__init__.py
CHANGED
|
@@ -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
|
-
"
|
|
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
|
-
"
|
|
25
|
+
"PythonStdioTransport",
|
|
26
|
+
"SSETransport",
|
|
27
|
+
"StdioTransport",
|
|
28
|
+
"StreamableHttpTransport",
|
|
29
|
+
"UvStdioTransport",
|
|
30
|
+
"UvxStdioTransport",
|
|
31
|
+
"WSTransport",
|
|
32
32
|
]
|
fastmcp/client/auth/oauth.py
CHANGED
|
@@ -1,30 +1,29 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import json
|
|
3
|
+
import time
|
|
5
4
|
import webbrowser
|
|
6
|
-
from asyncio import Future
|
|
7
5
|
from collections.abc import AsyncGenerator
|
|
8
|
-
from
|
|
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
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
285
|
-
|
|
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=
|
|
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
|
|
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
|
-
#
|
|
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
|
|
333
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|