Jeremiah Lowin commited on
Commit
73ff78e
·
1 Parent(s): 7befcc3
src/fastmcp/client/auth.py CHANGED
@@ -186,7 +186,7 @@ class FileTokenStorage(TokenStorage):
186
  async def set_tokens(self, tokens: _MCPOAuthToken) -> None:
187
  """Save tokens to file storage."""
188
  # Convert to custom model with expiration datetime
189
- tokens = OAuthToken.model_validate(tokens)
190
  path = self._get_file_path("tokens")
191
  path.write_text(tokens.model_dump_json(indent=2))
192
  logger.debug(f"Saved tokens for {self.get_base_url(self.server_url)}")
@@ -266,7 +266,7 @@ async def discover_oauth_metadata(
266
 
267
 
268
  async def check_if_auth_required(
269
- mcp_endpoint_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.
@@ -277,7 +277,7 @@ async def check_if_auth_required(
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_endpoint_url, timeout=5.0)
281
 
282
  # If we get 401/403, auth is likely required
283
  if response.status_code in (401, 403):
@@ -296,7 +296,7 @@ async def check_if_auth_required(
296
 
297
 
298
  def OAuth(
299
- mcp_endpoint_url: str,
300
  scopes: str | list[str] | None = None,
301
  client_name: str = "FastMCP Client",
302
  token_storage_cache_dir: Path | None = None,
@@ -309,17 +309,18 @@ def OAuth(
309
  httpx.AsyncClient (or appropriate FastMCP client/transport instance)
310
 
311
  Args:
312
- mcp_endpoint_url: Full URL to the MCP endpoint (e.g.,
313
- "http://host/mcp/sse") scopes: OAuth scopes to request. Can be a
314
- space-separated string or a list of strings. client_name: Name for this
315
- client during registration token_storage_cache_dir: Directory for
316
- FileTokenStorage additional_client_metadata: Extra fields for
317
- OAuthClientMetadata
 
318
 
319
  Returns:
320
  OAuthClientProvider
321
  """
322
- parsed_url = urlparse(mcp_endpoint_url)
323
  server_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
324
 
325
  # Setup OAuth client
@@ -347,7 +348,7 @@ def OAuth(
347
  # Define OAuth handlers
348
  async def redirect_handler(authorization_url: str) -> None:
349
  """Open browser for authorization."""
350
- logger.info(f"Opening browser for OAuth authorization: {authorization_url}")
351
  webbrowser.open(authorization_url)
352
 
353
  async def callback_handler() -> tuple[str, str | None]:
@@ -363,19 +364,19 @@ def OAuth(
363
  )
364
 
365
  # Run server until response is received with timeout logic
366
-
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
  try:
374
- with anyio.fail_after(300.0): # 5 minute timeout
375
  auth_code, state = await response_future
376
  return auth_code, state
377
  except TimeoutError:
378
- raise TimeoutError("OAuth callback timed out after 300 seconds")
379
  finally:
380
  server.should_exit = True
381
  await asyncio.sleep(0.1) # Allow server to shutdown gracefully
 
186
  async def set_tokens(self, tokens: _MCPOAuthToken) -> None:
187
  """Save tokens to file storage."""
188
  # Convert to custom model with expiration datetime
189
+ tokens = OAuthToken.model_validate(tokens.model_dump())
190
  path = self._get_file_path("tokens")
191
  path.write_text(tokens.model_dump_json(indent=2))
192
  logger.debug(f"Saved tokens for {self.get_base_url(self.server_url)}")
 
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.
 
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):
 
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,
 
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
 
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]:
 
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
src/fastmcp/client/oauth_callback.py CHANGED
@@ -9,8 +9,10 @@ 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
@@ -184,6 +186,21 @@ def find_available_port() -> int:
184
  return s.getsockname()[1]
185
 
186
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  def create_oauth_callback_server(
188
  port: int,
189
  callback_path: str = "/callback",
@@ -203,30 +220,31 @@ def create_oauth_callback_server(
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"FastMCP 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(
@@ -243,7 +261,9 @@ def create_oauth_callback_server(
243
 
244
  # Success case
245
  if response_future and not response_future.done():
246
- response_future.set_result((auth_code, state))
 
 
247
 
248
  return HTMLResponse(
249
  create_callback_html("FastMCP OAuth login complete!", server_url=server_url)
 
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
 
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",
 
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(
 
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)
src/fastmcp/server/auth/in_memory_provider.py CHANGED
@@ -25,8 +25,7 @@ from fastmcp.server.auth.auth import (
25
  # Default expiration times (in seconds)
26
  DEFAULT_AUTH_CODE_EXPIRY_SECONDS = 5 * 60 # 5 minutes
27
  DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS = 60 * 60 # 1 hour
28
- # Refresh tokens often have longer or no expiry; let's make them non-expiring for simplicity
29
- DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS = None
30
 
31
 
32
  class InMemoryOAuthProvider(OAuthProvider):
 
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):