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

Add basic server primitives

Browse files
src/fastmcp/server/auth/__init__.py ADDED
File without changes
src/fastmcp/server/auth/auth.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from mcp.server.auth.provider import (
2
+ AccessToken,
3
+ AuthorizationCode,
4
+ OAuthAuthorizationServerProvider,
5
+ RefreshToken,
6
+ )
7
+ from mcp.server.auth.settings import (
8
+ AuthSettings,
9
+ ClientRegistrationOptions,
10
+ RevocationOptions,
11
+ )
12
+ from pydantic import AnyHttpUrl
13
+
14
+
15
+ class OAuthProvider(
16
+ OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]
17
+ ):
18
+ def __init__(
19
+ self,
20
+ issuer_url: AnyHttpUrl | str,
21
+ service_documentation_url: AnyHttpUrl | str | None = None,
22
+ client_registration_options: ClientRegistrationOptions | None = None,
23
+ revocation_options: RevocationOptions | None = None,
24
+ required_scopes: list[str] | None = None,
25
+ ):
26
+ super().__init__()
27
+ if isinstance(issuer_url, str):
28
+ issuer_url = AnyHttpUrl(issuer_url)
29
+ if isinstance(service_documentation_url, str):
30
+ service_documentation_url = AnyHttpUrl(service_documentation_url)
31
+
32
+ self.settings = AuthSettings(
33
+ issuer_url=issuer_url,
34
+ service_documentation_url=service_documentation_url,
35
+ client_registration_options=client_registration_options,
36
+ revocation_options=revocation_options,
37
+ required_scopes=required_scopes,
38
+ )
src/fastmcp/server/auth/in_memory_provider.py ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import secrets
2
+ import time
3
+
4
+ from mcp.server.auth.provider import (
5
+ AccessToken,
6
+ AuthorizationCode,
7
+ AuthorizationParams,
8
+ AuthorizeError,
9
+ RefreshToken,
10
+ TokenError,
11
+ construct_redirect_uri,
12
+ )
13
+ from mcp.shared.auth import (
14
+ OAuthClientInformationFull,
15
+ OAuthToken,
16
+ )
17
+ from pydantic import AnyHttpUrl
18
+
19
+ from fastmcp.server.auth.auth import (
20
+ ClientRegistrationOptions,
21
+ OAuthProvider,
22
+ RevocationOptions,
23
+ )
24
+
25
+ # Default expiration times (in seconds)
26
+ DEFAULT_AUTH_CODE_EXPIRY_SECONDS = 5 * 60 # 5 minutes
27
+ DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS = 60 * 60 # 1 hour
28
+ # Refresh tokens often have longer or no expiry; let's make them non-expiring for simplicity
29
+ DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS = None
30
+
31
+
32
+ class InMemoryOAuthProvider(OAuthProvider):
33
+ """
34
+ An in-memory OAuth provider for testing purposes.
35
+ It simulates the OAuth 2.0 flow locally without external calls.
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ issuer_url: AnyHttpUrl | str | None = None,
41
+ service_documentation_url: AnyHttpUrl | str | None = None,
42
+ client_registration_options: ClientRegistrationOptions | None = None,
43
+ revocation_options: RevocationOptions | None = None,
44
+ required_scopes: list[str] | None = None,
45
+ ):
46
+ super().__init__(
47
+ issuer_url or "https://example.com",
48
+ service_documentation_url=service_documentation_url,
49
+ client_registration_options=client_registration_options,
50
+ revocation_options=revocation_options,
51
+ required_scopes=required_scopes,
52
+ )
53
+ self.clients: dict[str, OAuthClientInformationFull] = {}
54
+ self.auth_codes: dict[str, AuthorizationCode] = {}
55
+ self.access_tokens: dict[str, AccessToken] = {}
56
+ self.refresh_tokens: dict[str, RefreshToken] = {}
57
+
58
+ # For revoking associated tokens
59
+ self._access_to_refresh_map: dict[
60
+ str, str
61
+ ] = {} # access_token_str -> refresh_token_str
62
+ self._refresh_to_access_map: dict[
63
+ str, str
64
+ ] = {} # refresh_token_str -> access_token_str
65
+
66
+ async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
67
+ return self.clients.get(client_id)
68
+
69
+ async def register_client(self, client_info: OAuthClientInformationFull) -> None:
70
+ if client_info.client_id in self.clients:
71
+ # As per RFC 7591, if client_id is already known, it's an update.
72
+ # For this simple provider, we'll treat it as re-registration.
73
+ # A real provider might handle updates or raise errors for conflicts.
74
+ pass
75
+ self.clients[client_info.client_id] = client_info
76
+
77
+ async def authorize(
78
+ self, client: OAuthClientInformationFull, params: AuthorizationParams
79
+ ) -> str:
80
+ """
81
+ Simulates user authorization and generates an authorization code.
82
+ Returns a redirect URI with the code and state.
83
+ """
84
+ if client.client_id not in self.clients:
85
+ raise AuthorizeError(
86
+ error="unauthorized_client",
87
+ error_description=f"Client '{client.client_id}' not registered.",
88
+ )
89
+
90
+ # Validate redirect_uri (already validated by AuthorizationHandler, but good practice)
91
+ try:
92
+ # OAuthClientInformationFull should have a method like validate_redirect_uri
93
+ # For this test provider, we assume it's valid if it matches one in client_info
94
+ # The AuthorizationHandler already does robust validation using client.validate_redirect_uri
95
+ if params.redirect_uri not in client.redirect_uris:
96
+ # This check might be too simplistic if redirect_uris can be patterns
97
+ # or if params.redirect_uri is None and client has a default.
98
+ # However, the AuthorizationHandler handles the primary validation.
99
+ pass # Let's assume AuthorizationHandler did its job.
100
+ except Exception: # Replace with specific validation error if client.validate_redirect_uri existed
101
+ raise AuthorizeError(
102
+ error="invalid_request", error_description="Invalid redirect_uri."
103
+ )
104
+
105
+ auth_code_value = f"test_auth_code_{secrets.token_hex(16)}"
106
+ expires_at = time.time() + DEFAULT_AUTH_CODE_EXPIRY_SECONDS
107
+
108
+ # Ensure scopes are a list
109
+ scopes_list = params.scopes if params.scopes is not None else []
110
+ if client.scope: # Filter params.scopes against client's registered scopes
111
+ client_allowed_scopes = set(client.scope.split())
112
+ scopes_list = [s for s in scopes_list if s in client_allowed_scopes]
113
+
114
+ auth_code = AuthorizationCode(
115
+ code=auth_code_value,
116
+ client_id=client.client_id,
117
+ redirect_uri=params.redirect_uri,
118
+ redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly,
119
+ scopes=scopes_list,
120
+ expires_at=expires_at,
121
+ code_challenge=params.code_challenge,
122
+ # code_challenge_method is assumed S256 by the framework
123
+ )
124
+ self.auth_codes[auth_code_value] = auth_code
125
+
126
+ return construct_redirect_uri(
127
+ str(params.redirect_uri), code=auth_code_value, state=params.state
128
+ )
129
+
130
+ async def load_authorization_code(
131
+ self, client: OAuthClientInformationFull, authorization_code: str
132
+ ) -> AuthorizationCode | None:
133
+ auth_code_obj = self.auth_codes.get(authorization_code)
134
+ if auth_code_obj:
135
+ if auth_code_obj.client_id != client.client_id:
136
+ return None # Belongs to a different client
137
+ if auth_code_obj.expires_at < time.time():
138
+ del self.auth_codes[authorization_code] # Expired
139
+ return None
140
+ return auth_code_obj
141
+ return None
142
+
143
+ async def exchange_authorization_code(
144
+ self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
145
+ ) -> OAuthToken:
146
+ # Authorization code should have been validated (existence, expiry, client_id match)
147
+ # by the TokenHandler calling load_authorization_code before this.
148
+ # We might want to re-verify or simply trust it's valid.
149
+
150
+ if authorization_code.code not in self.auth_codes:
151
+ raise TokenError(
152
+ "invalid_grant", "Authorization code not found or already used."
153
+ )
154
+
155
+ # Consume the auth code
156
+ del self.auth_codes[authorization_code.code]
157
+
158
+ access_token_value = f"test_access_token_{secrets.token_hex(32)}"
159
+ refresh_token_value = f"test_refresh_token_{secrets.token_hex(32)}"
160
+
161
+ access_token_expires_at = int(time.time() + DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS)
162
+
163
+ # Refresh token expiry
164
+ refresh_token_expires_at = None
165
+ if DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS is not None:
166
+ refresh_token_expires_at = int(
167
+ time.time() + DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS
168
+ )
169
+
170
+ self.access_tokens[access_token_value] = AccessToken(
171
+ token=access_token_value,
172
+ client_id=client.client_id,
173
+ scopes=authorization_code.scopes,
174
+ expires_at=access_token_expires_at,
175
+ )
176
+ self.refresh_tokens[refresh_token_value] = RefreshToken(
177
+ token=refresh_token_value,
178
+ client_id=client.client_id,
179
+ scopes=authorization_code.scopes, # Refresh token inherits scopes
180
+ expires_at=refresh_token_expires_at,
181
+ )
182
+
183
+ self._access_to_refresh_map[access_token_value] = refresh_token_value
184
+ self._refresh_to_access_map[refresh_token_value] = access_token_value
185
+
186
+ return OAuthToken(
187
+ access_token=access_token_value,
188
+ token_type="bearer",
189
+ expires_in=DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS,
190
+ refresh_token=refresh_token_value,
191
+ scope=" ".join(authorization_code.scopes),
192
+ )
193
+
194
+ async def load_refresh_token(
195
+ self, client: OAuthClientInformationFull, refresh_token: str
196
+ ) -> RefreshToken | None:
197
+ token_obj = self.refresh_tokens.get(refresh_token)
198
+ if token_obj:
199
+ if token_obj.client_id != client.client_id:
200
+ return None # Belongs to different client
201
+ if token_obj.expires_at is not None and token_obj.expires_at < time.time():
202
+ self._revoke_internal(
203
+ refresh_token_str=token_obj.token
204
+ ) # Clean up expired
205
+ return None
206
+ return token_obj
207
+ return None
208
+
209
+ async def exchange_refresh_token(
210
+ self,
211
+ client: OAuthClientInformationFull,
212
+ refresh_token: RefreshToken, # This is the RefreshToken object, already loaded
213
+ scopes: list[str], # Requested scopes for the new access token
214
+ ) -> OAuthToken:
215
+ # Validate scopes: requested scopes must be a subset of original scopes
216
+ original_scopes = set(refresh_token.scopes)
217
+ requested_scopes = set(scopes)
218
+ if not requested_scopes.issubset(original_scopes):
219
+ raise TokenError(
220
+ "invalid_scope",
221
+ "Requested scopes exceed those authorized by the refresh token.",
222
+ )
223
+
224
+ # Invalidate old refresh token and its associated access token (rotation)
225
+ self._revoke_internal(refresh_token_str=refresh_token.token)
226
+
227
+ # Issue new tokens
228
+ new_access_token_value = f"test_access_token_{secrets.token_hex(32)}"
229
+ new_refresh_token_value = f"test_refresh_token_{secrets.token_hex(32)}"
230
+
231
+ access_token_expires_at = int(time.time() + DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS)
232
+
233
+ # Refresh token expiry
234
+ refresh_token_expires_at = None
235
+ if DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS is not None:
236
+ refresh_token_expires_at = int(
237
+ time.time() + DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS
238
+ )
239
+
240
+ self.access_tokens[new_access_token_value] = AccessToken(
241
+ token=new_access_token_value,
242
+ client_id=client.client_id,
243
+ scopes=scopes, # Use newly requested (and validated) scopes
244
+ expires_at=access_token_expires_at,
245
+ )
246
+ self.refresh_tokens[new_refresh_token_value] = RefreshToken(
247
+ token=new_refresh_token_value,
248
+ client_id=client.client_id,
249
+ scopes=scopes, # New refresh token also gets these scopes
250
+ expires_at=refresh_token_expires_at,
251
+ )
252
+
253
+ self._access_to_refresh_map[new_access_token_value] = new_refresh_token_value
254
+ self._refresh_to_access_map[new_refresh_token_value] = new_access_token_value
255
+
256
+ return OAuthToken(
257
+ access_token=new_access_token_value,
258
+ token_type="bearer",
259
+ expires_in=DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS,
260
+ refresh_token=new_refresh_token_value,
261
+ scope=" ".join(scopes),
262
+ )
263
+
264
+ async def load_access_token(self, token: str) -> AccessToken | None:
265
+ token_obj = self.access_tokens.get(token)
266
+ if token_obj:
267
+ if token_obj.expires_at is not None and token_obj.expires_at < time.time():
268
+ self._revoke_internal(
269
+ access_token_str=token_obj.token
270
+ ) # Clean up expired
271
+ return None
272
+ return token_obj
273
+ return None
274
+
275
+ def _revoke_internal(
276
+ self, access_token_str: str | None = None, refresh_token_str: str | None = None
277
+ ):
278
+ """Internal helper to remove tokens and their associations."""
279
+ removed_access_token = None
280
+ removed_refresh_token = None
281
+
282
+ if access_token_str:
283
+ if access_token_str in self.access_tokens:
284
+ del self.access_tokens[access_token_str]
285
+ removed_access_token = access_token_str
286
+
287
+ # Get associated refresh token
288
+ associated_refresh = self._access_to_refresh_map.pop(access_token_str, None)
289
+ if associated_refresh:
290
+ if associated_refresh in self.refresh_tokens:
291
+ del self.refresh_tokens[associated_refresh]
292
+ removed_refresh_token = associated_refresh
293
+ self._refresh_to_access_map.pop(associated_refresh, None)
294
+
295
+ if refresh_token_str:
296
+ if refresh_token_str in self.refresh_tokens:
297
+ del self.refresh_tokens[refresh_token_str]
298
+ removed_refresh_token = refresh_token_str
299
+
300
+ # Get associated access token
301
+ associated_access = self._refresh_to_access_map.pop(refresh_token_str, None)
302
+ if associated_access:
303
+ if associated_access in self.access_tokens:
304
+ del self.access_tokens[associated_access]
305
+ removed_access_token = associated_access
306
+ self._access_to_refresh_map.pop(associated_access, None)
307
+
308
+ # Clean up any dangling references if one part of the pair was already gone
309
+ if removed_access_token and removed_access_token in self._access_to_refresh_map:
310
+ del self._access_to_refresh_map[removed_access_token]
311
+ if (
312
+ removed_refresh_token
313
+ and removed_refresh_token in self._refresh_to_access_map
314
+ ):
315
+ del self._refresh_to_access_map[removed_refresh_token]
316
+
317
+ async def revoke_token(
318
+ self,
319
+ token: AccessToken | RefreshToken,
320
+ ) -> None:
321
+ """Revokes an access or refresh token and its counterpart."""
322
+ if isinstance(token, AccessToken):
323
+ self._revoke_internal(access_token_str=token.token)
324
+ elif isinstance(token, RefreshToken):
325
+ self._revoke_internal(refresh_token_str=token.token)
326
+ # If token is not found or already revoked, _revoke_internal does nothing, which is correct.
src/fastmcp/server/http.py CHANGED
@@ -10,14 +10,7 @@ from mcp.server.auth.middleware.bearer_auth import (
10
  BearerAuthBackend,
11
  RequireAuthMiddleware,
12
  )
13
- from mcp.server.auth.provider import (
14
- AccessTokenT,
15
- AuthorizationCodeT,
16
- OAuthAuthorizationServerProvider,
17
- RefreshTokenT,
18
- )
19
  from mcp.server.auth.routes import create_auth_routes
20
- from mcp.server.auth.settings import AuthSettings
21
  from mcp.server.lowlevel.server import LifespanResultT
22
  from mcp.server.sse import SseServerTransport
23
  from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
@@ -29,6 +22,7 @@ from starlette.responses import Response
29
  from starlette.routing import BaseRoute, Mount, Route
30
  from starlette.types import Lifespan, Receive, Scope, Send
31
 
 
32
  from fastmcp.utilities.logging import get_logger
33
 
34
  if TYPE_CHECKING:
@@ -75,17 +69,12 @@ class RequestContextMiddleware:
75
 
76
 
77
  def setup_auth_middleware_and_routes(
78
- auth_server_provider: OAuthAuthorizationServerProvider[
79
- AuthorizationCodeT, RefreshTokenT, AccessTokenT
80
- ]
81
- | None,
82
- auth_settings: AuthSettings | None,
83
  ) -> tuple[list[Middleware], list[BaseRoute], list[str]]:
84
  """Set up authentication middleware and routes if auth is enabled.
