Jeremiah Lowin commited on
Commit
8e65d31
·
unverified ·
2 Parent(s): f15abd4d78a967

Merge pull request #478 from jlowin/oauthclient

Browse files
pyproject.toml CHANGED
@@ -7,11 +7,12 @@ dependencies = [
7
  "python-dotenv>=1.1.0",
8
  "exceptiongroup>=1.2.2",
9
  "httpx>=0.28.1",
10
- "mcp>=1.9.0,<2.0.0",
11
  "openapi-pydantic>=0.5.1",
12
  "rich>=13.9.4",
13
  "typer>=0.15.2",
14
  "websockets>=14.0",
 
15
  ]
16
  requires-python = ">=3.10"
17
  readme = "README.md"
 
7
  "python-dotenv>=1.1.0",
8
  "exceptiongroup>=1.2.2",
9
  "httpx>=0.28.1",
10
+ "mcp>=1.9.2,<2.0.0",
11
  "openapi-pydantic>=0.5.1",
12
  "rich>=13.9.4",
13
  "typer>=0.15.2",
14
  "websockets>=14.0",
15
+ "authlib>=1.5.2",
16
  ]
17
  requires-python = ">=3.10"
18
  readme = "README.md"
src/fastmcp/client/__init__.py CHANGED
@@ -11,6 +11,7 @@ from .transports import (
11
  FastMCPTransport,
12
  StreamableHttpTransport,
13
  )
 
14
 
15
  __all__ = [
16
  "Client",
@@ -24,4 +25,5 @@ __all__ = [
24
  "NpxStdioTransport",
25
  "FastMCPTransport",
26
  "StreamableHttpTransport",
 
27
  ]
 
11
  FastMCPTransport,
12
  StreamableHttpTransport,
13
  )
14
+ from .auth import OAuth
15
 
16
  __all__ = [
17
  "Client",
 
25
  "NpxStdioTransport",
26
  "FastMCPTransport",
27
  "StreamableHttpTransport",
28
+ "OAuth",
29
  ]
