Jeremiah Lowin commited on
Commit
f16268d
·
1 Parent(s): 7e09c06

Add callback server

Browse files
src/fastmcp/client/auth.py CHANGED
@@ -2,13 +2,11 @@ from __future__ import annotations
2
 
3
  import asyncio
4
  import json
5
- import socket
6
  import webbrowser
7
  from pathlib import Path
8
- from typing import Any, Literal, cast
9
  from urllib.parse import urljoin, urlparse
10
 
11
- import anyio
12
  import httpx
13
  from mcp.client.auth import OAuthClientProvider as _MCPOAuthClientProvider
14
  from mcp.client.auth import TokenStorage
@@ -21,11 +19,11 @@ from mcp.shared.auth import (
21
  OAuthMetadata as _MCPServerOAuthMetadata,
22
  )
23
  from pydantic import AnyHttpUrl, ValidationError
24
- from starlette.applications import Starlette
25
- from starlette.responses import PlainTextResponse
26
- from starlette.routing import Route
27
- from uvicorn import Config, Server
28
 
 
 
 
 
29
  from fastmcp.settings import settings as fastmcp_global_settings
30
  from fastmcp.utilities.logging import get_logger
31
 
@@ -186,11 +184,8 @@ class FileTokenStorage(TokenStorage):
186
 
187
  def clear_cache(self) -> None:
188
  """Clear all cached data for this server."""
189
- # Use explicit literals to satisfy type checker
190
- for file_type in [
191
- cast(Literal["client_info", "tokens"], "client_info"),
192
- cast(Literal["client_info", "tokens"], "tokens"),
193
- ]:
194
  path = self._get_file_path(file_type)
195
  path.unlink(missing_ok=True)
196
  logger.info(f"Cleared OAuth cache for {self.get_base_url(self.server_url)}")
@@ -253,95 +248,13 @@ class FileTokenStorage(TokenStorage):
253
  if not cache_dir.exists():
254
  return
255
 
256
- # Use explicit literals to satisfy type checker
257
- for file_type in [
258
- cast(Literal["client_info", "tokens"], "client_info"),
259
- cast(Literal["client_info", "tokens"], "tokens"),
260
- ]:
261
  for file in cache_dir.glob(f"*_{file_type}.json"):
262
  file.unlink(missing_ok=True)
263
  logger.info("Cleared all OAuth client cache data.")
264
 
265
 
266
- def find_available_port() -> int:
267
- """Find an available port by letting the OS assign one."""
268
- with socket.socket() as s:
269
- s.bind(("127.0.0.1", 0))
270
- return s.getsockname()[1]
271
-
272
-
273
- async def _get_redirect_callback(
274
- port: int, path: str = "/callback", timeout: float = 300.0
275
- ) -> tuple[str, str | None]:
276
- """
277
- Start a temporary server to handle OAuth redirect and return auth code and state.
278
-
279
- Returns:
280
- Tuple of (authorization_code, state)
281
- """
282
- response_future = asyncio.get_running_loop().create_future()
283
-
284
- async def callback_handler(request):
285
- if not response_future.done():
286
- query_params = dict(request.query_params)
287
- auth_code = query_params.get("code")
288
- state = query_params.get("state")
289
- error = query_params.get("error")
290
-
291
- if error:
292
- error_desc = query_params.get("error_description", "Unknown error")
293
- response_future.set_exception(
294
- RuntimeError(f"OAuth error: {error} - {error_desc}")
295
- )
296
- return PlainTextResponse(
297
- f"❌ OAuth Error: {error}\n{error_desc}\nYou can close this tab.",
298
- status_code=400,
299
- )
300
-
301
- if not auth_code:
302
- response_future.set_exception(
303
- RuntimeError("OAuth callback missing authorization code")
304
- )
305
- return PlainTextResponse(
306
- "❌ OAuth Error: No authorization code received.\nYou can close this tab.",
307
- status_code=400,
308
- )
309
-
310
- response_future.set_result((auth_code, state))
311
- return PlainTextResponse(
312
- "✅ FastMCP OAuth login complete!\nYou can close this tab now."
313
- )
314
-
315
- return PlainTextResponse("Callback already processed. You can close this tab.")
316
-
317
- server = Server(
318
- Config(
319
- app=Starlette(routes=[Route(path, callback_handler)]),
320
- host="127.0.0.1",
321
- port=port,
322
- lifespan="off",
323
- log_level="warning",
324
- )
325
- )
326
-
327
- async with anyio.create_task_group() as tg:
328
- tg.start_soon(server.serve)
329
- logger.info(
330
- f"🎧 OAuth callback server started on http://127.0.0.1:{port}{path}"
331
- )
332
-
333
- try:
334
- with anyio.fail_after(timeout):
335
- auth_code, state = await response_future
336
- return auth_code, state
337
- except TimeoutError:
338
- raise TimeoutError(f"OAuth callback timed out after {timeout} seconds")
339
- finally:
340
- server.should_exit = True
341
- await asyncio.sleep(0.1) # Allow server to shutdown gracefully
342
- tg.cancel_scope.cancel()
343
-
344
-
345
  async def discover_oauth_metadata(
346
  server_base_url: str, httpx_kwargs: dict[str, Any] | None = None
347
  ) -> _MCPServerOAuthMetadata | None:
