Spaces:
Running
Running
Merge pull request #478 from jlowin/oauthclient
Browse files- pyproject.toml +2 -1
- src/fastmcp/client/__init__.py +2 -0
- src/fastmcp/client/auth.py +394 -0
- src/fastmcp/client/base.py +0 -0
- src/fastmcp/client/client.py +6 -1
- src/fastmcp/client/oauth_callback.py +317 -0
- src/fastmcp/client/transports.py +67 -4
- src/fastmcp/low_level/README.md +0 -1
- src/fastmcp/py.typed +0 -0
- src/fastmcp/{low_level → server/auth}/__init__.py +0 -0
- src/fastmcp/server/auth/auth.py +38 -0
- src/fastmcp/server/auth/in_memory_provider.py +325 -0
- src/fastmcp/server/http.py +38 -66
- src/fastmcp/server/server.py +6 -16
- src/fastmcp/settings.py +4 -7
- src/fastmcp/utilities/tests.py +5 -2
- tests/client/test_oauth.py +284 -0
- uv.lock +0 -0
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.
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
|
|
|
|
|
|
| 102 |
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
#
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
|
|
|
| 338 |
|
| 339 |
-
|
| 340 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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
|
|
|
|
| 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 =
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|