Spaces:
Running
Running
Jeremiah Lowin commited on
Commit ·
73ff78e
1
Parent(s): 7befcc3
Clean up
Browse files
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 |
-
|
| 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(
|
| 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 |
-
|
| 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 |
-
|
| 313 |
-
"http://host/mcp/sse")
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
|
|
|
| 318 |
|
| 319 |
Returns:
|
| 320 |
OAuthClientProvider
|
| 321 |
"""
|
| 322 |
-
parsed_url = urlparse(
|
| 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"
|
| 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(
|
| 375 |
auth_code, state = await response_future
|
| 376 |
return auth_code, state
|
| 377 |
except TimeoutError:
|
| 378 |
-
raise TimeoutError("OAuth callback timed out after
|
| 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 |
-
|
| 210 |
-
state = query_params.get("state")
|
| 211 |
-
error = query_params.get("error")
|
| 212 |
|
| 213 |
-
if error:
|
| 214 |
-
error_desc =
|
| 215 |
|
| 216 |
# Resolve future with exception if provided
|
| 217 |
if response_future and not response_future.done():
|
| 218 |
response_future.set_exception(
|
| 219 |
-
RuntimeError(
|
|
|
|
|
|
|
| 220 |
)
|
| 221 |
|
| 222 |
return HTMLResponse(
|
| 223 |
create_callback_html(
|
| 224 |
-
f"FastMCP OAuth Error: {error}<br>{error_desc}",
|
|
|
|
| 225 |
),
|
| 226 |
status_code=400,
|
| 227 |
)
|
| 228 |
|
| 229 |
-
if not
|
| 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(
|
|
|
|
|
|
|
| 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 |
-
|
| 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):
|