src/fastmcp/client/auth.py ADDED
@@ -0,0 +1,394 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import webbrowser
6
+ from pathlib import Path
7
+ from typing import Any, Literal
8
+ from urllib.parse import urljoin, urlparse
9
+
10
+ import anyio
11
+ import httpx
12
+ from mcp.client.auth import OAuthClientProvider as _MCPOAuthClientProvider
13
+ from mcp.client.auth import TokenStorage
14
+ from mcp.shared.auth import (
15
+ OAuthClientInformationFull,
16
+ OAuthClientMetadata,
17
+ )
18
+ from mcp.shared.auth import (
19
+ OAuthMetadata as _MCPServerOAuthMetadata,
20
+ )
21
+ from mcp.shared.auth import (
22
+ OAuthToken as OAuthToken,
23
+ )
24
+ from pydantic import AnyHttpUrl, ValidationError
25
+
26
+ from fastmcp.client.oauth_callback import (
27
+ create_oauth_callback_server,
28
+ find_available_port,
29
+ )
30
+ from fastmcp.settings import settings as fastmcp_global_settings
31
+ from fastmcp.utilities.logging import get_logger
32
+
33
+ __all__ = ["OAuth"]
34
+
35
+ logger = get_logger(__name__)
36
+
37
+
38
+ def default_cache_dir() -> Path:
39
+ return fastmcp_global_settings.home / "oauth-mcp-client-cache"
40
+
41
+
42
+ # Flexible OAuth models for real-world compatibility
43
+ class ServerOAuthMetadata(_MCPServerOAuthMetadata):
44
+ """
45
+ More flexible OAuth metadata model that accepts broader ranges of values
46
+ than the restrictive MCP standard model.
47
+
48
+ This handles real-world OAuth servers like PayPal that may support
49
+ additional methods not in the MCP specification.
50
+ """
51
+
52
+ # Allow any code challenge methods, not just S256
53
+ code_challenge_methods_supported: list[str] | None = None
54
+
55
+ # Allow any token endpoint auth methods
56
+ token_endpoint_auth_methods_supported: list[str] | None = None
57
+
58
+ # Allow any grant types
59
+ grant_types_supported: list[str] | None = None
60
+
61
+ # Allow any response types
62
+ response_types_supported: list[str] = ["code"]
63
+
64
+ # Allow any response modes
65
+ response_modes_supported: list[str] | None = None
66
+
67
+
68
+ class OAuthClientProvider(_MCPOAuthClientProvider):
69
+ """
70
+ OAuth client provider with more flexible OAuth metadata discovery.
71
+
72
+ This subclass handles real-world OAuth servers that may not conform
73
+ strictly to the MCP OAuth specification but are still valid OAuth 2.0 servers.
74
+ """
75
+
76
+ async def _discover_oauth_metadata(
77
+ self, server_url: str
78
+ ) -> ServerOAuthMetadata | None:
79
+ """
80
+ Discover OAuth metadata with flexible validation.
81
+
82
+ This is nearly identical to the parent implementation but uses
83
+ ServerOAuthMetadata instead of the restrictive MCP OAuthMetadata.
84
+ """
85
+ # Extract base URL per MCP spec
86
+ auth_base_url = self._get_authorization_base_url(server_url)
87
+ url = urljoin(auth_base_url, "/.well-known/oauth-authorization-server")
88
+
89
+ from mcp.types import LATEST_PROTOCOL_VERSION
90
+
91
+ headers = {"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION}
92
+
93
+ async with httpx.AsyncClient() as client:
94
+ try:
95
+ response = await client.get(url, headers=headers)
96
+ if response.status_code == 404:
97
+ return None
98
+ response.raise_for_status()
99
+ metadata_json = response.json()
100
+ logger.debug(f"OAuth metadata discovered: {metadata_json}")
101
+ return ServerOAuthMetadata.model_validate(metadata_json)
102
+ except Exception:
103
+ # Retry without MCP header for CORS compatibility
104
+ try:
105
+ response = await client.get(url)
106
+ if response.status_code == 404:
107
+ return None
108
+ response.raise_for_status()
109
+ metadata_json = response.json()
110
+ logger.debug(
111
+ f"OAuth metadata discovered (no MCP header): {metadata_json}"
112
+ )
113
+ return ServerOAuthMetadata.model_validate(metadata_json)
114
+ except Exception:
115
+ logger.exception("Failed to discover OAuth metadata")
116
+ return None
117
+
118
+
119
+ class FileTokenStorage(TokenStorage):
120
+ """
121
+ File-based token storage implementation for OAuth credentials and tokens.
122
+ Implements the mcp.client.auth.TokenStorage protocol.
123
+
124
+ Each instance is tied to a specific server URL for proper token isolation.
125
+ """
126
+
127
+ def __init__(self, server_url: str, cache_dir: Path | None = None):
128
+ """Initialize storage for a specific server URL."""
129
+ self.server_url = server_url
130
+ self.cache_dir = cache_dir or default_cache_dir()
131
+ self.cache_dir.mkdir(exist_ok=True, parents=True)
132
+
133
+ @staticmethod
134
+ def get_base_url(url: str) -> str:
135
+ """Extract the base URL (scheme + host) from a URL."""
136
+ parsed = urlparse(url)
137
+ return f"{parsed.scheme}://{parsed.netloc}"
138
+
139
+ def get_cache_key(self) -> str:
140
+ """Generate a safe filesystem key from the server's base URL."""
141
+ base_url = self.get_base_url(self.server_url)
142
+ return (
143
+ base_url.replace("://", "_")
144
+ .replace(".", "_")
145
+ .replace("/", "_")
146
+ .replace(":", "_")
147
+ )
148
+
149
+ def _get_file_path(self, file_type: Literal["client_info", "tokens"]) -> Path:
150
+ """Get the file path for the specified cache file type."""
151
+ key = self.get_cache_key()
152
+ return self.cache_dir / f"{key}_{file_type}.json"
153
+
154
+ async def get_tokens(self) -> OAuthToken | None:
155
+ """Load tokens from file storage."""
156
+ path = self._get_file_path("tokens")
157
+
158
+ try:
159
+ tokens = OAuthToken.model_validate_json(path.read_text())
160
+ # now = datetime.datetime.now(datetime.timezone.utc)
161
+ # if tokens.expires_at is not None and tokens.expires_at <= now:
162
+ # logger.debug(f"Token expired for {self.get_base_url(self.server_url)}")
163
+ # return None
164
+ return tokens
165
+ except (FileNotFoundError, json.JSONDecodeError, ValidationError) as e:
166
+ logger.debug(
167
+ f"Could not load tokens for {self.get_base_url(self.server_url)}: {e}"
168
+ )
169
+ return None
170
+
171
+ async def set_tokens(self, tokens: OAuthToken) -> None:
172
+ """Save tokens to file storage."""
173
+ path = self._get_file_path("tokens")
174
+ path.write_text(tokens.model_dump_json(indent=2))
175
+ logger.debug(f"Saved tokens for {self.get_base_url(self.server_url)}")
176
+
177
+ async def get_client_info(self) -> OAuthClientInformationFull | None:
178
+ """Load client information from file storage."""
179
+ path = self._get_file_path("client_info")
180
+ try:
181
+ client_info = OAuthClientInformationFull.model_validate_json(
182
+ path.read_text()
183
+ )
184
+ # Check if we have corresponding valid tokens
185
+ # If no tokens exist, the OAuth flow was incomplete and we should
186
+ # force a fresh client registration
187
+ tokens = await self.get_tokens()
188
+ if tokens is None:
189
+ logger.debug(
190
+ f"No tokens found for client info at {self.get_base_url(self.server_url)}. "
191
+ "OAuth flow may have been incomplete. Clearing client info to force fresh registration."
192
+ )
193
+ # Clear the incomplete client info
194
+ client_info_path = self._get_file_path("client_info")
195
+ client_info_path.unlink(missing_ok=True)
196
+ return None
197
+
198
+ return client_info
199
+ except (FileNotFoundError, json.JSONDecodeError, ValidationError) as e:
200
+ logger.debug(
201
+ f"Could not load client info for {self.get_base_url(self.server_url)}: {e}"
202
+ )
203
+ return None
204
+
205
+ async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
206
+ """Save client information to file storage."""
207
+ path = self._get_file_path("client_info")
208
+ path.write_text(client_info.model_dump_json(indent=2))
209
+ logger.debug(f"Saved client info for {self.get_base_url(self.server_url)}")
210
+
211
+ def clear(self) -> None:
212
+ """Clear all cached data for this server."""
213
+ file_types: list[Literal["client_info", "tokens"]] = ["client_info", "tokens"]
214
+ for file_type in file_types:
215
+ path = self._get_file_path(file_type)
216
+ path.unlink(missing_ok=True)
217
+ logger.info(f"Cleared OAuth cache for {self.get_base_url(self.server_url)}")
218
+
219
+ @classmethod
220
+ def clear_all(cls, cache_dir: Path | None = None) -> None:
221
+ """Clear all cached data for all servers."""
222
+ cache_dir = cache_dir or default_cache_dir()
223
+ if not cache_dir.exists():
224
+ return
225
+
226
+ file_types: list[Literal["client_info", "tokens"]] = ["client_info", "tokens"]
227
+ for file_type in file_types:
228
+ for file in cache_dir.glob(f"*_{file_type}.json"):
229
+ file.unlink(missing_ok=True)
230
+ logger.info("Cleared all OAuth client cache data.")
231
+
232
+
233
+ async def discover_oauth_metadata(
234
+ server_base_url: str, httpx_kwargs: dict[str, Any] | None = None
235
+ ) -> _MCPServerOAuthMetadata | None:
236
+ """
237
+ Discover OAuth metadata from the server using RFC 8414 well-known endpoint.
238
+
239
+ Args:
240
+ server_base_url: Base URL of the OAuth server (e.g., "https://example.com")
241
+ httpx_kwargs: Additional kwargs for httpx client
242
+
243
+ Returns:
244
+ OAuth metadata if found, None otherwise
245
+ """
246
+ well_known_url = urljoin(server_base_url, "/.well-known/oauth-authorization-server")
247
+ logger.debug(f"Discovering OAuth metadata from: {well_known_url}")
248
+
249
+ async with httpx.AsyncClient(**(httpx_kwargs or {})) as client:
250
+ try:
251
+ response = await client.get(well_known_url, timeout=10.0)
252
+ if response.status_code == 200:
253
+ logger.debug("Successfully discovered OAuth metadata")
254
+ return _MCPServerOAuthMetadata.model_validate(response.json())
255
+ elif response.status_code == 404:
256
+ logger.debug(
257
+ "OAuth metadata not found (404) - server may not require auth"
258
+ )
259
+ return None
260
+ else:
261
+ logger.warning(f"OAuth metadata request failed: {response.status_code}")
262
+ return None
263
+ except (httpx.RequestError, json.JSONDecodeError, ValidationError) as e:
264
+ logger.debug(f"OAuth metadata discovery failed: {e}")
265
+ return None
266
+
267
+
268
+ async def check_if_auth_required(
269
+ mcp_url: str, httpx_kwargs: dict[str, Any] | None = None
270
+ ) -> bool:
271
+ """
272
+ Check if the MCP endpoint requires authentication by making a test request.
273
+
274
+ Returns:
275
+ True if auth appears to be required, False otherwise
276
+ """
277
+ async with httpx.AsyncClient(**(httpx_kwargs or {})) as client:
278
+ try:
279
+ # Try a simple request to the endpoint
280
+ response = await client.get(mcp_url, timeout=5.0)
281
+
282
+ # If we get 401/403, auth is likely required
283
+ if response.status_code in (401, 403):
284
+ return True
285
+
286
+ # Check for WWW-Authenticate header
287
+ if "WWW-Authenticate" in response.headers:
288
+ return True
289
+
290
+ # If we get a successful response, auth may not be required
291
+ return False
292
+
293
+ except httpx.RequestError:
294
+ # If we can't connect, assume auth might be required
295
+ return True
296
+
297
+
298
+ def OAuth(
299
+ mcp_url: str,
300
+ scopes: str | list[str] | None = None,
301
+ client_name: str = "FastMCP Client",
302
+ token_storage_cache_dir: Path | None = None,
303
+ additional_client_metadata: dict[str, Any] | None = None,
304
+ ) -> _MCPOAuthClientProvider:
305
+ """
306
+ Create an OAuthClientProvider for an MCP server.
307
+
308
+ This is intended to be provided to the `auth` parameter of an
309
+ httpx.AsyncClient (or appropriate FastMCP client/transport instance)
310
+
311
+ Args:
312
+ mcp_url: Full URL to the MCP endpoint (e.g.,
313
+ "http://host/mcp/sse")
314
+ scopes: OAuth scopes to request. Can be a
315
+ space-separated string or a list of strings.
316
+ client_name: Name for this client during registration
317
+ token_storage_cache_dir: Directory for FileTokenStorage
318
+ additional_client_metadata: Extra fields for OAuthClientMetadata
319
+
320
+ Returns:
321
+ OAuthClientProvider
322
+ """
323
+ parsed_url = urlparse(mcp_url)
324
+ server_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
325
+
326
+ # Setup OAuth client
327
+ redirect_port = find_available_port()
328
+ redirect_uri = f"http://127.0.0.1:{redirect_port}/callback"
329
+
330
+ if isinstance(scopes, list):
331
+ scopes = " ".join(scopes)
332
+
333
+ client_metadata = OAuthClientMetadata(
334
+ client_name=client_name,
335
+ redirect_uris=[AnyHttpUrl(redirect_uri)],
336
+ grant_types=["authorization_code", "refresh_token"],
337
+ response_types=["code"],
338
+ token_endpoint_auth_method="client_secret_post",
339
+ scope=scopes,
340
+ **(additional_client_metadata or {}),
341
+ )
342
+
343
+ # Create server-specific token storage
344
+ storage = FileTokenStorage(
345
+ server_url=server_base_url, cache_dir=token_storage_cache_dir
346
+ )
347
+
348
+ # Define OAuth handlers
349
+ async def redirect_handler(authorization_url: str) -> None:
350
+ """Open browser for authorization."""
351
+ logger.info(f"OAuth authorization URL: {authorization_url}")
352
+ webbrowser.open(authorization_url)
353
+
354
+ async def callback_handler() -> tuple[str, str | None]:
355
+ """Handle OAuth callback and return (auth_code, state)."""
356
+ # Create a future to capture the OAuth response
357
+ response_future = asyncio.get_running_loop().create_future()
358
+
359
+ # Create server with the future
360
+ server = create_oauth_callback_server(
361
+ port=redirect_port,
362
+ server_url=server_base_url,
363
+ response_future=response_future,
364
+ )
365
+
366
+ # Run server until response is received with timeout logic
367
+ async with anyio.create_task_group() as tg:
368
+ tg.start_soon(server.serve)
369
+ logger.info(
370
+ f"🎧 OAuth callback server started on http://127.0.0.1:{redirect_port}"
371
+ )
372
+
373
+ TIMEOUT = 300.0 # 5 minute timeout
374
+ try:
375
+ with anyio.fail_after(TIMEOUT):
376
+ auth_code, state = await response_future
377
+ return auth_code, state
378
+ except TimeoutError:
379
+ raise TimeoutError(f"OAuth callback timed out after {TIMEOUT} seconds")
380
+ finally:
381
+ server.should_exit = True
382
+ await asyncio.sleep(0.1) # Allow server to shutdown gracefully
383
+ tg.cancel_scope.cancel()
384
+
385
+ # Create OAuth provider
386
+ oauth_provider = OAuthClientProvider(
387
+ server_url=server_base_url,
388
+ client_metadata=client_metadata,
389
+ storage=storage,
390
+ redirect_handler=redirect_handler,
391
+ callback_handler=callback_handler,
392
+ )
393
+
394
+ return oauth_provider
src/fastmcp/client/base.py DELETED
File without changes
src/fastmcp/client/client.py CHANGED
@@ -1,9 +1,10 @@
1
  import datetime
2
  from contextlib import AsyncExitStack, asynccontextmanager
3
  from pathlib import Path
4
- from typing import Any, Generic, cast, overload
5
 
6
  import anyio
 
7
  import mcp.types
8
  from exceptiongroup import catch
9
  from mcp import ClientSession
@@ -43,6 +44,7 @@ from .transports import (
43
 
44
  __all__ = [
45
  "Client",
 
46
  "RootsHandler",
47
  "RootsList",
48
  "LogHandler",
@@ -142,8 +144,11 @@ class Client(Generic[ClientTransportT]):
142
  progress_handler: ProgressHandler | None = None,
143
  timeout: datetime.timedelta | float | int | None = None,
144
  init_timeout: datetime.timedelta | float | int | None = None,
 
145
  ):
146
  self.transport = cast(ClientTransportT, infer_transport(transport))
 
 
147
  self._session: ClientSession | None = None
148
  self._exit_stack: AsyncExitStack | None = None
149
  self._nesting_counter: int = 0
 
1
  import datetime
2
  from contextlib import AsyncExitStack, asynccontextmanager
3
  from pathlib import Path
4
+ from typing import Any, Generic, Literal, cast, overload
5
 
6
  import anyio
7
+ import httpx
8
  import mcp.types
9
  from exceptiongroup import catch
10
  from mcp import ClientSession
 
44
 
45
  __all__ = [
46
  "Client",
47
+ "SessionKwargs",
48
  "RootsHandler",
49
  "RootsList",
50
  "LogHandler",
 
144
  progress_handler: ProgressHandler | None = None,
145
  timeout: datetime.timedelta | float | int | None = None,
146
  init_timeout: datetime.timedelta | float | int | None = None,
147
+ auth: httpx.Auth | Literal["oauth"] | str | None = None,
148
  ):
149
  self.transport = cast(ClientTransportT, infer_transport(transport))
150
+ if auth is not None:
151
+ self.transport._set_auth(auth)
152
  self._session: ClientSession | None = None
153
  self._exit_stack: AsyncExitStack | None = None
154
  self._nesting_counter: int = 0
src/fastmcp/client/oauth_callback.py ADDED
@@ -0,0 +1,317 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ OAuth callback server for handling authorization code flows.
3
+
4
+ This module provides a reusable callback server that can handle OAuth redirects
5
+ and display styled responses to users.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import socket
12
+ from dataclasses import dataclass
13
+
14
+ from starlette.applications import Starlette
15
+ from starlette.requests import Request
16
+ from starlette.responses import HTMLResponse
17
+ from starlette.routing import Route
18
+ from uvicorn import Config, Server
19
+
20
+ from fastmcp.utilities.logging import get_logger
21
+
22
+ logger = get_logger(__name__)
23
+
24
+
25
+ def create_callback_html(
26
+ message: str,
27
+ is_success: bool = True,
28
+ title: str = "FastMCP OAuth",
29
+ server_url: str | None = None,
30
+ ) -> str:
31
+ """Create a styled HTML response for OAuth callbacks."""
32
+ status_emoji = "✅" if is_success else "❌"
33
+ status_color = "#10b981" if is_success else "#ef4444" # emerald-500 / red-500
34
+
35
+ # Add server info for success cases
36
+ server_info = ""
37
+ if is_success and server_url:
38
+ server_info = f"""
39
+ <div class="server-info">
40
+ Connected to: <strong>{server_url}</strong>
41
+ </div>
42
+ """
43
+
44
+ return f"""
45
+ <!DOCTYPE html>
46
+ <html lang="en">
47
+ <head>
48
+ <meta charset="UTF-8">
49
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
50
+ <title>{title}</title>
51
+ <style>
52
+ body {{
53
+ font-family: 'SF Mono', 'Monaco', 'Consolas', 'Roboto Mono', monospace;
54
+ margin: 0;
55
+ padding: 0;
56
+ min-height: 100vh;
57
+ display: flex;
58
+ align-items: center;
59
+ justify-content: center;
60
+ background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 25%, #16213e 50%, #0f0f23 100%);
61
+ color: #e2e8f0;
62
+ overflow: hidden;
63
+ }}
64
+
65
+ body::before {{
66
+ content: '';
67
+ position: fixed;
68
+ top: 0;
69
+ left: 0;
70
+ width: 100%;
71
+ height: 100%;
72
+ background:
73
+ radial-gradient(circle at 20% 80%, rgba(120, 119, 198, 0.1) 0%, transparent 50%),
74
+ radial-gradient(circle at 80% 20%, rgba(16, 185, 129, 0.1) 0%, transparent 50%),
75
+ radial-gradient(circle at 40% 40%, rgba(14, 165, 233, 0.1) 0%, transparent 50%);
76
+ pointer-events: none;
77
+ z-index: -1;
78
+ }}
79
+
80
+ .container {{
81
+ background: rgba(30, 41, 59, 0.9);
82
+ backdrop-filter: blur(10px);
83
+ border: 1px solid rgba(71, 85, 105, 0.3);
84
+ padding: 3rem 2rem;
85
+ border-radius: 1rem;
86
+ box-shadow:
87
+ 0 25px 50px -12px rgba(0, 0, 0, 0.7),
88
+ 0 0 0 1px rgba(255, 255, 255, 0.05),
89
+ inset 0 1px 0 0 rgba(255, 255, 255, 0.1);
90
+ text-align: center;
91
+ max-width: 500px;
92
+ margin: 1rem;
93
+ position: relative;
94
+ }}
95
+
96
+ .container::before {{
97
+ content: '';
98
+ position: absolute;
99
+ top: 0;
100
+ left: 0;
101
+ right: 0;
102
+ height: 1px;
103
+ background: linear-gradient(90deg, transparent, rgba(16, 185, 129, 0.5), transparent);
104
+ }}
105
+
106
+ .status-icon {{
107
+ font-size: 4rem;
108
+ margin-bottom: 1rem;
109
+ display: block;
110
+ filter: drop-shadow(0 0 20px currentColor);
111
+ }}
112
+
113
+ .message {{
114
+ font-size: 1.25rem;
115
+ line-height: 1.6;
116
+ color: {status_color};
117
+ margin-bottom: 1.5rem;
118
+ font-weight: 600;
119
+ text-shadow: 0 0 10px rgba({
120
+ "16, 185, 129" if is_success else "239, 68, 68"
121
+ }, 0.3);
122
+ }}
123
+
124
+ .server-info {{
125
+ background: rgba(6, 182, 212, 0.1);
126
+ border: 1px solid rgba(6, 182, 212, 0.3);
127
+ border-radius: 0.75rem;
128
+ padding: 1rem;
129
+ margin: 1rem 0;
130
+ font-size: 0.9rem;
131
+ color: #67e8f9;
132
+ font-family: 'SF Mono', 'Monaco', 'Consolas', 'Roboto Mono', monospace;
133
+ text-shadow: 0 0 10px rgba(103, 232, 249, 0.3);
134
+ }}
135
+
136
+ .server-info strong {{
137
+ color: #22d3ee;
138
+ font-weight: 700;
139
+ }}
140
+
141
+ .subtitle {{
142
+ font-size: 1rem;
143
+ color: #94a3b8;
144
+ margin-top: 1rem;
145
+ }}
146
+
147
+ .close-instruction {{
148
+ background: rgba(51, 65, 85, 0.8);
149
+ border: 1px solid rgba(71, 85, 105, 0.4);
150
+ border-radius: 0.75rem;
151
+ padding: 1rem;
152
+ margin-top: 1.5rem;
153
+ font-size: 0.9rem;
154
+ color: #cbd5e1;
155
+ font-family: 'SF Mono', 'Monaco', 'Consolas', 'Roboto Mono', monospace;
156
+ }}
157
+
158
+ @keyframes glow {{
159
+ 0%, 100% {{ opacity: 1; }}
160
+ 50% {{ opacity: 0.7; }}
161
+ }}
162
+
163
+ .status-icon {{
164
+ animation: glow 2s ease-in-out infinite;
165
+ }}
166
+ </style>
167
+ </head>
168
+ <body>
169
+ <div class="container">
170
+ <span class="status-icon">{status_emoji}</span>
171
+ <div class="message">{message}</div>
172
+ {server_info}
173
+ <div class="close-instruction">
174
+ You can safely close this tab now.
175
+ </div>
176
+ </div>
177
+ </body>
178
+ </html>
179
+ """
180
+
181
+
182
+ def find_available_port() -> int:
183
+ """Find an available port by letting the OS assign one."""
184
+ with socket.socket() as s:
185
+ s.bind(("127.0.0.1", 0))
186
+ return s.getsockname()[1]
187
+
188
+
189
+ @dataclass
190
+ class CallbackResponse:
191
+ code: str | None = None
192
+ state: str | None = None
193
+ error: str | None = None
194
+ error_description: str | None = None
195
+
196
+ @classmethod
197
+ def from_dict(cls, data: dict[str, str]) -> CallbackResponse:
198
+ return cls(**{k: v for k, v in data.items() if k in cls.__annotations__})
199
+
200
+ def to_dict(self) -> dict[str, str]:
201
+ return {k: v for k, v in self.__dict__.items() if v is not None}
202
+
203
+
204
+ def create_oauth_callback_server(
205
+ port: int,
206
+ callback_path: str = "/callback",
207
+ server_url: str | None = None,
208
+ response_future: asyncio.Future | None = None,
209
+ ) -> Server:
210
+ """
211
+ Create an OAuth callback server.
212
+
213
+ Args:
214
+ port: The port to run the server on
215
+ callback_path: The path to listen for OAuth redirects on
216
+ server_url: Optional server URL to display in success messages
217
+ response_future: Optional future to resolve when OAuth callback is received
218
+
219
+ Returns:
220
+ Configured uvicorn Server instance (not yet running)
221
+ """
222
+
223
+ async def callback_handler(request: Request):
224
+ """Handle OAuth callback requests with proper HTML responses."""
225
+ query_params = dict(request.query_params)
226
+ callback_response = CallbackResponse.from_dict(query_params)
227
+
228
+ if callback_response.error:
229
+ error_desc = callback_response.error_description or "Unknown error"
230
+
231
+ # Resolve future with exception if provided
232
+ if response_future and not response_future.done():
233
+ response_future.set_exception(
234
+ RuntimeError(
235
+ f"OAuth error: {callback_response.error} - {error_desc}"
236
+ )
237
+ )
238
+
239
+ return HTMLResponse(
240
+ create_callback_html(
241
+ f"FastMCP OAuth Error: {callback_response.error}<br>{error_desc}",
242
+ is_success=False,
243
+ ),
244
+ status_code=400,
245
+ )
246
+
247
+ if not callback_response.code:
248
+ # Resolve future with exception if provided
249
+ if response_future and not response_future.done():
250
+ response_future.set_exception(
251
+ RuntimeError("OAuth callback missing authorization code")
252
+ )
253
+
254
+ return HTMLResponse(
255
+ create_callback_html(
256
+ "FastMCP OAuth Error: No authorization code received",
257
+ is_success=False,
258
+ ),
259
+ status_code=400,
260
+ )
261
+
262
+ # Success case
263
+ if response_future and not response_future.done():
264
+ response_future.set_result(
265
+ (callback_response.code, callback_response.state)
266
+ )
267
+
268
+ return HTMLResponse(
269
+ create_callback_html("FastMCP OAuth login complete!", server_url=server_url)
270
+ )
271
+
272
+ app = Starlette(routes=[Route(callback_path, callback_handler)])
273
+
274
+ return Server(
275
+ Config(
276
+ app=app,
277
+ host="127.0.0.1",
278
+ port=port,
279
+ lifespan="off",
280
+ log_level="warning",
281
+ )
282
+ )
283
+
284
+
285
+ if __name__ == "__main__":
286
+ """Run a test server when executed directly."""
287
+ import webbrowser
288
+
289
+ import uvicorn
290
+
291
+ port = find_available_port()
292
+ print("🎭 OAuth Callback Test Server")
293
+ print("📍 Test URLs:")
294
+ print(f" Success: http://localhost:{port}/callback?code=test123&state=xyz")
295
+ print(
296
+ f" Error: http://localhost:{port}/callback?error=access_denied&error_description=User%20denied"
297
+ )
298
+ print(f" Missing: http://localhost:{port}/callback")
299
+ print("🛑 Press Ctrl+C to stop")
300
+ print()
301
+
302
+ # Create test server without future (just for testing HTML responses)
303
+ server = create_oauth_callback_server(
304
+ port=port, server_url="https://fastmcp-test-server.example.com"
305
+ )
306
+
307
+ # Open browser to success example
308
+ webbrowser.open(f"http://localhost:{port}/callback?code=test123&state=xyz")
309
+
310
+ # Run with uvicorn directly
311
+ uvicorn.run(
312
+ server.config.app,
313
+ host="127.0.0.1",
314
+ port=port,
315
+ log_level="warning",
316
+ access_log=False,
317
+ )
src/fastmcp/client/transports.py CHANGED
@@ -6,10 +6,19 @@ import os
6
  import shutil
7
  import sys
8
  import warnings
9
- from collections.abc import AsyncIterator
10
  from pathlib import Path
11
- from typing import TYPE_CHECKING, Any, TypedDict, TypeVar, cast, overload
 
 
 
 
 
 
 
 
12
 
 
13
  from mcp import ClientSession, StdioServerParameters
14
  from mcp.client.session import (
15
  ListRootsFnT,
@@ -26,6 +35,7 @@ from mcp.shared.memory import create_connected_server_and_client_session
26
  from pydantic import AnyUrl
27
  from typing_extensions import Unpack
28
 
 
29
  from fastmcp.server import FastMCP as FastMCPServer
30
  from fastmcp.server.dependencies import get_http_headers
31
  from fastmcp.server.server import FastMCP
@@ -40,6 +50,21 @@ logger = get_logger(__name__)
40
  # TypeVar for preserving specific ClientTransport subclass types
41
  ClientTransportT = TypeVar("ClientTransportT", bound="ClientTransport")
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
  class SessionKwargs(TypedDict, total=False):
45
  """Keyword arguments for the MCP ClientSession constructor."""
@@ -92,6 +117,10 @@ class ClientTransport(abc.ABC):
92
  """Close the transport."""
93
  pass
94
 
 
 
 
 
95
 
96
  class WSTransport(ClientTransport):
97
  """Transport implementation that connects to an MCP server via WebSockets."""
@@ -131,7 +160,9 @@ class SSETransport(ClientTransport):
131
  self,
132
  url: str | AnyUrl,
133
  headers: dict[str, str] | None = None,
 
134
  sse_read_timeout: datetime.timedelta | float | int | None = None,
 
135
  ):
136
  if isinstance(url, AnyUrl):
137
  url = str(url)
@@ -139,11 +170,21 @@ class SSETransport(ClientTransport):
139
  raise ValueError("Invalid HTTP/S URL provided for SSE.")
140
  self.url = url
141
  self.headers = headers or {}
 
 
142
 
143
  if isinstance(sse_read_timeout, int | float):
144
  sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
145
  self.sse_read_timeout = sse_read_timeout
146
 
 
 
 
 
 
 
 
 
147
  @contextlib.asynccontextmanager
148
  async def connect_session(
149
  self, **session_kwargs: Unpack[SessionKwargs]
@@ -165,7 +206,10 @@ class SSETransport(ClientTransport):
165
  )
166
  client_kwargs["timeout"] = read_timeout_seconds.total_seconds()
167
 
168
- async with sse_client(self.url, **client_kwargs) as transport:
 
 
 
169
  read_stream, write_stream = transport
170
  async with ClientSession(
171
  read_stream, write_stream, **session_kwargs
@@ -183,7 +227,9 @@ class StreamableHttpTransport(ClientTransport):
183
  self,
184
  url: str | AnyUrl,
185
  headers: dict[str, str] | None = None,
 
186
  sse_read_timeout: datetime.timedelta | float | int | None = None,
 
187
  ):
188
  if isinstance(url, AnyUrl):
189
  url = str(url)
@@ -191,11 +237,21 @@ class StreamableHttpTransport(ClientTransport):
191
  raise ValueError("Invalid HTTP/S URL provided for Streamable HTTP.")
192
  self.url = url
193
  self.headers = headers or {}
 
 
194
 
195
  if isinstance(sse_read_timeout, int | float):
196
  sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
197
  self.sse_read_timeout = sse_read_timeout
198
 
 
 
 
 
 
 
 
 
199
  @contextlib.asynccontextmanager
200
  async def connect_session(
201
  self, **session_kwargs: Unpack[SessionKwargs]
@@ -214,7 +270,14 @@ class StreamableHttpTransport(ClientTransport):
214
  if session_kwargs.get("read_timeout_seconds", None) is not None:
215
  client_kwargs["timeout"] = session_kwargs.get("read_timeout_seconds")
216
 
217
- async with streamablehttp_client(self.url, **client_kwargs) as transport:
 
 
 
 
 
 
 
218
  read_stream, write_stream, _ = transport
219
  async with ClientSession(
220
  read_stream, write_stream, **session_kwargs
 
6
  import shutil
7
  import sys
8
  import warnings
9
+ from collections.abc import AsyncIterator, Callable
10
  from pathlib import Path
11
+ from typing import (
12
+ TYPE_CHECKING,
13
+ Any,
14
+ Literal,
15
+ TypedDict,
16
+ TypeVar,
17
+ cast,
18
+ overload,
19
+ )
20
 
21
+ import httpx
22
  from mcp import ClientSession, StdioServerParameters
23
  from mcp.client.session import (
24
  ListRootsFnT,
 
35
  from pydantic import AnyUrl
36
  from typing_extensions import Unpack
37
 
38
+ from fastmcp.client.auth import OAuth
39
  from fastmcp.server import FastMCP as FastMCPServer
40
  from fastmcp.server.dependencies import get_http_headers
41
  from fastmcp.server.server import FastMCP
 
50
  # TypeVar for preserving specific ClientTransport subclass types
51
  ClientTransportT = TypeVar("ClientTransportT", bound="ClientTransport")
52
 
53
+ __all__ = [
54
+ "ClientTransport",
55
+ "SSETransport",
56
+ "StreamableHttpTransport",
57
+ "FastMCPServer",
58
+ "StdioTransport",
59
+ "PythonStdioTransport",
60
+ "FastMCPStdioTransport",
61
+ "NodeStdioTransport",
62
+ "UvxStdioTransport",
63
+ "NpxStdioTransport",
64
+ "FastMCPTransport",
65
+ "infer_transport",
66
+ ]
67
+
68
 
69
  class SessionKwargs(TypedDict, total=False):
70
  """Keyword arguments for the MCP ClientSession constructor."""
 
117
  """Close the transport."""
118
  pass
119
 
120
+ def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
121
+ if auth is not None:
122
+ raise ValueError("This transport does not support auth")
123
+
124
 
125
  class WSTransport(ClientTransport):
126
  """Transport implementation that connects to an MCP server via WebSockets."""
 
160
  self,
161
  url: str | AnyUrl,
162
  headers: dict[str, str] | None = None,
163
+ auth: httpx.Auth | Literal["oauth"] | str | None = None,
164
  sse_read_timeout: datetime.timedelta | float | int | None = None,
165
+ httpx_client_factory: Callable[[], httpx.AsyncClient] | None = None,
166
  ):
167
  if isinstance(url, AnyUrl):
168
  url = str(url)
 
170
  raise ValueError("Invalid HTTP/S URL provided for SSE.")
171
  self.url = url
172
  self.headers = headers or {}
173
+ self._set_auth(auth)
174
+ self.httpx_client_factory = httpx_client_factory
175
 
176
  if isinstance(sse_read_timeout, int | float):
177
  sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
178
  self.sse_read_timeout = sse_read_timeout
179
 
180
+ def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
181
+ if auth == "oauth":
182
+ auth = OAuth(self.url)
183
+ elif isinstance(auth, str):
184
+ self.headers["Authorization"] = auth
185
+ auth = None
186
+ self.auth = auth
187
+
188
  @contextlib.asynccontextmanager
189
  async def connect_session(
190
  self, **session_kwargs: Unpack[SessionKwargs]
 
206
  )
207
  client_kwargs["timeout"] = read_timeout_seconds.total_seconds()
208
 
209
+ if self.httpx_client_factory is not None:
210
+ client_kwargs["httpx_client_factory"] = self.httpx_client_factory
211
+
212
+ async with sse_client(self.url, auth=self.auth, **client_kwargs) as transport:
213
  read_stream, write_stream = transport
214
  async with ClientSession(
215
  read_stream, write_stream, **session_kwargs
 
227
  self,
228
  url: str | AnyUrl,
229
  headers: dict[str, str] | None = None,
230
+ auth: httpx.Auth | Literal["oauth"] | str | None = None,
231
  sse_read_timeout: datetime.timedelta | float | int | None = None,
232
+ httpx_client_factory: Callable[[], httpx.AsyncClient] | None = None,
233
  ):
234
  if isinstance(url, AnyUrl):
235
  url = str(url)
 
237
  raise ValueError("Invalid HTTP/S URL provided for Streamable HTTP.")
238
  self.url = url
239
  self.headers = headers or {}
240
+ self._set_auth(auth)
241
+ self.httpx_client_factory = httpx_client_factory
242
 
243
  if isinstance(sse_read_timeout, int | float):
244
  sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
245
  self.sse_read_timeout = sse_read_timeout
246
 
247
+ def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
248
+ if auth == "oauth":
249
+ auth = OAuth(self.url)
250
+ elif isinstance(auth, str):
251
+ self.headers["Authorization"] = auth
252
+ auth = None
253
+ self.auth = auth
254
+
255
  @contextlib.asynccontextmanager
256
  async def connect_session(
257
  self, **session_kwargs: Unpack[SessionKwargs]
 
270
  if session_kwargs.get("read_timeout_seconds", None) is not None:
271
  client_kwargs["timeout"] = session_kwargs.get("read_timeout_seconds")
272
 
273
+ if self.httpx_client_factory is not None:
274
+ client_kwargs["httpx_client_factory"] = self.httpx_client_factory
275
+
276
+ async with streamablehttp_client(
277
+ self.url,
278
+ auth=self.auth,
279
+ **client_kwargs,
280
+ ) as transport:
281
  read_stream, write_stream, _ = transport
282
  async with ClientSession(
283
  read_stream, write_stream, **session_kwargs
src/fastmcp/low_level/README.md DELETED
@@ -1 +0,0 @@
1
- Patched low-level objects. When possible, we prefer the official SDK, but we patch bugs here if necessary.
 
 
src/fastmcp/py.typed DELETED
File without changes
src/fastmcp/{low_level → server/auth}/__init__.py RENAMED
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,325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS = None # No expiry
29
+
30
+
31
+ class InMemoryOAuthProvider(OAuthProvider):
32
+ """
33
+ An in-memory OAuth provider for testing purposes.
34
+ It simulates the OAuth 2.0 flow locally without external calls.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ issuer_url: AnyHttpUrl | str | None = None,
40
+ service_documentation_url: AnyHttpUrl | str | None = None,
41
+ client_registration_options: ClientRegistrationOptions | None = None,
42
+ revocation_options: RevocationOptions | None = None,
43
+ required_scopes: list[str] | None = None,
44
+ ):
45
+ super().__init__(
46
+ issuer_url or "https://example.com",
47
+ service_documentation_url=service_documentation_url,
48
+ client_registration_options=client_registration_options,
49
+ revocation_options=revocation_options,
50
+ required_scopes=required_scopes,
51
+ )
52
+ self.clients: dict[str, OAuthClientInformationFull] = {}
53
+ self.auth_codes: dict[str, AuthorizationCode] = {}
54
+ self.access_tokens: dict[str, AccessToken] = {}
55
+ self.refresh_tokens: dict[str, RefreshToken] = {}
56
+
57
+ # For revoking associated tokens
58
+ self._access_to_refresh_map: dict[
59
+ str, str
60
+ ] = {} # access_token_str -> refresh_token_str
61
+ self._refresh_to_access_map: dict[
62
+ str, str
63
+ ] = {} # refresh_token_str -> access_token_str
64
+
65
+ async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
66
+ return self.clients.get(client_id)
67
+
68
+ async def register_client(self, client_info: OAuthClientInformationFull) -> None:
69
+ if client_info.client_id in self.clients:
70
+ # As per RFC 7591, if client_id is already known, it's an update.
71
+ # For this simple provider, we'll treat it as re-registration.
72
+ # A real provider might handle updates or raise errors for conflicts.
73
+ pass
74
+ self.clients[client_info.client_id] = client_info
75
+
76
+ async def authorize(
77
+ self, client: OAuthClientInformationFull, params: AuthorizationParams
78
+ ) -> str:
79
+ """
80
+ Simulates user authorization and generates an authorization code.
81
+ Returns a redirect URI with the code and state.
82
+ """
83
+ if client.client_id not in self.clients:
84
+ raise AuthorizeError(
85
+ error="unauthorized_client",
86
+ error_description=f"Client '{client.client_id}' not registered.",
87
+ )
88
+
89
+ # Validate redirect_uri (already validated by AuthorizationHandler, but good practice)
90
+ try:
91
+ # OAuthClientInformationFull should have a method like validate_redirect_uri
92
+ # For this test provider, we assume it's valid if it matches one in client_info
93
+ # The AuthorizationHandler already does robust validation using client.validate_redirect_uri
94
+ if params.redirect_uri not in client.redirect_uris:
95
+ # This check might be too simplistic if redirect_uris can be patterns
96
+ # or if params.redirect_uri is None and client has a default.
97
+ # However, the AuthorizationHandler handles the primary validation.
98
+ pass # Let's assume AuthorizationHandler did its job.
99
+ except Exception: # Replace with specific validation error if client.validate_redirect_uri existed
100
+ raise AuthorizeError(
101
+ error="invalid_request", error_description="Invalid redirect_uri."
102
+ )
103
+
104
+ auth_code_value = f"test_auth_code_{secrets.token_hex(16)}"
105
+ expires_at = time.time() + DEFAULT_AUTH_CODE_EXPIRY_SECONDS
106
+
107
+ # Ensure scopes are a list
108
+ scopes_list = params.scopes if params.scopes is not None else []
109
+ if client.scope: # Filter params.scopes against client's registered scopes
110
+ client_allowed_scopes = set(client.scope.split())
111
+ scopes_list = [s for s in scopes_list if s in client_allowed_scopes]
112
+
113
+ auth_code = AuthorizationCode(
114
+ code=auth_code_value,
115
+ client_id=client.client_id,
116
+ redirect_uri=params.redirect_uri,
117
+ redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly,
118
+ scopes=scopes_list,
119
+ expires_at=expires_at,
120
+ code_challenge=params.code_challenge,
121
+ # code_challenge_method is assumed S256 by the framework
122
+ )
123
+ self.auth_codes[auth_code_value] = auth_code
124
+
125
+ return construct_redirect_uri(
126
+ str(params.redirect_uri), code=auth_code_value, state=params.state
127
+ )
128
+
129
+ async def load_authorization_code(
130
+ self, client: OAuthClientInformationFull, authorization_code: str
131
+ ) -> AuthorizationCode | None:
132
+ auth_code_obj = self.auth_codes.get(authorization_code)
133
+ if auth_code_obj:
134
+ if auth_code_obj.client_id != client.client_id:
135
+ return None # Belongs to a different client
136
+ if auth_code_obj.expires_at < time.time():
137
+ del self.auth_codes[authorization_code] # Expired
138
+ return None
139
+ return auth_code_obj
140
+ return None
141
+
142
+ async def exchange_authorization_code(
143
+ self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
144
+ ) -> OAuthToken:
145
+ # Authorization code should have been validated (existence, expiry, client_id match)
146
+ # by the TokenHandler calling load_authorization_code before this.
147
+ # We might want to re-verify or simply trust it's valid.
148
+
149
+ if authorization_code.code not in self.auth_codes:
150
+ raise TokenError(
151
+ "invalid_grant", "Authorization code not found or already used."
152
+ )
153
+
154
+ # Consume the auth code
155
+ del self.auth_codes[authorization_code.code]
156
+
157
+ access_token_value = f"test_access_token_{secrets.token_hex(32)}"
158
+ refresh_token_value = f"test_refresh_token_{secrets.token_hex(32)}"
159
+
160
+ access_token_expires_at = int(time.time() + DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS)
161
+
162
+ # Refresh token expiry
163
+ refresh_token_expires_at = None
164
+ if DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS is not None:
165
+ refresh_token_expires_at = int(
166
+ time.time() + DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS
167
+ )
168
+
169
+ self.access_tokens[access_token_value] = AccessToken(
170
+ token=access_token_value,
171
+ client_id=client.client_id,
172
+ scopes=authorization_code.scopes,
173
+ expires_at=access_token_expires_at,
174
+ )
175
+ self.refresh_tokens[refresh_token_value] = RefreshToken(
176
+ token=refresh_token_value,
177
+ client_id=client.client_id,
178
+ scopes=authorization_code.scopes, # Refresh token inherits scopes
179
+ expires_at=refresh_token_expires_at,
180
+ )
181
+
182
+ self._access_to_refresh_map[access_token_value] = refresh_token_value
183
+ self._refresh_to_access_map[refresh_token_value] = access_token_value
184
+
185
+ return OAuthToken(
186
+ access_token=access_token_value,
187
+ token_type="bearer",
188
+ expires_in=DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS,
189
+ refresh_token=refresh_token_value,
190
+ scope=" ".join(authorization_code.scopes),
191
+ )
192
+
193
+ async def load_refresh_token(
194
+ self, client: OAuthClientInformationFull, refresh_token: str
195
+ ) -> RefreshToken | None:
196
+ token_obj = self.refresh_tokens.get(refresh_token)
197
+ if token_obj:
198
+ if token_obj.client_id != client.client_id:
199
+ return None # Belongs to different client
200
+ if token_obj.expires_at is not None and token_obj.expires_at < time.time():
201
+ self._revoke_internal(
202
+ refresh_token_str=token_obj.token
203
+ ) # Clean up expired
204
+ return None
205
+ return token_obj
206
+ return None
207
+
208
+ async def exchange_refresh_token(
209
+ self,
210
+ client: OAuthClientInformationFull,
211
+ refresh_token: RefreshToken, # This is the RefreshToken object, already loaded
212
+ scopes: list[str], # Requested scopes for the new access token
213
+ ) -> OAuthToken:
214
+ # Validate scopes: requested scopes must be a subset of original scopes
215
+ original_scopes = set(refresh_token.scopes)
216
+ requested_scopes = set(scopes)
217
+ if not requested_scopes.issubset(original_scopes):
218
+ raise TokenError(
219
+ "invalid_scope",
220
+ "Requested scopes exceed those authorized by the refresh token.",
221
+ )
222
+
223
+ # Invalidate old refresh token and its associated access token (rotation)
224
+ self._revoke_internal(refresh_token_str=refresh_token.token)
225
+
226
+ # Issue new tokens
227
+ new_access_token_value = f"test_access_token_{secrets.token_hex(32)}"
228
+ new_refresh_token_value = f"test_refresh_token_{secrets.token_hex(32)}"
229
+
230
+ access_token_expires_at = int(time.time() + DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS)
231
+
232
+ # Refresh token expiry
233
+ refresh_token_expires_at = None
234
+ if DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS is not None:
235
+ refresh_token_expires_at = int(
236
+ time.time() + DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS
237
+ )
238
+
239
+ self.access_tokens[new_access_token_value] = AccessToken(
240
+ token=new_access_token_value,
241
+ client_id=client.client_id,
242
+ scopes=scopes, # Use newly requested (and validated) scopes
243
+ expires_at=access_token_expires_at,
244
+ )
245
+ self.refresh_tokens[new_refresh_token_value] = RefreshToken(
246
+ token=new_refresh_token_value,
247
+ client_id=client.client_id,
248
+ scopes=scopes, # New refresh token also gets these scopes
249
+ expires_at=refresh_token_expires_at,
250
+ )
251
+
252
+ self._access_to_refresh_map[new_access_token_value] = new_refresh_token_value
253
+ self._refresh_to_access_map[new_refresh_token_value] = new_access_token_value
254
+
255
+ return OAuthToken(
256
+ access_token=new_access_token_value,
257
+ token_type="bearer",
258
+ expires_in=DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS,
259
+ refresh_token=new_refresh_token_value,
260
+ scope=" ".join(scopes),
261
+ )
262
+
263
+ async def load_access_token(self, token: str) -> AccessToken | None:
264
+ token_obj = self.access_tokens.get(token)
265
+ if token_obj:
266
+ if token_obj.expires_at is not None and token_obj.expires_at < time.time():
267
+ self._revoke_internal(
268
+ access_token_str=token_obj.token
269
+ ) # Clean up expired
270
+ return None
271
+ return token_obj
272
+ return None
273
+
274
+ def _revoke_internal(
275
+ self, access_token_str: str | None = None, refresh_token_str: str | None = None
276
+ ):
277
+ """Internal helper to remove tokens and their associations."""
278
+ removed_access_token = None
279
+ removed_refresh_token = None
280
+
281
+ if access_token_str:
282
+ if access_token_str in self.access_tokens:
283
+ del self.access_tokens[access_token_str]
284
+ removed_access_token = access_token_str
285
+
286
+ # Get associated refresh token
287
+ associated_refresh = self._access_to_refresh_map.pop(access_token_str, None)
288
+ if associated_refresh:
289
+ if associated_refresh in self.refresh_tokens:
290
+ del self.refresh_tokens[associated_refresh]
291
+ removed_refresh_token = associated_refresh
292
+ self._refresh_to_access_map.pop(associated_refresh, None)
293
+
294
+ if refresh_token_str:
295
+ if refresh_token_str in self.refresh_tokens:
296
+ del self.refresh_tokens[refresh_token_str]
297
+ removed_refresh_token = refresh_token_str
298
+
299
+ # Get associated access token
300
+ associated_access = self._refresh_to_access_map.pop(refresh_token_str, None)
301
+ if associated_access:
302
+ if associated_access in self.access_tokens:
303
+ del self.access_tokens[associated_access]
304
+ removed_access_token = associated_access
305
+ self._access_to_refresh_map.pop(associated_access, None)
306
+
307
+ # Clean up any dangling references if one part of the pair was already gone
308
+ if removed_access_token and removed_access_token in self._access_to_refresh_map:
309
+ del self._access_to_refresh_map[removed_access_token]
310
+ if (
311
+ removed_refresh_token
312
+ and removed_refresh_token in self._refresh_to_access_map
313
+ ):
314
+ del self._refresh_to_access_map[removed_refresh_token]
315
+
316
+ async def revoke_token(
317
+ self,
318
+ token: AccessToken | RefreshToken,
319
+ ) -> None:
320
+ """Revokes an access or refresh token and its counterpart."""
321
+ if isinstance(token, AccessToken):
322
+ self._revoke_internal(access_token_str=token.token)
323
+ elif isinstance(token, RefreshToken):
324
+ self._revoke_internal(refresh_token_str=token.token)
325
+ # 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
@@ -1,16 +1,13 @@
1
  from __future__ import annotations as _annotations
2
 
3
  import inspect
4
- from typing import TYPE_CHECKING, Annotated, Literal
 
5
 
6
- from mcp.server.auth.settings import AuthSettings
7
  from pydantic import Field, model_validator
8
  from pydantic_settings import BaseSettings, SettingsConfigDict
9
  from typing_extensions import Self
10
 
11
- if TYPE_CHECKING:
12
- pass
13
-
14
  LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
15
 
16
  DuplicateBehavior = Literal["warn", "error", "replace", "ignore"]
@@ -27,6 +24,8 @@ class Settings(BaseSettings):
27
  nested_model_default_partial_update=True,
28
  )
29
 
 
 
30
  test_mode: bool = False
31
  log_level: LOG_LEVEL = "INFO"
32
  enable_rich_tracebacks: Annotated[
@@ -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 = (
 
1
  from __future__ import annotations as _annotations
2
 
3
  import inspect
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
10
 
 
 
 
11
  LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
12
 
13
  DuplicateBehavior = Literal["warn", "error", "replace", "ignore"]
 
24
  nested_model_default_partial_update=True,
25
  )
26
 
27
+ home: Path = Path.home() / ".fastmcp"
28
+
29
  test_mode: bool = False
30
  log_level: LOG_LEVEL = "INFO"
31
  enable_rich_tracebacks: Annotated[
 
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")
tests/client/test_oauth.py ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ from collections.abc import Generator
3
+ from unittest.mock import patch
4
+ from urllib.parse import parse_qs, urlparse
5
+
6
+ import httpx
7
+ import pytest
8
+ import uvicorn
9
+
10
+ import fastmcp.client.auth # Import module, not the function directly
11
+ from fastmcp.client import Client
12
+ from fastmcp.client.transports import StreamableHttpTransport
13
+ from fastmcp.server.auth.auth import ClientRegistrationOptions
14
+ from fastmcp.server.auth.in_memory_provider import InMemoryOAuthProvider
15
+ from fastmcp.server.server import FastMCP
16
+ from fastmcp.utilities.tests import run_server_in_process
17
+
18
+
19
+ def fastmcp_server(issuer_url: str):
20
+ """Create a FastMCP server with OAuth authentication."""
21
+ server = FastMCP(
22
+ "TestServer",
23
+ auth=InMemoryOAuthProvider(
24
+ issuer_url=issuer_url,
25
+ client_registration_options=ClientRegistrationOptions(enabled=True),
26
+ ),
27
+ )
28
+
29
+ @server.tool()
30
+ def add(a: int, b: int) -> int:
31
+ """Add two numbers together."""
32
+ return a + b
33
+
34
+ @server.resource("resource://test")
35
+ def get_test_resource() -> str:
36
+ """Get a test resource."""
37
+ return "Hello from authenticated resource!"
38
+
39
+ return server
40
+
41
+
42
+ def run_server(host: str, port: int, transport: str | None = None) -> None:
43
+ try:
44
+ # Configure OAuth provider with the actual server URL
45
+ issuer_url = f"http://{host}:{port}"
46
+ app = fastmcp_server(issuer_url).http_app()
47
+ server = uvicorn.Server(
48
+ config=uvicorn.Config(
49
+ app=app,
50
+ host=host,
51
+ port=port,
52
+ log_level="error",
53
+ lifespan="on",
54
+ )
55
+ )
56
+ server.run()
57
+ except Exception as e:
58
+ print(f"Server error: {e}")
59
+ sys.exit(1)
60
+ sys.exit(0)
61
+
62
+
63
+ @pytest.fixture(scope="module")
64
+ def streamable_http_server() -> Generator[str, None, None]:
65
+ with run_server_in_process(run_server) as url:
66
+ yield f"{url}/mcp"
67
+
68
+
69
+ @pytest.fixture()
70
+ def client_unauthorized(streamable_http_server: str) -> Client:
71
+ return Client(transport=StreamableHttpTransport(streamable_http_server))
72
+
73
+
74
+ class HeadlessOAuthProvider(httpx.Auth):
75
+ """
76
+ OAuth provider that bypasses browser interaction for testing.
77
+
78
+ This simulates the complete OAuth flow programmatically by:
79
+ 1. Discovering OAuth metadata from the server
80
+ 2. Registering a client
81
+ 3. Getting an authorization code (simulates user approval)
82
+ 4. Exchanging it for an access token
83
+ 5. Adding Bearer token to all requests
84
+
85
+ This enables testing OAuth-protected FastMCP servers without
86
+ requiring browser interaction or external OAuth providers.
87
+ """
88
+
89
+ def __init__(self, mcp_url: str):
90
+ self.mcp_url = mcp_url
91
+ parsed_url = urlparse(mcp_url)
92
+ self.server_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
93
+ self._access_token = None
94
+
95
+ async def async_auth_flow(self, request):
96
+ """httpx.Auth interface - add Bearer token to requests."""
97
+ if not self._access_token:
98
+ await self._obtain_token()
99
+
100
+ if self._access_token:
101
+ request.headers["Authorization"] = f"Bearer {self._access_token}"
102
+
103
+ yield request
104
+
105
+ async def _obtain_token(self):
106
+ """Get a valid access token by simulating the OAuth flow."""
107
+ import base64
108
+ import hashlib
109
+ import secrets
110
+
111
+ from mcp.shared.auth import OAuthClientInformationFull
112
+ from pydantic import AnyHttpUrl
113
+
114
+ # Generate PKCE challenge/verifier
115
+ code_verifier = (
116
+ base64.urlsafe_b64encode(secrets.token_bytes(32)).decode().rstrip("=")
117
+ )
118
+ code_challenge = (
119
+ base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest())
120
+ .decode()
121
+ .rstrip("=")
122
+ )
123
+
124
+ # Create HTTP client to talk to the server
125
+ async with httpx.AsyncClient() as http_client:
126
+ # 1. Discover OAuth metadata
127
+ metadata_url = (
128
+ f"{self.server_base_url}/.well-known/oauth-authorization-server"
129
+ )
130
+ response = await http_client.get(metadata_url)
131
+ response.raise_for_status()
132
+ metadata = response.json()
133
+
134
+ # 2. Register a client
135
+ client_info = OAuthClientInformationFull(
136
+ client_id="test_client_headless",
137
+ client_secret="test_secret_headless",
138
+ redirect_uris=[AnyHttpUrl("http://localhost:8080/callback")],
139
+ )
140
+
141
+ register_response = await http_client.post(
142
+ metadata["registration_endpoint"],
143
+ json=client_info.model_dump(mode="json"),
144
+ )
145
+ register_response.raise_for_status()
146
+ registered_client = register_response.json()
147
+
148
+ # 3. Get authorization code (simulate user approval)
149
+ auth_params = {
150
+ "response_type": "code",
151
+ "client_id": registered_client["client_id"],
152
+ "redirect_uri": "http://localhost:8080/callback",
153
+ "code_challenge": code_challenge,
154
+ "code_challenge_method": "S256",
155
+ "state": "test_state_headless",
156
+ }
157
+
158
+ auth_response = await http_client.get(
159
+ metadata["authorization_endpoint"],
160
+ params=auth_params,
161
+ follow_redirects=False,
162
+ )
163
+
164
+ # Extract auth code from redirect
165
+ if auth_response.status_code == 302:
166
+ redirect_url = auth_response.headers["location"]
167
+ parsed = urlparse(redirect_url)
168
+ query_params = parse_qs(parsed.query)
169
+
170
+ if "error" in query_params:
171
+ error = query_params["error"][0]
172
+ error_desc = query_params.get(
173
+ "error_description", ["Unknown error"]
174
+ )[0]
175
+ raise RuntimeError(
176
+ f"OAuth authorization failed: {error} - {error_desc}"
177
+ )
178
+
179
+ auth_code = query_params["code"][0]
180
+
181
+ # 4. Exchange auth code for access token
182
+ token_data = {
183
+ "grant_type": "authorization_code",
184
+ "client_id": registered_client["client_id"],
185
+ "client_secret": registered_client["client_secret"],
186
+ "code": auth_code,
187
+ "redirect_uri": "http://localhost:8080/callback",
188
+ "code_verifier": code_verifier,
189
+ }
190
+
191
+ token_response = await http_client.post(
192
+ metadata["token_endpoint"], data=token_data
193
+ )
194
+ token_response.raise_for_status()
195
+ token_info = token_response.json()
196
+
197
+ self._access_token = token_info["access_token"]
198
+ else:
199
+ raise RuntimeError(f"Authorization failed: {auth_response.status_code}")
200
+
201
+
202
+ @pytest.fixture()
203
+ def client_with_headless_oauth(
204
+ streamable_http_server: str,
205
+ ) -> Generator[Client, None, None]:
206
+ """Client with headless OAuth that bypasses browser interaction."""
207
+
208
+ # Patch the OAuth function to return our headless provider
209
+ def headless_oauth(*args, **kwargs):
210
+ mcp_url = args[0] if args else kwargs.get("mcp_url", "")
211
+ if not mcp_url:
212
+ raise ValueError("mcp_url is required")
213
+ return HeadlessOAuthProvider(mcp_url)
214
+
215
+ with patch("fastmcp.client.auth.OAuth", side_effect=headless_oauth):
216
+ client = Client(
217
+ transport=StreamableHttpTransport(streamable_http_server),
218
+ auth=fastmcp.client.auth.OAuth(mcp_url=streamable_http_server),
219
+ )
220
+ yield client
221
+
222
+
223
+ async def test_unauthorized(client_unauthorized: Client):
224
+ """Test that unauthenticated requests are rejected."""
225
+ with pytest.raises(httpx.HTTPStatusError, match="401 Unauthorized"):
226
+ async with client_unauthorized:
227
+ pass
228
+
229
+
230
+ async def test_ping(client_with_headless_oauth: Client):
231
+ """Test that we can ping the server."""
232
+ async with client_with_headless_oauth:
233
+ assert await client_with_headless_oauth.ping()
234
+
235
+
236
+ async def test_list_tools(client_with_headless_oauth: Client):
237
+ """Test that we can list tools."""
238
+ async with client_with_headless_oauth:
239
+ tools = await client_with_headless_oauth.list_tools()
240
+ tool_names = [tool.name for tool in tools]
241
+ assert "add" in tool_names
242
+
243
+
244
+ async def test_call_tool(client_with_headless_oauth: Client):
245
+ """Test that we can call a tool."""
246
+ async with client_with_headless_oauth:
247
+ result = await client_with_headless_oauth.call_tool("add", {"a": 5, "b": 3})
248
+ assert result[0].text == "8" # type: ignore[attr-defined]
249
+
250
+
251
+ async def test_list_resources(client_with_headless_oauth: Client):
252
+ """Test that we can list resources."""
253
+ async with client_with_headless_oauth:
254
+ resources = await client_with_headless_oauth.list_resources()
255
+ resource_uris = [str(resource.uri) for resource in resources]
256
+ assert "resource://test" in resource_uris
257
+
258
+
259
+ async def test_read_resource(client_with_headless_oauth: Client):
260
+ """Test that we can read a resource."""
261
+ async with client_with_headless_oauth:
262
+ resource = await client_with_headless_oauth.read_resource("resource://test")
263
+ assert resource[0].text == "Hello from authenticated resource!" # type: ignore[attr-defined]
264
+
265
+
266
+ async def test_oauth_server_metadata_discovery(streamable_http_server: str):
267
+ """Test that we can discover OAuth metadata from the running server."""
268
+ parsed_url = urlparse(streamable_http_server)
269
+ server_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
270
+
271
+ async with httpx.AsyncClient() as client:
272
+ # Test OAuth discovery endpoint
273
+ metadata_url = f"{server_base_url}/.well-known/oauth-authorization-server"
274
+ response = await client.get(metadata_url)
275
+ assert response.status_code == 200
276
+
277
+ metadata = response.json()
278
+ assert "authorization_endpoint" in metadata
279
+ assert "token_endpoint" in metadata
280
+ assert "registration_endpoint" in metadata
281
+
282
+ # The endpoints should be properly formed URLs
283
+ assert metadata["authorization_endpoint"].startswith(server_base_url)
284
+ assert metadata["token_endpoint"].startswith(server_base_url)
uv.lock CHANGED
The diff for this file is too large to render. See raw diff