@@ -417,12 +330,16 @@ def OAuth(
417
  """
418
  Create an OAuthClientProvider for an MCP server.
419
 
 
 
 
420
  Args:
421
- mcp_endpoint_url: Full URL to the MCP endpoint (e.g., "http://host/mcp/sse")
422
- scopes: OAuth scopes to request. Can be a space-separated string or a list of strings.
423
- client_name: Name for this client during registration
424
- token_storage_cache_dir: Directory for FileTokenStorage
425
- additional_client_metadata: Extra fields for OAuthClientMetadata
 
426
 
427
  Returns:
428
  OAuthClientProvider
@@ -460,7 +377,35 @@ def OAuth(
460
 
461
  async def callback_handler() -> tuple[str, str | None]:
462
  """Handle OAuth callback and return (auth_code, state)."""
463
- return await _get_redirect_callback(port=redirect_port)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
464
 
465
  # Create OAuth provider
466
  oauth_provider = OAuthClientProvider(
 
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 httpx
11
  from mcp.client.auth import OAuthClientProvider as _MCPOAuthClientProvider
12
  from mcp.client.auth import TokenStorage
 
19
  OAuthMetadata as _MCPServerOAuthMetadata,
20
  )
21
  from pydantic import AnyHttpUrl, ValidationError
 
 
 
 
22
 
23
+ from fastmcp.client.oauth_callback import (
24
+ create_oauth_callback_server,
25
+ find_available_port,
26
+ )
27
  from fastmcp.settings import settings as fastmcp_global_settings
28
  from fastmcp.utilities.logging import get_logger
29
 
 
184
 
185
  def clear_cache(self) -> None:
186
  """Clear all cached data for this server."""
187
+ file_types: list[Literal["client_info", "tokens"]] = ["client_info", "tokens"]
188
+ for file_type in file_types:
 
 
 
189
  path = self._get_file_path(file_type)
190
  path.unlink(missing_ok=True)
191
  logger.info(f"Cleared OAuth cache for {self.get_base_url(self.server_url)}")
 
248
  if not cache_dir.exists():
249
  return
250
 
251
+ file_types: list[Literal["client_info", "tokens"]] = ["client_info", "tokens"]
252
+ for file_type in file_types:
 
 
 
253
  for file in cache_dir.glob(f"*_{file_type}.json"):
254
  file.unlink(missing_ok=True)
255
  logger.info("Cleared all OAuth client cache data.")
256
 
257
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
  async def discover_oauth_metadata(
259
  server_base_url: str, httpx_kwargs: dict[str, Any] | None = None
260
  ) -> _MCPServerOAuthMetadata | None:
 
330
  """
331
  Create an OAuthClientProvider for an MCP server.
332
 
333
+ This is intended to be provided to the `auth` parameter of an
334
+ httpx.AsyncClient (or appropriate FastMCP client/transport instance)
335
+
336
  Args:
337
+ mcp_endpoint_url: Full URL to the MCP endpoint (e.g.,
338
+ "http://host/mcp/sse") scopes: OAuth scopes to request. Can be a
339
+ space-separated string or a list of strings. client_name: Name for this
340
+ client during registration token_storage_cache_dir: Directory for
341
+ FileTokenStorage additional_client_metadata: Extra fields for
342
+ OAuthClientMetadata
343
 
344
  Returns:
345
  OAuthClientProvider
 
377
 
378
  async def callback_handler() -> tuple[str, str | None]:
379
  """Handle OAuth callback and return (auth_code, state)."""
380
+ # Create a future to capture the OAuth response
381
+ response_future = asyncio.get_running_loop().create_future()
382
+
383
+ # Create server with the future
384
+ server = create_oauth_callback_server(
385
+ port=redirect_port,
386
+ server_url=server_base_url,
387
+ response_future=response_future,
388
+ )
389
+
390
+ # Run server until response is received with timeout logic
391
+ import anyio
392
+
393
+ async with anyio.create_task_group() as tg:
394
+ tg.start_soon(server.serve)
395
+ logger.info(
396
+ f"🎧 OAuth callback server started on http://127.0.0.1:{redirect_port}"
397
+ )
398
+
399
+ try:
400
+ with anyio.fail_after(300.0): # 5 minute timeout
401
+ auth_code, state = await response_future
402
+ return auth_code, state
403
+ except TimeoutError:
404
+ raise TimeoutError("OAuth callback timed out after 300 seconds")
405
+ finally:
406
+ server.should_exit = True
407
+ await asyncio.sleep(0.1) # Allow server to shutdown gracefully
408
+ tg.cancel_scope.cancel()
409
 
410
  # Create OAuth provider
411
  oauth_provider = OAuthClientProvider(
src/fastmcp/client/oauth_callback.py ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
13
+ from starlette.applications import Starlette
14
+ from starlette.responses import HTMLResponse
15
+ from starlette.routing import Route
16
+ from uvicorn import Config, Server
17
+
18
+ from fastmcp.utilities.logging import get_logger
19
+
20
+ logger = get_logger(__name__)
21
+
22
+
23
+ def create_callback_html(
24
+ message: str,
25
+ is_success: bool = True,
26
+ title: str = "FastMCP OAuth",
27
+ server_url: str | None = None,
28
+ ) -> str:
29
+ """Create a styled HTML response for OAuth callbacks."""
30
+ status_emoji = "✅" if is_success else "❌"
31
+ status_color = "#10b981" if is_success else "#ef4444" # emerald-500 / red-500
32
+
33
+ # Add server info for success cases
34
+ server_info = ""
35
+ if is_success and server_url:
36
+ server_info = f"""
37
+ <div class="server-info">
38
+ Connected to: <strong>{server_url}</strong>
39
+ </div>
40
+ """
41
+
42
+ return f"""
43
+ <!DOCTYPE html>
44
+ <html lang="en">
45
+ <head>
46
+ <meta charset="UTF-8">
47
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
48
+ <title>{title}</title>
49
+ <style>
50
+ body {{
51
+ font-family: 'SF Mono', 'Monaco', 'Consolas', 'Roboto Mono', monospace;
52
+ margin: 0;
53
+ padding: 0;
54
+ min-height: 100vh;
55
+ display: flex;
56
+ align-items: center;
57
+ justify-content: center;
58
+ background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 25%, #16213e 50%, #0f0f23 100%);
59
+ color: #e2e8f0;
60
+ overflow: hidden;
61
+ }}
62
+
63
+ body::before {{
64
+ content: '';
65
+ position: fixed;
66
+ top: 0;
67
+ left: 0;
68
+ width: 100%;
69
+ height: 100%;
70
+ background:
71
+ radial-gradient(circle at 20% 80%, rgba(120, 119, 198, 0.1) 0%, transparent 50%),
72
+ radial-gradient(circle at 80% 20%, rgba(16, 185, 129, 0.1) 0%, transparent 50%),
73
+ radial-gradient(circle at 40% 40%, rgba(14, 165, 233, 0.1) 0%, transparent 50%);
74
+ pointer-events: none;
75
+ z-index: -1;
76
+ }}
77
+
78
+ .container {{
79
+ background: rgba(30, 41, 59, 0.9);
80
+ backdrop-filter: blur(10px);
81
+ border: 1px solid rgba(71, 85, 105, 0.3);
82
+ padding: 3rem 2rem;
83
+ border-radius: 1rem;
84
+ box-shadow:
85
+ 0 25px 50px -12px rgba(0, 0, 0, 0.7),
86
+ 0 0 0 1px rgba(255, 255, 255, 0.05),
87
+ inset 0 1px 0 0 rgba(255, 255, 255, 0.1);
88
+ text-align: center;
89
+ max-width: 500px;
90
+ margin: 1rem;
91
+ position: relative;
92
+ }}
93
+
94
+ .container::before {{
95
+ content: '';
96
+ position: absolute;
97
+ top: 0;
98
+ left: 0;
99
+ right: 0;
100
+ height: 1px;
101
+ background: linear-gradient(90deg, transparent, rgba(16, 185, 129, 0.5), transparent);
102
+ }}
103
+
104
+ .status-icon {{
105
+ font-size: 4rem;
106
+ margin-bottom: 1rem;
107
+ display: block;
108
+ filter: drop-shadow(0 0 20px currentColor);
109
+ }}
110
+
111
+ .message {{
112
+ font-size: 1.25rem;
113
+ line-height: 1.6;
114
+ color: {status_color};
115
+ margin-bottom: 1.5rem;
116
+ font-weight: 600;
117
+ text-shadow: 0 0 10px rgba({
118
+ "16, 185, 129" if is_success else "239, 68, 68"
119
+ }, 0.3);
120
+ }}
121
+
122
+ .server-info {{
123
+ background: rgba(6, 182, 212, 0.1);
124
+ border: 1px solid rgba(6, 182, 212, 0.3);
125
+ border-radius: 0.75rem;
126
+ padding: 1rem;
127
+ margin: 1rem 0;
128
+ font-size: 0.9rem;
129
+ color: #67e8f9;
130
+ font-family: 'SF Mono', 'Monaco', 'Consolas', 'Roboto Mono', monospace;
131
+ text-shadow: 0 0 10px rgba(103, 232, 249, 0.3);
132
+ }}
133
+
134
+ .server-info strong {{
135
+ color: #22d3ee;
136
+ font-weight: 700;
137
+ }}
138
+
139
+ .subtitle {{
140
+ font-size: 1rem;
141
+ color: #94a3b8;
142
+ margin-top: 1rem;
143
+ }}
144
+
145
+ .close-instruction {{
146
+ background: rgba(51, 65, 85, 0.8);
147
+ border: 1px solid rgba(71, 85, 105, 0.4);
148
+ border-radius: 0.75rem;
149
+ padding: 1rem;
150
+ margin-top: 1.5rem;
151
+ font-size: 0.9rem;
152
+ color: #cbd5e1;
153
+ font-family: 'SF Mono', 'Monaco', 'Consolas', 'Roboto Mono', monospace;
154
+ }}
155
+
156
+ @keyframes glow {{
157
+ 0%, 100% {{ opacity: 1; }}
158
+ 50% {{ opacity: 0.7; }}
159
+ }}
160
+
161
+ .status-icon {{
162
+ animation: glow 2s ease-in-out infinite;
163
+ }}
164
+ </style>
165
+ </head>
166
+ <body>
167
+ <div class="container">
168
+ <span class="status-icon">{status_emoji}</span>
169
+ <div class="message">{message}</div>
170
+ {server_info}
171
+ <div class="close-instruction">
172
+ You can safely close this tab now.
173
+ </div>
174
+ </div>
175
+ </body>
176
+ </html>
177
+ """
178
+
179
+
180
+ def find_available_port() -> int:
181
+ """Find an available port by letting the OS assign one."""
182
+ with socket.socket() as s:
183
+ s.bind(("127.0.0.1", 0))
184
+ return s.getsockname()[1]
185
+
186
+
187
+ def create_oauth_callback_server(
188
+ port: int,
189
+ callback_path: str = "/callback",
190
+ server_url: str | None = None,
191
+ response_future: asyncio.Future | None = None,
192
+ ) -> Server:
193
+ """
194
+ Create an OAuth callback server.
195
+
196
+ Args:
197
+ port: The port to run the server on
198
+ callback_path: The path to listen for OAuth redirects on
199
+ server_url: Optional server URL to display in success messages
200
+ response_future: Optional future to resolve when OAuth callback is received
201
+
202
+ Returns:
203
+ Configured uvicorn Server instance (not yet running)
204
+ """
205
+
206
+ async def callback_handler(request):
207
+ """Handle OAuth callback requests with proper HTML responses."""
208
+ query_params = dict(request.query_params)
209
+ auth_code = query_params.get("code")
210
+ state = query_params.get("state")
211
+ error = query_params.get("error")
212
+
213
+ if error:
214
+ error_desc = query_params.get("error_description", "Unknown error")
215
+
216
+ # Resolve future with exception if provided
217
+ if response_future and not response_future.done():
218
+ response_future.set_exception(
219
+ RuntimeError(f"OAuth error: {error} - {error_desc}")
220
+ )
221
+
222
+ return HTMLResponse(
223
+ create_callback_html(
224
+ f"OAuth Error: {error}<br>{error_desc}", is_success=False
225
+ ),
226
+ status_code=400,
227
+ )
228
+
229
+ if not auth_code:
230
+ # Resolve future with exception if provided
231
+ if response_future and not response_future.done():
232
+ response_future.set_exception(
233
+ RuntimeError("OAuth callback missing authorization code")
234
+ )
235
+
236
+ return HTMLResponse(
237
+ create_callback_html(
238
+ "OAuth Error: No authorization code received", is_success=False
239
+ ),
240
+ status_code=400,
241
+ )
242
+
243
+ # Success case
244
+ if response_future and not response_future.done():
245
+ response_future.set_result((auth_code, state))
246
+
247
+ return HTMLResponse(
248
+ create_callback_html("OAuth login complete!", server_url=server_url)
249
+ )
250
+
251
+ app = Starlette(routes=[Route(callback_path, callback_handler)])
252
+
253
+ return Server(
254
+ Config(
255
+ app=app,
256
+ host="127.0.0.1",
257
+ port=port,
258
+ lifespan="off",
259
+ log_level="warning",
260
+ )
261
+ )
262
+
263
+
264
+ if __name__ == "__main__":
265
+ """Run a test server when executed directly."""
266
+ import webbrowser
267
+
268
+ import uvicorn
269
+
270
+ port = find_available_port()
271
+ print("🎭 OAuth Callback Test Server")
272
+ print("📍 Test URLs:")
273
+ print(f" Success: http://localhost:{port}/callback?code=test123&state=xyz")
274
+ print(
275
+ f" Error: http://localhost:{port}/callback?error=access_denied&error_description=User%20denied"
276
+ )
277
+ print(f" Missing: http://localhost:{port}/callback")
278
+ print("🛑 Press Ctrl+C to stop")
279
+ print()
280
+
281
+ # Create test server without future (just for testing HTML responses)
282
+ server = create_oauth_callback_server(
283
+ port=port, server_url="https://fastmcp-test-server.example.com"
284
+ )
285
+
286
+ # Open browser to success example
287
+ webbrowser.open(f"http://localhost:{port}/callback?code=test123&state=xyz")
288
+
289
+ # Run with uvicorn directly
290
+ uvicorn.run(
291
+ server.config.app,
292
+ host="127.0.0.1",
293
+ port=port,
294
+ log_level="warning",
295
+ access_log=False,
296
+ )