fastmcp 2.12.5__py3-none-any.whl → 2.14.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/__init__.py +2 -23
- fastmcp/cli/__init__.py +0 -3
- fastmcp/cli/__main__.py +5 -0
- fastmcp/cli/cli.py +19 -33
- fastmcp/cli/install/claude_code.py +6 -6
- fastmcp/cli/install/claude_desktop.py +3 -3
- fastmcp/cli/install/cursor.py +18 -12
- fastmcp/cli/install/gemini_cli.py +3 -3
- fastmcp/cli/install/mcp_json.py +3 -3
- fastmcp/cli/install/shared.py +0 -15
- fastmcp/cli/run.py +13 -8
- fastmcp/cli/tasks.py +110 -0
- fastmcp/client/__init__.py +9 -9
- fastmcp/client/auth/oauth.py +123 -225
- fastmcp/client/client.py +697 -95
- fastmcp/client/elicitation.py +11 -5
- fastmcp/client/logging.py +18 -14
- fastmcp/client/messages.py +7 -5
- fastmcp/client/oauth_callback.py +85 -171
- fastmcp/client/roots.py +2 -1
- fastmcp/client/sampling.py +1 -1
- fastmcp/client/tasks.py +614 -0
- fastmcp/client/transports.py +117 -30
- fastmcp/contrib/component_manager/__init__.py +1 -1
- fastmcp/contrib/component_manager/component_manager.py +2 -2
- fastmcp/contrib/component_manager/component_service.py +10 -26
- fastmcp/contrib/mcp_mixin/README.md +32 -1
- fastmcp/contrib/mcp_mixin/__init__.py +2 -2
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -2
- fastmcp/dependencies.py +25 -0
- fastmcp/experimental/sampling/handlers/openai.py +3 -3
- fastmcp/experimental/server/openapi/__init__.py +20 -21
- fastmcp/experimental/utilities/openapi/__init__.py +16 -47
- fastmcp/mcp_config.py +3 -4
- fastmcp/prompts/__init__.py +1 -1
- fastmcp/prompts/prompt.py +54 -51
- fastmcp/prompts/prompt_manager.py +16 -101
- fastmcp/resources/__init__.py +5 -5
- fastmcp/resources/resource.py +43 -21
- fastmcp/resources/resource_manager.py +9 -168
- fastmcp/resources/template.py +161 -61
- fastmcp/resources/types.py +30 -24
- fastmcp/server/__init__.py +1 -1
- fastmcp/server/auth/__init__.py +9 -14
- fastmcp/server/auth/auth.py +197 -46
- 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 +1469 -298
- fastmcp/server/auth/oidc_proxy.py +91 -20
- fastmcp/server/auth/providers/auth0.py +40 -21
- fastmcp/server/auth/providers/aws.py +29 -3
- fastmcp/server/auth/providers/azure.py +312 -131
- fastmcp/server/auth/providers/debug.py +114 -0
- fastmcp/server/auth/providers/descope.py +86 -29
- fastmcp/server/auth/providers/discord.py +308 -0
- fastmcp/server/auth/providers/github.py +29 -8
- fastmcp/server/auth/providers/google.py +48 -9
- fastmcp/server/auth/providers/in_memory.py +29 -5
- 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 +35 -17
- fastmcp/server/context.py +236 -116
- fastmcp/server/dependencies.py +503 -18
- fastmcp/server/elicitation.py +286 -48
- fastmcp/server/event_store.py +177 -0
- fastmcp/server/http.py +71 -20
- fastmcp/server/low_level.py +165 -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 +50 -39
- fastmcp/server/middleware/middleware.py +29 -16
- fastmcp/server/middleware/rate_limiting.py +3 -3
- fastmcp/server/middleware/tool_injection.py +116 -0
- fastmcp/server/openapi/__init__.py +35 -0
- fastmcp/{experimental/server → server}/openapi/components.py +15 -10
- fastmcp/{experimental/server → server}/openapi/routing.py +3 -3
- fastmcp/{experimental/server → server}/openapi/server.py +6 -5
- fastmcp/server/proxy.py +72 -48
- fastmcp/server/server.py +1415 -733
- fastmcp/server/tasks/__init__.py +21 -0
- fastmcp/server/tasks/capabilities.py +22 -0
- fastmcp/server/tasks/config.py +89 -0
- fastmcp/server/tasks/converters.py +205 -0
- fastmcp/server/tasks/handlers.py +356 -0
- fastmcp/server/tasks/keys.py +93 -0
- fastmcp/server/tasks/protocol.py +355 -0
- fastmcp/server/tasks/subscriptions.py +205 -0
- fastmcp/settings.py +125 -113
- fastmcp/tools/__init__.py +1 -1
- fastmcp/tools/tool.py +138 -55
- fastmcp/tools/tool_manager.py +30 -112
- fastmcp/tools/tool_transform.py +12 -21
- fastmcp/utilities/cli.py +67 -28
- fastmcp/utilities/components.py +10 -5
- fastmcp/utilities/inspect.py +79 -23
- fastmcp/utilities/json_schema.py +4 -4
- fastmcp/utilities/json_schema_type.py +8 -8
- fastmcp/utilities/logging.py +118 -8
- fastmcp/utilities/mcp_config.py +1 -2
- 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 +6 -6
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +5 -5
- fastmcp/utilities/mcp_server_config/v1/schema.json +3 -0
- fastmcp/utilities/mcp_server_config/v1/sources/base.py +0 -1
- fastmcp/{experimental/utilities → utilities}/openapi/README.md +7 -35
- fastmcp/utilities/openapi/__init__.py +63 -0
- fastmcp/{experimental/utilities → utilities}/openapi/director.py +14 -15
- fastmcp/{experimental/utilities → utilities}/openapi/formatters.py +5 -5
- fastmcp/{experimental/utilities → utilities}/openapi/json_schema_converter.py +7 -3
- fastmcp/{experimental/utilities → utilities}/openapi/parser.py +37 -16
- fastmcp/utilities/tests.py +92 -5
- fastmcp/utilities/types.py +86 -16
- fastmcp/utilities/ui.py +626 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.14.0.dist-info}/METADATA +24 -15
- fastmcp-2.14.0.dist-info/RECORD +156 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.14.0.dist-info}/WHEEL +1 -1
- fastmcp/cli/claude.py +0 -135
- fastmcp/server/auth/providers/bearer.py +0 -25
- fastmcp/server/openapi.py +0 -1083
- fastmcp/utilities/openapi.py +0 -1568
- fastmcp/utilities/storage.py +0 -204
- fastmcp-2.12.5.dist-info/RECORD +0 -134
- fastmcp/{experimental/server → server}/openapi/README.md +0 -0
- fastmcp/{experimental/utilities → utilities}/openapi/models.py +3 -3
- fastmcp/{experimental/utilities → utilities}/openapi/schemas.py +2 -2
- {fastmcp-2.12.5.dist-info → fastmcp-2.14.0.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.14.0.dist-info}/licenses/LICENSE +0 -0
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
|
|
10
|
-
from urllib.parse import urlparse
|
|
6
|
+
from typing import Any
|
|
11
7
|
|
|
12
8
|
import anyio
|
|
13
9
|
import httpx
|
|
10
|
+
from key_value.aio.adapters.pydantic import PydanticAdapter
|
|
11
|
+
from key_value.aio.protocols import AsyncKeyValue
|
|
12
|
+
from key_value.aio.stores.memory import MemoryStore
|
|
14
13
|
from mcp.client.auth import OAuthClientProvider, TokenStorage
|
|
14
|
+
from mcp.shared._httpx_utils import McpHttpClientFactory
|
|
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
|
|
|
@@ -38,176 +36,6 @@ logger = get_logger(__name__)
|
|
|
38
36
|
class ClientNotFoundError(Exception):
|
|
39
37
|
"""Raised when OAuth client credentials are not found on the server."""
|
|
40
38
|
|
|
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
|
-
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
39
|
|
|
212
40
|
async def check_if_auth_required(
|
|
213
41
|
mcp_url: str, httpx_kwargs: dict[str, Any] | None = None
|
|
@@ -228,7 +56,7 @@ async def check_if_auth_required(
|
|
|
228
56
|
return True
|
|
229
57
|
|
|
230
58
|
# Check for WWW-Authenticate header
|
|
231
|
-
if "WWW-Authenticate" in response.headers:
|
|
59
|
+
if "WWW-Authenticate" in response.headers: # noqa: SIM103
|
|
232
60
|
return True
|
|
233
61
|
|
|
234
62
|
# If we get a successful response, auth may not be required
|
|
@@ -239,6 +67,70 @@ async def check_if_auth_required(
|
|
|
239
67
|
return True
|
|
240
68
|
|
|
241
69
|
|
|
70
|
+
class TokenStorageAdapter(TokenStorage):
|
|
71
|
+
_server_url: str
|
|
72
|
+
_key_value_store: AsyncKeyValue
|
|
73
|
+
_storage_oauth_token: PydanticAdapter[OAuthToken]
|
|
74
|
+
_storage_client_info: PydanticAdapter[OAuthClientInformationFull]
|
|
75
|
+
|
|
76
|
+
def __init__(self, async_key_value: AsyncKeyValue, server_url: str):
|
|
77
|
+
self._server_url = server_url
|
|
78
|
+
self._key_value_store = async_key_value
|
|
79
|
+
self._storage_oauth_token = PydanticAdapter[OAuthToken](
|
|
80
|
+
default_collection="mcp-oauth-token",
|
|
81
|
+
key_value=async_key_value,
|
|
82
|
+
pydantic_model=OAuthToken,
|
|
83
|
+
raise_on_validation_error=True,
|
|
84
|
+
)
|
|
85
|
+
self._storage_client_info = PydanticAdapter[OAuthClientInformationFull](
|
|
86
|
+
default_collection="mcp-oauth-client-info",
|
|
87
|
+
key_value=async_key_value,
|
|
88
|
+
pydantic_model=OAuthClientInformationFull,
|
|
89
|
+
raise_on_validation_error=True,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def _get_token_cache_key(self) -> str:
|
|
93
|
+
return f"{self._server_url}/tokens"
|
|
94
|
+
|
|
95
|
+
def _get_client_info_cache_key(self) -> str:
|
|
96
|
+
return f"{self._server_url}/client_info"
|
|
97
|
+
|
|
98
|
+
async def clear(self) -> None:
|
|
99
|
+
await self._storage_oauth_token.delete(key=self._get_token_cache_key())
|
|
100
|
+
await self._storage_client_info.delete(key=self._get_client_info_cache_key())
|
|
101
|
+
|
|
102
|
+
@override
|
|
103
|
+
async def get_tokens(self) -> OAuthToken | None:
|
|
104
|
+
return await self._storage_oauth_token.get(key=self._get_token_cache_key())
|
|
105
|
+
|
|
106
|
+
@override
|
|
107
|
+
async def set_tokens(self, tokens: OAuthToken) -> None:
|
|
108
|
+
await self._storage_oauth_token.put(
|
|
109
|
+
key=self._get_token_cache_key(),
|
|
110
|
+
value=tokens,
|
|
111
|
+
ttl=tokens.expires_in,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
@override
|
|
115
|
+
async def get_client_info(self) -> OAuthClientInformationFull | None:
|
|
116
|
+
return await self._storage_client_info.get(
|
|
117
|
+
key=self._get_client_info_cache_key()
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
@override
|
|
121
|
+
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
|
|
122
|
+
ttl: int | None = None
|
|
123
|
+
|
|
124
|
+
if client_info.client_secret_expires_at:
|
|
125
|
+
ttl = client_info.client_secret_expires_at - int(time.time())
|
|
126
|
+
|
|
127
|
+
await self._storage_client_info.put(
|
|
128
|
+
key=self._get_client_info_cache_key(),
|
|
129
|
+
value=client_info,
|
|
130
|
+
ttl=ttl,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
242
134
|
class OAuth(OAuthClientProvider):
|
|
243
135
|
"""
|
|
244
136
|
OAuth client provider for MCP servers with browser-based authentication.
|
|
@@ -252,9 +144,10 @@ class OAuth(OAuthClientProvider):
|
|
|
252
144
|
mcp_url: str,
|
|
253
145
|
scopes: str | list[str] | None = None,
|
|
254
146
|
client_name: str = "FastMCP Client",
|
|
255
|
-
|
|
147
|
+
token_storage: AsyncKeyValue | None = None,
|
|
256
148
|
additional_client_metadata: dict[str, Any] | None = None,
|
|
257
149
|
callback_port: int | None = None,
|
|
150
|
+
httpx_client_factory: McpHttpClientFactory | None = None,
|
|
258
151
|
):
|
|
259
152
|
"""
|
|
260
153
|
Initialize OAuth client provider for an MCP server.
|
|
@@ -264,14 +157,15 @@ class OAuth(OAuthClientProvider):
|
|
|
264
157
|
scopes: OAuth scopes to request. Can be a
|
|
265
158
|
space-separated string or a list of strings.
|
|
266
159
|
client_name: Name for this client during registration
|
|
267
|
-
|
|
160
|
+
token_storage: An AsyncKeyValue-compatible token store, tokens are stored in memory if not provided
|
|
268
161
|
additional_client_metadata: Extra fields for OAuthClientMetadata
|
|
269
162
|
callback_port: Fixed port for OAuth callback (default: random available port)
|
|
270
163
|
"""
|
|
271
|
-
|
|
272
|
-
|
|
164
|
+
# Normalize the MCP URL (strip trailing slashes for consistency)
|
|
165
|
+
mcp_url = mcp_url.rstrip("/")
|
|
273
166
|
|
|
274
167
|
# Setup OAuth client
|
|
168
|
+
self.httpx_client_factory = httpx_client_factory or httpx.AsyncClient
|
|
275
169
|
self.redirect_port = callback_port or find_available_port()
|
|
276
170
|
redirect_uri = f"http://localhost:{self.redirect_port}/callback"
|
|
277
171
|
|
|
@@ -294,18 +188,31 @@ 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 -- tokens will be lost when the client restarts. "
|
|
198
|
+
+ "For persistent storage across multiple MCP servers, provide an encrypted AsyncKeyValue backend. "
|
|
199
|
+
+ "See https://gofastmcp.com/clients/auth/oauth#token-storage for details.",
|
|
200
|
+
stacklevel=2,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Use full URL for token storage to properly separate tokens per MCP endpoint
|
|
204
|
+
self.token_storage_adapter: TokenStorageAdapter = TokenStorageAdapter(
|
|
205
|
+
async_key_value=token_storage, server_url=mcp_url
|
|
299
206
|
)
|
|
300
207
|
|
|
301
|
-
# Store
|
|
302
|
-
self.
|
|
208
|
+
# Store full MCP URL for use in callback_handler display
|
|
209
|
+
self.mcp_url = mcp_url
|
|
303
210
|
|
|
304
|
-
# Initialize parent class
|
|
211
|
+
# Initialize parent class with full URL for proper OAuth metadata discovery
|
|
305
212
|
super().__init__(
|
|
306
|
-
server_url=
|
|
213
|
+
server_url=mcp_url,
|
|
307
214
|
client_metadata=client_metadata,
|
|
308
|
-
storage=
|
|
215
|
+
storage=self.token_storage_adapter,
|
|
309
216
|
redirect_handler=self.redirect_handler,
|
|
310
217
|
callback_handler=self.callback_handler,
|
|
311
218
|
)
|
|
@@ -322,7 +229,7 @@ class OAuth(OAuthClientProvider):
|
|
|
322
229
|
async def redirect_handler(self, authorization_url: str) -> None:
|
|
323
230
|
"""Open browser for authorization, with pre-flight check for invalid client."""
|
|
324
231
|
# Pre-flight check to detect invalid client_id before opening browser
|
|
325
|
-
async with
|
|
232
|
+
async with self.httpx_client_factory() as client:
|
|
326
233
|
response = await client.get(authorization_url, follow_redirects=False)
|
|
327
234
|
|
|
328
235
|
# Check for client not found error (400 typically means bad client_id)
|
|
@@ -342,14 +249,16 @@ class OAuth(OAuthClientProvider):
|
|
|
342
249
|
|
|
343
250
|
async def callback_handler(self) -> tuple[str, str | None]:
|
|
344
251
|
"""Handle OAuth callback and return (auth_code, state)."""
|
|
345
|
-
# Create
|
|
346
|
-
|
|
252
|
+
# Create result container and event to capture the OAuth response
|
|
253
|
+
result = OAuthCallbackResult()
|
|
254
|
+
result_ready = anyio.Event()
|
|
347
255
|
|
|
348
|
-
# Create server with
|
|
256
|
+
# Create server with result tracking
|
|
349
257
|
server: Server = create_oauth_callback_server(
|
|
350
258
|
port=self.redirect_port,
|
|
351
|
-
server_url=self.
|
|
352
|
-
|
|
259
|
+
server_url=self.mcp_url,
|
|
260
|
+
result_container=result,
|
|
261
|
+
result_ready=result_ready,
|
|
353
262
|
)
|
|
354
263
|
|
|
355
264
|
# Run server until response is received with timeout logic
|
|
@@ -362,13 +271,17 @@ class OAuth(OAuthClientProvider):
|
|
|
362
271
|
TIMEOUT = 300.0 # 5 minute timeout
|
|
363
272
|
try:
|
|
364
273
|
with anyio.fail_after(TIMEOUT):
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
|
369
282
|
finally:
|
|
370
283
|
server.should_exit = True
|
|
371
|
-
await
|
|
284
|
+
await anyio.sleep(0.1) # Allow server to shut down gracefully
|
|
372
285
|
tg.cancel_scope.cancel()
|
|
373
286
|
|
|
374
287
|
raise RuntimeError("OAuth callback handler could not be started")
|
|
@@ -387,7 +300,8 @@ class OAuth(OAuthClientProvider):
|
|
|
387
300
|
response = None
|
|
388
301
|
while True:
|
|
389
302
|
try:
|
|
390
|
-
|
|
303
|
+
# First iteration sends None, subsequent iterations send response
|
|
304
|
+
yielded_request = await gen.asend(response) # ty: ignore[invalid-argument-type]
|
|
391
305
|
response = yield yielded_request
|
|
392
306
|
except StopAsyncIteration:
|
|
393
307
|
break
|
|
@@ -396,32 +310,16 @@ class OAuth(OAuthClientProvider):
|
|
|
396
310
|
logger.debug(
|
|
397
311
|
"OAuth client not found on server, clearing cache and retrying..."
|
|
398
312
|
)
|
|
399
|
-
|
|
400
313
|
# Clear cached state and retry once
|
|
401
314
|
self._initialized = False
|
|
315
|
+
await self.token_storage_adapter.clear()
|
|
402
316
|
|
|
403
|
-
#
|
|
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
|
|
419
|
-
|
|
317
|
+
# Retry with fresh registration
|
|
420
318
|
gen = super().async_auth_flow(request)
|
|
421
319
|
response = None
|
|
422
320
|
while True:
|
|
423
321
|
try:
|
|
424
|
-
yielded_request = await gen.asend(response)
|
|
322
|
+
yielded_request = await gen.asend(response) # ty: ignore[invalid-argument-type]
|
|
425
323
|
response = yield yielded_request
|
|
426
324
|
except StopAsyncIteration:
|
|
427
325
|
break
|