fastmcp 2.5.2__py3-none-any.whl → 2.6.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/client/__init__.py +3 -0
- fastmcp/client/auth/__init__.py +4 -0
- fastmcp/client/auth/bearer.py +17 -0
- fastmcp/client/auth/oauth.py +394 -0
- fastmcp/client/client.py +74 -26
- fastmcp/client/oauth_callback.py +310 -0
- fastmcp/client/transports.py +76 -14
- fastmcp/server/auth/__init__.py +4 -0
- fastmcp/server/auth/auth.py +45 -0
- fastmcp/server/auth/providers/bearer.py +377 -0
- fastmcp/server/auth/providers/bearer_env.py +62 -0
- fastmcp/server/auth/providers/in_memory.py +330 -0
- fastmcp/server/dependencies.py +10 -0
- fastmcp/server/http.py +38 -66
- fastmcp/server/openapi.py +2 -0
- fastmcp/server/server.py +21 -16
- fastmcp/settings.py +27 -8
- fastmcp/tools/tool.py +22 -3
- fastmcp/tools/tool_manager.py +2 -0
- fastmcp/utilities/http.py +8 -0
- fastmcp/utilities/tests.py +22 -10
- {fastmcp-2.5.2.dist-info → fastmcp-2.6.0.dist-info}/METADATA +9 -8
- {fastmcp-2.5.2.dist-info → fastmcp-2.6.0.dist-info}/RECORD +27 -20
- fastmcp/client/base.py +0 -0
- fastmcp/low_level/README.md +0 -1
- fastmcp/py.typed +0 -0
- /fastmcp/{low_level → server/auth/providers}/__init__.py +0 -0
- {fastmcp-2.5.2.dist-info → fastmcp-2.6.0.dist-info}/WHEEL +0 -0
- {fastmcp-2.5.2.dist-info → fastmcp-2.6.0.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.5.2.dist-info → fastmcp-2.6.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This is a simple in-memory OAuth provider for testing purposes.
|
|
3
|
+
It simulates the OAuth 2.0 flow locally without external calls.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import secrets
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from mcp.server.auth.provider import (
|
|
10
|
+
AccessToken,
|
|
11
|
+
AuthorizationCode,
|
|
12
|
+
AuthorizationParams,
|
|
13
|
+
AuthorizeError,
|
|
14
|
+
RefreshToken,
|
|
15
|
+
TokenError,
|
|
16
|
+
construct_redirect_uri,
|
|
17
|
+
)
|
|
18
|
+
from mcp.shared.auth import (
|
|
19
|
+
OAuthClientInformationFull,
|
|
20
|
+
OAuthToken,
|
|
21
|
+
)
|
|
22
|
+
from pydantic import AnyHttpUrl
|
|
23
|
+
|
|
24
|
+
from fastmcp.server.auth.auth import (
|
|
25
|
+
ClientRegistrationOptions,
|
|
26
|
+
OAuthProvider,
|
|
27
|
+
RevocationOptions,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Default expiration times (in seconds)
|
|
31
|
+
DEFAULT_AUTH_CODE_EXPIRY_SECONDS = 5 * 60 # 5 minutes
|
|
32
|
+
DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS = 60 * 60 # 1 hour
|
|
33
|
+
DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS = None # No expiry
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class InMemoryOAuthProvider(OAuthProvider):
|
|
37
|
+
"""
|
|
38
|
+
An in-memory OAuth provider for testing purposes.
|
|
39
|
+
It simulates the OAuth 2.0 flow locally without external calls.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
issuer_url: AnyHttpUrl | str | None = None,
|
|
45
|
+
service_documentation_url: AnyHttpUrl | str | None = None,
|
|
46
|
+
client_registration_options: ClientRegistrationOptions | None = None,
|
|
47
|
+
revocation_options: RevocationOptions | None = None,
|
|
48
|
+
required_scopes: list[str] | None = None,
|
|
49
|
+
):
|
|
50
|
+
super().__init__(
|
|
51
|
+
issuer_url=issuer_url or "http://fastmcp.example.com",
|
|
52
|
+
service_documentation_url=service_documentation_url,
|
|
53
|
+
client_registration_options=client_registration_options,
|
|
54
|
+
revocation_options=revocation_options,
|
|
55
|
+
required_scopes=required_scopes,
|
|
56
|
+
)
|
|
57
|
+
self.clients: dict[str, OAuthClientInformationFull] = {}
|
|
58
|
+
self.auth_codes: dict[str, AuthorizationCode] = {}
|
|
59
|
+
self.access_tokens: dict[str, AccessToken] = {}
|
|
60
|
+
self.refresh_tokens: dict[str, RefreshToken] = {}
|
|
61
|
+
|
|
62
|
+
# For revoking associated tokens
|
|
63
|
+
self._access_to_refresh_map: dict[
|
|
64
|
+
str, str
|
|
65
|
+
] = {} # access_token_str -> refresh_token_str
|
|
66
|
+
self._refresh_to_access_map: dict[
|
|
67
|
+
str, str
|
|
68
|
+
] = {} # refresh_token_str -> access_token_str
|
|
69
|
+
|
|
70
|
+
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
|
71
|
+
return self.clients.get(client_id)
|
|
72
|
+
|
|
73
|
+
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
|
74
|
+
if client_info.client_id in self.clients:
|
|
75
|
+
# As per RFC 7591, if client_id is already known, it's an update.
|
|
76
|
+
# For this simple provider, we'll treat it as re-registration.
|
|
77
|
+
# A real provider might handle updates or raise errors for conflicts.
|
|
78
|
+
pass
|
|
79
|
+
self.clients[client_info.client_id] = client_info
|
|
80
|
+
|
|
81
|
+
async def authorize(
|
|
82
|
+
self, client: OAuthClientInformationFull, params: AuthorizationParams
|
|
83
|
+
) -> str:
|
|
84
|
+
"""
|
|
85
|
+
Simulates user authorization and generates an authorization code.
|
|
86
|
+
Returns a redirect URI with the code and state.
|
|
87
|
+
"""
|
|
88
|
+
if client.client_id not in self.clients:
|
|
89
|
+
raise AuthorizeError(
|
|
90
|
+
error="unauthorized_client",
|
|
91
|
+
error_description=f"Client '{client.client_id}' not registered.",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Validate redirect_uri (already validated by AuthorizationHandler, but good practice)
|
|
95
|
+
try:
|
|
96
|
+
# OAuthClientInformationFull should have a method like validate_redirect_uri
|
|
97
|
+
# For this test provider, we assume it's valid if it matches one in client_info
|
|
98
|
+
# The AuthorizationHandler already does robust validation using client.validate_redirect_uri
|
|
99
|
+
if params.redirect_uri not in client.redirect_uris:
|
|
100
|
+
# This check might be too simplistic if redirect_uris can be patterns
|
|
101
|
+
# or if params.redirect_uri is None and client has a default.
|
|
102
|
+
# However, the AuthorizationHandler handles the primary validation.
|
|
103
|
+
pass # Let's assume AuthorizationHandler did its job.
|
|
104
|
+
except Exception: # Replace with specific validation error if client.validate_redirect_uri existed
|
|
105
|
+
raise AuthorizeError(
|
|
106
|
+
error="invalid_request", error_description="Invalid redirect_uri."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
auth_code_value = f"test_auth_code_{secrets.token_hex(16)}"
|
|
110
|
+
expires_at = time.time() + DEFAULT_AUTH_CODE_EXPIRY_SECONDS
|
|
111
|
+
|
|
112
|
+
# Ensure scopes are a list
|
|
113
|
+
scopes_list = params.scopes if params.scopes is not None else []
|
|
114
|
+
if client.scope: # Filter params.scopes against client's registered scopes
|
|
115
|
+
client_allowed_scopes = set(client.scope.split())
|
|
116
|
+
scopes_list = [s for s in scopes_list if s in client_allowed_scopes]
|
|
117
|
+
|
|
118
|
+
auth_code = AuthorizationCode(
|
|
119
|
+
code=auth_code_value,
|
|
120
|
+
client_id=client.client_id,
|
|
121
|
+
redirect_uri=params.redirect_uri,
|
|
122
|
+
redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly,
|
|
123
|
+
scopes=scopes_list,
|
|
124
|
+
expires_at=expires_at,
|
|
125
|
+
code_challenge=params.code_challenge,
|
|
126
|
+
# code_challenge_method is assumed S256 by the framework
|
|
127
|
+
)
|
|
128
|
+
self.auth_codes[auth_code_value] = auth_code
|
|
129
|
+
|
|
130
|
+
return construct_redirect_uri(
|
|
131
|
+
str(params.redirect_uri), code=auth_code_value, state=params.state
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
async def load_authorization_code(
|
|
135
|
+
self, client: OAuthClientInformationFull, authorization_code: str
|
|
136
|
+
) -> AuthorizationCode | None:
|
|
137
|
+
auth_code_obj = self.auth_codes.get(authorization_code)
|
|
138
|
+
if auth_code_obj:
|
|
139
|
+
if auth_code_obj.client_id != client.client_id:
|
|
140
|
+
return None # Belongs to a different client
|
|
141
|
+
if auth_code_obj.expires_at < time.time():
|
|
142
|
+
del self.auth_codes[authorization_code] # Expired
|
|
143
|
+
return None
|
|
144
|
+
return auth_code_obj
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
async def exchange_authorization_code(
|
|
148
|
+
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
|
|
149
|
+
) -> OAuthToken:
|
|
150
|
+
# Authorization code should have been validated (existence, expiry, client_id match)
|
|
151
|
+
# by the TokenHandler calling load_authorization_code before this.
|
|
152
|
+
# We might want to re-verify or simply trust it's valid.
|
|
153
|
+
|
|
154
|
+
if authorization_code.code not in self.auth_codes:
|
|
155
|
+
raise TokenError(
|
|
156
|
+
"invalid_grant", "Authorization code not found or already used."
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Consume the auth code
|
|
160
|
+
del self.auth_codes[authorization_code.code]
|
|
161
|
+
|
|
162
|
+
access_token_value = f"test_access_token_{secrets.token_hex(32)}"
|
|
163
|
+
refresh_token_value = f"test_refresh_token_{secrets.token_hex(32)}"
|
|
164
|
+
|
|
165
|
+
access_token_expires_at = int(time.time() + DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS)
|
|
166
|
+
|
|
167
|
+
# Refresh token expiry
|
|
168
|
+
refresh_token_expires_at = None
|
|
169
|
+
if DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS is not None:
|
|
170
|
+
refresh_token_expires_at = int(
|
|
171
|
+
time.time() + DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
self.access_tokens[access_token_value] = AccessToken(
|
|
175
|
+
token=access_token_value,
|
|
176
|
+
client_id=client.client_id,
|
|
177
|
+
scopes=authorization_code.scopes,
|
|
178
|
+
expires_at=access_token_expires_at,
|
|
179
|
+
)
|
|
180
|
+
self.refresh_tokens[refresh_token_value] = RefreshToken(
|
|
181
|
+
token=refresh_token_value,
|
|
182
|
+
client_id=client.client_id,
|
|
183
|
+
scopes=authorization_code.scopes, # Refresh token inherits scopes
|
|
184
|
+
expires_at=refresh_token_expires_at,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
self._access_to_refresh_map[access_token_value] = refresh_token_value
|
|
188
|
+
self._refresh_to_access_map[refresh_token_value] = access_token_value
|
|
189
|
+
|
|
190
|
+
return OAuthToken(
|
|
191
|
+
access_token=access_token_value,
|
|
192
|
+
token_type="bearer",
|
|
193
|
+
expires_in=DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS,
|
|
194
|
+
refresh_token=refresh_token_value,
|
|
195
|
+
scope=" ".join(authorization_code.scopes),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
async def load_refresh_token(
|
|
199
|
+
self, client: OAuthClientInformationFull, refresh_token: str
|
|
200
|
+
) -> RefreshToken | None:
|
|
201
|
+
token_obj = self.refresh_tokens.get(refresh_token)
|
|
202
|
+
if token_obj:
|
|
203
|
+
if token_obj.client_id != client.client_id:
|
|
204
|
+
return None # Belongs to different client
|
|
205
|
+
if token_obj.expires_at is not None and token_obj.expires_at < time.time():
|
|
206
|
+
self._revoke_internal(
|
|
207
|
+
refresh_token_str=token_obj.token
|
|
208
|
+
) # Clean up expired
|
|
209
|
+
return None
|
|
210
|
+
return token_obj
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
async def exchange_refresh_token(
|
|
214
|
+
self,
|
|
215
|
+
client: OAuthClientInformationFull,
|
|
216
|
+
refresh_token: RefreshToken, # This is the RefreshToken object, already loaded
|
|
217
|
+
scopes: list[str], # Requested scopes for the new access token
|
|
218
|
+
) -> OAuthToken:
|
|
219
|
+
# Validate scopes: requested scopes must be a subset of original scopes
|
|
220
|
+
original_scopes = set(refresh_token.scopes)
|
|
221
|
+
requested_scopes = set(scopes)
|
|
222
|
+
if not requested_scopes.issubset(original_scopes):
|
|
223
|
+
raise TokenError(
|
|
224
|
+
"invalid_scope",
|
|
225
|
+
"Requested scopes exceed those authorized by the refresh token.",
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# Invalidate old refresh token and its associated access token (rotation)
|
|
229
|
+
self._revoke_internal(refresh_token_str=refresh_token.token)
|
|
230
|
+
|
|
231
|
+
# Issue new tokens
|
|
232
|
+
new_access_token_value = f"test_access_token_{secrets.token_hex(32)}"
|
|
233
|
+
new_refresh_token_value = f"test_refresh_token_{secrets.token_hex(32)}"
|
|
234
|
+
|
|
235
|
+
access_token_expires_at = int(time.time() + DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS)
|
|
236
|
+
|
|
237
|
+
# Refresh token expiry
|
|
238
|
+
refresh_token_expires_at = None
|
|
239
|
+
if DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS is not None:
|
|
240
|
+
refresh_token_expires_at = int(
|
|
241
|
+
time.time() + DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
self.access_tokens[new_access_token_value] = AccessToken(
|
|
245
|
+
token=new_access_token_value,
|
|
246
|
+
client_id=client.client_id,
|
|
247
|
+
scopes=scopes, # Use newly requested (and validated) scopes
|
|
248
|
+
expires_at=access_token_expires_at,
|
|
249
|
+
)
|
|
250
|
+
self.refresh_tokens[new_refresh_token_value] = RefreshToken(
|
|
251
|
+
token=new_refresh_token_value,
|
|
252
|
+
client_id=client.client_id,
|
|
253
|
+
scopes=scopes, # New refresh token also gets these scopes
|
|
254
|
+
expires_at=refresh_token_expires_at,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
self._access_to_refresh_map[new_access_token_value] = new_refresh_token_value
|
|
258
|
+
self._refresh_to_access_map[new_refresh_token_value] = new_access_token_value
|
|
259
|
+
|
|
260
|
+
return OAuthToken(
|
|
261
|
+
access_token=new_access_token_value,
|
|
262
|
+
token_type="bearer",
|
|
263
|
+
expires_in=DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS,
|
|
264
|
+
refresh_token=new_refresh_token_value,
|
|
265
|
+
scope=" ".join(scopes),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
async def load_access_token(self, token: str) -> AccessToken | None:
|
|
269
|
+
token_obj = self.access_tokens.get(token)
|
|
270
|
+
if token_obj:
|
|
271
|
+
if token_obj.expires_at is not None and token_obj.expires_at < time.time():
|
|
272
|
+
self._revoke_internal(
|
|
273
|
+
access_token_str=token_obj.token
|
|
274
|
+
) # Clean up expired
|
|
275
|
+
return None
|
|
276
|
+
return token_obj
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
def _revoke_internal(
|
|
280
|
+
self, access_token_str: str | None = None, refresh_token_str: str | None = None
|
|
281
|
+
):
|
|
282
|
+
"""Internal helper to remove tokens and their associations."""
|
|
283
|
+
removed_access_token = None
|
|
284
|
+
removed_refresh_token = None
|
|
285
|
+
|
|
286
|
+
if access_token_str:
|
|
287
|
+
if access_token_str in self.access_tokens:
|
|
288
|
+
del self.access_tokens[access_token_str]
|
|
289
|
+
removed_access_token = access_token_str
|
|
290
|
+
|
|
291
|
+
# Get associated refresh token
|
|
292
|
+
associated_refresh = self._access_to_refresh_map.pop(access_token_str, None)
|
|
293
|
+
if associated_refresh:
|
|
294
|
+
if associated_refresh in self.refresh_tokens:
|
|
295
|
+
del self.refresh_tokens[associated_refresh]
|
|
296
|
+
removed_refresh_token = associated_refresh
|
|
297
|
+
self._refresh_to_access_map.pop(associated_refresh, None)
|
|
298
|
+
|
|
299
|
+
if refresh_token_str:
|
|
300
|
+
if refresh_token_str in self.refresh_tokens:
|
|
301
|
+
del self.refresh_tokens[refresh_token_str]
|
|
302
|
+
removed_refresh_token = refresh_token_str
|
|
303
|
+
|
|
304
|
+
# Get associated access token
|
|
305
|
+
associated_access = self._refresh_to_access_map.pop(refresh_token_str, None)
|
|
306
|
+
if associated_access:
|
|
307
|
+
if associated_access in self.access_tokens:
|
|
308
|
+
del self.access_tokens[associated_access]
|
|
309
|
+
removed_access_token = associated_access
|
|
310
|
+
self._access_to_refresh_map.pop(associated_access, None)
|
|
311
|
+
|
|
312
|
+
# Clean up any dangling references if one part of the pair was already gone
|
|
313
|
+
if removed_access_token and removed_access_token in self._access_to_refresh_map:
|
|
314
|
+
del self._access_to_refresh_map[removed_access_token]
|
|
315
|
+
if (
|
|
316
|
+
removed_refresh_token
|
|
317
|
+
and removed_refresh_token in self._refresh_to_access_map
|
|
318
|
+
):
|
|
319
|
+
del self._refresh_to_access_map[removed_refresh_token]
|
|
320
|
+
|
|
321
|
+
async def revoke_token(
|
|
322
|
+
self,
|
|
323
|
+
token: AccessToken | RefreshToken,
|
|
324
|
+
) -> None:
|
|
325
|
+
"""Revokes an access or refresh token and its counterpart."""
|
|
326
|
+
if isinstance(token, AccessToken):
|
|
327
|
+
self._revoke_internal(access_token_str=token.token)
|
|
328
|
+
elif isinstance(token, RefreshToken):
|
|
329
|
+
self._revoke_internal(refresh_token_str=token.token)
|
|
330
|
+
# If token is not found or already revoked, _revoke_internal does nothing, which is correct.
|
fastmcp/server/dependencies.py
CHANGED
|
@@ -2,6 +2,8 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from typing import TYPE_CHECKING, ParamSpec, TypeVar
|
|
4
4
|
|
|
5
|
+
from mcp.server.auth.middleware.auth_context import get_access_token
|
|
6
|
+
from mcp.server.auth.provider import AccessToken
|
|
5
7
|
from starlette.requests import Request
|
|
6
8
|
|
|
7
9
|
if TYPE_CHECKING:
|
|
@@ -10,6 +12,14 @@ if TYPE_CHECKING:
|
|
|
10
12
|
P = ParamSpec("P")
|
|
11
13
|
R = TypeVar("R")
|
|
12
14
|
|
|
15
|
+
__all__ = [
|
|
16
|
+
"get_context",
|
|
17
|
+
"get_http_request",
|
|
18
|
+
"get_http_headers",
|
|
19
|
+
"get_access_token",
|
|
20
|
+
"AccessToken",
|
|
21
|
+
]
|
|
22
|
+
|
|
13
23
|
|
|
14
24
|
# --- Context ---
|
|
15
25
|
|
fastmcp/server/http.py
CHANGED
|
@@ -10,14 +10,7 @@ from mcp.server.auth.middleware.bearer_auth import (
|
|
|
10
10
|
BearerAuthBackend,
|
|
11
11
|
RequireAuthMiddleware,
|
|
12
12
|
)
|
|
13
|
-
from mcp.server.auth.provider import (
|
|
14
|
-
AccessTokenT,
|
|
15
|
-
AuthorizationCodeT,
|
|
16
|
-
OAuthAuthorizationServerProvider,
|
|
17
|
-
RefreshTokenT,
|
|
18
|
-
)
|
|
19
13
|
from mcp.server.auth.routes import create_auth_routes
|
|
20
|
-
from mcp.server.auth.settings import AuthSettings
|
|
21
14
|
from mcp.server.lowlevel.server import LifespanResultT
|
|
22
15
|
from mcp.server.sse import SseServerTransport
|
|
23
16
|
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
|
@@ -29,6 +22,7 @@ from starlette.responses import Response
|
|
|
29
22
|
from starlette.routing import BaseRoute, Mount, Route
|
|
30
23
|
from starlette.types import Lifespan, Receive, Scope, Send
|
|
31
24
|
|
|
25
|
+
from fastmcp.server.auth.auth import OAuthProvider
|
|
32
26
|
from fastmcp.utilities.logging import get_logger
|
|
33
27
|
|
|
34
28
|
if TYPE_CHECKING:
|
|
@@ -75,17 +69,12 @@ class RequestContextMiddleware:
|
|
|
75
69
|
|
|
76
70
|
|
|
77
71
|
def setup_auth_middleware_and_routes(
|
|
78
|
-
|
|
79
|
-
AuthorizationCodeT, RefreshTokenT, AccessTokenT
|
|
80
|
-
]
|
|
81
|
-
| None,
|
|
82
|
-
auth_settings: AuthSettings | None,
|
|
72
|
+
auth: OAuthProvider,
|
|
83
73
|
) -> tuple[list[Middleware], list[BaseRoute], list[str]]:
|
|
84
74
|
"""Set up authentication middleware and routes if auth is enabled.
|
|
85
75
|
|
|
86
76
|
Args:
|
|
87
|
-
|
|
88
|
-
auth_settings: The auth settings
|
|
77
|
+
auth: The OAuthProvider authorization server provider
|
|
89
78
|
|
|
90
79
|
Returns:
|
|
91
80
|
Tuple of (middleware, auth_routes, required_scopes)
|
|
@@ -94,31 +83,25 @@ def setup_auth_middleware_and_routes(
|
|
|
94
83
|
auth_routes: list[BaseRoute] = []
|
|
95
84
|
required_scopes: list[str] = []
|
|
96
85
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
86
|
+
middleware = [
|
|
87
|
+
Middleware(
|
|
88
|
+
AuthenticationMiddleware,
|
|
89
|
+
backend=BearerAuthBackend(provider=auth),
|
|
90
|
+
),
|
|
91
|
+
Middleware(AuthContextMiddleware),
|
|
92
|
+
]
|
|
102
93
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
auth_routes.extend(
|
|
114
|
-
create_auth_routes(
|
|
115
|
-
provider=auth_server_provider,
|
|
116
|
-
issuer_url=auth_settings.issuer_url,
|
|
117
|
-
service_documentation_url=auth_settings.service_documentation_url,
|
|
118
|
-
client_registration_options=auth_settings.client_registration_options,
|
|
119
|
-
revocation_options=auth_settings.revocation_options,
|
|
120
|
-
)
|
|
94
|
+
required_scopes = auth.required_scopes or []
|
|
95
|
+
|
|
96
|
+
auth_routes.extend(
|
|
97
|
+
create_auth_routes(
|
|
98
|
+
provider=auth,
|
|
99
|
+
issuer_url=auth.issuer_url,
|
|
100
|
+
service_documentation_url=auth.service_documentation_url,
|
|
101
|
+
client_registration_options=auth.client_registration_options,
|
|
102
|
+
revocation_options=auth.revocation_options,
|
|
121
103
|
)
|
|
104
|
+
)
|
|
122
105
|
|
|
123
106
|
return middleware, auth_routes, required_scopes
|
|
124
107
|
|
|
@@ -155,11 +138,7 @@ def create_sse_app(
|
|
|
155
138
|
server: FastMCP[LifespanResultT],
|
|
156
139
|
message_path: str,
|
|
157
140
|
sse_path: str,
|
|
158
|
-
|
|
159
|
-
AuthorizationCodeT, RefreshTokenT, AccessTokenT
|
|
160
|
-
]
|
|
161
|
-
| None = None,
|
|
162
|
-
auth_settings: AuthSettings | None = None,
|
|
141
|
+
auth: OAuthProvider | None = None,
|
|
163
142
|
debug: bool = False,
|
|
164
143
|
routes: list[BaseRoute] | None = None,
|
|
165
144
|
middleware: list[Middleware] | None = None,
|
|
@@ -170,8 +149,7 @@ def create_sse_app(
|
|
|
170
149
|
server: The FastMCP server instance
|
|
171
150
|
message_path: Path for SSE messages
|
|
172
151
|
sse_path: Path for SSE connections
|
|
173
|
-
|
|
174
|
-
auth_settings: Optional auth settings
|
|
152
|
+
auth: Optional auth provider
|
|
175
153
|
debug: Whether to enable debug mode
|
|
176
154
|
routes: Optional list of custom routes
|
|
177
155
|
middleware: Optional list of middleware
|
|
@@ -196,15 +174,15 @@ def create_sse_app(
|
|
|
196
174
|
return Response()
|
|
197
175
|
|
|
198
176
|
# Get auth middleware and routes
|
|
199
|
-
auth_middleware, auth_routes, required_scopes = setup_auth_middleware_and_routes(
|
|
200
|
-
auth_server_provider, auth_settings
|
|
201
|
-
)
|
|
202
|
-
|
|
203
|
-
server_routes.extend(auth_routes)
|
|
204
|
-
server_middleware.extend(auth_middleware)
|
|
205
177
|
|
|
206
178
|
# Add SSE routes with or without auth
|
|
207
|
-
if
|
|
179
|
+
if auth:
|
|
180
|
+
auth_middleware, auth_routes, required_scopes = (
|
|
181
|
+
setup_auth_middleware_and_routes(auth)
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
server_routes.extend(auth_routes)
|
|
185
|
+
server_middleware.extend(auth_middleware)
|
|
208
186
|
# Auth is enabled, wrap endpoints with RequireAuthMiddleware
|
|
209
187
|
server_routes.append(
|
|
210
188
|
Route(
|
|
@@ -264,11 +242,7 @@ def create_streamable_http_app(
|
|
|
264
242
|
server: FastMCP[LifespanResultT],
|
|
265
243
|
streamable_http_path: str,
|
|
266
244
|
event_store: None = None,
|
|
267
|
-
|
|
268
|
-
AuthorizationCodeT, RefreshTokenT, AccessTokenT
|
|
269
|
-
]
|
|
270
|
-
| None = None,
|
|
271
|
-
auth_settings: AuthSettings | None = None,
|
|
245
|
+
auth: OAuthProvider | None = None,
|
|
272
246
|
json_response: bool = False,
|
|
273
247
|
stateless_http: bool = False,
|
|
274
248
|
debug: bool = False,
|
|
@@ -281,8 +255,7 @@ def create_streamable_http_app(
|
|
|
281
255
|
server: The FastMCP server instance
|
|
282
256
|
streamable_http_path: Path for StreamableHTTP connections
|
|
283
257
|
event_store: Optional event store for session management
|
|
284
|
-
|
|
285
|
-
auth_settings: Optional auth settings
|
|
258
|
+
auth: Optional auth provider
|
|
286
259
|
json_response: Whether to use JSON response format
|
|
287
260
|
stateless_http: Whether to use stateless mode (new transport per request)
|
|
288
261
|
debug: Whether to enable debug mode
|
|
@@ -331,16 +304,15 @@ def create_streamable_http_app(
|
|
|
331
304
|
# Re-raise other RuntimeErrors if they don't match the specific message
|
|
332
305
|
raise
|
|
333
306
|
|
|
334
|
-
#
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
307
|
+
# Add StreamableHTTP routes with or without auth
|
|
308
|
+
if auth:
|
|
309
|
+
auth_middleware, auth_routes, required_scopes = (
|
|
310
|
+
setup_auth_middleware_and_routes(auth)
|
|
311
|
+
)
|
|
338
312
|
|
|
339
|
-
|
|
340
|
-
|
|
313
|
+
server_routes.extend(auth_routes)
|
|
314
|
+
server_middleware.extend(auth_middleware)
|
|
341
315
|
|
|
342
|
-
# Add StreamableHTTP routes with or without auth
|
|
343
|
-
if auth_server_provider:
|
|
344
316
|
# Auth is enabled, wrap endpoint with RequireAuthMiddleware
|
|
345
317
|
server_routes.append(
|
|
346
318
|
Mount(
|
fastmcp/server/openapi.py
CHANGED
|
@@ -226,6 +226,7 @@ class OpenAPITool(Tool):
|
|
|
226
226
|
tags: set[str] = set(),
|
|
227
227
|
timeout: float | None = None,
|
|
228
228
|
annotations: ToolAnnotations | None = None,
|
|
229
|
+
exclude_args: list[str] | None = None,
|
|
229
230
|
serializer: Callable[[Any], str] | None = None,
|
|
230
231
|
):
|
|
231
232
|
super().__init__(
|
|
@@ -235,6 +236,7 @@ class OpenAPITool(Tool):
|
|
|
235
236
|
fn=self._execute_request, # We'll use an instance method instead of a global function
|
|
236
237
|
tags=tags,
|
|
237
238
|
annotations=annotations,
|
|
239
|
+
exclude_args=exclude_args,
|
|
238
240
|
serializer=serializer,
|
|
239
241
|
)
|
|
240
242
|
self._client = client
|
fastmcp/server/server.py
CHANGED
|
@@ -18,7 +18,6 @@ from typing import TYPE_CHECKING, Any, Generic, Literal
|
|
|
18
18
|
import anyio
|
|
19
19
|
import httpx
|
|
20
20
|
import uvicorn
|
|
21
|
-
from mcp.server.auth.provider import OAuthAuthorizationServerProvider
|
|
22
21
|
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
|
23
22
|
from mcp.server.lowlevel.server import LifespanResultT, NotificationOptions
|
|
24
23
|
from mcp.server.lowlevel.server import Server as MCPServer
|
|
@@ -48,6 +47,8 @@ from fastmcp.prompts import Prompt, PromptManager
|
|
|
48
47
|
from fastmcp.prompts.prompt import PromptResult
|
|
49
48
|
from fastmcp.resources import Resource, ResourceManager
|
|
50
49
|
from fastmcp.resources.template import ResourceTemplate
|
|
50
|
+
from fastmcp.server.auth.auth import OAuthProvider
|
|
51
|
+
from fastmcp.server.auth.providers.bearer_env import EnvBearerAuthProvider
|
|
51
52
|
from fastmcp.server.http import (
|
|
52
53
|
StarletteWithLifespan,
|
|
53
54
|
create_sse_app,
|
|
@@ -110,8 +111,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
110
111
|
self,
|
|
111
112
|
name: str | None = None,
|
|
112
113
|
instructions: str | None = None,
|
|
113
|
-
|
|
114
|
-
| None = None,
|
|
114
|
+
auth: OAuthProvider | None = None,
|
|
115
115
|
lifespan: (
|
|
116
116
|
Callable[
|
|
117
117
|
[FastMCP[LifespanResultT]],
|
|
@@ -128,6 +128,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
128
128
|
on_duplicate_prompts: DuplicateBehavior | None = None,
|
|
129
129
|
resource_prefix_format: Literal["protocol", "path"] | None = None,
|
|
130
130
|
mask_error_details: bool | None = None,
|
|
131
|
+
tools: list[Tool | Callable[..., Any]] | None = None,
|
|
131
132
|
**settings: Any,
|
|
132
133
|
):
|
|
133
134
|
if settings:
|
|
@@ -186,13 +187,16 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
186
187
|
lifespan=_lifespan_wrapper(self, lifespan),
|
|
187
188
|
)
|
|
188
189
|
|
|
189
|
-
if
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
190
|
+
if auth is None and self.settings.default_auth_provider == "bearer_env":
|
|
191
|
+
auth = EnvBearerAuthProvider()
|
|
192
|
+
self.auth = auth
|
|
193
|
+
|
|
194
|
+
if tools:
|
|
195
|
+
for tool in tools:
|
|
196
|
+
if isinstance(tool, Tool):
|
|
197
|
+
self._tool_manager.add_tool(tool)
|
|
198
|
+
else:
|
|
199
|
+
self.add_tool(tool)
|
|
196
200
|
|
|
197
201
|
# Set up MCP protocol handlers
|
|
198
202
|
self._setup_handlers()
|
|
@@ -497,6 +501,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
497
501
|
description: str | None = None,
|
|
498
502
|
tags: set[str] | None = None,
|
|
499
503
|
annotations: ToolAnnotations | dict[str, Any] | None = None,
|
|
504
|
+
exclude_args: list[str] | None = None,
|
|
500
505
|
) -> None:
|
|
501
506
|
"""Add a tool to the server.
|
|
502
507
|
|
|
@@ -519,6 +524,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
519
524
|
description=description,
|
|
520
525
|
tags=tags,
|
|
521
526
|
annotations=annotations,
|
|
527
|
+
exclude_args=exclude_args,
|
|
522
528
|
)
|
|
523
529
|
self._cache.clear()
|
|
524
530
|
|
|
@@ -540,6 +546,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
540
546
|
description: str | None = None,
|
|
541
547
|
tags: set[str] | None = None,
|
|
542
548
|
annotations: ToolAnnotations | dict[str, Any] | None = None,
|
|
549
|
+
exclude_args: list[str] | None = None,
|
|
543
550
|
) -> Callable[[AnyFunction], AnyFunction]:
|
|
544
551
|
"""Decorator to register a tool.
|
|
545
552
|
|
|
@@ -583,6 +590,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
583
590
|
description=description,
|
|
584
591
|
tags=tags,
|
|
585
592
|
annotations=annotations,
|
|
593
|
+
exclude_args=exclude_args,
|
|
586
594
|
)
|
|
587
595
|
return fn
|
|
588
596
|
|
|
@@ -903,8 +911,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
903
911
|
server=self,
|
|
904
912
|
message_path=message_path or self.settings.message_path,
|
|
905
913
|
sse_path=path or self.settings.sse_path,
|
|
906
|
-
|
|
907
|
-
auth_settings=self.settings.auth,
|
|
914
|
+
auth=self.auth,
|
|
908
915
|
debug=self.settings.debug,
|
|
909
916
|
middleware=middleware,
|
|
910
917
|
)
|
|
@@ -951,8 +958,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
951
958
|
server=self,
|
|
952
959
|
streamable_http_path=path or self.settings.streamable_http_path,
|
|
953
960
|
event_store=None,
|
|
954
|
-
|
|
955
|
-
auth_settings=self.settings.auth,
|
|
961
|
+
auth=self.auth,
|
|
956
962
|
json_response=self.settings.json_response,
|
|
957
963
|
stateless_http=self.settings.stateless_http,
|
|
958
964
|
debug=self.settings.debug,
|
|
@@ -963,8 +969,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
|
963
969
|
server=self,
|
|
964
970
|
message_path=self.settings.message_path,
|
|
965
971
|
sse_path=path or self.settings.sse_path,
|
|
966
|
-
|
|
967
|
-
auth_settings=self.settings.auth,
|
|
972
|
+
auth=self.auth,
|
|
968
973
|
debug=self.settings.debug,
|
|
969
974
|
middleware=middleware,
|
|
970
975
|
)
|