85
 
86
  Args:
87
- auth_server_provider: The OAuth authorization server provider
88
- auth_settings: The auth settings
89
 
90
  Returns:
91
  Tuple of (middleware, auth_routes, required_scopes)
@@ -94,31 +83,25 @@ def setup_auth_middleware_and_routes(
94
  auth_routes: list[BaseRoute] = []
95
  required_scopes: list[str] = []
96
 
97
- if auth_server_provider:
98
- if not auth_settings:
99
- raise ValueError(
100
- "auth_settings must be provided when auth_server_provider is specified"
101
- )
 
 
102
 
103
- middleware = [
104
- Middleware(
105
- AuthenticationMiddleware,
106
- backend=BearerAuthBackend(provider=auth_server_provider),
107
- ),
108
- Middleware(AuthContextMiddleware),
109
- ]
110
-
111
- required_scopes = auth_settings.required_scopes or []
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
- )
121
  )
 
122
 
123
  return middleware, auth_routes, required_scopes
124
 
@@ -155,11 +138,7 @@ def create_sse_app(
155
  server: FastMCP[LifespanResultT],
156
  message_path: str,
157
  sse_path: str,
158
- auth_server_provider: OAuthAuthorizationServerProvider[
159
- AuthorizationCodeT, RefreshTokenT, AccessTokenT
160
- ]
161
- | None = None,
162
- auth_settings: AuthSettings | None = None,
163
  debug: bool = False,
164
  routes: list[BaseRoute] | None = None,
165
  middleware: list[Middleware] | None = None,
@@ -170,8 +149,7 @@ def create_sse_app(
170
  server: The FastMCP server instance
171
  message_path: Path for SSE messages
172
  sse_path: Path for SSE connections
173
- auth_server_provider: Optional auth provider
174
- auth_settings: Optional auth settings
175
  debug: Whether to enable debug mode
176
  routes: Optional list of custom routes
177
  middleware: Optional list of middleware
@@ -196,15 +174,15 @@ def create_sse_app(
196
  return Response()
197
 
198
  # 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
 
206
  # Add SSE routes with or without auth
207
- if auth_server_provider:
 
 
 
 
 
 
208
  # Auth is enabled, wrap endpoints with RequireAuthMiddleware
209
  server_routes.append(
210
  Route(
@@ -264,11 +242,7 @@ def create_streamable_http_app(
264
  server: FastMCP[LifespanResultT],
265
  streamable_http_path: str,
266
  event_store: None = None,
267
- auth_server_provider: OAuthAuthorizationServerProvider[
268
- AuthorizationCodeT, RefreshTokenT, AccessTokenT
269
- ]
270
- | None = None,
271
- auth_settings: AuthSettings | None = None,
272
  json_response: bool = False,
273
  stateless_http: bool = False,
274
  debug: bool = False,
@@ -281,8 +255,7 @@ def create_streamable_http_app(
281
  server: The FastMCP server instance
282
  streamable_http_path: Path for StreamableHTTP connections
283
  event_store: Optional event store for session management
284
- auth_server_provider: Optional auth provider
285
- auth_settings: Optional auth settings
286
  json_response: Whether to use JSON response format
287
  stateless_http: Whether to use stateless mode (new transport per request)
288
  debug: Whether to enable debug mode
@@ -331,16 +304,15 @@ def create_streamable_http_app(
331
  # Re-raise other RuntimeErrors if they don't match the specific message
332
  raise
333
 
334
- # Get auth middleware and routes
335
- auth_middleware, auth_routes, required_scopes = setup_auth_middleware_and_routes(
336
- auth_server_provider, auth_settings
337
- )
 
338
 
339
- server_routes.extend(auth_routes)
340
- server_middleware.extend(auth_middleware)
341
 
342
- # Add StreamableHTTP routes with or without auth
343
- if auth_server_provider:
344
  # Auth is enabled, wrap endpoint with RequireAuthMiddleware
345
  server_routes.append(
346
  Mount(
 
10
  BearerAuthBackend,
11
  RequireAuthMiddleware,
12
  )
 
 
 
 
 
 
13
  from mcp.server.auth.routes import create_auth_routes
 
14
  from mcp.server.lowlevel.server import LifespanResultT
15
  from mcp.server.sse import SseServerTransport
16
  from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
 
22
  from starlette.routing import BaseRoute, Mount, Route
23
  from starlette.types import Lifespan, Receive, Scope, Send
24
 
25
+ from fastmcp.server.auth.auth import OAuthProvider
26
  from fastmcp.utilities.logging import get_logger
27
 
28
  if TYPE_CHECKING:
 
69
 
70
 
71
  def setup_auth_middleware_and_routes(
72
+ auth: OAuthProvider,
 
 
 
 
73
  ) -> tuple[list[Middleware], list[BaseRoute], list[str]]:
74
  """Set up authentication middleware and routes if auth is enabled.
75
 
76
  Args:
77
+ auth: The OAuthProvider authorization server provider
 
78
 
79
  Returns:
80
  Tuple of (middleware, auth_routes, required_scopes)
 
83
  auth_routes: list[BaseRoute] = []
84
  required_scopes: list[str] = []
85
 
86
+ middleware = [
87
+ Middleware(
88
+ AuthenticationMiddleware,
89
+ backend=BearerAuthBackend(provider=auth),
90
+ ),
91
+ Middleware(AuthContextMiddleware),
92
+ ]
93
 
94
+ required_scopes = auth.settings.required_scopes or []
95
+
96
+ auth_routes.extend(
97
+ create_auth_routes(
98
+ provider=auth,
99
+ issuer_url=auth.settings.issuer_url,
100
+ service_documentation_url=auth.settings.service_documentation_url,
101
+ client_registration_options=auth.settings.client_registration_options,
102
+ revocation_options=auth.settings.revocation_options,
 
 
 
 
 
 
 
 
 
103
  )
104
+ )
105
 
106
  return middleware, auth_routes, required_scopes
107
 
 
138
  server: FastMCP[LifespanResultT],
139
  message_path: str,
140
  sse_path: str,
141
+ auth: OAuthProvider | None = None,
 
 
 
 
142
  debug: bool = False,
143
  routes: list[BaseRoute] | None = None,
144
  middleware: list[Middleware] | None = None,
 
149
  server: The FastMCP server instance
150
  message_path: Path for SSE messages
151
  sse_path: Path for SSE connections
152
+ auth: Optional auth provider
 
153
  debug: Whether to enable debug mode
154
  routes: Optional list of custom routes
155
  middleware: Optional list of middleware
 
174
  return Response()
175
 
176
  # Get auth middleware and routes
 
 
 
 
 
 
177
 
178
  # Add SSE routes with or without auth
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)
186
  # Auth is enabled, wrap endpoints with RequireAuthMiddleware
187
  server_routes.append(
188
  Route(
 
242
  server: FastMCP[LifespanResultT],
243
  streamable_http_path: str,
244
  event_store: None = None,
245
+ auth: OAuthProvider | None = None,
 
 
 
 
246
  json_response: bool = False,
247
  stateless_http: bool = False,
248
  debug: bool = False,
 
255
  server: The FastMCP server instance
256
  streamable_http_path: Path for StreamableHTTP connections
257
  event_store: Optional event store for session management
258
+ auth: Optional auth provider
 
259
  json_response: Whether to use JSON response format
260
  stateless_http: Whether to use stateless mode (new transport per request)
261
  debug: Whether to enable debug mode
 
304
  # Re-raise other RuntimeErrors if they don't match the specific message
305
  raise
306
 
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
+ )
312
 
313
+ server_routes.extend(auth_routes)
314
+ server_middleware.extend(auth_middleware)
315
 
 
 
316
  # Auth is enabled, wrap endpoint with RequireAuthMiddleware
317
  server_routes.append(
318
  Mount(
src/fastmcp/server/server.py CHANGED
@@ -18,7 +18,6 @@ from typing import TYPE_CHECKING, Any, Generic, Literal
18
  import anyio
19
  import httpx
20
  import uvicorn
21
- from mcp.server.auth.provider import OAuthAuthorizationServerProvider
22
  from mcp.server.lowlevel.helper_types import ReadResourceContents
23
  from mcp.server.lowlevel.server import LifespanResultT, NotificationOptions
24
  from mcp.server.lowlevel.server import Server as MCPServer
@@ -48,6 +47,7 @@ from fastmcp.prompts import Prompt, PromptManager
48
  from fastmcp.prompts.prompt import PromptResult
49
  from fastmcp.resources import Resource, ResourceManager
50
  from fastmcp.resources.template import ResourceTemplate
 
51
  from fastmcp.server.http import (
52
  StarletteWithLifespan,
53
  create_sse_app,
@@ -110,8 +110,7 @@ class FastMCP(Generic[LifespanResultT]):
110
  self,
111
  name: str | None = None,
112
  instructions: str | None = None,
113
- auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any]
114
- | None = None,
115
  lifespan: (
116
  Callable[
117
  [FastMCP[LifespanResultT]],
@@ -186,13 +185,7 @@ class FastMCP(Generic[LifespanResultT]):
186
  lifespan=_lifespan_wrapper(self, lifespan),
187
  )
188
 
189
- if (self.settings.auth is not None) != (auth_server_provider is not None):
190
- # TODO: after we support separate authorization servers (see
191
- raise ValueError(
192
- "settings.auth must be specified if and only if auth_server_provider "
193
- "is specified"
194
- )
195
- self._auth_server_provider = auth_server_provider
196
 
197
  # Set up MCP protocol handlers
198
  self._setup_handlers()
@@ -903,8 +896,7 @@ class FastMCP(Generic[LifespanResultT]):
903
  server=self,
904
  message_path=message_path or self.settings.message_path,
905
  sse_path=path or self.settings.sse_path,
906
- auth_server_provider=self._auth_server_provider,
907
- auth_settings=self.settings.auth,
908
  debug=self.settings.debug,
909
  middleware=middleware,
910
  )
@@ -951,8 +943,7 @@ class FastMCP(Generic[LifespanResultT]):
951
  server=self,
952
  streamable_http_path=path or self.settings.streamable_http_path,
953
  event_store=None,
954
- auth_server_provider=self._auth_server_provider,
955
- auth_settings=self.settings.auth,
956
  json_response=self.settings.json_response,
957
  stateless_http=self.settings.stateless_http,
958
  debug=self.settings.debug,
@@ -963,8 +954,7 @@ class FastMCP(Generic[LifespanResultT]):
963
  server=self,
964
  message_path=self.settings.message_path,
965
  sse_path=path or self.settings.sse_path,
966
- auth_server_provider=self._auth_server_provider,
967
- auth_settings=self.settings.auth,
968
  debug=self.settings.debug,
969
  middleware=middleware,
970
  )
 
18
  import anyio
19
  import httpx
20
  import uvicorn
 
21
  from mcp.server.lowlevel.helper_types import ReadResourceContents
22
  from mcp.server.lowlevel.server import LifespanResultT, NotificationOptions
23
  from mcp.server.lowlevel.server import Server as MCPServer
 
47
  from fastmcp.prompts.prompt import PromptResult
48
  from fastmcp.resources import Resource, ResourceManager
49
  from fastmcp.resources.template import ResourceTemplate
50
+ from fastmcp.server.auth.auth import OAuthProvider
51
  from fastmcp.server.http import (
52
  StarletteWithLifespan,
53
  create_sse_app,
 
110
  self,
111
  name: str | None = None,
112
  instructions: str | None = None,
113
+ auth: OAuthProvider | None = None,
 
114
  lifespan: (
115
  Callable[
116
  [FastMCP[LifespanResultT]],
 
185
  lifespan=_lifespan_wrapper(self, lifespan),
186
  )
187
 
188
+ self.auth = auth
 
 
 
 
 
 
189
 
190
  # Set up MCP protocol handlers
191
  self._setup_handlers()
 
896
  server=self,
897
  message_path=message_path or self.settings.message_path,
898
  sse_path=path or self.settings.sse_path,
899
+ auth=self.auth,
 
900
  debug=self.settings.debug,
901
  middleware=middleware,
902
  )
 
943
  server=self,
944
  streamable_http_path=path or self.settings.streamable_http_path,
945
  event_store=None,
946
+ auth=self.auth,
 
947
  json_response=self.settings.json_response,
948
  stateless_http=self.settings.stateless_http,
949
  debug=self.settings.debug,
 
954
  server=self,
955
  message_path=self.settings.message_path,
956
  sse_path=path or self.settings.sse_path,
957
+ auth=self.auth,
 
958
  debug=self.settings.debug,
959
  middleware=middleware,
960
  )
src/fastmcp/settings.py CHANGED
@@ -4,7 +4,6 @@ import inspect
4
  from pathlib import Path
5
  from typing import Annotated, Literal
6
 
7
- from mcp.server.auth.settings import AuthSettings
8
  from pydantic import Field, model_validator
9
  from pydantic_settings import BaseSettings, SettingsConfigDict
10
  from typing_extensions import Self
@@ -171,8 +170,6 @@ class ServerSettings(BaseSettings):
171
  # cache settings (for checking mounted servers)
172
  cache_expiration_seconds: float = 0
173
 
174
- auth: AuthSettings | None = None
175
-
176
  # StreamableHTTP settings
177
  json_response: bool = False
178
  stateless_http: bool = (
 
4
  from pathlib import Path
5
  from typing import Annotated, Literal
6
 
 
7
  from pydantic import Field, model_validator
8
  from pydantic_settings import BaseSettings, SettingsConfigDict
9
  from typing_extensions import Self
 
170
  # cache settings (for checking mounted servers)
171
  cache_expiration_seconds: float = 0
172
 
 
 
173
  # StreamableHTTP settings
174
  json_response: bool = False
175
  stateless_http: bool = (
src/fastmcp/utilities/tests.py CHANGED
@@ -94,7 +94,7 @@ def run_server_in_process(
94
  proc.start()
95
 
96
  # Wait for server to be running
97
- max_attempts = 100
98
  attempt = 0
99
  while attempt < max_attempts and proc.is_alive():
100
  try:
@@ -102,7 +102,10 @@ def run_server_in_process(
102
  s.connect((host, port))
103
  break
104
  except ConnectionRefusedError:
105
- time.sleep(0.01)
 
 
 
106
  attempt += 1
107
  else:
108
  raise RuntimeError(f"Server failed to start after {max_attempts} attempts")
 
94
  proc.start()
95
 
96
  # Wait for server to be running
97
+ max_attempts = 10
98
  attempt = 0
99
  while attempt < max_attempts and proc.is_alive():
100
  try:
 
102
  s.connect((host, port))
103
  break
104
  except ConnectionRefusedError:
105
+ if attempt < 3:
106
+ time.sleep(0.01)
107
+ else:
108
+ time.sleep(0.1)
109
  attempt += 1
110
  else:
111
  raise RuntimeError(f"Server failed to start after {max_attempts} attempts")