Spaces:
Running
Running
Merge branch 'main' into feature/exclude-args-support
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- docs/servers/fastmcp.mdx +1 -0
- pyproject.toml +4 -1
- src/fastmcp/client/__init__.py +2 -0
- src/fastmcp/client/auth.py +403 -0
- src/fastmcp/client/client.py +74 -26
- src/fastmcp/client/oauth_callback.py +310 -0
- src/fastmcp/client/transports.py +76 -14
- src/fastmcp/low_level/README.md +0 -1
- src/fastmcp/{low_level → server/auth}/__init__.py +0 -0
- src/fastmcp/server/auth/auth.py +45 -0
- src/fastmcp/{client/base.py → server/auth/providers/__init__.py} +0 -0
- src/fastmcp/server/auth/providers/bearer.py +377 -0
- src/fastmcp/server/auth/providers/bearer_env.py +62 -0
- src/fastmcp/server/auth/providers/in_memory.py +330 -0
- src/fastmcp/server/dependencies.py +10 -0
- src/fastmcp/server/http.py +38 -66
- src/fastmcp/server/server.py +17 -16
- src/fastmcp/settings.py +27 -8
- src/fastmcp/utilities/http.py +8 -0
- src/fastmcp/utilities/tests.py +22 -10
- src/fastmcp/py.typed → tests/auth/__init__.py +0 -0
- tests/auth/providers/test_bearer.py +635 -0
- tests/auth/providers/test_bearer_env.py +82 -0
- tests/auth/test_oauth_client.py +265 -0
- tests/cli/__init__.py +0 -0
- tests/client/__init__.py +0 -1
- tests/client/test_client.py +54 -14
- tests/client/test_openapi.py +26 -79
- tests/client/test_roots.py +1 -3
- tests/client/test_sse.py +12 -28
- tests/client/test_stdio.py +9 -19
- tests/client/test_streamable_http.py +23 -45
- tests/prompts/test_prompt_manager.py +2 -4
- tests/resources/test_file_resources.py +0 -1
- tests/server/http/__init__.py +0 -0
- tests/server/http/test_http_dependencies.py +10 -53
- tests/server/openapi/__init__.py +0 -0
- tests/server/openapi/test_openapi.py +16 -38
- tests/server/test_import_server.py +6 -14
- tests/server/test_lifespan.py +0 -396
- tests/server/test_logging.py +4 -3
- tests/server/test_mount.py +12 -26
- tests/server/test_proxy.py +31 -17
- tests/server/test_server.py +56 -78
- tests/server/test_server_interactions.py +59 -111
- tests/server/test_tool_annotations.py +3 -4
- tests/test_examples.py +8 -26
- tests/tools/test_tool.py +14 -32
- tests/tools/test_tool_manager.py +21 -90
- tests/utilities/test_mcp_config.py +2 -6
docs/servers/fastmcp.mdx
CHANGED
|
@@ -35,6 +35,7 @@ The `FastMCP` constructor accepts several arguments:
|
|
| 35 |
* `instructions`: (Optional) Description of how to interact with this server. These instructions help clients understand the server's purpose and available functionality.
|
| 36 |
* `lifespan`: (Optional) An async context manager function for server startup and shutdown logic.
|
| 37 |
* `tags`: (Optional) A set of strings to tag the server itself.
|
|
|
|
| 38 |
* `**settings`: Keyword arguments corresponding to additional `ServerSettings` configuration
|
| 39 |
|
| 40 |
## Components
|
|
|
|
| 35 |
* `instructions`: (Optional) Description of how to interact with this server. These instructions help clients understand the server's purpose and available functionality.
|
| 36 |
* `lifespan`: (Optional) An async context manager function for server startup and shutdown logic.
|
| 37 |
* `tags`: (Optional) A set of strings to tag the server itself.
|
| 38 |
+
* `tools`: (Optional) A list of tools (or functions to convert to tools) to add to the server. In some cases, providing tools programmatically may be more convenient than using the `@mcp.tool` decorator.
|
| 39 |
* `**settings`: Keyword arguments corresponding to additional `ServerSettings` configuration
|
| 40 |
|
| 41 |
## Components
|
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"
|
|
@@ -44,12 +45,14 @@ dev = [
|
|
| 44 |
"ipython>=8.12.3",
|
| 45 |
"pdbpp>=0.10.3",
|
| 46 |
"pre-commit",
|
|
|
|
| 47 |
"pyright>=1.1.389",
|
| 48 |
"pytest>=8.3.3",
|
| 49 |
"pytest-asyncio>=0.23.5",
|
| 50 |
"pytest-cov>=6.1.1",
|
| 51 |
"pytest-env>=1.1.5",
|
| 52 |
"pytest-flakefinder",
|
|
|
|
| 53 |
"pytest-report>=0.2.1",
|
| 54 |
"pytest-timeout>=2.4.0",
|
| 55 |
"pytest-xdist>=3.6.1",
|
|
|
|
| 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"
|
|
|
|
| 45 |
"ipython>=8.12.3",
|
| 46 |
"pdbpp>=0.10.3",
|
| 47 |
"pre-commit",
|
| 48 |
+
"pyinstrument>=5.0.2",
|
| 49 |
"pyright>=1.1.389",
|
| 50 |
"pytest>=8.3.3",
|
| 51 |
"pytest-asyncio>=0.23.5",
|
| 52 |
"pytest-cov>=6.1.1",
|
| 53 |
"pytest-env>=1.1.5",
|
| 54 |
"pytest-flakefinder",
|
| 55 |
+
"pytest-httpx>=0.35.0",
|
| 56 |
"pytest-report>=0.2.1",
|
| 57 |
"pytest-timeout>=2.4.0",
|
| 58 |
"pytest-xdist>=3.6.1",
|
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,403 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
)
|
| 29 |
+
from fastmcp.settings import settings as fastmcp_global_settings
|
| 30 |
+
from fastmcp.utilities.http import find_available_port
|
| 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
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
class BearerAuth(httpx.Auth):
|
| 398 |
+
def __init__(self, token: str):
|
| 399 |
+
self.token = token
|
| 400 |
+
|
| 401 |
+
def auth_flow(self, request):
|
| 402 |
+
request.headers["Authorization"] = f"Bearer {self.token}"
|
| 403 |
+
yield request
|
src/fastmcp/client/client.py
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
|
|
| 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 +45,7 @@ from .transports import (
|
|
| 43 |
|
| 44 |
__all__ = [
|
| 45 |
"Client",
|
|
|
|
| 46 |
"RootsHandler",
|
| 47 |
"RootsList",
|
| 48 |
"LogHandler",
|
|
@@ -142,11 +145,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 |
-
|
| 148 |
-
|
| 149 |
-
self._nesting_counter: int = 0
|
| 150 |
self._initialize_result: mcp.types.InitializeResult | None = None
|
| 151 |
|
| 152 |
if log_handler is None:
|
|
@@ -187,6 +190,15 @@ class Client(Generic[ClientTransportT]):
|
|
| 187 |
sampling_handler
|
| 188 |
)
|
| 189 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
@property
|
| 191 |
def session(self) -> ClientSession:
|
| 192 |
"""Get the current active session. Raises RuntimeError if not connected."""
|
|
@@ -237,39 +249,75 @@ class Client(Generic[ClientTransportT]):
|
|
| 237 |
except TimeoutError:
|
| 238 |
raise RuntimeError("Failed to initialize server session")
|
| 239 |
finally:
|
| 240 |
-
self._exit_stack = None
|
| 241 |
self._session = None
|
| 242 |
self._initialize_result = None
|
| 243 |
|
| 244 |
async def __aenter__(self):
|
| 245 |
-
|
| 246 |
-
# Create exit stack to manage both context managers
|
| 247 |
-
stack = AsyncExitStack()
|
| 248 |
-
await stack.__aenter__()
|
| 249 |
-
|
| 250 |
-
await stack.enter_async_context(self._context_manager())
|
| 251 |
-
|
| 252 |
-
self._exit_stack = stack
|
| 253 |
-
|
| 254 |
-
self._nesting_counter += 1
|
| 255 |
-
|
| 256 |
return self
|
| 257 |
|
| 258 |
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
| 259 |
-
self.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
|
| 269 |
async def close(self):
|
|
|
|
| 270 |
await self.transport.close()
|
| 271 |
-
self._session = None
|
| 272 |
-
self._initialize_result = None
|
| 273 |
|
| 274 |
# --- MCP Client Methods ---
|
| 275 |
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
import datetime
|
| 3 |
from contextlib import AsyncExitStack, asynccontextmanager
|
| 4 |
from pathlib import Path
|
| 5 |
+
from typing import Any, Generic, Literal, cast, overload
|
| 6 |
|
| 7 |
import anyio
|
| 8 |
+
import httpx
|
| 9 |
import mcp.types
|
| 10 |
from exceptiongroup import catch
|
| 11 |
from mcp import ClientSession
|
|
|
|
| 45 |
|
| 46 |
__all__ = [
|
| 47 |
"Client",
|
| 48 |
+
"SessionKwargs",
|
| 49 |
"RootsHandler",
|
| 50 |
"RootsList",
|
| 51 |
"LogHandler",
|
|
|
|
| 145 |
progress_handler: ProgressHandler | None = None,
|
| 146 |
timeout: datetime.timedelta | float | int | None = None,
|
| 147 |
init_timeout: datetime.timedelta | float | int | None = None,
|
| 148 |
+
auth: httpx.Auth | Literal["oauth"] | str | None = None,
|
| 149 |
):
|
| 150 |
self.transport = cast(ClientTransportT, infer_transport(transport))
|
| 151 |
+
if auth is not None:
|
| 152 |
+
self.transport._set_auth(auth)
|
|
|
|
| 153 |
self._initialize_result: mcp.types.InitializeResult | None = None
|
| 154 |
|
| 155 |
if log_handler is None:
|
|
|
|
| 190 |
sampling_handler
|
| 191 |
)
|
| 192 |
|
| 193 |
+
# session context management
|
| 194 |
+
self._session: ClientSession | None = None
|
| 195 |
+
self._exit_stack: AsyncExitStack | None = None
|
| 196 |
+
self._nesting_counter: int = 0
|
| 197 |
+
self._context_lock = anyio.Lock()
|
| 198 |
+
self._session_task: asyncio.Task | None = None
|
| 199 |
+
self._ready_event = anyio.Event()
|
| 200 |
+
self._stop_event = anyio.Event()
|
| 201 |
+
|
| 202 |
@property
|
| 203 |
def session(self) -> ClientSession:
|
| 204 |
"""Get the current active session. Raises RuntimeError if not connected."""
|
|
|
|
| 249 |
except TimeoutError:
|
| 250 |
raise RuntimeError("Failed to initialize server session")
|
| 251 |
finally:
|
|
|
|
| 252 |
self._session = None
|
| 253 |
self._initialize_result = None
|
| 254 |
|
| 255 |
async def __aenter__(self):
|
| 256 |
+
await self._connect()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
return self
|
| 258 |
|
| 259 |
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
| 260 |
+
await self._disconnect()
|
| 261 |
+
|
| 262 |
+
async def _connect(self):
|
| 263 |
+
# ensure only one session is running at a time to avoid race conditions
|
| 264 |
+
async with self._context_lock:
|
| 265 |
+
need_to_start = self._session_task is None or self._session_task.done()
|
| 266 |
+
if need_to_start:
|
| 267 |
+
self._stop_event = anyio.Event()
|
| 268 |
+
self._ready_event = anyio.Event()
|
| 269 |
+
self._session_task = asyncio.create_task(self._session_runner())
|
| 270 |
+
await self._ready_event.wait()
|
| 271 |
+
self._nesting_counter += 1
|
| 272 |
+
return self
|
| 273 |
|
| 274 |
+
async def _disconnect(self, force: bool = False):
|
| 275 |
+
# ensure only one session is running at a time to avoid race conditions
|
| 276 |
+
async with self._context_lock:
|
| 277 |
+
# if we are forcing a disconnect, reset the nesting counter
|
| 278 |
+
if force:
|
| 279 |
+
self._nesting_counter = 0
|
| 280 |
+
|
| 281 |
+
# otherwise decrement to check if we are done nesting
|
| 282 |
+
else:
|
| 283 |
+
self._nesting_counter = max(0, self._nesting_counter - 1)
|
| 284 |
+
|
| 285 |
+
# if we are still nested, return
|
| 286 |
+
if self._nesting_counter > 0:
|
| 287 |
+
return
|
| 288 |
+
|
| 289 |
+
# stop the active seesion
|
| 290 |
+
if self._session_task is None:
|
| 291 |
+
return
|
| 292 |
+
self._stop_event.set()
|
| 293 |
+
runner_task = self._session_task
|
| 294 |
+
self._session_task = None
|
| 295 |
+
|
| 296 |
+
# wait for the session to finish
|
| 297 |
+
if runner_task:
|
| 298 |
+
await runner_task
|
| 299 |
+
|
| 300 |
+
# Reset for future reconnects
|
| 301 |
+
self._stop_event = anyio.Event()
|
| 302 |
+
self._ready_event = anyio.Event()
|
| 303 |
+
self._session = None
|
| 304 |
+
self._initialize_result = None
|
| 305 |
+
|
| 306 |
+
async def _session_runner(self):
|
| 307 |
+
async with AsyncExitStack() as stack:
|
| 308 |
+
try:
|
| 309 |
+
await stack.enter_async_context(self._context_manager())
|
| 310 |
+
# Session/context is now ready
|
| 311 |
+
self._ready_event.set()
|
| 312 |
+
# Wait until disconnect/stop is requested
|
| 313 |
+
await self._stop_event.wait()
|
| 314 |
+
finally:
|
| 315 |
+
# On exit, ensure ready event is set (idempotent)
|
| 316 |
+
self._ready_event.set()
|
| 317 |
|
| 318 |
async def close(self):
|
| 319 |
+
await self._disconnect(force=True)
|
| 320 |
await self.transport.close()
|
|
|
|
|
|
|
| 321 |
|
| 322 |
# --- MCP Client Methods ---
|
| 323 |
|
src/fastmcp/client/oauth_callback.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
from dataclasses import dataclass
|
| 12 |
+
|
| 13 |
+
from starlette.applications import Starlette
|
| 14 |
+
from starlette.requests import Request
|
| 15 |
+
from starlette.responses import HTMLResponse
|
| 16 |
+
from starlette.routing import Route
|
| 17 |
+
from uvicorn import Config, Server
|
| 18 |
+
|
| 19 |
+
from fastmcp.utilities.http import find_available_port
|
| 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 |
+
@dataclass
|
| 183 |
+
class CallbackResponse:
|
| 184 |
+
code: str | None = None
|
| 185 |
+
state: str | None = None
|
| 186 |
+
error: str | None = None
|
| 187 |
+
error_description: str | None = None
|
| 188 |
+
|
| 189 |
+
@classmethod
|
| 190 |
+
def from_dict(cls, data: dict[str, str]) -> CallbackResponse:
|
| 191 |
+
return cls(**{k: v for k, v in data.items() if k in cls.__annotations__})
|
| 192 |
+
|
| 193 |
+
def to_dict(self) -> dict[str, str]:
|
| 194 |
+
return {k: v for k, v in self.__dict__.items() if v is not None}
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def create_oauth_callback_server(
|
| 198 |
+
port: int,
|
| 199 |
+
callback_path: str = "/callback",
|
| 200 |
+
server_url: str | None = None,
|
| 201 |
+
response_future: asyncio.Future | None = None,
|
| 202 |
+
) -> Server:
|
| 203 |
+
"""
|
| 204 |
+
Create an OAuth callback server.
|
| 205 |
+
|
| 206 |
+
Args:
|
| 207 |
+
port: The port to run the server on
|
| 208 |
+
callback_path: The path to listen for OAuth redirects on
|
| 209 |
+
server_url: Optional server URL to display in success messages
|
| 210 |
+
response_future: Optional future to resolve when OAuth callback is received
|
| 211 |
+
|
| 212 |
+
Returns:
|
| 213 |
+
Configured uvicorn Server instance (not yet running)
|
| 214 |
+
"""
|
| 215 |
+
|
| 216 |
+
async def callback_handler(request: Request):
|
| 217 |
+
"""Handle OAuth callback requests with proper HTML responses."""
|
| 218 |
+
query_params = dict(request.query_params)
|
| 219 |
+
callback_response = CallbackResponse.from_dict(query_params)
|
| 220 |
+
|
| 221 |
+
if callback_response.error:
|
| 222 |
+
error_desc = callback_response.error_description or "Unknown error"
|
| 223 |
+
|
| 224 |
+
# Resolve future with exception if provided
|
| 225 |
+
if response_future and not response_future.done():
|
| 226 |
+
response_future.set_exception(
|
| 227 |
+
RuntimeError(
|
| 228 |
+
f"OAuth error: {callback_response.error} - {error_desc}"
|
| 229 |
+
)
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
return HTMLResponse(
|
| 233 |
+
create_callback_html(
|
| 234 |
+
f"FastMCP OAuth Error: {callback_response.error}<br>{error_desc}",
|
| 235 |
+
is_success=False,
|
| 236 |
+
),
|
| 237 |
+
status_code=400,
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
if not callback_response.code:
|
| 241 |
+
# Resolve future with exception if provided
|
| 242 |
+
if response_future and not response_future.done():
|
| 243 |
+
response_future.set_exception(
|
| 244 |
+
RuntimeError("OAuth callback missing authorization code")
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
return HTMLResponse(
|
| 248 |
+
create_callback_html(
|
| 249 |
+
"FastMCP OAuth Error: No authorization code received",
|
| 250 |
+
is_success=False,
|
| 251 |
+
),
|
| 252 |
+
status_code=400,
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
# Success case
|
| 256 |
+
if response_future and not response_future.done():
|
| 257 |
+
response_future.set_result(
|
| 258 |
+
(callback_response.code, callback_response.state)
|
| 259 |
+
)
|
| 260 |
+
|
| 261 |
+
return HTMLResponse(
|
| 262 |
+
create_callback_html("FastMCP OAuth login complete!", server_url=server_url)
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
app = Starlette(routes=[Route(callback_path, callback_handler)])
|
| 266 |
+
|
| 267 |
+
return Server(
|
| 268 |
+
Config(
|
| 269 |
+
app=app,
|
| 270 |
+
host="127.0.0.1",
|
| 271 |
+
port=port,
|
| 272 |
+
lifespan="off",
|
| 273 |
+
log_level="warning",
|
| 274 |
+
)
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
if __name__ == "__main__":
|
| 279 |
+
"""Run a test server when executed directly."""
|
| 280 |
+
import webbrowser
|
| 281 |
+
|
| 282 |
+
import uvicorn
|
| 283 |
+
|
| 284 |
+
port = find_available_port()
|
| 285 |
+
print("🎭 OAuth Callback Test Server")
|
| 286 |
+
print("📍 Test URLs:")
|
| 287 |
+
print(f" Success: http://localhost:{port}/callback?code=test123&state=xyz")
|
| 288 |
+
print(
|
| 289 |
+
f" Error: http://localhost:{port}/callback?error=access_denied&error_description=User%20denied"
|
| 290 |
+
)
|
| 291 |
+
print(f" Missing: http://localhost:{port}/callback")
|
| 292 |
+
print("🛑 Press Ctrl+C to stop")
|
| 293 |
+
print()
|
| 294 |
+
|
| 295 |
+
# Create test server without future (just for testing HTML responses)
|
| 296 |
+
server = create_oauth_callback_server(
|
| 297 |
+
port=port, server_url="https://fastmcp-test-server.example.com"
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
# Open browser to success example
|
| 301 |
+
webbrowser.open(f"http://localhost:{port}/callback?code=test123&state=xyz")
|
| 302 |
+
|
| 303 |
+
# Run with uvicorn directly
|
| 304 |
+
uvicorn.run(
|
| 305 |
+
server.config.app,
|
| 306 |
+
host="127.0.0.1",
|
| 307 |
+
port=port,
|
| 308 |
+
log_level="warning",
|
| 309 |
+
access_log=False,
|
| 310 |
+
)
|
src/fastmcp/client/transports.py
CHANGED
|
@@ -6,10 +6,20 @@ 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,7 +36,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.
|
| 30 |
from fastmcp.server.dependencies import get_http_headers
|
| 31 |
from fastmcp.server.server import FastMCP
|
| 32 |
from fastmcp.utilities.logging import get_logger
|
|
@@ -40,6 +50,20 @@ 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 +116,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 +159,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 +169,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 +205,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 +226,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 +236,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 +269,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
|
|
@@ -264,8 +326,8 @@ class StdioTransport(ClientTransport):
|
|
| 264 |
|
| 265 |
self._session: ClientSession | None = None
|
| 266 |
self._connect_task: asyncio.Task | None = None
|
| 267 |
-
self._ready_event =
|
| 268 |
-
self._stop_event =
|
| 269 |
|
| 270 |
@contextlib.asynccontextmanager
|
| 271 |
async def connect_session(
|
|
@@ -328,8 +390,8 @@ class StdioTransport(ClientTransport):
|
|
| 328 |
|
| 329 |
# reset variables and events for potential future reconnects
|
| 330 |
self._connect_task = None
|
| 331 |
-
self._stop_event =
|
| 332 |
-
self._ready_event =
|
| 333 |
|
| 334 |
async def close(self):
|
| 335 |
await self.disconnect()
|
|
@@ -592,7 +654,7 @@ class FastMCPTransport(ClientTransport):
|
|
| 592 |
tests or scenarios where client and server run in the same runtime.
|
| 593 |
"""
|
| 594 |
|
| 595 |
-
def __init__(self, mcp:
|
| 596 |
"""Initialize a FastMCPTransport from a FastMCP server instance."""
|
| 597 |
|
| 598 |
# Accept both FastMCP 2.x and FastMCP 1.0 servers. Both expose a
|
|
@@ -706,7 +768,7 @@ def infer_transport(transport: ClientTransportT) -> ClientTransportT: ...
|
|
| 706 |
|
| 707 |
|
| 708 |
@overload
|
| 709 |
-
def infer_transport(transport:
|
| 710 |
|
| 711 |
|
| 712 |
@overload
|
|
@@ -741,7 +803,7 @@ def infer_transport(transport: Path) -> PythonStdioTransport | NodeStdioTranspor
|
|
| 741 |
|
| 742 |
def infer_transport(
|
| 743 |
transport: ClientTransport
|
| 744 |
-
|
|
| 745 |
| FastMCP1Server
|
| 746 |
| AnyUrl
|
| 747 |
| Path
|
|
@@ -758,7 +820,7 @@ def infer_transport(
|
|
| 758 |
|
| 759 |
The function supports these input types:
|
| 760 |
- ClientTransport: Used directly without modification
|
| 761 |
-
-
|
| 762 |
- Path or str (file path): Creates PythonStdioTransport (.py) or NodeStdioTransport (.js)
|
| 763 |
- AnyUrl or str (URL): Creates StreamableHttpTransport (default) or SSETransport (for /sse endpoints)
|
| 764 |
- MCPConfig or dict: Creates MCPConfigTransport, potentially connecting to multiple servers
|
|
@@ -796,7 +858,7 @@ def infer_transport(
|
|
| 796 |
return transport
|
| 797 |
|
| 798 |
# the transport is a FastMCP server (2.x or 1.0)
|
| 799 |
-
elif isinstance(transport,
|
| 800 |
inferred_transport = FastMCPTransport(mcp=transport)
|
| 801 |
|
| 802 |
# the transport is a path to a script
|
|
|
|
| 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 anyio
|
| 22 |
+
import httpx
|
| 23 |
from mcp import ClientSession, StdioServerParameters
|
| 24 |
from mcp.client.session import (
|
| 25 |
ListRootsFnT,
|
|
|
|
| 36 |
from pydantic import AnyUrl
|
| 37 |
from typing_extensions import Unpack
|
| 38 |
|
| 39 |
+
from fastmcp.client.auth import OAuth
|
| 40 |
from fastmcp.server.dependencies import get_http_headers
|
| 41 |
from fastmcp.server.server import FastMCP
|
| 42 |
from fastmcp.utilities.logging import get_logger
|
|
|
|
| 50 |
# TypeVar for preserving specific ClientTransport subclass types
|
| 51 |
ClientTransportT = TypeVar("ClientTransportT", bound="ClientTransport")
|
| 52 |
|
| 53 |
+
__all__ = [
|
| 54 |
+
"ClientTransport",
|
| 55 |
+
"SSETransport",
|
| 56 |
+
"StreamableHttpTransport",
|
| 57 |
+
"StdioTransport",
|
| 58 |
+
"PythonStdioTransport",
|
| 59 |
+
"FastMCPStdioTransport",
|
| 60 |
+
"NodeStdioTransport",
|
| 61 |
+
"UvxStdioTransport",
|
| 62 |
+
"NpxStdioTransport",
|
| 63 |
+
"FastMCPTransport",
|
| 64 |
+
"infer_transport",
|
| 65 |
+
]
|
| 66 |
+
|
| 67 |
|
| 68 |
class SessionKwargs(TypedDict, total=False):
|
| 69 |
"""Keyword arguments for the MCP ClientSession constructor."""
|
|
|
|
| 116 |
"""Close the transport."""
|
| 117 |
pass
|
| 118 |
|
| 119 |
+
def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
|
| 120 |
+
if auth is not None:
|
| 121 |
+
raise ValueError("This transport does not support auth")
|
| 122 |
+
|
| 123 |
|
| 124 |
class WSTransport(ClientTransport):
|
| 125 |
"""Transport implementation that connects to an MCP server via WebSockets."""
|
|
|
|
| 159 |
self,
|
| 160 |
url: str | AnyUrl,
|
| 161 |
headers: dict[str, str] | None = None,
|
| 162 |
+
auth: httpx.Auth | Literal["oauth"] | str | None = None,
|
| 163 |
sse_read_timeout: datetime.timedelta | float | int | None = None,
|
| 164 |
+
httpx_client_factory: Callable[[], httpx.AsyncClient] | None = None,
|
| 165 |
):
|
| 166 |
if isinstance(url, AnyUrl):
|
| 167 |
url = str(url)
|
|
|
|
| 169 |
raise ValueError("Invalid HTTP/S URL provided for SSE.")
|
| 170 |
self.url = url
|
| 171 |
self.headers = headers or {}
|
| 172 |
+
self._set_auth(auth)
|
| 173 |
+
self.httpx_client_factory = httpx_client_factory
|
| 174 |
|
| 175 |
if isinstance(sse_read_timeout, int | float):
|
| 176 |
sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
|
| 177 |
self.sse_read_timeout = sse_read_timeout
|
| 178 |
|
| 179 |
+
def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
|
| 180 |
+
if auth == "oauth":
|
| 181 |
+
auth = OAuth(self.url)
|
| 182 |
+
elif isinstance(auth, str):
|
| 183 |
+
self.headers["Authorization"] = auth
|
| 184 |
+
auth = None
|
| 185 |
+
self.auth = auth
|
| 186 |
+
|
| 187 |
@contextlib.asynccontextmanager
|
| 188 |
async def connect_session(
|
| 189 |
self, **session_kwargs: Unpack[SessionKwargs]
|
|
|
|
| 205 |
)
|
| 206 |
client_kwargs["timeout"] = read_timeout_seconds.total_seconds()
|
| 207 |
|
| 208 |
+
if self.httpx_client_factory is not None:
|
| 209 |
+
client_kwargs["httpx_client_factory"] = self.httpx_client_factory
|
| 210 |
+
|
| 211 |
+
async with sse_client(self.url, auth=self.auth, **client_kwargs) as transport:
|
| 212 |
read_stream, write_stream = transport
|
| 213 |
async with ClientSession(
|
| 214 |
read_stream, write_stream, **session_kwargs
|
|
|
|
| 226 |
self,
|
| 227 |
url: str | AnyUrl,
|
| 228 |
headers: dict[str, str] | None = None,
|
| 229 |
+
auth: httpx.Auth | Literal["oauth"] | str | None = None,
|
| 230 |
sse_read_timeout: datetime.timedelta | float | int | None = None,
|
| 231 |
+
httpx_client_factory: Callable[[], httpx.AsyncClient] | None = None,
|
| 232 |
):
|
| 233 |
if isinstance(url, AnyUrl):
|
| 234 |
url = str(url)
|
|
|
|
| 236 |
raise ValueError("Invalid HTTP/S URL provided for Streamable HTTP.")
|
| 237 |
self.url = url
|
| 238 |
self.headers = headers or {}
|
| 239 |
+
self._set_auth(auth)
|
| 240 |
+
self.httpx_client_factory = httpx_client_factory
|
| 241 |
|
| 242 |
if isinstance(sse_read_timeout, int | float):
|
| 243 |
sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
|
| 244 |
self.sse_read_timeout = sse_read_timeout
|
| 245 |
|
| 246 |
+
def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
|
| 247 |
+
if auth == "oauth":
|
| 248 |
+
auth = OAuth(self.url)
|
| 249 |
+
elif isinstance(auth, str):
|
| 250 |
+
self.headers["Authorization"] = auth
|
| 251 |
+
auth = None
|
| 252 |
+
self.auth = auth
|
| 253 |
+
|
| 254 |
@contextlib.asynccontextmanager
|
| 255 |
async def connect_session(
|
| 256 |
self, **session_kwargs: Unpack[SessionKwargs]
|
|
|
|
| 269 |
if session_kwargs.get("read_timeout_seconds", None) is not None:
|
| 270 |
client_kwargs["timeout"] = session_kwargs.get("read_timeout_seconds")
|
| 271 |
|
| 272 |
+
if self.httpx_client_factory is not None:
|
| 273 |
+
client_kwargs["httpx_client_factory"] = self.httpx_client_factory
|
| 274 |
+
|
| 275 |
+
async with streamablehttp_client(
|
| 276 |
+
self.url,
|
| 277 |
+
auth=self.auth,
|
| 278 |
+
**client_kwargs,
|
| 279 |
+
) as transport:
|
| 280 |
read_stream, write_stream, _ = transport
|
| 281 |
async with ClientSession(
|
| 282 |
read_stream, write_stream, **session_kwargs
|
|
|
|
| 326 |
|
| 327 |
self._session: ClientSession | None = None
|
| 328 |
self._connect_task: asyncio.Task | None = None
|
| 329 |
+
self._ready_event = anyio.Event()
|
| 330 |
+
self._stop_event = anyio.Event()
|
| 331 |
|
| 332 |
@contextlib.asynccontextmanager
|
| 333 |
async def connect_session(
|
|
|
|
| 390 |
|
| 391 |
# reset variables and events for potential future reconnects
|
| 392 |
self._connect_task = None
|
| 393 |
+
self._stop_event = anyio.Event()
|
| 394 |
+
self._ready_event = anyio.Event()
|
| 395 |
|
| 396 |
async def close(self):
|
| 397 |
await self.disconnect()
|
|
|
|
| 654 |
tests or scenarios where client and server run in the same runtime.
|
| 655 |
"""
|
| 656 |
|
| 657 |
+
def __init__(self, mcp: FastMCP | FastMCP1Server):
|
| 658 |
"""Initialize a FastMCPTransport from a FastMCP server instance."""
|
| 659 |
|
| 660 |
# Accept both FastMCP 2.x and FastMCP 1.0 servers. Both expose a
|
|
|
|
| 768 |
|
| 769 |
|
| 770 |
@overload
|
| 771 |
+
def infer_transport(transport: FastMCP) -> FastMCPTransport: ...
|
| 772 |
|
| 773 |
|
| 774 |
@overload
|
|
|
|
| 803 |
|
| 804 |
def infer_transport(
|
| 805 |
transport: ClientTransport
|
| 806 |
+
| FastMCP
|
| 807 |
| FastMCP1Server
|
| 808 |
| AnyUrl
|
| 809 |
| Path
|
|
|
|
| 820 |
|
| 821 |
The function supports these input types:
|
| 822 |
- ClientTransport: Used directly without modification
|
| 823 |
+
- FastMCP or FastMCP1Server: Creates an in-memory FastMCPTransport
|
| 824 |
- Path or str (file path): Creates PythonStdioTransport (.py) or NodeStdioTransport (.js)
|
| 825 |
- AnyUrl or str (URL): Creates StreamableHttpTransport (default) or SSETransport (for /sse endpoints)
|
| 826 |
- MCPConfig or dict: Creates MCPConfigTransport, potentially connecting to multiple servers
|
|
|
|
| 858 |
return transport
|
| 859 |
|
| 860 |
# the transport is a FastMCP server (2.x or 1.0)
|
| 861 |
+
elif isinstance(transport, FastMCP | FastMCP1Server):
|
| 862 |
inferred_transport = FastMCPTransport(mcp=transport)
|
| 863 |
|
| 864 |
# the transport is a path to a script
|
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/{low_level → server/auth}/__init__.py
RENAMED
|
File without changes
|
src/fastmcp/server/auth/auth.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from mcp.server.auth.provider import (
|
| 2 |
+
AccessToken,
|
| 3 |
+
AuthorizationCode,
|
| 4 |
+
OAuthAuthorizationServerProvider,
|
| 5 |
+
RefreshToken,
|
| 6 |
+
)
|
| 7 |
+
from mcp.server.auth.settings import (
|
| 8 |
+
ClientRegistrationOptions,
|
| 9 |
+
RevocationOptions,
|
| 10 |
+
)
|
| 11 |
+
from pydantic import AnyHttpUrl
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class OAuthProvider(
|
| 15 |
+
OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]
|
| 16 |
+
):
|
| 17 |
+
def __init__(
|
| 18 |
+
self,
|
| 19 |
+
issuer_url: AnyHttpUrl | str,
|
| 20 |
+
service_documentation_url: AnyHttpUrl | str | None = None,
|
| 21 |
+
client_registration_options: ClientRegistrationOptions | None = None,
|
| 22 |
+
revocation_options: RevocationOptions | None = None,
|
| 23 |
+
required_scopes: list[str] | None = None,
|
| 24 |
+
):
|
| 25 |
+
"""
|
| 26 |
+
Initialize the OAuth provider.
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
issuer_url: The URL of the OAuth issuer.
|
| 30 |
+
service_documentation_url: The URL of the service documentation.
|
| 31 |
+
client_registration_options: The client registration options.
|
| 32 |
+
revocation_options: The revocation options.
|
| 33 |
+
required_scopes: Scopes that are required for all requests.
|
| 34 |
+
"""
|
| 35 |
+
super().__init__()
|
| 36 |
+
if isinstance(issuer_url, str):
|
| 37 |
+
issuer_url = AnyHttpUrl(issuer_url)
|
| 38 |
+
if isinstance(service_documentation_url, str):
|
| 39 |
+
service_documentation_url = AnyHttpUrl(service_documentation_url)
|
| 40 |
+
|
| 41 |
+
self.issuer_url = issuer_url
|
| 42 |
+
self.service_documentation_url = service_documentation_url
|
| 43 |
+
self.client_registration_options = client_registration_options
|
| 44 |
+
self.revocation_options = revocation_options
|
| 45 |
+
self.required_scopes = required_scopes
|
src/fastmcp/{client/base.py → server/auth/providers/__init__.py}
RENAMED
|
File without changes
|
src/fastmcp/server/auth/providers/bearer.py
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
from dataclasses import dataclass
|
| 3 |
+
from typing import Any, TypedDict
|
| 4 |
+
|
| 5 |
+
import httpx
|
| 6 |
+
from authlib.jose import JsonWebKey, JsonWebToken
|
| 7 |
+
from authlib.jose.errors import JoseError
|
| 8 |
+
from cryptography.hazmat.primitives import serialization
|
| 9 |
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
| 10 |
+
from mcp.server.auth.provider import (
|
| 11 |
+
AccessToken,
|
| 12 |
+
AuthorizationCode,
|
| 13 |
+
AuthorizationParams,
|
| 14 |
+
RefreshToken,
|
| 15 |
+
)
|
| 16 |
+
from mcp.shared.auth import (
|
| 17 |
+
OAuthClientInformationFull,
|
| 18 |
+
OAuthToken,
|
| 19 |
+
)
|
| 20 |
+
from pydantic import SecretStr
|
| 21 |
+
|
| 22 |
+
from fastmcp.server.auth.auth import (
|
| 23 |
+
ClientRegistrationOptions,
|
| 24 |
+
OAuthProvider,
|
| 25 |
+
RevocationOptions,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class JWKData(TypedDict, total=False):
|
| 30 |
+
"""JSON Web Key data structure."""
|
| 31 |
+
|
| 32 |
+
kty: str # Key type (e.g., "RSA") - required
|
| 33 |
+
kid: str # Key ID (optional but recommended)
|
| 34 |
+
use: str # Usage (e.g., "sig")
|
| 35 |
+
alg: str # Algorithm (e.g., "RS256")
|
| 36 |
+
n: str # Modulus (for RSA keys)
|
| 37 |
+
e: str # Exponent (for RSA keys)
|
| 38 |
+
x5c: list[str] # X.509 certificate chain (for JWKs)
|
| 39 |
+
x5t: str # X.509 certificate thumbprint (for JWKs)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class JWKSData(TypedDict):
|
| 43 |
+
"""JSON Web Key Set data structure."""
|
| 44 |
+
|
| 45 |
+
keys: list[JWKData]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@dataclass(frozen=True, kw_only=True, repr=False)
|
| 49 |
+
class RSAKeyPair:
|
| 50 |
+
private_key: SecretStr
|
| 51 |
+
public_key: str
|
| 52 |
+
|
| 53 |
+
@classmethod
|
| 54 |
+
def generate(cls) -> "RSAKeyPair":
|
| 55 |
+
"""
|
| 56 |
+
Generate an RSA key pair for testing.
|
| 57 |
+
|
| 58 |
+
Returns:
|
| 59 |
+
tuple: (private_key_pem, public_key_pem)
|
| 60 |
+
"""
|
| 61 |
+
# Generate private key
|
| 62 |
+
private_key = rsa.generate_private_key(
|
| 63 |
+
public_exponent=65537,
|
| 64 |
+
key_size=2048,
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
# Get public key
|
| 68 |
+
public_key = private_key.public_key()
|
| 69 |
+
|
| 70 |
+
# Serialize private key to PEM format
|
| 71 |
+
private_pem = private_key.private_bytes(
|
| 72 |
+
encoding=serialization.Encoding.PEM,
|
| 73 |
+
format=serialization.PrivateFormat.PKCS8,
|
| 74 |
+
encryption_algorithm=serialization.NoEncryption(),
|
| 75 |
+
).decode("utf-8")
|
| 76 |
+
|
| 77 |
+
# Serialize public key to PEM format
|
| 78 |
+
public_pem = public_key.public_bytes(
|
| 79 |
+
encoding=serialization.Encoding.PEM,
|
| 80 |
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
| 81 |
+
).decode("utf-8")
|
| 82 |
+
|
| 83 |
+
return cls(
|
| 84 |
+
private_key=SecretStr(private_pem),
|
| 85 |
+
public_key=public_pem,
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
def create_token(
|
| 89 |
+
self,
|
| 90 |
+
subject: str = "fastmcp-user",
|
| 91 |
+
issuer: str = "https://fastmcp.example.com",
|
| 92 |
+
audience: str | None = None,
|
| 93 |
+
scopes: list[str] | None = None,
|
| 94 |
+
expires_in_seconds: int = 3600,
|
| 95 |
+
additional_claims: dict[str, Any] | None = None,
|
| 96 |
+
kid: str | None = None,
|
| 97 |
+
) -> str:
|
| 98 |
+
"""
|
| 99 |
+
Generate a test JWT token for testing purposes.
|
| 100 |
+
|
| 101 |
+
Args:
|
| 102 |
+
private_key_pem: RSA private key in PEM format
|
| 103 |
+
subject: Subject claim (usually user ID)
|
| 104 |
+
issuer: Issuer claim
|
| 105 |
+
audience: Audience claim (optional)
|
| 106 |
+
scopes: List of scopes to include
|
| 107 |
+
expires_in_seconds: Token expiration time in seconds
|
| 108 |
+
additional_claims: Any additional claims to include
|
| 109 |
+
kid: Key ID for JWKS lookup (optional)
|
| 110 |
+
|
| 111 |
+
Returns:
|
| 112 |
+
Signed JWT token string
|
| 113 |
+
"""
|
| 114 |
+
jwt = JsonWebToken(["RS256"])
|
| 115 |
+
|
| 116 |
+
now = int(time.time())
|
| 117 |
+
|
| 118 |
+
# Build payload
|
| 119 |
+
payload = {
|
| 120 |
+
"iss": issuer,
|
| 121 |
+
"sub": subject,
|
| 122 |
+
"iat": now,
|
| 123 |
+
"exp": now + expires_in_seconds,
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
if audience:
|
| 127 |
+
payload["aud"] = audience
|
| 128 |
+
|
| 129 |
+
if scopes:
|
| 130 |
+
payload["scope"] = " ".join(scopes)
|
| 131 |
+
|
| 132 |
+
if additional_claims:
|
| 133 |
+
payload.update(additional_claims)
|
| 134 |
+
|
| 135 |
+
# Create header
|
| 136 |
+
header = {"alg": "RS256"}
|
| 137 |
+
if kid:
|
| 138 |
+
header["kid"] = kid
|
| 139 |
+
|
| 140 |
+
# Sign and return token
|
| 141 |
+
token_bytes = jwt.encode(
|
| 142 |
+
header,
|
| 143 |
+
payload,
|
| 144 |
+
key=self.private_key.get_secret_value(),
|
| 145 |
+
)
|
| 146 |
+
return token_bytes.decode("utf-8")
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
class BearerAuthProvider(OAuthProvider):
|
| 150 |
+
"""
|
| 151 |
+
Simple JWT Bearer Token validator for hosted MCP servers.
|
| 152 |
+
Uses RS256 asymmetric encryption. Supports either static public key
|
| 153 |
+
or JWKS URI for key rotation.
|
| 154 |
+
|
| 155 |
+
Note that this provider DOES NOT permit client registration or revocation, or any OAuth flows.
|
| 156 |
+
It is intended to be used with a control plane that manages clients and tokens.
|
| 157 |
+
"""
|
| 158 |
+
|
| 159 |
+
def __init__(
|
| 160 |
+
self,
|
| 161 |
+
public_key: str | None = None,
|
| 162 |
+
jwks_uri: str | None = None,
|
| 163 |
+
issuer: str | None = None,
|
| 164 |
+
audience: str | None = None,
|
| 165 |
+
required_scopes: list[str] | None = None,
|
| 166 |
+
):
|
| 167 |
+
"""
|
| 168 |
+
Initialize the provider. Either public_key or jwks_uri must be provided.
|
| 169 |
+
|
| 170 |
+
Args:
|
| 171 |
+
public_key: RSA public key in PEM format (for static key)
|
| 172 |
+
jwks_uri: URI to fetch keys from (for key rotation)
|
| 173 |
+
issuer: Expected issuer claim (optional)
|
| 174 |
+
audience: Expected audience claim (optional)
|
| 175 |
+
required_scopes: List of required scopes for access (optional)
|
| 176 |
+
"""
|
| 177 |
+
if not (public_key or jwks_uri):
|
| 178 |
+
raise ValueError("Either public_key or jwks_uri must be provided")
|
| 179 |
+
if public_key and jwks_uri:
|
| 180 |
+
raise ValueError("Provide either public_key or jwks_uri, not both")
|
| 181 |
+
|
| 182 |
+
super().__init__(
|
| 183 |
+
issuer_url=issuer or "https://fastmcp.example.com",
|
| 184 |
+
client_registration_options=ClientRegistrationOptions(enabled=False),
|
| 185 |
+
revocation_options=RevocationOptions(enabled=False),
|
| 186 |
+
required_scopes=required_scopes,
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
self.issuer = issuer
|
| 190 |
+
self.audience = audience
|
| 191 |
+
self.public_key = public_key
|
| 192 |
+
self.jwks_uri = jwks_uri
|
| 193 |
+
self.jwt = JsonWebToken(["RS256"])
|
| 194 |
+
|
| 195 |
+
# Simple JWKS cache
|
| 196 |
+
self._jwks_cache: dict[str, str] = {}
|
| 197 |
+
self._jwks_cache_time: float = 0
|
| 198 |
+
self._cache_ttl = 3600 # 1 hour
|
| 199 |
+
|
| 200 |
+
async def _get_verification_key(self, token: str) -> str:
|
| 201 |
+
"""Get the verification key for the token."""
|
| 202 |
+
if self.public_key:
|
| 203 |
+
return self.public_key
|
| 204 |
+
|
| 205 |
+
# Extract kid from token header for JWKS lookup
|
| 206 |
+
try:
|
| 207 |
+
import base64
|
| 208 |
+
import json
|
| 209 |
+
|
| 210 |
+
header_b64 = token.split(".")[0]
|
| 211 |
+
header_b64 += "=" * (4 - len(header_b64) % 4) # Add padding
|
| 212 |
+
header = json.loads(base64.urlsafe_b64decode(header_b64))
|
| 213 |
+
kid = header.get("kid")
|
| 214 |
+
|
| 215 |
+
return await self._get_jwks_key(kid)
|
| 216 |
+
|
| 217 |
+
except Exception as e:
|
| 218 |
+
raise ValueError(f"Failed to extract key ID from token: {e}")
|
| 219 |
+
|
| 220 |
+
async def _get_jwks_key(self, kid: str | None) -> str:
|
| 221 |
+
"""Fetch key from JWKS with simple caching."""
|
| 222 |
+
if not self.jwks_uri:
|
| 223 |
+
raise ValueError("JWKS URI not configured")
|
| 224 |
+
|
| 225 |
+
current_time = time.time()
|
| 226 |
+
|
| 227 |
+
# Check cache first
|
| 228 |
+
if current_time - self._jwks_cache_time < self._cache_ttl:
|
| 229 |
+
if kid and kid in self._jwks_cache:
|
| 230 |
+
return self._jwks_cache[kid]
|
| 231 |
+
elif not kid and len(self._jwks_cache) == 1:
|
| 232 |
+
# If no kid but only one key cached, use it
|
| 233 |
+
return next(iter(self._jwks_cache.values()))
|
| 234 |
+
|
| 235 |
+
# Fetch JWKS
|
| 236 |
+
try:
|
| 237 |
+
async with httpx.AsyncClient() as client:
|
| 238 |
+
response = await client.get(self.jwks_uri)
|
| 239 |
+
response.raise_for_status()
|
| 240 |
+
jwks_data = response.json()
|
| 241 |
+
|
| 242 |
+
# Cache all keys
|
| 243 |
+
self._jwks_cache = {}
|
| 244 |
+
for key_data in jwks_data.get("keys", []):
|
| 245 |
+
key_kid = key_data.get("kid")
|
| 246 |
+
jwk = JsonWebKey.import_key(key_data)
|
| 247 |
+
public_key = jwk.get_public_key() # type: ignore
|
| 248 |
+
|
| 249 |
+
if key_kid:
|
| 250 |
+
self._jwks_cache[key_kid] = public_key
|
| 251 |
+
else:
|
| 252 |
+
# Key without kid - use a default identifier
|
| 253 |
+
self._jwks_cache["_default"] = public_key
|
| 254 |
+
|
| 255 |
+
self._jwks_cache_time = current_time
|
| 256 |
+
|
| 257 |
+
# Select the appropriate key
|
| 258 |
+
if kid:
|
| 259 |
+
if kid not in self._jwks_cache:
|
| 260 |
+
raise ValueError(f"Key ID '{kid}' not found in JWKS")
|
| 261 |
+
return self._jwks_cache[kid]
|
| 262 |
+
else:
|
| 263 |
+
# No kid in token - only allow if there's exactly one key
|
| 264 |
+
if len(self._jwks_cache) == 1:
|
| 265 |
+
return next(iter(self._jwks_cache.values()))
|
| 266 |
+
elif len(self._jwks_cache) > 1:
|
| 267 |
+
raise ValueError(
|
| 268 |
+
"Multiple keys in JWKS but no key ID (kid) in token"
|
| 269 |
+
)
|
| 270 |
+
else:
|
| 271 |
+
raise ValueError("No keys found in JWKS")
|
| 272 |
+
|
| 273 |
+
except Exception as e:
|
| 274 |
+
raise ValueError(f"Failed to fetch JWKS: {e}")
|
| 275 |
+
|
| 276 |
+
async def load_access_token(self, token: str) -> AccessToken | None:
|
| 277 |
+
"""
|
| 278 |
+
Validates the provided JWT bearer token.
|
| 279 |
+
|
| 280 |
+
Args:
|
| 281 |
+
token: The JWT token string to validate
|
| 282 |
+
|
| 283 |
+
Returns:
|
| 284 |
+
AccessToken object if valid, None if invalid or expired
|
| 285 |
+
"""
|
| 286 |
+
try:
|
| 287 |
+
# Get verification key (static or from JWKS)
|
| 288 |
+
verification_key = await self._get_verification_key(token)
|
| 289 |
+
|
| 290 |
+
# Decode and verify the JWT token
|
| 291 |
+
claims = self.jwt.decode(token, verification_key)
|
| 292 |
+
|
| 293 |
+
# Validate expiration
|
| 294 |
+
exp = claims.get("exp")
|
| 295 |
+
if exp and exp < time.time():
|
| 296 |
+
return None
|
| 297 |
+
|
| 298 |
+
# Validate issuer - note we use issuer instead of issuer_url here because
|
| 299 |
+
# issuer is optional, allowing users to make this check optional
|
| 300 |
+
if self.issuer:
|
| 301 |
+
if claims.get("iss") != self.issuer:
|
| 302 |
+
return None
|
| 303 |
+
|
| 304 |
+
# Validate audience if configured
|
| 305 |
+
if self.audience:
|
| 306 |
+
aud = claims.get("aud")
|
| 307 |
+
if isinstance(aud, list):
|
| 308 |
+
if self.audience not in aud:
|
| 309 |
+
return None
|
| 310 |
+
elif aud != self.audience:
|
| 311 |
+
return None
|
| 312 |
+
|
| 313 |
+
# Extract claims - prefer client_id over sub for OAuth application identification
|
| 314 |
+
client_id = claims.get("client_id") or claims.get("sub") or "unknown"
|
| 315 |
+
scopes = self._extract_scopes(claims)
|
| 316 |
+
|
| 317 |
+
return AccessToken(
|
| 318 |
+
token=token,
|
| 319 |
+
client_id=str(client_id),
|
| 320 |
+
scopes=scopes,
|
| 321 |
+
expires_at=int(exp) if exp else None,
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
except JoseError:
|
| 325 |
+
return None
|
| 326 |
+
except Exception:
|
| 327 |
+
return None
|
| 328 |
+
|
| 329 |
+
def _extract_scopes(self, claims: dict[str, Any]) -> list[str]:
|
| 330 |
+
"""Extract scopes from JWT claims."""
|
| 331 |
+
scope_claim = claims.get("scope", "")
|
| 332 |
+
if isinstance(scope_claim, str):
|
| 333 |
+
return scope_claim.split()
|
| 334 |
+
elif isinstance(scope_claim, list):
|
| 335 |
+
return scope_claim
|
| 336 |
+
return []
|
| 337 |
+
|
| 338 |
+
# --- Unused OAuth server methods ---
|
| 339 |
+
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
| 340 |
+
raise NotImplementedError("Client management not supported")
|
| 341 |
+
|
| 342 |
+
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
| 343 |
+
raise NotImplementedError("Client registration not supported")
|
| 344 |
+
|
| 345 |
+
async def authorize(
|
| 346 |
+
self, client: OAuthClientInformationFull, params: AuthorizationParams
|
| 347 |
+
) -> str:
|
| 348 |
+
raise NotImplementedError("Authorization flow not supported")
|
| 349 |
+
|
| 350 |
+
async def load_authorization_code(
|
| 351 |
+
self, client: OAuthClientInformationFull, authorization_code: str
|
| 352 |
+
) -> AuthorizationCode | None:
|
| 353 |
+
raise NotImplementedError("Authorization code flow not supported")
|
| 354 |
+
|
| 355 |
+
async def exchange_authorization_code(
|
| 356 |
+
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
|
| 357 |
+
) -> OAuthToken:
|
| 358 |
+
raise NotImplementedError("Authorization code exchange not supported")
|
| 359 |
+
|
| 360 |
+
async def load_refresh_token(
|
| 361 |
+
self, client: OAuthClientInformationFull, refresh_token: str
|
| 362 |
+
) -> RefreshToken | None:
|
| 363 |
+
raise NotImplementedError("Refresh token flow not supported")
|
| 364 |
+
|
| 365 |
+
async def exchange_refresh_token(
|
| 366 |
+
self,
|
| 367 |
+
client: OAuthClientInformationFull,
|
| 368 |
+
refresh_token: RefreshToken,
|
| 369 |
+
scopes: list[str],
|
| 370 |
+
) -> OAuthToken:
|
| 371 |
+
raise NotImplementedError("Refresh token exchange not supported")
|
| 372 |
+
|
| 373 |
+
async def revoke_token(
|
| 374 |
+
self,
|
| 375 |
+
token: AccessToken | RefreshToken,
|
| 376 |
+
) -> None:
|
| 377 |
+
raise NotImplementedError("Token revocation not supported")
|
src/fastmcp/server/auth/providers/bearer_env.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 2 |
+
|
| 3 |
+
from fastmcp.server.auth.providers.bearer import BearerAuthProvider
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
# Sentinel object to indicate that a setting is not set
|
| 7 |
+
class _NotSet:
|
| 8 |
+
pass
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class EnvBearerAuthProviderSettings(BaseSettings):
|
| 12 |
+
"""Settings for the BearerAuthProvider."""
|
| 13 |
+
|
| 14 |
+
model_config = SettingsConfigDict(
|
| 15 |
+
env_prefix="FASTMCP_AUTH_BEARER_",
|
| 16 |
+
env_file=".env",
|
| 17 |
+
extra="ignore",
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
public_key: str | None = None
|
| 21 |
+
jwks_uri: str | None = None
|
| 22 |
+
issuer: str | None = None
|
| 23 |
+
audience: str | None = None
|
| 24 |
+
required_scopes: list[str] | None = None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class EnvBearerAuthProvider(BearerAuthProvider):
|
| 28 |
+
"""
|
| 29 |
+
A BearerAuthProvider that loads settings from environment variables. Any
|
| 30 |
+
providing setting will always take precedence over the environment
|
| 31 |
+
variables.
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
def __init__(
|
| 35 |
+
self,
|
| 36 |
+
public_key: str | None | type[_NotSet] = _NotSet,
|
| 37 |
+
jwks_uri: str | None | type[_NotSet] = _NotSet,
|
| 38 |
+
issuer: str | None | type[_NotSet] = _NotSet,
|
| 39 |
+
audience: str | None | type[_NotSet] = _NotSet,
|
| 40 |
+
required_scopes: list[str] | None | type[_NotSet] = _NotSet,
|
| 41 |
+
):
|
| 42 |
+
"""
|
| 43 |
+
Initialize the provider.
|
| 44 |
+
|
| 45 |
+
Args:
|
| 46 |
+
public_key: RSA public key in PEM format (for static key)
|
| 47 |
+
jwks_uri: URI to fetch keys from (for key rotation)
|
| 48 |
+
issuer: Expected issuer claim (optional)
|
| 49 |
+
audience: Expected audience claim (optional)
|
| 50 |
+
required_scopes: List of required scopes for access (optional)
|
| 51 |
+
"""
|
| 52 |
+
kwargs = {
|
| 53 |
+
"public_key": public_key,
|
| 54 |
+
"jwks_uri": jwks_uri,
|
| 55 |
+
"issuer": issuer,
|
| 56 |
+
"audience": audience,
|
| 57 |
+
"required_scopes": required_scopes,
|
| 58 |
+
}
|
| 59 |
+
settings = EnvBearerAuthProviderSettings(
|
| 60 |
+
**{k: v for k, v in kwargs.items() if v is not _NotSet}
|
| 61 |
+
)
|
| 62 |
+
super().__init__(**settings.model_dump())
|
src/fastmcp/server/auth/providers/in_memory.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
This is a simple in-memory OAuth provider for testing purposes.
|
| 3 |
+
It simulates the OAuth 2.0 flow locally without external calls.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import secrets
|
| 7 |
+
import time
|
| 8 |
+
|
| 9 |
+
from mcp.server.auth.provider import (
|
| 10 |
+
AccessToken,
|
| 11 |
+
AuthorizationCode,
|
| 12 |
+
AuthorizationParams,
|
| 13 |
+
AuthorizeError,
|
| 14 |
+
RefreshToken,
|
| 15 |
+
TokenError,
|
| 16 |
+
construct_redirect_uri,
|
| 17 |
+
)
|
| 18 |
+
from mcp.shared.auth import (
|
| 19 |
+
OAuthClientInformationFull,
|
| 20 |
+
OAuthToken,
|
| 21 |
+
)
|
| 22 |
+
from pydantic import AnyHttpUrl
|
| 23 |
+
|
| 24 |
+
from fastmcp.server.auth.auth import (
|
| 25 |
+
ClientRegistrationOptions,
|
| 26 |
+
OAuthProvider,
|
| 27 |
+
RevocationOptions,
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
# Default expiration times (in seconds)
|
| 31 |
+
DEFAULT_AUTH_CODE_EXPIRY_SECONDS = 5 * 60 # 5 minutes
|
| 32 |
+
DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS = 60 * 60 # 1 hour
|
| 33 |
+
DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS = None # No expiry
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class InMemoryOAuthProvider(OAuthProvider):
|
| 37 |
+
"""
|
| 38 |
+
An in-memory OAuth provider for testing purposes.
|
| 39 |
+
It simulates the OAuth 2.0 flow locally without external calls.
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
def __init__(
|
| 43 |
+
self,
|
| 44 |
+
issuer_url: AnyHttpUrl | str | None = None,
|
| 45 |
+
service_documentation_url: AnyHttpUrl | str | None = None,
|
| 46 |
+
client_registration_options: ClientRegistrationOptions | None = None,
|
| 47 |
+
revocation_options: RevocationOptions | None = None,
|
| 48 |
+
required_scopes: list[str] | None = None,
|
| 49 |
+
):
|
| 50 |
+
super().__init__(
|
| 51 |
+
issuer_url=issuer_url or "http://fastmcp.example.com",
|
| 52 |
+
service_documentation_url=service_documentation_url,
|
| 53 |
+
client_registration_options=client_registration_options,
|
| 54 |
+
revocation_options=revocation_options,
|
| 55 |
+
required_scopes=required_scopes,
|
| 56 |
+
)
|
| 57 |
+
self.clients: dict[str, OAuthClientInformationFull] = {}
|
| 58 |
+
self.auth_codes: dict[str, AuthorizationCode] = {}
|
| 59 |
+
self.access_tokens: dict[str, AccessToken] = {}
|
| 60 |
+
self.refresh_tokens: dict[str, RefreshToken] = {}
|
| 61 |
+
|
| 62 |
+
# For revoking associated tokens
|
| 63 |
+
self._access_to_refresh_map: dict[
|
| 64 |
+
str, str
|
| 65 |
+
] = {} # access_token_str -> refresh_token_str
|
| 66 |
+
self._refresh_to_access_map: dict[
|
| 67 |
+
str, str
|
| 68 |
+
] = {} # refresh_token_str -> access_token_str
|
| 69 |
+
|
| 70 |
+
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
| 71 |
+
return self.clients.get(client_id)
|
| 72 |
+
|
| 73 |
+
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
| 74 |
+
if client_info.client_id in self.clients:
|
| 75 |
+
# As per RFC 7591, if client_id is already known, it's an update.
|
| 76 |
+
# For this simple provider, we'll treat it as re-registration.
|
| 77 |
+
# A real provider might handle updates or raise errors for conflicts.
|
| 78 |
+
pass
|
| 79 |
+
self.clients[client_info.client_id] = client_info
|
| 80 |
+
|
| 81 |
+
async def authorize(
|
| 82 |
+
self, client: OAuthClientInformationFull, params: AuthorizationParams
|
| 83 |
+
) -> str:
|
| 84 |
+
"""
|
| 85 |
+
Simulates user authorization and generates an authorization code.
|
| 86 |
+
Returns a redirect URI with the code and state.
|
| 87 |
+
"""
|
| 88 |
+
if client.client_id not in self.clients:
|
| 89 |
+
raise AuthorizeError(
|
| 90 |
+
error="unauthorized_client",
|
| 91 |
+
error_description=f"Client '{client.client_id}' not registered.",
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
# Validate redirect_uri (already validated by AuthorizationHandler, but good practice)
|
| 95 |
+
try:
|
| 96 |
+
# OAuthClientInformationFull should have a method like validate_redirect_uri
|
| 97 |
+
# For this test provider, we assume it's valid if it matches one in client_info
|
| 98 |
+
# The AuthorizationHandler already does robust validation using client.validate_redirect_uri
|
| 99 |
+
if params.redirect_uri not in client.redirect_uris:
|
| 100 |
+
# This check might be too simplistic if redirect_uris can be patterns
|
| 101 |
+
# or if params.redirect_uri is None and client has a default.
|
| 102 |
+
# However, the AuthorizationHandler handles the primary validation.
|
| 103 |
+
pass # Let's assume AuthorizationHandler did its job.
|
| 104 |
+
except Exception: # Replace with specific validation error if client.validate_redirect_uri existed
|
| 105 |
+
raise AuthorizeError(
|
| 106 |
+
error="invalid_request", error_description="Invalid redirect_uri."
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
auth_code_value = f"test_auth_code_{secrets.token_hex(16)}"
|
| 110 |
+
expires_at = time.time() + DEFAULT_AUTH_CODE_EXPIRY_SECONDS
|
| 111 |
+
|
| 112 |
+
# Ensure scopes are a list
|
| 113 |
+
scopes_list = params.scopes if params.scopes is not None else []
|
| 114 |
+
if client.scope: # Filter params.scopes against client's registered scopes
|
| 115 |
+
client_allowed_scopes = set(client.scope.split())
|
| 116 |
+
scopes_list = [s for s in scopes_list if s in client_allowed_scopes]
|
| 117 |
+
|
| 118 |
+
auth_code = AuthorizationCode(
|
| 119 |
+
code=auth_code_value,
|
| 120 |
+
client_id=client.client_id,
|
| 121 |
+
redirect_uri=params.redirect_uri,
|
| 122 |
+
redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly,
|
| 123 |
+
scopes=scopes_list,
|
| 124 |
+
expires_at=expires_at,
|
| 125 |
+
code_challenge=params.code_challenge,
|
| 126 |
+
# code_challenge_method is assumed S256 by the framework
|
| 127 |
+
)
|
| 128 |
+
self.auth_codes[auth_code_value] = auth_code
|
| 129 |
+
|
| 130 |
+
return construct_redirect_uri(
|
| 131 |
+
str(params.redirect_uri), code=auth_code_value, state=params.state
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
async def load_authorization_code(
|
| 135 |
+
self, client: OAuthClientInformationFull, authorization_code: str
|
| 136 |
+
) -> AuthorizationCode | None:
|
| 137 |
+
auth_code_obj = self.auth_codes.get(authorization_code)
|
| 138 |
+
if auth_code_obj:
|
| 139 |
+
if auth_code_obj.client_id != client.client_id:
|
| 140 |
+
return None # Belongs to a different client
|
| 141 |
+
if auth_code_obj.expires_at < time.time():
|
| 142 |
+
del self.auth_codes[authorization_code] # Expired
|
| 143 |
+
return None
|
| 144 |
+
return auth_code_obj
|
| 145 |
+
return None
|
| 146 |
+
|
| 147 |
+
async def exchange_authorization_code(
|
| 148 |
+
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
|
| 149 |
+
) -> OAuthToken:
|
| 150 |
+
# Authorization code should have been validated (existence, expiry, client_id match)
|
| 151 |
+
# by the TokenHandler calling load_authorization_code before this.
|
| 152 |
+
# We might want to re-verify or simply trust it's valid.
|
| 153 |
+
|
| 154 |
+
if authorization_code.code not in self.auth_codes:
|
| 155 |
+
raise TokenError(
|
| 156 |
+
"invalid_grant", "Authorization code not found or already used."
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
# Consume the auth code
|
| 160 |
+
del self.auth_codes[authorization_code.code]
|
| 161 |
+
|
| 162 |
+
access_token_value = f"test_access_token_{secrets.token_hex(32)}"
|
| 163 |
+
refresh_token_value = f"test_refresh_token_{secrets.token_hex(32)}"
|
| 164 |
+
|
| 165 |
+
access_token_expires_at = int(time.time() + DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS)
|
| 166 |
+
|
| 167 |
+
# Refresh token expiry
|
| 168 |
+
refresh_token_expires_at = None
|
| 169 |
+
if DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS is not None:
|
| 170 |
+
refresh_token_expires_at = int(
|
| 171 |
+
time.time() + DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
self.access_tokens[access_token_value] = AccessToken(
|
| 175 |
+
token=access_token_value,
|
| 176 |
+
client_id=client.client_id,
|
| 177 |
+
scopes=authorization_code.scopes,
|
| 178 |
+
expires_at=access_token_expires_at,
|
| 179 |
+
)
|
| 180 |
+
self.refresh_tokens[refresh_token_value] = RefreshToken(
|
| 181 |
+
token=refresh_token_value,
|
| 182 |
+
client_id=client.client_id,
|
| 183 |
+
scopes=authorization_code.scopes, # Refresh token inherits scopes
|
| 184 |
+
expires_at=refresh_token_expires_at,
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
self._access_to_refresh_map[access_token_value] = refresh_token_value
|
| 188 |
+
self._refresh_to_access_map[refresh_token_value] = access_token_value
|
| 189 |
+
|
| 190 |
+
return OAuthToken(
|
| 191 |
+
access_token=access_token_value,
|
| 192 |
+
token_type="bearer",
|
| 193 |
+
expires_in=DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS,
|
| 194 |
+
refresh_token=refresh_token_value,
|
| 195 |
+
scope=" ".join(authorization_code.scopes),
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
async def load_refresh_token(
|
| 199 |
+
self, client: OAuthClientInformationFull, refresh_token: str
|
| 200 |
+
) -> RefreshToken | None:
|
| 201 |
+
token_obj = self.refresh_tokens.get(refresh_token)
|
| 202 |
+
if token_obj:
|
| 203 |
+
if token_obj.client_id != client.client_id:
|
| 204 |
+
return None # Belongs to different client
|
| 205 |
+
if token_obj.expires_at is not None and token_obj.expires_at < time.time():
|
| 206 |
+
self._revoke_internal(
|
| 207 |
+
refresh_token_str=token_obj.token
|
| 208 |
+
) # Clean up expired
|
| 209 |
+
return None
|
| 210 |
+
return token_obj
|
| 211 |
+
return None
|
| 212 |
+
|
| 213 |
+
async def exchange_refresh_token(
|
| 214 |
+
self,
|
| 215 |
+
client: OAuthClientInformationFull,
|
| 216 |
+
refresh_token: RefreshToken, # This is the RefreshToken object, already loaded
|
| 217 |
+
scopes: list[str], # Requested scopes for the new access token
|
| 218 |
+
) -> OAuthToken:
|
| 219 |
+
# Validate scopes: requested scopes must be a subset of original scopes
|
| 220 |
+
original_scopes = set(refresh_token.scopes)
|
| 221 |
+
requested_scopes = set(scopes)
|
| 222 |
+
if not requested_scopes.issubset(original_scopes):
|
| 223 |
+
raise TokenError(
|
| 224 |
+
"invalid_scope",
|
| 225 |
+
"Requested scopes exceed those authorized by the refresh token.",
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
# Invalidate old refresh token and its associated access token (rotation)
|
| 229 |
+
self._revoke_internal(refresh_token_str=refresh_token.token)
|
| 230 |
+
|
| 231 |
+
# Issue new tokens
|
| 232 |
+
new_access_token_value = f"test_access_token_{secrets.token_hex(32)}"
|
| 233 |
+
new_refresh_token_value = f"test_refresh_token_{secrets.token_hex(32)}"
|
| 234 |
+
|
| 235 |
+
access_token_expires_at = int(time.time() + DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS)
|
| 236 |
+
|
| 237 |
+
# Refresh token expiry
|
| 238 |
+
refresh_token_expires_at = None
|
| 239 |
+
if DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS is not None:
|
| 240 |
+
refresh_token_expires_at = int(
|
| 241 |
+
time.time() + DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
self.access_tokens[new_access_token_value] = AccessToken(
|
| 245 |
+
token=new_access_token_value,
|
| 246 |
+
client_id=client.client_id,
|
| 247 |
+
scopes=scopes, # Use newly requested (and validated) scopes
|
| 248 |
+
expires_at=access_token_expires_at,
|
| 249 |
+
)
|
| 250 |
+
self.refresh_tokens[new_refresh_token_value] = RefreshToken(
|
| 251 |
+
token=new_refresh_token_value,
|
| 252 |
+
client_id=client.client_id,
|
| 253 |
+
scopes=scopes, # New refresh token also gets these scopes
|
| 254 |
+
expires_at=refresh_token_expires_at,
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
self._access_to_refresh_map[new_access_token_value] = new_refresh_token_value
|
| 258 |
+
self._refresh_to_access_map[new_refresh_token_value] = new_access_token_value
|
| 259 |
+
|
| 260 |
+
return OAuthToken(
|
| 261 |
+
access_token=new_access_token_value,
|
| 262 |
+
token_type="bearer",
|
| 263 |
+
expires_in=DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS,
|
| 264 |
+
refresh_token=new_refresh_token_value,
|
| 265 |
+
scope=" ".join(scopes),
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
async def load_access_token(self, token: str) -> AccessToken | None:
|
| 269 |
+
token_obj = self.access_tokens.get(token)
|
| 270 |
+
if token_obj:
|
| 271 |
+
if token_obj.expires_at is not None and token_obj.expires_at < time.time():
|
| 272 |
+
self._revoke_internal(
|
| 273 |
+
access_token_str=token_obj.token
|
| 274 |
+
) # Clean up expired
|
| 275 |
+
return None
|
| 276 |
+
return token_obj
|
| 277 |
+
return None
|
| 278 |
+
|
| 279 |
+
def _revoke_internal(
|
| 280 |
+
self, access_token_str: str | None = None, refresh_token_str: str | None = None
|
| 281 |
+
):
|
| 282 |
+
"""Internal helper to remove tokens and their associations."""
|
| 283 |
+
removed_access_token = None
|
| 284 |
+
removed_refresh_token = None
|
| 285 |
+
|
| 286 |
+
if access_token_str:
|
| 287 |
+
if access_token_str in self.access_tokens:
|
| 288 |
+
del self.access_tokens[access_token_str]
|
| 289 |
+
removed_access_token = access_token_str
|
| 290 |
+
|
| 291 |
+
# Get associated refresh token
|
| 292 |
+
associated_refresh = self._access_to_refresh_map.pop(access_token_str, None)
|
| 293 |
+
if associated_refresh:
|
| 294 |
+
if associated_refresh in self.refresh_tokens:
|
| 295 |
+
del self.refresh_tokens[associated_refresh]
|
| 296 |
+
removed_refresh_token = associated_refresh
|
| 297 |
+
self._refresh_to_access_map.pop(associated_refresh, None)
|
| 298 |
+
|
| 299 |
+
if refresh_token_str:
|
| 300 |
+
if refresh_token_str in self.refresh_tokens:
|
| 301 |
+
del self.refresh_tokens[refresh_token_str]
|
| 302 |
+
removed_refresh_token = refresh_token_str
|
| 303 |
+
|
| 304 |
+
# Get associated access token
|
| 305 |
+
associated_access = self._refresh_to_access_map.pop(refresh_token_str, None)
|
| 306 |
+
if associated_access:
|
| 307 |
+
if associated_access in self.access_tokens:
|
| 308 |
+
del self.access_tokens[associated_access]
|
| 309 |
+
removed_access_token = associated_access
|
| 310 |
+
self._access_to_refresh_map.pop(associated_access, None)
|
| 311 |
+
|
| 312 |
+
# Clean up any dangling references if one part of the pair was already gone
|
| 313 |
+
if removed_access_token and removed_access_token in self._access_to_refresh_map:
|
| 314 |
+
del self._access_to_refresh_map[removed_access_token]
|
| 315 |
+
if (
|
| 316 |
+
removed_refresh_token
|
| 317 |
+
and removed_refresh_token in self._refresh_to_access_map
|
| 318 |
+
):
|
| 319 |
+
del self._refresh_to_access_map[removed_refresh_token]
|
| 320 |
+
|
| 321 |
+
async def revoke_token(
|
| 322 |
+
self,
|
| 323 |
+
token: AccessToken | RefreshToken,
|
| 324 |
+
) -> None:
|
| 325 |
+
"""Revokes an access or refresh token and its counterpart."""
|
| 326 |
+
if isinstance(token, AccessToken):
|
| 327 |
+
self._revoke_internal(access_token_str=token.token)
|
| 328 |
+
elif isinstance(token, RefreshToken):
|
| 329 |
+
self._revoke_internal(refresh_token_str=token.token)
|
| 330 |
+
# If token is not found or already revoked, _revoke_internal does nothing, which is correct.
|
src/fastmcp/server/dependencies.py
CHANGED
|
@@ -2,6 +2,8 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
from typing import TYPE_CHECKING, ParamSpec, TypeVar
|
| 4 |
|
|
|
|
|
|
|
| 5 |
from starlette.requests import Request
|
| 6 |
|
| 7 |
if TYPE_CHECKING:
|
|
@@ -10,6 +12,14 @@ if TYPE_CHECKING:
|
|
| 10 |
P = ParamSpec("P")
|
| 11 |
R = TypeVar("R")
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
# --- Context ---
|
| 15 |
|
|
|
|
| 2 |
|
| 3 |
from typing import TYPE_CHECKING, ParamSpec, TypeVar
|
| 4 |
|
| 5 |
+
from mcp.server.auth.middleware.auth_context import get_access_token
|
| 6 |
+
from mcp.server.auth.provider import AccessToken
|
| 7 |
from starlette.requests import Request
|
| 8 |
|
| 9 |
if TYPE_CHECKING:
|
|
|
|
| 12 |
P = ParamSpec("P")
|
| 13 |
R = TypeVar("R")
|
| 14 |
|
| 15 |
+
__all__ = [
|
| 16 |
+
"get_context",
|
| 17 |
+
"get_http_request",
|
| 18 |
+
"get_http_headers",
|
| 19 |
+
"get_access_token",
|
| 20 |
+
"AccessToken",
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
|
| 24 |
# --- Context ---
|
| 25 |
|
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.required_scopes or []
|
| 95 |
+
|
| 96 |
+
auth_routes.extend(
|
| 97 |
+
create_auth_routes(
|
| 98 |
+
provider=auth,
|
| 99 |
+
issuer_url=auth.issuer_url,
|
| 100 |
+
service_documentation_url=auth.service_documentation_url,
|
| 101 |
+
client_registration_options=auth.client_registration_options,
|
| 102 |
+
revocation_options=auth.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,8 @@ 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 +111,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]],
|
|
@@ -128,6 +128,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 128 |
on_duplicate_prompts: DuplicateBehavior | None = None,
|
| 129 |
resource_prefix_format: Literal["protocol", "path"] | None = None,
|
| 130 |
mask_error_details: bool | None = None,
|
|
|
|
| 131 |
**settings: Any,
|
| 132 |
):
|
| 133 |
if settings:
|
|
@@ -186,13 +187,16 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 186 |
lifespan=_lifespan_wrapper(self, lifespan),
|
| 187 |
)
|
| 188 |
|
| 189 |
-
if
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
| 196 |
|
| 197 |
# Set up MCP protocol handlers
|
| 198 |
self._setup_handlers()
|
|
@@ -907,8 +911,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 907 |
server=self,
|
| 908 |
message_path=message_path or self.settings.message_path,
|
| 909 |
sse_path=path or self.settings.sse_path,
|
| 910 |
-
|
| 911 |
-
auth_settings=self.settings.auth,
|
| 912 |
debug=self.settings.debug,
|
| 913 |
middleware=middleware,
|
| 914 |
)
|
|
@@ -955,8 +958,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 955 |
server=self,
|
| 956 |
streamable_http_path=path or self.settings.streamable_http_path,
|
| 957 |
event_store=None,
|
| 958 |
-
|
| 959 |
-
auth_settings=self.settings.auth,
|
| 960 |
json_response=self.settings.json_response,
|
| 961 |
stateless_http=self.settings.stateless_http,
|
| 962 |
debug=self.settings.debug,
|
|
@@ -967,8 +969,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 967 |
server=self,
|
| 968 |
message_path=self.settings.message_path,
|
| 969 |
sse_path=path or self.settings.sse_path,
|
| 970 |
-
|
| 971 |
-
auth_settings=self.settings.auth,
|
| 972 |
debug=self.settings.debug,
|
| 973 |
middleware=middleware,
|
| 974 |
)
|
|
|
|
| 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.auth.providers.bearer_env import EnvBearerAuthProvider
|
| 52 |
from fastmcp.server.http import (
|
| 53 |
StarletteWithLifespan,
|
| 54 |
create_sse_app,
|
|
|
|
| 111 |
self,
|
| 112 |
name: str | None = None,
|
| 113 |
instructions: str | None = None,
|
| 114 |
+
auth: OAuthProvider | None = None,
|
|
|
|
| 115 |
lifespan: (
|
| 116 |
Callable[
|
| 117 |
[FastMCP[LifespanResultT]],
|
|
|
|
| 128 |
on_duplicate_prompts: DuplicateBehavior | None = None,
|
| 129 |
resource_prefix_format: Literal["protocol", "path"] | None = None,
|
| 130 |
mask_error_details: bool | None = None,
|
| 131 |
+
tools: list[Tool | Callable[..., Any]] | None = None,
|
| 132 |
**settings: Any,
|
| 133 |
):
|
| 134 |
if settings:
|
|
|
|
| 187 |
lifespan=_lifespan_wrapper(self, lifespan),
|
| 188 |
)
|
| 189 |
|
| 190 |
+
if auth is None and self.settings.default_auth_provider == "bearer_env":
|
| 191 |
+
auth = EnvBearerAuthProvider()
|
| 192 |
+
self.auth = auth
|
| 193 |
+
|
| 194 |
+
if tools:
|
| 195 |
+
for tool in tools:
|
| 196 |
+
if isinstance(tool, Tool):
|
| 197 |
+
self._tool_manager.add_tool(tool)
|
| 198 |
+
else:
|
| 199 |
+
self.add_tool(tool)
|
| 200 |
|
| 201 |
# Set up MCP protocol handlers
|
| 202 |
self._setup_handlers()
|
|
|
|
| 911 |
server=self,
|
| 912 |
message_path=message_path or self.settings.message_path,
|
| 913 |
sse_path=path or self.settings.sse_path,
|
| 914 |
+
auth=self.auth,
|
|
|
|
| 915 |
debug=self.settings.debug,
|
| 916 |
middleware=middleware,
|
| 917 |
)
|
|
|
|
| 958 |
server=self,
|
| 959 |
streamable_http_path=path or self.settings.streamable_http_path,
|
| 960 |
event_store=None,
|
| 961 |
+
auth=self.auth,
|
|
|
|
| 962 |
json_response=self.settings.json_response,
|
| 963 |
stateless_http=self.settings.stateless_http,
|
| 964 |
debug=self.settings.debug,
|
|
|
|
| 969 |
server=self,
|
| 970 |
message_path=self.settings.message_path,
|
| 971 |
sse_path=path or self.settings.sse_path,
|
| 972 |
+
auth=self.auth,
|
|
|
|
| 973 |
debug=self.settings.debug,
|
| 974 |
middleware=middleware,
|
| 975 |
)
|
src/fastmcp/settings.py
CHANGED
|
@@ -1,16 +1,16 @@
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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 +27,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,13 +173,30 @@ 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 = (
|
| 179 |
False # If True, uses true stateless mode (new transport per request)
|
| 180 |
)
|
| 181 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
|
| 183 |
settings = Settings()
|
|
|
|
| 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 (
|
| 9 |
+
BaseSettings,
|
| 10 |
+
SettingsConfigDict,
|
| 11 |
+
)
|
| 12 |
from typing_extensions import Self
|
| 13 |
|
|
|
|
|
|
|
|
|
|
| 14 |
LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
| 15 |
|
| 16 |
DuplicateBehavior = Literal["warn", "error", "replace", "ignore"]
|
|
|
|
| 27 |
nested_model_default_partial_update=True,
|
| 28 |
)
|
| 29 |
|
| 30 |
+
home: Path = Path.home() / ".fastmcp"
|
| 31 |
+
|
| 32 |
test_mode: bool = False
|
| 33 |
log_level: LOG_LEVEL = "INFO"
|
| 34 |
enable_rich_tracebacks: Annotated[
|
|
|
|
| 173 |
# cache settings (for checking mounted servers)
|
| 174 |
cache_expiration_seconds: float = 0
|
| 175 |
|
|
|
|
|
|
|
| 176 |
# StreamableHTTP settings
|
| 177 |
json_response: bool = False
|
| 178 |
stateless_http: bool = (
|
| 179 |
False # If True, uses true stateless mode (new transport per request)
|
| 180 |
)
|
| 181 |
|
| 182 |
+
# Auth settings
|
| 183 |
+
default_auth_provider: Annotated[
|
| 184 |
+
Literal["bearer_env"] | None,
|
| 185 |
+
Field(
|
| 186 |
+
description=inspect.cleandoc(
|
| 187 |
+
"""
|
| 188 |
+
Configure the authentication provider. This setting is intended only to
|
| 189 |
+
be used for remote confirugation of providers that fully support
|
| 190 |
+
environment variable configuration.
|
| 191 |
+
|
| 192 |
+
If None, no automatic configuration will take place.
|
| 193 |
+
|
| 194 |
+
This setting is *always* overriden by any auth provider passed to the
|
| 195 |
+
FastMCP constructor.
|
| 196 |
+
"""
|
| 197 |
+
),
|
| 198 |
+
),
|
| 199 |
+
] = None
|
| 200 |
+
|
| 201 |
|
| 202 |
settings = Settings()
|
src/fastmcp/utilities/http.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import socket
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def find_available_port() -> int:
|
| 5 |
+
"""Find an available port by letting the OS assign one."""
|
| 6 |
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
| 7 |
+
s.bind(("127.0.0.1", 0))
|
| 8 |
+
return s.getsockname()[1]
|
src/fastmcp/utilities/tests.py
CHANGED
|
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any, Literal
|
|
| 11 |
import uvicorn
|
| 12 |
|
| 13 |
from fastmcp.settings import settings
|
|
|
|
| 14 |
|
| 15 |
if TYPE_CHECKING:
|
| 16 |
from fastmcp.server.server import FastMCP
|
|
@@ -71,30 +72,38 @@ def _run_server(mcp_server: FastMCP, transport: Literal["sse"], port: int) -> No
|
|
| 71 |
|
| 72 |
@contextmanager
|
| 73 |
def run_server_in_process(
|
| 74 |
-
server_fn: Callable[..., None],
|
|
|
|
|
|
|
|
|
|
| 75 |
) -> Generator[str, None, None]:
|
| 76 |
"""
|
| 77 |
-
Context manager that runs a
|
| 78 |
-
server URL. When the context manager is exited, the server process is killed.
|
| 79 |
|
| 80 |
Args:
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
Returns:
|
| 84 |
The server URL.
|
| 85 |
"""
|
| 86 |
host = "127.0.0.1"
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
|
|
|
| 90 |
|
| 91 |
proc = multiprocessing.Process(
|
| 92 |
-
target=server_fn, args=
|
| 93 |
)
|
| 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 +111,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")
|
|
|
|
| 11 |
import uvicorn
|
| 12 |
|
| 13 |
from fastmcp.settings import settings
|
| 14 |
+
from fastmcp.utilities.http import find_available_port
|
| 15 |
|
| 16 |
if TYPE_CHECKING:
|
| 17 |
from fastmcp.server.server import FastMCP
|
|
|
|
| 72 |
|
| 73 |
@contextmanager
|
| 74 |
def run_server_in_process(
|
| 75 |
+
server_fn: Callable[..., None],
|
| 76 |
+
*args,
|
| 77 |
+
provide_host_and_port: bool = True,
|
| 78 |
+
**kwargs,
|
| 79 |
) -> Generator[str, None, None]:
|
| 80 |
"""
|
| 81 |
+
Context manager that runs a FastMCP server in a separate process and
|
| 82 |
+
returns the server URL. When the context manager is exited, the server process is killed.
|
| 83 |
|
| 84 |
Args:
|
| 85 |
+
server_fn: The function that runs a FastMCP server. FastMCP servers are
|
| 86 |
+
not pickleable, so we need a function that creates and runs one.
|
| 87 |
+
*args: Arguments to pass to the server function.
|
| 88 |
+
provide_host_and_port: Whether to provide the host and port to the server function as kwargs.
|
| 89 |
+
**kwargs: Keyword arguments to pass to the server function.
|
| 90 |
|
| 91 |
Returns:
|
| 92 |
The server URL.
|
| 93 |
"""
|
| 94 |
host = "127.0.0.1"
|
| 95 |
+
port = find_available_port()
|
| 96 |
+
|
| 97 |
+
if provide_host_and_port:
|
| 98 |
+
kwargs |= {"host": host, "port": port}
|
| 99 |
|
| 100 |
proc = multiprocessing.Process(
|
| 101 |
+
target=server_fn, args=args, kwargs=kwargs, daemon=True
|
| 102 |
)
|
| 103 |
proc.start()
|
| 104 |
|
| 105 |
# Wait for server to be running
|
| 106 |
+
max_attempts = 10
|
| 107 |
attempt = 0
|
| 108 |
while attempt < max_attempts and proc.is_alive():
|
| 109 |
try:
|
|
|
|
| 111 |
s.connect((host, port))
|
| 112 |
break
|
| 113 |
except ConnectionRefusedError:
|
| 114 |
+
if attempt < 3:
|
| 115 |
+
time.sleep(0.01)
|
| 116 |
+
else:
|
| 117 |
+
time.sleep(0.1)
|
| 118 |
attempt += 1
|
| 119 |
else:
|
| 120 |
raise RuntimeError(f"Server failed to start after {max_attempts} attempts")
|
src/fastmcp/py.typed → tests/auth/__init__.py
RENAMED
|
File without changes
|
tests/auth/providers/test_bearer.py
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from collections.abc import Generator
|
| 2 |
+
from typing import Any
|
| 3 |
+
|
| 4 |
+
import httpx
|
| 5 |
+
import pytest
|
| 6 |
+
from pytest_httpx import HTTPXMock
|
| 7 |
+
|
| 8 |
+
from fastmcp import Client, FastMCP
|
| 9 |
+
from fastmcp.client.auth import BearerAuth
|
| 10 |
+
from fastmcp.server.auth.providers.bearer import (
|
| 11 |
+
BearerAuthProvider,
|
| 12 |
+
JWKData,
|
| 13 |
+
JWKSData,
|
| 14 |
+
RSAKeyPair,
|
| 15 |
+
)
|
| 16 |
+
from fastmcp.utilities.tests import run_server_in_process
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@pytest.fixture(scope="module")
|
| 20 |
+
def rsa_key_pair() -> RSAKeyPair:
|
| 21 |
+
return RSAKeyPair.generate()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@pytest.fixture(scope="module")
|
| 25 |
+
def bearer_token(rsa_key_pair: RSAKeyPair) -> str:
|
| 26 |
+
return rsa_key_pair.create_token(
|
| 27 |
+
subject="test-user",
|
| 28 |
+
issuer="https://test.example.com",
|
| 29 |
+
audience="https://api.example.com",
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@pytest.fixture
|
| 34 |
+
def bearer_provider(rsa_key_pair: RSAKeyPair) -> BearerAuthProvider:
|
| 35 |
+
return BearerAuthProvider(
|
| 36 |
+
public_key=rsa_key_pair.public_key,
|
| 37 |
+
issuer="https://test.example.com",
|
| 38 |
+
audience="https://api.example.com",
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def run_mcp_server(
|
| 43 |
+
public_key: str,
|
| 44 |
+
host: str,
|
| 45 |
+
port: int,
|
| 46 |
+
auth_kwargs: dict[str, Any] | None = None,
|
| 47 |
+
run_kwargs: dict[str, Any] | None = None,
|
| 48 |
+
) -> None:
|
| 49 |
+
mcp = FastMCP(
|
| 50 |
+
auth=BearerAuthProvider(
|
| 51 |
+
public_key=public_key,
|
| 52 |
+
**auth_kwargs or {},
|
| 53 |
+
)
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
@mcp.tool()
|
| 57 |
+
def add(a: int, b: int) -> int:
|
| 58 |
+
return a + b
|
| 59 |
+
|
| 60 |
+
mcp.run(host=host, port=port, **run_kwargs or {})
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@pytest.fixture(scope="module")
|
| 64 |
+
def mcp_server_url(rsa_key_pair: RSAKeyPair) -> Generator[str]:
|
| 65 |
+
with run_server_in_process(
|
| 66 |
+
run_mcp_server,
|
| 67 |
+
public_key=rsa_key_pair.public_key,
|
| 68 |
+
run_kwargs=dict(transport="streamable-http"),
|
| 69 |
+
) as url:
|
| 70 |
+
yield f"{url}/mcp"
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class TestRSAKeyPair:
|
| 74 |
+
def test_generate_key_pair(self):
|
| 75 |
+
"""Test RSA key pair generation."""
|
| 76 |
+
key_pair = RSAKeyPair.generate()
|
| 77 |
+
|
| 78 |
+
assert key_pair.private_key is not None
|
| 79 |
+
assert key_pair.public_key is not None
|
| 80 |
+
|
| 81 |
+
# Check that keys are in PEM format
|
| 82 |
+
private_pem = key_pair.private_key.get_secret_value()
|
| 83 |
+
public_pem = key_pair.public_key
|
| 84 |
+
|
| 85 |
+
assert "-----BEGIN PRIVATE KEY-----" in private_pem
|
| 86 |
+
assert "-----END PRIVATE KEY-----" in private_pem
|
| 87 |
+
assert "-----BEGIN PUBLIC KEY-----" in public_pem
|
| 88 |
+
assert "-----END PUBLIC KEY-----" in public_pem
|
| 89 |
+
|
| 90 |
+
def test_create_basic_token(self, rsa_key_pair: RSAKeyPair):
|
| 91 |
+
"""Test basic token creation."""
|
| 92 |
+
token = rsa_key_pair.create_token(
|
| 93 |
+
subject="test-user",
|
| 94 |
+
issuer="https://test.example.com",
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
assert isinstance(token, str)
|
| 98 |
+
assert len(token.split(".")) == 3 # JWT has 3 parts
|
| 99 |
+
|
| 100 |
+
def test_create_token_with_scopes(self, rsa_key_pair: RSAKeyPair):
|
| 101 |
+
"""Test token creation with scopes."""
|
| 102 |
+
token = rsa_key_pair.create_token(
|
| 103 |
+
subject="test-user",
|
| 104 |
+
issuer="https://test.example.com",
|
| 105 |
+
scopes=["read", "write"],
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
assert isinstance(token, str)
|
| 109 |
+
# We'll validate the scopes in the BearerToken tests
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
class TestBearerTokenJWKS:
|
| 113 |
+
"""Tests for JWKS URI functionality."""
|
| 114 |
+
|
| 115 |
+
@pytest.fixture
|
| 116 |
+
def jwks_provider(self, rsa_key_pair: RSAKeyPair) -> BearerAuthProvider:
|
| 117 |
+
"""Provider configured with JWKS URI."""
|
| 118 |
+
return BearerAuthProvider(
|
| 119 |
+
jwks_uri="https://test.example.com/.well-known/jwks.json",
|
| 120 |
+
issuer="https://test.example.com",
|
| 121 |
+
audience="https://api.example.com",
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
@pytest.fixture
|
| 125 |
+
def mock_jwks_data(self, rsa_key_pair: RSAKeyPair) -> JWKSData:
|
| 126 |
+
"""Create mock JWKS data from RSA key pair."""
|
| 127 |
+
from authlib.jose import JsonWebKey
|
| 128 |
+
|
| 129 |
+
# Create JWK from the RSA public key
|
| 130 |
+
jwk = JsonWebKey.import_key(rsa_key_pair.public_key) # type: ignore
|
| 131 |
+
jwk_data: JWKData = jwk.as_dict() # type: ignore
|
| 132 |
+
jwk_data["kid"] = "test-key-1"
|
| 133 |
+
jwk_data["alg"] = "RS256"
|
| 134 |
+
|
| 135 |
+
return {"keys": [jwk_data]}
|
| 136 |
+
|
| 137 |
+
async def test_jwks_token_validation(
|
| 138 |
+
self,
|
| 139 |
+
rsa_key_pair: RSAKeyPair,
|
| 140 |
+
jwks_provider: BearerAuthProvider,
|
| 141 |
+
mock_jwks_data: JWKSData,
|
| 142 |
+
httpx_mock: HTTPXMock,
|
| 143 |
+
):
|
| 144 |
+
"""Test token validation using JWKS URI."""
|
| 145 |
+
httpx_mock.add_response(
|
| 146 |
+
url="https://test.example.com/.well-known/jwks.json",
|
| 147 |
+
json=mock_jwks_data,
|
| 148 |
+
)
|
| 149 |
+
token = rsa_key_pair.create_token(
|
| 150 |
+
subject="test-user",
|
| 151 |
+
issuer="https://test.example.com",
|
| 152 |
+
audience="https://api.example.com",
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
access_token = await jwks_provider.load_access_token(token)
|
| 156 |
+
assert access_token is not None
|
| 157 |
+
assert access_token.client_id == "test-user"
|
| 158 |
+
|
| 159 |
+
async def test_jwks_token_validation_with_invalid_key(
|
| 160 |
+
self,
|
| 161 |
+
rsa_key_pair: RSAKeyPair,
|
| 162 |
+
jwks_provider: BearerAuthProvider,
|
| 163 |
+
mock_jwks_data: JWKSData,
|
| 164 |
+
httpx_mock: HTTPXMock,
|
| 165 |
+
):
|
| 166 |
+
httpx_mock.add_response(
|
| 167 |
+
url="https://test.example.com/.well-known/jwks.json",
|
| 168 |
+
json=mock_jwks_data,
|
| 169 |
+
)
|
| 170 |
+
token = RSAKeyPair.generate().create_token(
|
| 171 |
+
subject="test-user",
|
| 172 |
+
issuer="https://test.example.com",
|
| 173 |
+
audience="https://api.example.com",
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
access_token = await jwks_provider.load_access_token(token)
|
| 177 |
+
assert access_token is None
|
| 178 |
+
|
| 179 |
+
async def test_jwks_token_validation_with_kid(
|
| 180 |
+
self,
|
| 181 |
+
rsa_key_pair: RSAKeyPair,
|
| 182 |
+
jwks_provider: BearerAuthProvider,
|
| 183 |
+
mock_jwks_data: JWKSData,
|
| 184 |
+
httpx_mock: HTTPXMock,
|
| 185 |
+
):
|
| 186 |
+
mock_jwks_data["keys"][0]["kid"] = "test-key-1"
|
| 187 |
+
httpx_mock.add_response(
|
| 188 |
+
url="https://test.example.com/.well-known/jwks.json",
|
| 189 |
+
json=mock_jwks_data,
|
| 190 |
+
)
|
| 191 |
+
token = rsa_key_pair.create_token(
|
| 192 |
+
subject="test-user",
|
| 193 |
+
issuer="https://test.example.com",
|
| 194 |
+
audience="https://api.example.com",
|
| 195 |
+
kid="test-key-1",
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
access_token = await jwks_provider.load_access_token(token)
|
| 199 |
+
assert access_token is not None
|
| 200 |
+
assert access_token.client_id == "test-user"
|
| 201 |
+
|
| 202 |
+
async def test_jwks_token_validation_with_kid_and_no_kid_in_token(
|
| 203 |
+
self,
|
| 204 |
+
rsa_key_pair: RSAKeyPair,
|
| 205 |
+
jwks_provider: BearerAuthProvider,
|
| 206 |
+
mock_jwks_data: JWKSData,
|
| 207 |
+
httpx_mock: HTTPXMock,
|
| 208 |
+
):
|
| 209 |
+
mock_jwks_data["keys"][0]["kid"] = "test-key-1"
|
| 210 |
+
httpx_mock.add_response(
|
| 211 |
+
url="https://test.example.com/.well-known/jwks.json",
|
| 212 |
+
json=mock_jwks_data,
|
| 213 |
+
)
|
| 214 |
+
token = rsa_key_pair.create_token(
|
| 215 |
+
subject="test-user",
|
| 216 |
+
issuer="https://test.example.com",
|
| 217 |
+
audience="https://api.example.com",
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
access_token = await jwks_provider.load_access_token(token)
|
| 221 |
+
assert access_token is not None
|
| 222 |
+
assert access_token.client_id == "test-user"
|
| 223 |
+
|
| 224 |
+
async def test_jwks_token_validation_with_no_kid_and_kid_in_jwks(
|
| 225 |
+
self,
|
| 226 |
+
rsa_key_pair: RSAKeyPair,
|
| 227 |
+
jwks_provider: BearerAuthProvider,
|
| 228 |
+
mock_jwks_data: JWKSData,
|
| 229 |
+
httpx_mock: HTTPXMock,
|
| 230 |
+
):
|
| 231 |
+
mock_jwks_data["keys"][0]["kid"] = "test-key-1"
|
| 232 |
+
httpx_mock.add_response(
|
| 233 |
+
url="https://test.example.com/.well-known/jwks.json",
|
| 234 |
+
json=mock_jwks_data,
|
| 235 |
+
)
|
| 236 |
+
token = rsa_key_pair.create_token(
|
| 237 |
+
subject="test-user",
|
| 238 |
+
issuer="https://test.example.com",
|
| 239 |
+
audience="https://api.example.com",
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
access_token = await jwks_provider.load_access_token(token)
|
| 243 |
+
assert access_token is not None
|
| 244 |
+
assert access_token.client_id == "test-user"
|
| 245 |
+
|
| 246 |
+
async def test_jwks_token_validation_with_kid_mismatch(
|
| 247 |
+
self,
|
| 248 |
+
rsa_key_pair: RSAKeyPair,
|
| 249 |
+
jwks_provider: BearerAuthProvider,
|
| 250 |
+
mock_jwks_data: JWKSData,
|
| 251 |
+
httpx_mock: HTTPXMock,
|
| 252 |
+
):
|
| 253 |
+
mock_jwks_data["keys"][0]["kid"] = "test-key-1"
|
| 254 |
+
httpx_mock.add_response(
|
| 255 |
+
url="https://test.example.com/.well-known/jwks.json",
|
| 256 |
+
json=mock_jwks_data,
|
| 257 |
+
)
|
| 258 |
+
token = rsa_key_pair.create_token(
|
| 259 |
+
subject="test-user",
|
| 260 |
+
issuer="https://test.example.com",
|
| 261 |
+
audience="https://api.example.com",
|
| 262 |
+
kid="test-key-2",
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
access_token = await jwks_provider.load_access_token(token)
|
| 266 |
+
assert access_token is None
|
| 267 |
+
|
| 268 |
+
async def test_jwks_token_validation_with_multiple_keys_and_no_kid_in_token(
|
| 269 |
+
self,
|
| 270 |
+
rsa_key_pair: RSAKeyPair,
|
| 271 |
+
jwks_provider: BearerAuthProvider,
|
| 272 |
+
mock_jwks_data: JWKSData,
|
| 273 |
+
httpx_mock: HTTPXMock,
|
| 274 |
+
):
|
| 275 |
+
mock_jwks_data["keys"] = [
|
| 276 |
+
{
|
| 277 |
+
"kid": "test-key-1",
|
| 278 |
+
"alg": "RS256",
|
| 279 |
+
},
|
| 280 |
+
{
|
| 281 |
+
"kid": "test-key-2",
|
| 282 |
+
"alg": "RS256",
|
| 283 |
+
},
|
| 284 |
+
]
|
| 285 |
+
|
| 286 |
+
httpx_mock.add_response(
|
| 287 |
+
url="https://test.example.com/.well-known/jwks.json",
|
| 288 |
+
json=mock_jwks_data,
|
| 289 |
+
)
|
| 290 |
+
token = rsa_key_pair.create_token(
|
| 291 |
+
subject="test-user",
|
| 292 |
+
issuer="https://test.example.com",
|
| 293 |
+
audience="https://api.example.com",
|
| 294 |
+
)
|
| 295 |
+
|
| 296 |
+
access_token = await jwks_provider.load_access_token(token)
|
| 297 |
+
assert access_token is None
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
class TestBearerToken:
|
| 301 |
+
def test_initialization_with_public_key(self, rsa_key_pair: RSAKeyPair):
|
| 302 |
+
"""Test provider initialization with public key."""
|
| 303 |
+
provider = BearerAuthProvider(
|
| 304 |
+
public_key=rsa_key_pair.public_key, issuer="https://test.example.com"
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
assert provider.issuer == "https://test.example.com"
|
| 308 |
+
assert provider.public_key is not None
|
| 309 |
+
assert provider.jwks_uri is None
|
| 310 |
+
|
| 311 |
+
def test_initialization_with_jwks_uri(self):
|
| 312 |
+
"""Test provider initialization with JWKS URI."""
|
| 313 |
+
provider = BearerAuthProvider(
|
| 314 |
+
jwks_uri="https://test.example.com/.well-known/jwks.json",
|
| 315 |
+
issuer="https://test.example.com",
|
| 316 |
+
)
|
| 317 |
+
|
| 318 |
+
assert provider.issuer == "https://test.example.com"
|
| 319 |
+
assert provider.jwks_uri == "https://test.example.com/.well-known/jwks.json"
|
| 320 |
+
assert provider.public_key is None
|
| 321 |
+
|
| 322 |
+
def test_initialization_requires_key_or_uri(self):
|
| 323 |
+
"""Test that either public_key or jwks_uri is required."""
|
| 324 |
+
with pytest.raises(
|
| 325 |
+
ValueError, match="Either public_key or jwks_uri must be provided"
|
| 326 |
+
):
|
| 327 |
+
BearerAuthProvider(issuer="https://test.example.com")
|
| 328 |
+
|
| 329 |
+
def test_initialization_rejects_both_key_and_uri(self, rsa_key_pair: RSAKeyPair):
|
| 330 |
+
"""Test that both public_key and jwks_uri cannot be provided."""
|
| 331 |
+
with pytest.raises(
|
| 332 |
+
ValueError, match="Provide either public_key or jwks_uri, not both"
|
| 333 |
+
):
|
| 334 |
+
BearerAuthProvider(
|
| 335 |
+
public_key=rsa_key_pair.public_key,
|
| 336 |
+
jwks_uri="https://test.example.com/.well-known/jwks.json",
|
| 337 |
+
issuer="https://test.example.com",
|
| 338 |
+
)
|
| 339 |
+
|
| 340 |
+
async def test_valid_token_validation(
|
| 341 |
+
self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
|
| 342 |
+
):
|
| 343 |
+
"""Test validation of a valid token."""
|
| 344 |
+
token = rsa_key_pair.create_token(
|
| 345 |
+
subject="test-user",
|
| 346 |
+
issuer="https://test.example.com",
|
| 347 |
+
audience="https://api.example.com",
|
| 348 |
+
scopes=["read", "write"],
|
| 349 |
+
)
|
| 350 |
+
|
| 351 |
+
access_token = await bearer_provider.load_access_token(token)
|
| 352 |
+
|
| 353 |
+
assert access_token is not None
|
| 354 |
+
assert access_token.client_id == "test-user"
|
| 355 |
+
assert "read" in access_token.scopes
|
| 356 |
+
assert "write" in access_token.scopes
|
| 357 |
+
assert access_token.expires_at is not None
|
| 358 |
+
|
| 359 |
+
async def test_expired_token_rejection(
|
| 360 |
+
self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
|
| 361 |
+
):
|
| 362 |
+
"""Test rejection of expired tokens."""
|
| 363 |
+
token = rsa_key_pair.create_token(
|
| 364 |
+
subject="test-user",
|
| 365 |
+
issuer="https://test.example.com",
|
| 366 |
+
audience="https://api.example.com",
|
| 367 |
+
expires_in_seconds=-3600, # Expired 1 hour ago
|
| 368 |
+
)
|
| 369 |
+
|
| 370 |
+
access_token = await bearer_provider.load_access_token(token)
|
| 371 |
+
assert access_token is None
|
| 372 |
+
|
| 373 |
+
async def test_invalid_issuer_rejection(
|
| 374 |
+
self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
|
| 375 |
+
):
|
| 376 |
+
"""Test rejection of tokens with invalid issuer."""
|
| 377 |
+
token = rsa_key_pair.create_token(
|
| 378 |
+
subject="test-user",
|
| 379 |
+
issuer="https://evil.example.com", # Wrong issuer
|
| 380 |
+
audience="https://api.example.com",
|
| 381 |
+
)
|
| 382 |
+
|
| 383 |
+
access_token = await bearer_provider.load_access_token(token)
|
| 384 |
+
assert access_token is None
|
| 385 |
+
|
| 386 |
+
async def test_invalid_audience_rejection(
|
| 387 |
+
self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
|
| 388 |
+
):
|
| 389 |
+
"""Test rejection of tokens with invalid audience."""
|
| 390 |
+
token = rsa_key_pair.create_token(
|
| 391 |
+
subject="test-user",
|
| 392 |
+
issuer="https://test.example.com",
|
| 393 |
+
audience="https://wrong-api.example.com", # Wrong audience
|
| 394 |
+
)
|
| 395 |
+
|
| 396 |
+
access_token = await bearer_provider.load_access_token(token)
|
| 397 |
+
assert access_token is None
|
| 398 |
+
|
| 399 |
+
async def test_no_issuer_validation_when_none(self, rsa_key_pair: RSAKeyPair):
|
| 400 |
+
"""Test that issuer validation is skipped when provider has no issuer configured."""
|
| 401 |
+
provider = BearerAuthProvider(
|
| 402 |
+
public_key=rsa_key_pair.public_key,
|
| 403 |
+
issuer=None, # No issuer validation
|
| 404 |
+
)
|
| 405 |
+
|
| 406 |
+
token = rsa_key_pair.create_token(
|
| 407 |
+
subject="test-user", issuer="https://any.example.com"
|
| 408 |
+
)
|
| 409 |
+
|
| 410 |
+
access_token = await provider.load_access_token(token)
|
| 411 |
+
assert access_token is not None
|
| 412 |
+
|
| 413 |
+
async def test_no_audience_validation_when_none(self, rsa_key_pair: RSAKeyPair):
|
| 414 |
+
"""Test that audience validation is skipped when provider has no audience configured."""
|
| 415 |
+
provider = BearerAuthProvider(
|
| 416 |
+
public_key=rsa_key_pair.public_key,
|
| 417 |
+
issuer="https://test.example.com",
|
| 418 |
+
audience=None, # No audience validation
|
| 419 |
+
)
|
| 420 |
+
|
| 421 |
+
token = rsa_key_pair.create_token(
|
| 422 |
+
subject="test-user",
|
| 423 |
+
issuer="https://test.example.com",
|
| 424 |
+
audience="https://any-api.example.com",
|
| 425 |
+
)
|
| 426 |
+
|
| 427 |
+
access_token = await provider.load_access_token(token)
|
| 428 |
+
assert access_token is not None
|
| 429 |
+
|
| 430 |
+
async def test_multiple_audiences_validation(self, rsa_key_pair: RSAKeyPair):
|
| 431 |
+
"""Test validation with multiple audiences in token."""
|
| 432 |
+
provider = BearerAuthProvider(
|
| 433 |
+
public_key=rsa_key_pair.public_key,
|
| 434 |
+
issuer="https://test.example.com",
|
| 435 |
+
audience="https://api.example.com",
|
| 436 |
+
)
|
| 437 |
+
|
| 438 |
+
token = rsa_key_pair.create_token(
|
| 439 |
+
subject="test-user",
|
| 440 |
+
issuer="https://test.example.com",
|
| 441 |
+
additional_claims={
|
| 442 |
+
"aud": ["https://api.example.com", "https://other-api.example.com"]
|
| 443 |
+
},
|
| 444 |
+
)
|
| 445 |
+
|
| 446 |
+
access_token = await provider.load_access_token(token)
|
| 447 |
+
assert access_token is not None
|
| 448 |
+
|
| 449 |
+
async def test_scope_extraction_string(
|
| 450 |
+
self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
|
| 451 |
+
):
|
| 452 |
+
"""Test scope extraction from space-separated string."""
|
| 453 |
+
token = rsa_key_pair.create_token(
|
| 454 |
+
subject="test-user",
|
| 455 |
+
issuer="https://test.example.com",
|
| 456 |
+
audience="https://api.example.com",
|
| 457 |
+
scopes=["read", "write", "admin"],
|
| 458 |
+
)
|
| 459 |
+
|
| 460 |
+
access_token = await bearer_provider.load_access_token(token)
|
| 461 |
+
|
| 462 |
+
assert access_token is not None
|
| 463 |
+
assert set(access_token.scopes) == {"read", "write", "admin"}
|
| 464 |
+
|
| 465 |
+
async def test_scope_extraction_list(
|
| 466 |
+
self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
|
| 467 |
+
):
|
| 468 |
+
"""Test scope extraction from list format."""
|
| 469 |
+
token = rsa_key_pair.create_token(
|
| 470 |
+
subject="test-user",
|
| 471 |
+
issuer="https://test.example.com",
|
| 472 |
+
audience="https://api.example.com",
|
| 473 |
+
additional_claims={"scope": ["read", "write"]}, # List format
|
| 474 |
+
)
|
| 475 |
+
|
| 476 |
+
access_token = await bearer_provider.load_access_token(token)
|
| 477 |
+
|
| 478 |
+
assert access_token is not None
|
| 479 |
+
assert set(access_token.scopes) == {"read", "write"}
|
| 480 |
+
|
| 481 |
+
async def test_no_scopes(
|
| 482 |
+
self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
|
| 483 |
+
):
|
| 484 |
+
"""Test token with no scopes."""
|
| 485 |
+
token = rsa_key_pair.create_token(
|
| 486 |
+
subject="test-user",
|
| 487 |
+
issuer="https://test.example.com",
|
| 488 |
+
audience="https://api.example.com",
|
| 489 |
+
# No scopes
|
| 490 |
+
)
|
| 491 |
+
|
| 492 |
+
access_token = await bearer_provider.load_access_token(token)
|
| 493 |
+
|
| 494 |
+
assert access_token is not None
|
| 495 |
+
assert access_token.scopes == []
|
| 496 |
+
|
| 497 |
+
async def test_malformed_token_rejection(self, bearer_provider: BearerAuthProvider):
|
| 498 |
+
"""Test rejection of malformed tokens."""
|
| 499 |
+
malformed_tokens = [
|
| 500 |
+
"not.a.jwt",
|
| 501 |
+
"too.many.parts.here.invalid",
|
| 502 |
+
"invalid-token",
|
| 503 |
+
"",
|
| 504 |
+
"header.body", # Missing signature
|
| 505 |
+
]
|
| 506 |
+
|
| 507 |
+
for token in malformed_tokens:
|
| 508 |
+
access_token = await bearer_provider.load_access_token(token)
|
| 509 |
+
assert access_token is None
|
| 510 |
+
|
| 511 |
+
async def test_invalid_signature_rejection(
|
| 512 |
+
self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
|
| 513 |
+
):
|
| 514 |
+
"""Test rejection of tokens with invalid signatures."""
|
| 515 |
+
# Create a token with a different key pair
|
| 516 |
+
other_key_pair = RSAKeyPair.generate()
|
| 517 |
+
token = other_key_pair.create_token(
|
| 518 |
+
subject="test-user",
|
| 519 |
+
issuer="https://test.example.com",
|
| 520 |
+
audience="https://api.example.com",
|
| 521 |
+
)
|
| 522 |
+
|
| 523 |
+
access_token = await bearer_provider.load_access_token(token)
|
| 524 |
+
assert access_token is None
|
| 525 |
+
|
| 526 |
+
async def test_client_id_fallback(
|
| 527 |
+
self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider
|
| 528 |
+
):
|
| 529 |
+
"""Test client_id extraction with fallback logic."""
|
| 530 |
+
# Test with explicit client_id claim
|
| 531 |
+
token = rsa_key_pair.create_token(
|
| 532 |
+
subject="user123",
|
| 533 |
+
issuer="https://test.example.com",
|
| 534 |
+
audience="https://api.example.com",
|
| 535 |
+
additional_claims={"client_id": "app456"},
|
| 536 |
+
)
|
| 537 |
+
|
| 538 |
+
access_token = await bearer_provider.load_access_token(token)
|
| 539 |
+
assert access_token is not None
|
| 540 |
+
assert access_token.client_id == "app456" # Should prefer client_id over sub
|
| 541 |
+
|
| 542 |
+
|
| 543 |
+
class TestFastMCPBearerAuth:
|
| 544 |
+
def test_bearer_auth(self):
|
| 545 |
+
mcp = FastMCP(
|
| 546 |
+
auth=BearerAuthProvider(issuer="https://test.example.com", public_key="abc")
|
| 547 |
+
)
|
| 548 |
+
assert isinstance(mcp.auth, BearerAuthProvider)
|
| 549 |
+
|
| 550 |
+
async def test_unauthorized_access(self, mcp_server_url: str):
|
| 551 |
+
with pytest.raises(httpx.HTTPStatusError) as exc_info:
|
| 552 |
+
async with Client(mcp_server_url) as client:
|
| 553 |
+
tools = await client.list_tools() # noqa: F841
|
| 554 |
+
assert exc_info.value.response.status_code == 401
|
| 555 |
+
assert "tools" not in locals()
|
| 556 |
+
|
| 557 |
+
async def test_authorized_access(self, mcp_server_url: str, bearer_token):
|
| 558 |
+
async with Client(mcp_server_url, auth=BearerAuth(bearer_token)) as client:
|
| 559 |
+
tools = await client.list_tools() # noqa: F841
|
| 560 |
+
assert tools
|
| 561 |
+
|
| 562 |
+
async def test_invalid_token_raises_401(self, mcp_server_url: str):
|
| 563 |
+
with pytest.raises(httpx.HTTPStatusError) as exc_info:
|
| 564 |
+
async with Client(mcp_server_url, auth=BearerAuth("invalid")) as client:
|
| 565 |
+
tools = await client.list_tools() # noqa: F841
|
| 566 |
+
assert exc_info.value.response.status_code == 401
|
| 567 |
+
assert "tools" not in locals()
|
| 568 |
+
|
| 569 |
+
async def test_expired_token(self, mcp_server_url: str, rsa_key_pair: RSAKeyPair):
|
| 570 |
+
token = rsa_key_pair.create_token(
|
| 571 |
+
subject="test-user",
|
| 572 |
+
issuer="https://test.example.com",
|
| 573 |
+
audience="https://api.example.com",
|
| 574 |
+
expires_in_seconds=-3600,
|
| 575 |
+
)
|
| 576 |
+
|
| 577 |
+
with pytest.raises(httpx.HTTPStatusError) as exc_info:
|
| 578 |
+
async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
|
| 579 |
+
tools = await client.list_tools() # noqa: F841
|
| 580 |
+
assert exc_info.value.response.status_code == 401
|
| 581 |
+
assert "tools" not in locals()
|
| 582 |
+
|
| 583 |
+
async def test_token_with_bad_signature(self, mcp_server_url: str):
|
| 584 |
+
rsa_key_pair = RSAKeyPair.generate()
|
| 585 |
+
token = rsa_key_pair.create_token()
|
| 586 |
+
|
| 587 |
+
with pytest.raises(httpx.HTTPStatusError) as exc_info:
|
| 588 |
+
async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
|
| 589 |
+
tools = await client.list_tools() # noqa: F841
|
| 590 |
+
assert exc_info.value.response.status_code == 401
|
| 591 |
+
assert "tools" not in locals()
|
| 592 |
+
|
| 593 |
+
async def test_token_with_insufficient_scopes(
|
| 594 |
+
self, mcp_server_url: str, rsa_key_pair: RSAKeyPair
|
| 595 |
+
):
|
| 596 |
+
token = rsa_key_pair.create_token(
|
| 597 |
+
subject="test-user",
|
| 598 |
+
issuer="https://test.example.com",
|
| 599 |
+
audience="https://api.example.com",
|
| 600 |
+
scopes=["read"],
|
| 601 |
+
)
|
| 602 |
+
|
| 603 |
+
with run_server_in_process(
|
| 604 |
+
run_mcp_server,
|
| 605 |
+
public_key=rsa_key_pair.public_key,
|
| 606 |
+
auth_kwargs=dict(required_scopes=["read", "write"]),
|
| 607 |
+
run_kwargs=dict(transport="streamable-http"),
|
| 608 |
+
) as url:
|
| 609 |
+
mcp_server_url = f"{url}/mcp"
|
| 610 |
+
with pytest.raises(httpx.HTTPStatusError) as exc_info:
|
| 611 |
+
async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
|
| 612 |
+
tools = await client.list_tools() # noqa: F841
|
| 613 |
+
assert exc_info.value.response.status_code == 403
|
| 614 |
+
assert "tools" not in locals()
|
| 615 |
+
|
| 616 |
+
async def test_token_with_sufficient_scopes(
|
| 617 |
+
self, mcp_server_url: str, rsa_key_pair: RSAKeyPair
|
| 618 |
+
):
|
| 619 |
+
token = rsa_key_pair.create_token(
|
| 620 |
+
subject="test-user",
|
| 621 |
+
issuer="https://test.example.com",
|
| 622 |
+
audience="https://api.example.com",
|
| 623 |
+
scopes=["read", "write"],
|
| 624 |
+
)
|
| 625 |
+
|
| 626 |
+
with run_server_in_process(
|
| 627 |
+
run_mcp_server,
|
| 628 |
+
public_key=rsa_key_pair.public_key,
|
| 629 |
+
auth_kwargs=dict(required_scopes=["read", "write"]),
|
| 630 |
+
run_kwargs=dict(transport="streamable-http"),
|
| 631 |
+
) as url:
|
| 632 |
+
mcp_server_url = f"{url}/mcp"
|
| 633 |
+
async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
|
| 634 |
+
tools = await client.list_tools()
|
| 635 |
+
assert tools
|
tests/auth/providers/test_bearer_env.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from pydantic import AnyHttpUrl, ValidationError
|
| 3 |
+
|
| 4 |
+
from fastmcp import FastMCP
|
| 5 |
+
from fastmcp.server.auth.providers.bearer import BearerAuthProvider
|
| 6 |
+
from fastmcp.server.auth.providers.bearer_env import EnvBearerAuthProvider
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def test_load_bearer_env_from_env_var(monkeypatch):
|
| 10 |
+
mcp = FastMCP()
|
| 11 |
+
assert mcp.auth is None
|
| 12 |
+
|
| 13 |
+
monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env")
|
| 14 |
+
monkeypatch.setenv("FASTMCP_AUTH_BEARER_PUBLIC_KEY", "test-public-key")
|
| 15 |
+
|
| 16 |
+
mcp_with_auth = FastMCP()
|
| 17 |
+
assert isinstance(mcp_with_auth.auth, EnvBearerAuthProvider)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def test_load_bearer_env_from_env_var_requires_public_key_or_jwks_uri(monkeypatch):
|
| 21 |
+
mcp = FastMCP()
|
| 22 |
+
assert mcp.auth is None
|
| 23 |
+
|
| 24 |
+
monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env")
|
| 25 |
+
|
| 26 |
+
with pytest.raises(
|
| 27 |
+
ValueError, match="Either public_key or jwks_uri must be provided"
|
| 28 |
+
):
|
| 29 |
+
FastMCP()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_configure_bearer_env_from_env_var(monkeypatch):
|
| 33 |
+
monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env")
|
| 34 |
+
monkeypatch.setenv("FASTMCP_AUTH_BEARER_PUBLIC_KEY", "test-public-key")
|
| 35 |
+
monkeypatch.setenv("FASTMCP_AUTH_BEARER_ISSUER", "http://test-issuer")
|
| 36 |
+
monkeypatch.setenv("FASTMCP_AUTH_BEARER_AUDIENCE", "test-audience")
|
| 37 |
+
monkeypatch.setenv(
|
| 38 |
+
"FASTMCP_AUTH_BEARER_REQUIRED_SCOPES", '["test-scope1", "test-scope2"]'
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
mcp = FastMCP()
|
| 42 |
+
assert isinstance(mcp.auth, EnvBearerAuthProvider)
|
| 43 |
+
assert mcp.auth.public_key == "test-public-key"
|
| 44 |
+
assert mcp.auth.issuer_url == AnyHttpUrl("http://test-issuer")
|
| 45 |
+
assert mcp.auth.audience == "test-audience"
|
| 46 |
+
assert mcp.auth.required_scopes == ["test-scope1", "test-scope2"]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_list_of_scopes_must_be_a_list(monkeypatch):
|
| 50 |
+
monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env")
|
| 51 |
+
monkeypatch.setenv("FASTMCP_AUTH_BEARER_REQUIRED_SCOPES", "test-scope1")
|
| 52 |
+
|
| 53 |
+
with pytest.raises(ValidationError, match="Input should be a valid list"):
|
| 54 |
+
FastMCP()
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_configure_bearer_env_jwks_uri_from_env_var(monkeypatch):
|
| 58 |
+
monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env")
|
| 59 |
+
monkeypatch.setenv("FASTMCP_AUTH_BEARER_JWKS_URI", "test-jwks-uri")
|
| 60 |
+
|
| 61 |
+
mcp = FastMCP()
|
| 62 |
+
assert isinstance(mcp.auth, EnvBearerAuthProvider)
|
| 63 |
+
assert mcp.auth.jwks_uri == "test-jwks-uri"
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_configure_bearer_env_public_key_and_jwks_uri_error(monkeypatch):
|
| 67 |
+
monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env")
|
| 68 |
+
monkeypatch.setenv("FASTMCP_AUTH_BEARER_PUBLIC_KEY", "test-public-key")
|
| 69 |
+
monkeypatch.setenv("FASTMCP_AUTH_BEARER_JWKS_URI", "test-jwks-uri")
|
| 70 |
+
|
| 71 |
+
with pytest.raises(ValueError, match="Provide either public_key or jwks_uri"):
|
| 72 |
+
FastMCP()
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def test_provided_auth_takes_precedence_over_env_vars(monkeypatch):
|
| 76 |
+
monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env")
|
| 77 |
+
monkeypatch.setenv("FASTMCP_AUTH_BEARER_PUBLIC_KEY", "test-public-key")
|
| 78 |
+
|
| 79 |
+
mcp = FastMCP(auth=BearerAuthProvider(public_key="test-public-key-2"))
|
| 80 |
+
assert isinstance(mcp.auth, BearerAuthProvider)
|
| 81 |
+
assert not isinstance(mcp.auth, EnvBearerAuthProvider)
|
| 82 |
+
assert mcp.auth.public_key == "test-public-key-2"
|
tests/auth/test_oauth_client.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from collections.abc import Generator
|
| 2 |
+
from unittest.mock import patch
|
| 3 |
+
from urllib.parse import parse_qs, urlparse
|
| 4 |
+
|
| 5 |
+
import httpx
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
import fastmcp.client.auth # Import module, not the function directly
|
| 9 |
+
from fastmcp.client import Client
|
| 10 |
+
from fastmcp.client.transports import StreamableHttpTransport
|
| 11 |
+
from fastmcp.server.auth.auth import ClientRegistrationOptions
|
| 12 |
+
from fastmcp.server.auth.providers.in_memory import InMemoryOAuthProvider
|
| 13 |
+
from fastmcp.server.server import FastMCP
|
| 14 |
+
from fastmcp.utilities.tests import run_server_in_process
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def fastmcp_server(issuer_url: str):
|
| 18 |
+
"""Create a FastMCP server with OAuth authentication."""
|
| 19 |
+
server = FastMCP(
|
| 20 |
+
"TestServer",
|
| 21 |
+
auth=InMemoryOAuthProvider(
|
| 22 |
+
issuer_url=issuer_url,
|
| 23 |
+
client_registration_options=ClientRegistrationOptions(enabled=True),
|
| 24 |
+
),
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
@server.tool()
|
| 28 |
+
def add(a: int, b: int) -> int:
|
| 29 |
+
"""Add two numbers together."""
|
| 30 |
+
return a + b
|
| 31 |
+
|
| 32 |
+
@server.resource("resource://test")
|
| 33 |
+
def get_test_resource() -> str:
|
| 34 |
+
"""Get a test resource."""
|
| 35 |
+
return "Hello from authenticated resource!"
|
| 36 |
+
|
| 37 |
+
return server
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def run_server(host: str, port: int, **kwargs) -> None:
|
| 41 |
+
fastmcp_server(f"http://{host}:{port}").run(host=host, port=port, **kwargs)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@pytest.fixture(scope="module")
|
| 45 |
+
def streamable_http_server() -> Generator[str, None, None]:
|
| 46 |
+
with run_server_in_process(run_server, transport="streamable-http") as url:
|
| 47 |
+
yield f"{url}/mcp"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@pytest.fixture()
|
| 51 |
+
def client_unauthorized(streamable_http_server: str) -> Client:
|
| 52 |
+
return Client(transport=StreamableHttpTransport(streamable_http_server))
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class HeadlessOAuthProvider(httpx.Auth):
|
| 56 |
+
"""
|
| 57 |
+
OAuth provider that bypasses browser interaction for testing.
|
| 58 |
+
|
| 59 |
+
This simulates the complete OAuth flow programmatically by:
|
| 60 |
+
1. Discovering OAuth metadata from the server
|
| 61 |
+
2. Registering a client
|
| 62 |
+
3. Getting an authorization code (simulates user approval)
|
| 63 |
+
4. Exchanging it for an access token
|
| 64 |
+
5. Adding Bearer token to all requests
|
| 65 |
+
|
| 66 |
+
This enables testing OAuth-protected FastMCP servers without
|
| 67 |
+
requiring browser interaction or external OAuth providers.
|
| 68 |
+
"""
|
| 69 |
+
|
| 70 |
+
def __init__(self, mcp_url: str):
|
| 71 |
+
self.mcp_url = mcp_url
|
| 72 |
+
parsed_url = urlparse(mcp_url)
|
| 73 |
+
self.server_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
|
| 74 |
+
self._access_token = None
|
| 75 |
+
|
| 76 |
+
async def async_auth_flow(self, request):
|
| 77 |
+
"""httpx.Auth interface - add Bearer token to requests."""
|
| 78 |
+
if not self._access_token:
|
| 79 |
+
await self._obtain_token()
|
| 80 |
+
|
| 81 |
+
if self._access_token:
|
| 82 |
+
request.headers["Authorization"] = f"Bearer {self._access_token}"
|
| 83 |
+
|
| 84 |
+
yield request
|
| 85 |
+
|
| 86 |
+
async def _obtain_token(self):
|
| 87 |
+
"""Get a valid access token by simulating the OAuth flow."""
|
| 88 |
+
import base64
|
| 89 |
+
import hashlib
|
| 90 |
+
import secrets
|
| 91 |
+
|
| 92 |
+
from mcp.shared.auth import OAuthClientInformationFull
|
| 93 |
+
from pydantic import AnyHttpUrl
|
| 94 |
+
|
| 95 |
+
# Generate PKCE challenge/verifier
|
| 96 |
+
code_verifier = (
|
| 97 |
+
base64.urlsafe_b64encode(secrets.token_bytes(32)).decode().rstrip("=")
|
| 98 |
+
)
|
| 99 |
+
code_challenge = (
|
| 100 |
+
base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest())
|
| 101 |
+
.decode()
|
| 102 |
+
.rstrip("=")
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
# Create HTTP client to talk to the server
|
| 106 |
+
async with httpx.AsyncClient() as http_client:
|
| 107 |
+
# 1. Discover OAuth metadata
|
| 108 |
+
metadata_url = (
|
| 109 |
+
f"{self.server_base_url}/.well-known/oauth-authorization-server"
|
| 110 |
+
)
|
| 111 |
+
response = await http_client.get(metadata_url)
|
| 112 |
+
response.raise_for_status()
|
| 113 |
+
metadata = response.json()
|
| 114 |
+
|
| 115 |
+
# 2. Register a client
|
| 116 |
+
client_info = OAuthClientInformationFull(
|
| 117 |
+
client_id="test_client_headless",
|
| 118 |
+
client_secret="test_secret_headless",
|
| 119 |
+
redirect_uris=[AnyHttpUrl("http://localhost:8080/callback")],
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
register_response = await http_client.post(
|
| 123 |
+
metadata["registration_endpoint"],
|
| 124 |
+
json=client_info.model_dump(mode="json"),
|
| 125 |
+
)
|
| 126 |
+
register_response.raise_for_status()
|
| 127 |
+
registered_client = register_response.json()
|
| 128 |
+
|
| 129 |
+
# 3. Get authorization code (simulate user approval)
|
| 130 |
+
auth_params = {
|
| 131 |
+
"response_type": "code",
|
| 132 |
+
"client_id": registered_client["client_id"],
|
| 133 |
+
"redirect_uri": "http://localhost:8080/callback",
|
| 134 |
+
"code_challenge": code_challenge,
|
| 135 |
+
"code_challenge_method": "S256",
|
| 136 |
+
"state": "test_state_headless",
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
auth_response = await http_client.get(
|
| 140 |
+
metadata["authorization_endpoint"],
|
| 141 |
+
params=auth_params,
|
| 142 |
+
follow_redirects=False,
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
# Extract auth code from redirect
|
| 146 |
+
if auth_response.status_code == 302:
|
| 147 |
+
redirect_url = auth_response.headers["location"]
|
| 148 |
+
parsed = urlparse(redirect_url)
|
| 149 |
+
query_params = parse_qs(parsed.query)
|
| 150 |
+
|
| 151 |
+
if "error" in query_params:
|
| 152 |
+
error = query_params["error"][0]
|
| 153 |
+
error_desc = query_params.get(
|
| 154 |
+
"error_description", ["Unknown error"]
|
| 155 |
+
)[0]
|
| 156 |
+
raise RuntimeError(
|
| 157 |
+
f"OAuth authorization failed: {error} - {error_desc}"
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
auth_code = query_params["code"][0]
|
| 161 |
+
|
| 162 |
+
# 4. Exchange auth code for access token
|
| 163 |
+
token_data = {
|
| 164 |
+
"grant_type": "authorization_code",
|
| 165 |
+
"client_id": registered_client["client_id"],
|
| 166 |
+
"client_secret": registered_client["client_secret"],
|
| 167 |
+
"code": auth_code,
|
| 168 |
+
"redirect_uri": "http://localhost:8080/callback",
|
| 169 |
+
"code_verifier": code_verifier,
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
token_response = await http_client.post(
|
| 173 |
+
metadata["token_endpoint"], data=token_data
|
| 174 |
+
)
|
| 175 |
+
token_response.raise_for_status()
|
| 176 |
+
token_info = token_response.json()
|
| 177 |
+
|
| 178 |
+
self._access_token = token_info["access_token"]
|
| 179 |
+
else:
|
| 180 |
+
raise RuntimeError(f"Authorization failed: {auth_response.status_code}")
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
@pytest.fixture()
|
| 184 |
+
def client_with_headless_oauth(
|
| 185 |
+
streamable_http_server: str,
|
| 186 |
+
) -> Generator[Client, None, None]:
|
| 187 |
+
"""Client with headless OAuth that bypasses browser interaction."""
|
| 188 |
+
|
| 189 |
+
# Patch the OAuth function to return our headless provider
|
| 190 |
+
def headless_oauth(*args, **kwargs):
|
| 191 |
+
mcp_url = args[0] if args else kwargs.get("mcp_url", "")
|
| 192 |
+
if not mcp_url:
|
| 193 |
+
raise ValueError("mcp_url is required")
|
| 194 |
+
return HeadlessOAuthProvider(mcp_url)
|
| 195 |
+
|
| 196 |
+
with patch("fastmcp.client.auth.OAuth", side_effect=headless_oauth):
|
| 197 |
+
client = Client(
|
| 198 |
+
transport=StreamableHttpTransport(streamable_http_server),
|
| 199 |
+
auth=fastmcp.client.auth.OAuth(mcp_url=streamable_http_server),
|
| 200 |
+
)
|
| 201 |
+
yield client
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
async def test_unauthorized(client_unauthorized: Client):
|
| 205 |
+
"""Test that unauthenticated requests are rejected."""
|
| 206 |
+
with pytest.raises(httpx.HTTPStatusError, match="401 Unauthorized"):
|
| 207 |
+
async with client_unauthorized:
|
| 208 |
+
pass
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
async def test_ping(client_with_headless_oauth: Client):
|
| 212 |
+
"""Test that we can ping the server."""
|
| 213 |
+
async with client_with_headless_oauth:
|
| 214 |
+
assert await client_with_headless_oauth.ping()
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
async def test_list_tools(client_with_headless_oauth: Client):
|
| 218 |
+
"""Test that we can list tools."""
|
| 219 |
+
async with client_with_headless_oauth:
|
| 220 |
+
tools = await client_with_headless_oauth.list_tools()
|
| 221 |
+
tool_names = [tool.name for tool in tools]
|
| 222 |
+
assert "add" in tool_names
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
async def test_call_tool(client_with_headless_oauth: Client):
|
| 226 |
+
"""Test that we can call a tool."""
|
| 227 |
+
async with client_with_headless_oauth:
|
| 228 |
+
result = await client_with_headless_oauth.call_tool("add", {"a": 5, "b": 3})
|
| 229 |
+
assert result[0].text == "8" # type: ignore[attr-defined]
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
async def test_list_resources(client_with_headless_oauth: Client):
|
| 233 |
+
"""Test that we can list resources."""
|
| 234 |
+
async with client_with_headless_oauth:
|
| 235 |
+
resources = await client_with_headless_oauth.list_resources()
|
| 236 |
+
resource_uris = [str(resource.uri) for resource in resources]
|
| 237 |
+
assert "resource://test" in resource_uris
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
async def test_read_resource(client_with_headless_oauth: Client):
|
| 241 |
+
"""Test that we can read a resource."""
|
| 242 |
+
async with client_with_headless_oauth:
|
| 243 |
+
resource = await client_with_headless_oauth.read_resource("resource://test")
|
| 244 |
+
assert resource[0].text == "Hello from authenticated resource!" # type: ignore[attr-defined]
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
async def test_oauth_server_metadata_discovery(streamable_http_server: str):
|
| 248 |
+
"""Test that we can discover OAuth metadata from the running server."""
|
| 249 |
+
parsed_url = urlparse(streamable_http_server)
|
| 250 |
+
server_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
|
| 251 |
+
|
| 252 |
+
async with httpx.AsyncClient() as client:
|
| 253 |
+
# Test OAuth discovery endpoint
|
| 254 |
+
metadata_url = f"{server_base_url}/.well-known/oauth-authorization-server"
|
| 255 |
+
response = await client.get(metadata_url)
|
| 256 |
+
assert response.status_code == 200
|
| 257 |
+
|
| 258 |
+
metadata = response.json()
|
| 259 |
+
assert "authorization_endpoint" in metadata
|
| 260 |
+
assert "token_endpoint" in metadata
|
| 261 |
+
assert "registration_endpoint" in metadata
|
| 262 |
+
|
| 263 |
+
# The endpoints should be properly formed URLs
|
| 264 |
+
assert metadata["authorization_endpoint"].startswith(server_base_url)
|
| 265 |
+
assert metadata["token_endpoint"].startswith(server_base_url)
|
tests/cli/__init__.py
ADDED
|
File without changes
|
tests/client/__init__.py
CHANGED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
"""Client tests package."""
|
|
|
|
|
|
tests/client/test_client.py
CHANGED
|
@@ -16,7 +16,6 @@ from fastmcp.client.transports import (
|
|
| 16 |
infer_transport,
|
| 17 |
)
|
| 18 |
from fastmcp.exceptions import ResourceError, ToolError
|
| 19 |
-
from fastmcp.prompts.prompt import TextContent
|
| 20 |
from fastmcp.server.server import FastMCP
|
| 21 |
|
| 22 |
|
|
@@ -201,8 +200,7 @@ async def test_get_prompt(fastmcp_server):
|
|
| 201 |
result = await client.get_prompt("welcome", {"name": "Developer"})
|
| 202 |
|
| 203 |
# The result should contain our welcome message
|
| 204 |
-
assert
|
| 205 |
-
assert result.messages[0].content.text == "Welcome to FastMCP, Developer!"
|
| 206 |
assert result.description == "Example greeting prompt."
|
| 207 |
|
| 208 |
|
|
@@ -214,8 +212,7 @@ async def test_get_prompt_mcp(fastmcp_server):
|
|
| 214 |
result = await client.get_prompt_mcp("welcome", {"name": "Developer"})
|
| 215 |
|
| 216 |
# The result should contain our welcome message
|
| 217 |
-
assert
|
| 218 |
-
assert result.messages[0].content.text == "Welcome to FastMCP, Developer!"
|
| 219 |
assert result.description == "Example greeting prompt."
|
| 220 |
|
| 221 |
|
|
@@ -342,6 +339,52 @@ async def test_client_nested_context_manager(fastmcp_server):
|
|
| 342 |
assert client._session is None
|
| 343 |
|
| 344 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
async def test_resource_template(fastmcp_server):
|
| 346 |
"""Test using a resource template with InMemoryClient."""
|
| 347 |
client = Client(transport=FastMCPTransport(fastmcp_server))
|
|
@@ -476,9 +519,8 @@ class TestErrorHandling:
|
|
| 476 |
async with client:
|
| 477 |
result = await client.call_tool_mcp("error_tool", {})
|
| 478 |
assert result.isError
|
| 479 |
-
assert
|
| 480 |
-
assert "
|
| 481 |
-
assert "abc" in result.content[0].text
|
| 482 |
|
| 483 |
async def test_general_tool_exceptions_are_masked_when_enabled(self):
|
| 484 |
mcp = FastMCP("TestServer", mask_error_details=True)
|
|
@@ -492,9 +534,8 @@ class TestErrorHandling:
|
|
| 492 |
async with client:
|
| 493 |
result = await client.call_tool_mcp("error_tool", {})
|
| 494 |
assert result.isError
|
| 495 |
-
assert
|
| 496 |
-
assert "
|
| 497 |
-
assert "abc" not in result.content[0].text
|
| 498 |
|
| 499 |
async def test_specific_tool_errors_are_sent_to_client(self):
|
| 500 |
mcp = FastMCP("TestServer")
|
|
@@ -508,9 +549,8 @@ class TestErrorHandling:
|
|
| 508 |
async with client:
|
| 509 |
result = await client.call_tool_mcp("custom_error_tool", {})
|
| 510 |
assert result.isError
|
| 511 |
-
assert
|
| 512 |
-
assert "
|
| 513 |
-
assert "abc" in result.content[0].text
|
| 514 |
|
| 515 |
async def test_general_resource_exceptions_are_not_masked_by_default(self):
|
| 516 |
mcp = FastMCP("TestServer")
|
|
|
|
| 16 |
infer_transport,
|
| 17 |
)
|
| 18 |
from fastmcp.exceptions import ResourceError, ToolError
|
|
|
|
| 19 |
from fastmcp.server.server import FastMCP
|
| 20 |
|
| 21 |
|
|
|
|
| 200 |
result = await client.get_prompt("welcome", {"name": "Developer"})
|
| 201 |
|
| 202 |
# The result should contain our welcome message
|
| 203 |
+
assert result.messages[0].content.text == "Welcome to FastMCP, Developer!" # type: ignore[attr-defined]
|
|
|
|
| 204 |
assert result.description == "Example greeting prompt."
|
| 205 |
|
| 206 |
|
|
|
|
| 212 |
result = await client.get_prompt_mcp("welcome", {"name": "Developer"})
|
| 213 |
|
| 214 |
# The result should contain our welcome message
|
| 215 |
+
assert result.messages[0].content.text == "Welcome to FastMCP, Developer!" # type: ignore[attr-defined]
|
|
|
|
| 216 |
assert result.description == "Example greeting prompt."
|
| 217 |
|
| 218 |
|
|
|
|
| 339 |
assert client._session is None
|
| 340 |
|
| 341 |
|
| 342 |
+
async def test_concurrent_client_context_managers():
|
| 343 |
+
"""
|
| 344 |
+
Test that concurrent client usage doesn't cause cross-task cancel scope issues.
|
| 345 |
+
https://github.com/jlowin/fastmcp/pull/643
|
| 346 |
+
"""
|
| 347 |
+
# Create a simple server
|
| 348 |
+
server = FastMCP("Test Server")
|
| 349 |
+
|
| 350 |
+
@server.tool()
|
| 351 |
+
def echo(text: str) -> str:
|
| 352 |
+
"""Echo tool"""
|
| 353 |
+
return text
|
| 354 |
+
|
| 355 |
+
# Create client
|
| 356 |
+
client = Client(server)
|
| 357 |
+
|
| 358 |
+
# Track results
|
| 359 |
+
results = {}
|
| 360 |
+
errors = []
|
| 361 |
+
|
| 362 |
+
async def use_client(task_id: str, delay: float = 0):
|
| 363 |
+
"""Use the client with a small delay to ensure overlap"""
|
| 364 |
+
try:
|
| 365 |
+
async with client:
|
| 366 |
+
# Add a small delay to ensure contexts overlap
|
| 367 |
+
await asyncio.sleep(delay)
|
| 368 |
+
# Make an actual call to exercise the session
|
| 369 |
+
tools = await client.list_tools()
|
| 370 |
+
results[task_id] = len(tools)
|
| 371 |
+
except Exception as e:
|
| 372 |
+
errors.append((task_id, str(e)))
|
| 373 |
+
|
| 374 |
+
# Run multiple tasks concurrently
|
| 375 |
+
# The key is having them enter and exit the context at different times
|
| 376 |
+
await asyncio.gather(
|
| 377 |
+
use_client("task1", 0.0),
|
| 378 |
+
use_client("task2", 0.01), # Slight delay to ensure overlap
|
| 379 |
+
use_client("task3", 0.02),
|
| 380 |
+
return_exceptions=False,
|
| 381 |
+
)
|
| 382 |
+
|
| 383 |
+
assert len(errors) == 0, f"Errors occurred: {errors}"
|
| 384 |
+
assert len(results) == 3
|
| 385 |
+
assert all(count == 1 for count in results.values()) # All should see 1 tool
|
| 386 |
+
|
| 387 |
+
|
| 388 |
async def test_resource_template(fastmcp_server):
|
| 389 |
"""Test using a resource template with InMemoryClient."""
|
| 390 |
client = Client(transport=FastMCPTransport(fastmcp_server))
|
|
|
|
| 519 |
async with client:
|
| 520 |
result = await client.call_tool_mcp("error_tool", {})
|
| 521 |
assert result.isError
|
| 522 |
+
assert "test error" in result.content[0].text # type: ignore[attr-defined]
|
| 523 |
+
assert "abc" in result.content[0].text # type: ignore[attr-defined]
|
|
|
|
| 524 |
|
| 525 |
async def test_general_tool_exceptions_are_masked_when_enabled(self):
|
| 526 |
mcp = FastMCP("TestServer", mask_error_details=True)
|
|
|
|
| 534 |
async with client:
|
| 535 |
result = await client.call_tool_mcp("error_tool", {})
|
| 536 |
assert result.isError
|
| 537 |
+
assert "test error" not in result.content[0].text # type: ignore[attr-defined]
|
| 538 |
+
assert "abc" not in result.content[0].text # type: ignore[attr-defined]
|
|
|
|
| 539 |
|
| 540 |
async def test_specific_tool_errors_are_sent_to_client(self):
|
| 541 |
mcp = FastMCP("TestServer")
|
|
|
|
| 549 |
async with client:
|
| 550 |
result = await client.call_tool_mcp("custom_error_tool", {})
|
| 551 |
assert result.isError
|
| 552 |
+
assert "test error" in result.content[0].text # type: ignore[attr-defined]
|
| 553 |
+
assert "abc" in result.content[0].text # type: ignore[attr-defined]
|
|
|
|
| 554 |
|
| 555 |
async def test_general_resource_exceptions_are_not_masked_by_default(self):
|
| 556 |
mcp = FastMCP("TestServer")
|
tests/client/test_openapi.py
CHANGED
|
@@ -1,11 +1,8 @@
|
|
| 1 |
import json
|
| 2 |
-
import sys
|
| 3 |
from collections.abc import Generator
|
| 4 |
|
| 5 |
import pytest
|
| 6 |
-
import uvicorn
|
| 7 |
from fastapi import FastAPI, Request
|
| 8 |
-
from mcp.types import TextContent, TextResourceContents
|
| 9 |
|
| 10 |
from fastmcp import Client, FastMCP
|
| 11 |
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
|
|
@@ -35,75 +32,34 @@ def fastmcp_server_for_headers() -> FastMCP:
|
|
| 35 |
return mcp
|
| 36 |
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
try:
|
| 41 |
-
app = fastmcp_server_for_headers().http_app(transport="streamable-http")
|
| 42 |
-
server = uvicorn.Server(
|
| 43 |
-
config=uvicorn.Config(
|
| 44 |
-
app=app,
|
| 45 |
-
host=host,
|
| 46 |
-
port=port,
|
| 47 |
-
log_level="error",
|
| 48 |
-
lifespan="on",
|
| 49 |
-
)
|
| 50 |
-
)
|
| 51 |
-
server.run()
|
| 52 |
-
except Exception as e:
|
| 53 |
-
print(f"Server error: {e}")
|
| 54 |
-
sys.exit(1)
|
| 55 |
-
sys.exit(0)
|
| 56 |
-
|
| 57 |
-
def run_sse_server(self, host: str, port: int) -> None:
|
| 58 |
-
try:
|
| 59 |
-
app = fastmcp_server_for_headers().http_app(transport="sse")
|
| 60 |
-
server = uvicorn.Server(
|
| 61 |
-
config=uvicorn.Config(
|
| 62 |
-
app=app,
|
| 63 |
-
host=host,
|
| 64 |
-
port=port,
|
| 65 |
-
log_level="error",
|
| 66 |
-
lifespan="on",
|
| 67 |
-
)
|
| 68 |
-
)
|
| 69 |
-
server.run()
|
| 70 |
-
except Exception as e:
|
| 71 |
-
print(f"Server error: {e}")
|
| 72 |
-
sys.exit(1)
|
| 73 |
-
sys.exit(0)
|
| 74 |
-
|
| 75 |
-
def run_proxy_server(self, host: str, port: int, remote_url: str) -> None:
|
| 76 |
-
try:
|
| 77 |
-
client = Client(transport=StreamableHttpTransport(remote_url))
|
| 78 |
-
app = FastMCP.as_proxy(client).http_app(transport="streamable-http")
|
| 79 |
-
server = uvicorn.Server(
|
| 80 |
-
config=uvicorn.Config(
|
| 81 |
-
app=app,
|
| 82 |
-
host=host,
|
| 83 |
-
port=port,
|
| 84 |
-
log_level="error",
|
| 85 |
-
lifespan="on",
|
| 86 |
-
)
|
| 87 |
-
)
|
| 88 |
-
server.run()
|
| 89 |
-
except Exception as e:
|
| 90 |
-
print(f"Server error: {e}")
|
| 91 |
-
sys.exit(1)
|
| 92 |
-
sys.exit(0)
|
| 93 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
@pytest.fixture(scope="class")
|
| 95 |
def shttp_server(self) -> Generator[str, None, None]:
|
| 96 |
-
with run_server_in_process(
|
| 97 |
yield f"{url}/mcp"
|
| 98 |
|
| 99 |
@pytest.fixture(scope="class")
|
| 100 |
def sse_server(self) -> Generator[str, None, None]:
|
| 101 |
-
with run_server_in_process(
|
| 102 |
yield f"{url}/sse"
|
| 103 |
|
| 104 |
@pytest.fixture(scope="class")
|
| 105 |
def proxy_server(self, shttp_server: str) -> Generator[str, None, None]:
|
| 106 |
-
with run_server_in_process(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
yield f"{url}/mcp"
|
| 108 |
|
| 109 |
async def test_client_headers_sse_resource(self, sse_server: str):
|
|
@@ -111,8 +67,7 @@ class TestClientHeaders:
|
|
| 111 |
transport=SSETransport(sse_server, headers={"X-TEST": "test-123"})
|
| 112 |
) as client:
|
| 113 |
result = await client.read_resource("resource://get_headers_headers_get")
|
| 114 |
-
|
| 115 |
-
headers = json.loads(result[0].text)
|
| 116 |
assert headers["x-test"] == "test-123"
|
| 117 |
|
| 118 |
async def test_client_headers_shttp_resource(self, shttp_server: str):
|
|
@@ -122,8 +77,7 @@ class TestClientHeaders:
|
|
| 122 |
)
|
| 123 |
) as client:
|
| 124 |
result = await client.read_resource("resource://get_headers_headers_get")
|
| 125 |
-
|
| 126 |
-
headers = json.loads(result[0].text)
|
| 127 |
assert headers["x-test"] == "test-123"
|
| 128 |
|
| 129 |
async def test_client_headers_sse_resource_template(self, sse_server: str):
|
|
@@ -133,8 +87,7 @@ class TestClientHeaders:
|
|
| 133 |
result = await client.read_resource(
|
| 134 |
"resource://get_header_by_name_headers/x-test"
|
| 135 |
)
|
| 136 |
-
|
| 137 |
-
header = json.loads(result[0].text)
|
| 138 |
assert header == "test-123"
|
| 139 |
|
| 140 |
async def test_client_headers_shttp_resource_template(self, shttp_server: str):
|
|
@@ -146,8 +99,7 @@ class TestClientHeaders:
|
|
| 146 |
result = await client.read_resource(
|
| 147 |
"resource://get_header_by_name_headers/x-test"
|
| 148 |
)
|
| 149 |
-
|
| 150 |
-
header = json.loads(result[0].text)
|
| 151 |
assert header == "test-123"
|
| 152 |
|
| 153 |
async def test_client_headers_sse_tool(self, sse_server: str):
|
|
@@ -155,8 +107,7 @@ class TestClientHeaders:
|
|
| 155 |
transport=SSETransport(sse_server, headers={"X-TEST": "test-123"})
|
| 156 |
) as client:
|
| 157 |
result = await client.call_tool("post_headers_headers_post")
|
| 158 |
-
|
| 159 |
-
headers = json.loads(result[0].text)
|
| 160 |
assert headers["x-test"] == "test-123"
|
| 161 |
|
| 162 |
async def test_client_headers_shttp_tool(self, shttp_server: str):
|
|
@@ -166,8 +117,7 @@ class TestClientHeaders:
|
|
| 166 |
)
|
| 167 |
) as client:
|
| 168 |
result = await client.call_tool("post_headers_headers_post")
|
| 169 |
-
|
| 170 |
-
headers = json.loads(result[0].text)
|
| 171 |
assert headers["x-test"] == "test-123"
|
| 172 |
|
| 173 |
async def test_client_overrides_server_headers(self, shttp_server: str):
|
|
@@ -177,8 +127,7 @@ class TestClientHeaders:
|
|
| 177 |
)
|
| 178 |
) as client:
|
| 179 |
result = await client.read_resource("resource://get_headers_headers_get")
|
| 180 |
-
|
| 181 |
-
headers = json.loads(result[0].text)
|
| 182 |
assert headers["x-server-header"] == "test-client"
|
| 183 |
|
| 184 |
async def test_client_with_excluded_header_is_ignored(self, sse_server: str):
|
|
@@ -193,8 +142,7 @@ class TestClientHeaders:
|
|
| 193 |
)
|
| 194 |
) as client:
|
| 195 |
result = await client.read_resource("resource://get_headers_headers_get")
|
| 196 |
-
|
| 197 |
-
headers = json.loads(result[0].text)
|
| 198 |
assert headers["not-host"] == "1.2.3.4"
|
| 199 |
assert headers["host"] == "fastapi"
|
| 200 |
|
|
@@ -204,6 +152,5 @@ class TestClientHeaders:
|
|
| 204 |
"""
|
| 205 |
async with Client(transport=StreamableHttpTransport(proxy_server)) as client:
|
| 206 |
result = await client.read_resource("resource://get_headers_headers_get")
|
| 207 |
-
|
| 208 |
-
headers = json.loads(result[0].text)
|
| 209 |
assert headers["x-server-header"] == "test-abc"
|
|
|
|
| 1 |
import json
|
|
|
|
| 2 |
from collections.abc import Generator
|
| 3 |
|
| 4 |
import pytest
|
|
|
|
| 5 |
from fastapi import FastAPI, Request
|
|
|
|
| 6 |
|
| 7 |
from fastmcp import Client, FastMCP
|
| 8 |
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
|
|
|
|
| 32 |
return mcp
|
| 33 |
|
| 34 |
|
| 35 |
+
def run_server(host: str, port: int, **kwargs) -> None:
|
| 36 |
+
fastmcp_server_for_headers().run(host=host, port=port, **kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
+
|
| 39 |
+
def run_proxy_server(host: str, port: int, shttp_url: str, **kwargs) -> None:
|
| 40 |
+
client = Client(transport=StreamableHttpTransport(shttp_url))
|
| 41 |
+
app = FastMCP.as_proxy(client)
|
| 42 |
+
app.run(host=host, port=port, **kwargs)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class TestClientHeaders:
|
| 46 |
@pytest.fixture(scope="class")
|
| 47 |
def shttp_server(self) -> Generator[str, None, None]:
|
| 48 |
+
with run_server_in_process(run_server, transport="streamable-http") as url:
|
| 49 |
yield f"{url}/mcp"
|
| 50 |
|
| 51 |
@pytest.fixture(scope="class")
|
| 52 |
def sse_server(self) -> Generator[str, None, None]:
|
| 53 |
+
with run_server_in_process(run_server, transport="sse") as url:
|
| 54 |
yield f"{url}/sse"
|
| 55 |
|
| 56 |
@pytest.fixture(scope="class")
|
| 57 |
def proxy_server(self, shttp_server: str) -> Generator[str, None, None]:
|
| 58 |
+
with run_server_in_process(
|
| 59 |
+
run_proxy_server,
|
| 60 |
+
shttp_url=shttp_server,
|
| 61 |
+
transport="streamable-http",
|
| 62 |
+
) as url:
|
| 63 |
yield f"{url}/mcp"
|
| 64 |
|
| 65 |
async def test_client_headers_sse_resource(self, sse_server: str):
|
|
|
|
| 67 |
transport=SSETransport(sse_server, headers={"X-TEST": "test-123"})
|
| 68 |
) as client:
|
| 69 |
result = await client.read_resource("resource://get_headers_headers_get")
|
| 70 |
+
headers = json.loads(result[0].text) # type: ignore[attr-defined]
|
|
|
|
| 71 |
assert headers["x-test"] == "test-123"
|
| 72 |
|
| 73 |
async def test_client_headers_shttp_resource(self, shttp_server: str):
|
|
|
|
| 77 |
)
|
| 78 |
) as client:
|
| 79 |
result = await client.read_resource("resource://get_headers_headers_get")
|
| 80 |
+
headers = json.loads(result[0].text) # type: ignore[attr-defined]
|
|
|
|
| 81 |
assert headers["x-test"] == "test-123"
|
| 82 |
|
| 83 |
async def test_client_headers_sse_resource_template(self, sse_server: str):
|
|
|
|
| 87 |
result = await client.read_resource(
|
| 88 |
"resource://get_header_by_name_headers/x-test"
|
| 89 |
)
|
| 90 |
+
header = json.loads(result[0].text) # type: ignore[attr-defined]
|
|
|
|
| 91 |
assert header == "test-123"
|
| 92 |
|
| 93 |
async def test_client_headers_shttp_resource_template(self, shttp_server: str):
|
|
|
|
| 99 |
result = await client.read_resource(
|
| 100 |
"resource://get_header_by_name_headers/x-test"
|
| 101 |
)
|
| 102 |
+
header = json.loads(result[0].text) # type: ignore[attr-defined]
|
|
|
|
| 103 |
assert header == "test-123"
|
| 104 |
|
| 105 |
async def test_client_headers_sse_tool(self, sse_server: str):
|
|
|
|
| 107 |
transport=SSETransport(sse_server, headers={"X-TEST": "test-123"})
|
| 108 |
) as client:
|
| 109 |
result = await client.call_tool("post_headers_headers_post")
|
| 110 |
+
headers = json.loads(result[0].text) # type: ignore[attr-defined]
|
|
|
|
| 111 |
assert headers["x-test"] == "test-123"
|
| 112 |
|
| 113 |
async def test_client_headers_shttp_tool(self, shttp_server: str):
|
|
|
|
| 117 |
)
|
| 118 |
) as client:
|
| 119 |
result = await client.call_tool("post_headers_headers_post")
|
| 120 |
+
headers = json.loads(result[0].text) # type: ignore[attr-defined]
|
|
|
|
| 121 |
assert headers["x-test"] == "test-123"
|
| 122 |
|
| 123 |
async def test_client_overrides_server_headers(self, shttp_server: str):
|
|
|
|
| 127 |
)
|
| 128 |
) as client:
|
| 129 |
result = await client.read_resource("resource://get_headers_headers_get")
|
| 130 |
+
headers = json.loads(result[0].text) # type: ignore[attr-defined]
|
|
|
|
| 131 |
assert headers["x-server-header"] == "test-client"
|
| 132 |
|
| 133 |
async def test_client_with_excluded_header_is_ignored(self, sse_server: str):
|
|
|
|
| 142 |
)
|
| 143 |
) as client:
|
| 144 |
result = await client.read_resource("resource://get_headers_headers_get")
|
| 145 |
+
headers = json.loads(result[0].text) # type: ignore[attr-defined]
|
|
|
|
| 146 |
assert headers["not-host"] == "1.2.3.4"
|
| 147 |
assert headers["host"] == "fastapi"
|
| 148 |
|
|
|
|
| 152 |
"""
|
| 153 |
async with Client(transport=StreamableHttpTransport(proxy_server)) as client:
|
| 154 |
result = await client.read_resource("resource://get_headers_headers_get")
|
| 155 |
+
headers = json.loads(result[0].text) # type: ignore[attr-defined]
|
|
|
|
| 156 |
assert headers["x-server-header"] == "test-abc"
|
tests/client/test_roots.py
CHANGED
|
@@ -1,7 +1,6 @@
|
|
| 1 |
import json
|
| 2 |
|
| 3 |
import pytest
|
| 4 |
-
from mcp.types import TextContent
|
| 5 |
|
| 6 |
from fastmcp import Client, Context, FastMCP
|
| 7 |
|
|
@@ -41,8 +40,7 @@ class TestClientRoots:
|
|
| 41 |
async def test_valid_roots(self, fastmcp_server: FastMCP, roots: list[str]):
|
| 42 |
async with Client(fastmcp_server, roots=roots) as client:
|
| 43 |
result = await client.call_tool("list_roots", {})
|
| 44 |
-
assert
|
| 45 |
-
assert json.loads(result[0].text) == [
|
| 46 |
"file://x/y/z",
|
| 47 |
"file://x/y/z",
|
| 48 |
]
|
|
|
|
| 1 |
import json
|
| 2 |
|
| 3 |
import pytest
|
|
|
|
| 4 |
|
| 5 |
from fastmcp import Client, Context, FastMCP
|
| 6 |
|
|
|
|
| 40 |
async def test_valid_roots(self, fastmcp_server: FastMCP, roots: list[str]):
|
| 41 |
async with Client(fastmcp_server, roots=roots) as client:
|
| 42 |
result = await client.call_tool("list_roots", {})
|
| 43 |
+
assert json.loads(result[0].text) == [ # type: ignore[attr-defined]
|
|
|
|
| 44 |
"file://x/y/z",
|
| 45 |
"file://x/y/z",
|
| 46 |
]
|
tests/client/test_sse.py
CHANGED
|
@@ -6,7 +6,6 @@ from collections.abc import Generator
|
|
| 6 |
import pytest
|
| 7 |
import uvicorn
|
| 8 |
from mcp import McpError
|
| 9 |
-
from mcp.types import TextResourceContents
|
| 10 |
from starlette.applications import Starlette
|
| 11 |
from starlette.routing import Mount
|
| 12 |
|
|
@@ -64,22 +63,13 @@ def fastmcp_server():
|
|
| 64 |
return server
|
| 65 |
|
| 66 |
|
| 67 |
-
def run_server(host: str, port: int,
|
| 68 |
-
|
| 69 |
-
app = fastmcp_server().http_app(transport="sse", path=path)
|
| 70 |
-
server = uvicorn.Server(
|
| 71 |
-
config=uvicorn.Config(app=app, host=host, port=port, log_level="error")
|
| 72 |
-
)
|
| 73 |
-
server.run()
|
| 74 |
-
except Exception as e:
|
| 75 |
-
print(f"Server error: {e}")
|
| 76 |
-
sys.exit(1)
|
| 77 |
-
sys.exit(0)
|
| 78 |
|
| 79 |
|
| 80 |
@pytest.fixture(autouse=True, scope="module")
|
| 81 |
def sse_server() -> Generator[str, None, None]:
|
| 82 |
-
with run_server_in_process(run_server) as url:
|
| 83 |
yield f"{url}/sse"
|
| 84 |
|
| 85 |
|
|
@@ -96,29 +86,23 @@ async def test_http_headers(sse_server: str):
|
|
| 96 |
transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
|
| 97 |
) as client:
|
| 98 |
raw_result = await client.read_resource("request://headers")
|
| 99 |
-
|
| 100 |
-
json_result = json.loads(raw_result[0].text)
|
| 101 |
assert "x-demo-header" in json_result
|
| 102 |
assert json_result["x-demo-header"] == "ABC"
|
| 103 |
|
| 104 |
|
| 105 |
def run_nested_server(host: str, port: int) -> None:
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
server.run()
|
| 114 |
-
except Exception as e:
|
| 115 |
-
print(f"Server error: {e}")
|
| 116 |
-
sys.exit(1)
|
| 117 |
-
sys.exit(0)
|
| 118 |
|
| 119 |
|
| 120 |
async def test_run_server_on_path():
|
| 121 |
-
with run_server_in_process(run_server, "/help") as url:
|
| 122 |
async with Client(transport=SSETransport(f"{url}/help")) as client:
|
| 123 |
result = await client.ping()
|
| 124 |
assert result is True
|
|
|
|
| 6 |
import pytest
|
| 7 |
import uvicorn
|
| 8 |
from mcp import McpError
|
|
|
|
| 9 |
from starlette.applications import Starlette
|
| 10 |
from starlette.routing import Mount
|
| 11 |
|
|
|
|
| 63 |
return server
|
| 64 |
|
| 65 |
|
| 66 |
+
def run_server(host: str, port: int, **kwargs) -> None:
|
| 67 |
+
fastmcp_server().run(host=host, port=port, **kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
|
| 70 |
@pytest.fixture(autouse=True, scope="module")
|
| 71 |
def sse_server() -> Generator[str, None, None]:
|
| 72 |
+
with run_server_in_process(run_server, transport="sse") as url:
|
| 73 |
yield f"{url}/sse"
|
| 74 |
|
| 75 |
|
|
|
|
| 86 |
transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
|
| 87 |
) as client:
|
| 88 |
raw_result = await client.read_resource("request://headers")
|
| 89 |
+
json_result = json.loads(raw_result[0].text) # type: ignore[attr-defined]
|
|
|
|
| 90 |
assert "x-demo-header" in json_result
|
| 91 |
assert json_result["x-demo-header"] == "ABC"
|
| 92 |
|
| 93 |
|
| 94 |
def run_nested_server(host: str, port: int) -> None:
|
| 95 |
+
app = fastmcp_server().sse_app(path="/mcp/sse", message_path="/mcp/messages")
|
| 96 |
+
mount = Starlette(routes=[Mount("/nest-inner", app=app)])
|
| 97 |
+
mount2 = Starlette(routes=[Mount("/nest-outer", app=mount)])
|
| 98 |
+
server = uvicorn.Server(
|
| 99 |
+
config=uvicorn.Config(app=mount2, host=host, port=port, log_level="error")
|
| 100 |
+
)
|
| 101 |
+
server.run()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
|
| 104 |
async def test_run_server_on_path():
|
| 105 |
+
with run_server_in_process(run_server, transport="sse", path="/help") as url:
|
| 106 |
async with Client(transport=SSETransport(f"{url}/help")) as client:
|
| 107 |
result = await client.ping()
|
| 108 |
assert result is True
|
tests/client/test_stdio.py
CHANGED
|
@@ -1,7 +1,6 @@
|
|
| 1 |
import inspect
|
| 2 |
|
| 3 |
import pytest
|
| 4 |
-
from mcp.types import TextContent
|
| 5 |
|
| 6 |
from fastmcp import Client
|
| 7 |
from fastmcp.client.transports import PythonStdioTransport, StdioTransport
|
|
@@ -49,13 +48,11 @@ class TestKeepAlive:
|
|
| 49 |
|
| 50 |
async with client:
|
| 51 |
result1 = await client.call_tool("pid")
|
| 52 |
-
|
| 53 |
-
pid1 = int(result1[0].text)
|
| 54 |
|
| 55 |
async with client:
|
| 56 |
result2 = await client.call_tool("pid")
|
| 57 |
-
|
| 58 |
-
pid2 = int(result2[0].text)
|
| 59 |
|
| 60 |
assert pid1 == pid2
|
| 61 |
|
|
@@ -69,13 +66,11 @@ class TestKeepAlive:
|
|
| 69 |
|
| 70 |
async with client:
|
| 71 |
result1 = await client.call_tool("pid")
|
| 72 |
-
|
| 73 |
-
pid1 = int(result1[0].text)
|
| 74 |
|
| 75 |
async with client:
|
| 76 |
result2 = await client.call_tool("pid")
|
| 77 |
-
|
| 78 |
-
pid2 = int(result2[0].text)
|
| 79 |
|
| 80 |
assert pid1 != pid2
|
| 81 |
|
|
@@ -85,15 +80,13 @@ class TestKeepAlive:
|
|
| 85 |
|
| 86 |
async with client:
|
| 87 |
result1 = await client.call_tool("pid")
|
| 88 |
-
|
| 89 |
-
pid1 = int(result1[0].text)
|
| 90 |
|
| 91 |
await client.close()
|
| 92 |
|
| 93 |
async with client:
|
| 94 |
result2 = await client.call_tool("pid")
|
| 95 |
-
|
| 96 |
-
pid2 = int(result2[0].text)
|
| 97 |
|
| 98 |
assert pid1 != pid2
|
| 99 |
|
|
@@ -103,17 +96,14 @@ class TestKeepAlive:
|
|
| 103 |
|
| 104 |
async with client:
|
| 105 |
result1 = await client.call_tool("pid")
|
| 106 |
-
|
| 107 |
-
pid1 = int(result1[0].text)
|
| 108 |
|
| 109 |
async with client:
|
| 110 |
result2 = await client.call_tool("pid")
|
| 111 |
-
|
| 112 |
-
pid2 = int(result2[0].text)
|
| 113 |
|
| 114 |
result3 = await client.call_tool("pid")
|
| 115 |
-
|
| 116 |
-
pid3 = int(result3[0].text)
|
| 117 |
|
| 118 |
assert pid1 == pid2 == pid3
|
| 119 |
|
|
|
|
| 1 |
import inspect
|
| 2 |
|
| 3 |
import pytest
|
|
|
|
| 4 |
|
| 5 |
from fastmcp import Client
|
| 6 |
from fastmcp.client.transports import PythonStdioTransport, StdioTransport
|
|
|
|
| 48 |
|
| 49 |
async with client:
|
| 50 |
result1 = await client.call_tool("pid")
|
| 51 |
+
pid1 = int(result1[0].text) # type: ignore[attr-defined]
|
|
|
|
| 52 |
|
| 53 |
async with client:
|
| 54 |
result2 = await client.call_tool("pid")
|
| 55 |
+
pid2 = int(result2[0].text) # type: ignore[attr-defined]
|
|
|
|
| 56 |
|
| 57 |
assert pid1 == pid2
|
| 58 |
|
|
|
|
| 66 |
|
| 67 |
async with client:
|
| 68 |
result1 = await client.call_tool("pid")
|
| 69 |
+
pid1 = int(result1[0].text) # type: ignore[attr-defined]
|
|
|
|
| 70 |
|
| 71 |
async with client:
|
| 72 |
result2 = await client.call_tool("pid")
|
| 73 |
+
pid2 = int(result2[0].text) # type: ignore[attr-defined]
|
|
|
|
| 74 |
|
| 75 |
assert pid1 != pid2
|
| 76 |
|
|
|
|
| 80 |
|
| 81 |
async with client:
|
| 82 |
result1 = await client.call_tool("pid")
|
| 83 |
+
pid1 = int(result1[0].text) # type: ignore[attr-defined]
|
|
|
|
| 84 |
|
| 85 |
await client.close()
|
| 86 |
|
| 87 |
async with client:
|
| 88 |
result2 = await client.call_tool("pid")
|
| 89 |
+
pid2 = int(result2[0].text) # type: ignore[attr-defined]
|
|
|
|
| 90 |
|
| 91 |
assert pid1 != pid2
|
| 92 |
|
|
|
|
| 96 |
|
| 97 |
async with client:
|
| 98 |
result1 = await client.call_tool("pid")
|
| 99 |
+
pid1 = int(result1[0].text) # type: ignore[attr-defined]
|
|
|
|
| 100 |
|
| 101 |
async with client:
|
| 102 |
result2 = await client.call_tool("pid")
|
| 103 |
+
pid2 = int(result2[0].text) # type: ignore[attr-defined]
|
|
|
|
| 104 |
|
| 105 |
result3 = await client.call_tool("pid")
|
| 106 |
+
pid3 = int(result3[0].text) # type: ignore[attr-defined]
|
|
|
|
| 107 |
|
| 108 |
assert pid1 == pid2 == pid3
|
| 109 |
|
tests/client/test_streamable_http.py
CHANGED
|
@@ -6,7 +6,6 @@ from collections.abc import Generator
|
|
| 6 |
import pytest
|
| 7 |
import uvicorn
|
| 8 |
from mcp import McpError
|
| 9 |
-
from mcp.types import TextResourceContents
|
| 10 |
from starlette.applications import Starlette
|
| 11 |
from starlette.routing import Mount
|
| 12 |
|
|
@@ -64,28 +63,33 @@ def fastmcp_server():
|
|
| 64 |
return server
|
| 65 |
|
| 66 |
|
| 67 |
-
def run_server(host: str, port: int) -> None:
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
)
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
print(f"Server error: {e}")
|
| 82 |
-
sys.exit(1)
|
| 83 |
-
sys.exit(0)
|
| 84 |
|
| 85 |
|
| 86 |
@pytest.fixture(scope="module")
|
| 87 |
def streamable_http_server() -> Generator[str, None, None]:
|
| 88 |
-
with run_server_in_process(run_server) as url:
|
| 89 |
yield f"{url}/mcp"
|
| 90 |
|
| 91 |
|
|
@@ -106,37 +110,11 @@ async def test_http_headers(streamable_http_server: str):
|
|
| 106 |
)
|
| 107 |
) as client:
|
| 108 |
raw_result = await client.read_resource("request://headers")
|
| 109 |
-
|
| 110 |
-
json_result = json.loads(raw_result[0].text)
|
| 111 |
assert "x-demo-header" in json_result
|
| 112 |
assert json_result["x-demo-header"] == "ABC"
|
| 113 |
|
| 114 |
|
| 115 |
-
def run_nested_server(host: str, port: int) -> None:
|
| 116 |
-
try:
|
| 117 |
-
mcp_app = fastmcp_server().http_app(path="/final/mcp")
|
| 118 |
-
|
| 119 |
-
mount = Starlette(routes=[Mount("/nest-inner", app=mcp_app)])
|
| 120 |
-
mount2 = Starlette(
|
| 121 |
-
routes=[Mount("/nest-outer", app=mount)],
|
| 122 |
-
lifespan=mcp_app.lifespan,
|
| 123 |
-
)
|
| 124 |
-
server = uvicorn.Server(
|
| 125 |
-
config=uvicorn.Config(
|
| 126 |
-
app=mount2,
|
| 127 |
-
host=host,
|
| 128 |
-
port=port,
|
| 129 |
-
log_level="error",
|
| 130 |
-
lifespan="on",
|
| 131 |
-
)
|
| 132 |
-
)
|
| 133 |
-
server.run()
|
| 134 |
-
except Exception as e:
|
| 135 |
-
print(f"Server error: {e}")
|
| 136 |
-
sys.exit(1)
|
| 137 |
-
sys.exit(0)
|
| 138 |
-
|
| 139 |
-
|
| 140 |
async def test_nested_streamable_http_server_resolves_correctly():
|
| 141 |
# tests patch for
|
| 142 |
# https://github.com/modelcontextprotocol/python-sdk/pull/659
|
|
|
|
| 6 |
import pytest
|
| 7 |
import uvicorn
|
| 8 |
from mcp import McpError
|
|
|
|
| 9 |
from starlette.applications import Starlette
|
| 10 |
from starlette.routing import Mount
|
| 11 |
|
|
|
|
| 63 |
return server
|
| 64 |
|
| 65 |
|
| 66 |
+
def run_server(host: str, port: int, **kwargs) -> None:
|
| 67 |
+
fastmcp_server().run(host=host, port=port, **kwargs)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def run_nested_server(host: str, port: int) -> None:
|
| 71 |
+
mcp_app = fastmcp_server().http_app(path="/final/mcp")
|
| 72 |
+
|
| 73 |
+
mount = Starlette(routes=[Mount("/nest-inner", app=mcp_app)])
|
| 74 |
+
mount2 = Starlette(
|
| 75 |
+
routes=[Mount("/nest-outer", app=mount)],
|
| 76 |
+
lifespan=mcp_app.lifespan,
|
| 77 |
+
)
|
| 78 |
+
server = uvicorn.Server(
|
| 79 |
+
config=uvicorn.Config(
|
| 80 |
+
app=mount2,
|
| 81 |
+
host=host,
|
| 82 |
+
port=port,
|
| 83 |
+
log_level="error",
|
| 84 |
+
lifespan="on",
|
| 85 |
)
|
| 86 |
+
)
|
| 87 |
+
server.run()
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
|
| 90 |
@pytest.fixture(scope="module")
|
| 91 |
def streamable_http_server() -> Generator[str, None, None]:
|
| 92 |
+
with run_server_in_process(run_server, transport="streamable-http") as url:
|
| 93 |
yield f"{url}/mcp"
|
| 94 |
|
| 95 |
|
|
|
|
| 110 |
)
|
| 111 |
) as client:
|
| 112 |
raw_result = await client.read_resource("request://headers")
|
| 113 |
+
json_result = json.loads(raw_result[0].text) # type: ignore[attr-defined]
|
|
|
|
| 114 |
assert "x-demo-header" in json_result
|
| 115 |
assert json_result["x-demo-header"] == "ABC"
|
| 116 |
|
| 117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
async def test_nested_streamable_http_server_resolves_correctly():
|
| 119 |
# tests patch for
|
| 120 |
# https://github.com/modelcontextprotocol/python-sdk/pull/659
|
tests/prompts/test_prompt_manager.py
CHANGED
|
@@ -393,8 +393,7 @@ class TestContextHandling:
|
|
| 393 |
messages = await prompt.render(arguments={"x": 42})
|
| 394 |
|
| 395 |
assert len(messages) == 1
|
| 396 |
-
assert
|
| 397 |
-
assert messages[0].content.text == "42"
|
| 398 |
|
| 399 |
async def test_context_optional(self):
|
| 400 |
"""Test that context is optional when rendering prompts."""
|
|
@@ -416,8 +415,7 @@ class TestContextHandling:
|
|
| 416 |
)
|
| 417 |
|
| 418 |
assert len(messages) == 1
|
| 419 |
-
assert
|
| 420 |
-
assert messages[0].content.text == "42"
|
| 421 |
|
| 422 |
async def test_annotated_context_parameter_detection(self):
|
| 423 |
"""Test that annotated context parameters are properly detected in
|
|
|
|
| 393 |
messages = await prompt.render(arguments={"x": 42})
|
| 394 |
|
| 395 |
assert len(messages) == 1
|
| 396 |
+
assert messages[0].content.text == "42" # type: ignore[attr-defined]
|
|
|
|
| 397 |
|
| 398 |
async def test_context_optional(self):
|
| 399 |
"""Test that context is optional when rendering prompts."""
|
|
|
|
| 415 |
)
|
| 416 |
|
| 417 |
assert len(messages) == 1
|
| 418 |
+
assert messages[0].content.text == "42" # type: ignore[attr-defined]
|
|
|
|
| 419 |
|
| 420 |
async def test_annotated_context_parameter_detection(self):
|
| 421 |
"""Test that annotated context parameters are properly detected in
|
tests/resources/test_file_resources.py
CHANGED
|
@@ -74,7 +74,6 @@ class TestFileResource:
|
|
| 74 |
is_binary=True,
|
| 75 |
)
|
| 76 |
content = await resource.read()
|
| 77 |
-
assert isinstance(content, bytes)
|
| 78 |
assert content == b"test content"
|
| 79 |
|
| 80 |
def test_relative_path_error(self):
|
|
|
|
| 74 |
is_binary=True,
|
| 75 |
)
|
| 76 |
content = await resource.read()
|
|
|
|
| 77 |
assert content == b"test content"
|
| 78 |
|
| 79 |
def test_relative_path_error(self):
|
tests/server/http/__init__.py
ADDED
|
File without changes
|
tests/server/http/test_http_dependencies.py
CHANGED
|
@@ -1,10 +1,7 @@
|
|
| 1 |
import json
|
| 2 |
-
import sys
|
| 3 |
from collections.abc import Generator
|
| 4 |
|
| 5 |
import pytest
|
| 6 |
-
import uvicorn
|
| 7 |
-
from mcp.types import TextContent, TextResourceContents
|
| 8 |
|
| 9 |
from fastmcp.client import Client
|
| 10 |
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
|
|
@@ -41,53 +38,19 @@ def fastmcp_server():
|
|
| 41 |
return server
|
| 42 |
|
| 43 |
|
| 44 |
-
def
|
| 45 |
-
|
| 46 |
-
app = fastmcp_server().http_app(transport="streamable-http")
|
| 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 |
-
def run_sse_server(host: str, port: int) -> None:
|
| 64 |
-
try:
|
| 65 |
-
app = fastmcp_server().http_app(transport="sse")
|
| 66 |
-
server = uvicorn.Server(
|
| 67 |
-
config=uvicorn.Config(
|
| 68 |
-
app=app,
|
| 69 |
-
host=host,
|
| 70 |
-
port=port,
|
| 71 |
-
log_level="error",
|
| 72 |
-
lifespan="on",
|
| 73 |
-
)
|
| 74 |
-
)
|
| 75 |
-
server.run()
|
| 76 |
-
except Exception as e:
|
| 77 |
-
print(f"Server error: {e}")
|
| 78 |
-
sys.exit(1)
|
| 79 |
-
sys.exit(0)
|
| 80 |
|
| 81 |
|
| 82 |
@pytest.fixture(autouse=True, scope="module")
|
| 83 |
def shttp_server() -> Generator[str, None, None]:
|
| 84 |
-
with run_server_in_process(
|
| 85 |
yield f"{url}/mcp"
|
| 86 |
|
| 87 |
|
| 88 |
@pytest.fixture(autouse=True, scope="module")
|
| 89 |
def sse_server() -> Generator[str, None, None]:
|
| 90 |
-
with run_server_in_process(
|
| 91 |
yield f"{url}/sse"
|
| 92 |
|
| 93 |
|
|
@@ -99,8 +62,7 @@ async def test_http_headers_resource_shttp(shttp_server: str):
|
|
| 99 |
)
|
| 100 |
) as client:
|
| 101 |
raw_result = await client.read_resource("request://headers")
|
| 102 |
-
|
| 103 |
-
json_result = json.loads(raw_result[0].text)
|
| 104 |
assert "x-demo-header" in json_result
|
| 105 |
assert json_result["x-demo-header"] == "ABC"
|
| 106 |
|
|
@@ -111,8 +73,7 @@ async def test_http_headers_resource_sse(sse_server: str):
|
|
| 111 |
transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
|
| 112 |
) as client:
|
| 113 |
raw_result = await client.read_resource("request://headers")
|
| 114 |
-
|
| 115 |
-
json_result = json.loads(raw_result[0].text)
|
| 116 |
assert "x-demo-header" in json_result
|
| 117 |
assert json_result["x-demo-header"] == "ABC"
|
| 118 |
|
|
@@ -125,8 +86,7 @@ async def test_http_headers_tool_shttp(shttp_server: str):
|
|
| 125 |
)
|
| 126 |
) as client:
|
| 127 |
result = await client.call_tool("get_headers_tool")
|
| 128 |
-
|
| 129 |
-
json_result = json.loads(result[0].text)
|
| 130 |
assert "x-demo-header" in json_result
|
| 131 |
assert json_result["x-demo-header"] == "ABC"
|
| 132 |
|
|
@@ -136,8 +96,7 @@ async def test_http_headers_tool_sse(sse_server: str):
|
|
| 136 |
transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
|
| 137 |
) as client:
|
| 138 |
result = await client.call_tool("get_headers_tool")
|
| 139 |
-
|
| 140 |
-
json_result = json.loads(result[0].text)
|
| 141 |
assert "x-demo-header" in json_result
|
| 142 |
assert json_result["x-demo-header"] == "ABC"
|
| 143 |
|
|
@@ -150,8 +109,7 @@ async def test_http_headers_prompt_shttp(shttp_server: str):
|
|
| 150 |
)
|
| 151 |
) as client:
|
| 152 |
result = await client.get_prompt("get_headers_prompt")
|
| 153 |
-
|
| 154 |
-
json_result = json.loads(result.messages[0].content.text)
|
| 155 |
assert "x-demo-header" in json_result
|
| 156 |
assert json_result["x-demo-header"] == "ABC"
|
| 157 |
|
|
@@ -162,7 +120,6 @@ async def test_http_headers_prompt_sse(sse_server: str):
|
|
| 162 |
transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
|
| 163 |
) as client:
|
| 164 |
result = await client.get_prompt("get_headers_prompt")
|
| 165 |
-
|
| 166 |
-
json_result = json.loads(result.messages[0].content.text)
|
| 167 |
assert "x-demo-header" in json_result
|
| 168 |
assert json_result["x-demo-header"] == "ABC"
|
|
|
|
| 1 |
import json
|
|
|
|
| 2 |
from collections.abc import Generator
|
| 3 |
|
| 4 |
import pytest
|
|
|
|
|
|
|
| 5 |
|
| 6 |
from fastmcp.client import Client
|
| 7 |
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
|
|
|
|
| 38 |
return server
|
| 39 |
|
| 40 |
|
| 41 |
+
def run_server(host: str, port: int, **kwargs) -> None:
|
| 42 |
+
fastmcp_server().run(host=host, port=port, **kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
|
| 45 |
@pytest.fixture(autouse=True, scope="module")
|
| 46 |
def shttp_server() -> Generator[str, None, None]:
|
| 47 |
+
with run_server_in_process(run_server, transport="streamable-http") as url:
|
| 48 |
yield f"{url}/mcp"
|
| 49 |
|
| 50 |
|
| 51 |
@pytest.fixture(autouse=True, scope="module")
|
| 52 |
def sse_server() -> Generator[str, None, None]:
|
| 53 |
+
with run_server_in_process(run_server, transport="sse") as url:
|
| 54 |
yield f"{url}/sse"
|
| 55 |
|
| 56 |
|
|
|
|
| 62 |
)
|
| 63 |
) as client:
|
| 64 |
raw_result = await client.read_resource("request://headers")
|
| 65 |
+
json_result = json.loads(raw_result[0].text) # type: ignore[attr-defined]
|
|
|
|
| 66 |
assert "x-demo-header" in json_result
|
| 67 |
assert json_result["x-demo-header"] == "ABC"
|
| 68 |
|
|
|
|
| 73 |
transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
|
| 74 |
) as client:
|
| 75 |
raw_result = await client.read_resource("request://headers")
|
| 76 |
+
json_result = json.loads(raw_result[0].text) # type: ignore[attr-defined]
|
|
|
|
| 77 |
assert "x-demo-header" in json_result
|
| 78 |
assert json_result["x-demo-header"] == "ABC"
|
| 79 |
|
|
|
|
| 86 |
)
|
| 87 |
) as client:
|
| 88 |
result = await client.call_tool("get_headers_tool")
|
| 89 |
+
json_result = json.loads(result[0].text) # type: ignore[attr-defined]
|
|
|
|
| 90 |
assert "x-demo-header" in json_result
|
| 91 |
assert json_result["x-demo-header"] == "ABC"
|
| 92 |
|
|
|
|
| 96 |
transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
|
| 97 |
) as client:
|
| 98 |
result = await client.call_tool("get_headers_tool")
|
| 99 |
+
json_result = json.loads(result[0].text) # type: ignore[attr-defined]
|
|
|
|
| 100 |
assert "x-demo-header" in json_result
|
| 101 |
assert json_result["x-demo-header"] == "ABC"
|
| 102 |
|
|
|
|
| 109 |
)
|
| 110 |
) as client:
|
| 111 |
result = await client.get_prompt("get_headers_prompt")
|
| 112 |
+
json_result = json.loads(result.messages[0].content.text) # type: ignore[attr-defined]
|
|
|
|
| 113 |
assert "x-demo-header" in json_result
|
| 114 |
assert json_result["x-demo-header"] == "ABC"
|
| 115 |
|
|
|
|
| 120 |
transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
|
| 121 |
) as client:
|
| 122 |
result = await client.get_prompt("get_headers_prompt")
|
| 123 |
+
json_result = json.loads(result.messages[0].content.text) # type: ignore[attr-defined]
|
|
|
|
| 124 |
assert "x-demo-header" in json_result
|
| 125 |
assert json_result["x-demo-header"] == "ABC"
|
tests/server/openapi/__init__.py
ADDED
|
File without changes
|
tests/server/openapi/test_openapi.py
CHANGED
|
@@ -9,7 +9,7 @@ from dirty_equals import IsStr
|
|
| 9 |
from fastapi import FastAPI, HTTPException, Response
|
| 10 |
from fastapi.responses import PlainTextResponse
|
| 11 |
from httpx import ASGITransport, AsyncClient
|
| 12 |
-
from mcp.types import BlobResourceContents
|
| 13 |
from pydantic import BaseModel, TypeAdapter
|
| 14 |
from pydantic.networks import AnyUrl
|
| 15 |
|
|
@@ -234,11 +234,7 @@ class TestTools:
|
|
| 234 |
"create_user_users_post", {"name": "David", "active": False}
|
| 235 |
)
|
| 236 |
|
| 237 |
-
|
| 238 |
-
assert isinstance(tool_response, list) and len(tool_response) == 1
|
| 239 |
-
assert isinstance(tool_response[0], TextContent)
|
| 240 |
-
|
| 241 |
-
response_data = json.loads(tool_response[0].text)
|
| 242 |
expected_user = User(id=4, name="David", active=False).model_dump()
|
| 243 |
assert response_data == expected_user
|
| 244 |
|
|
@@ -249,8 +245,7 @@ class TestTools:
|
|
| 249 |
# Check that the user was created via MCP
|
| 250 |
async with Client(fastmcp_openapi_server) as client:
|
| 251 |
user_response = await client.read_resource("resource://get_user_users/4")
|
| 252 |
-
|
| 253 |
-
response_text = user_response[0].text
|
| 254 |
user = json.loads(response_text)
|
| 255 |
assert user == expected_user
|
| 256 |
|
|
@@ -266,11 +261,7 @@ class TestTools:
|
|
| 266 |
{"user_id": 1, "name": "XYZ"},
|
| 267 |
)
|
| 268 |
|
| 269 |
-
|
| 270 |
-
assert isinstance(tool_response, list) and len(tool_response) == 1
|
| 271 |
-
assert isinstance(tool_response[0], TextContent)
|
| 272 |
-
|
| 273 |
-
response_data = json.loads(tool_response[0].text)
|
| 274 |
expected_data = dict(id=1, name="XYZ", active=True)
|
| 275 |
assert response_data == expected_data
|
| 276 |
|
|
@@ -281,8 +272,7 @@ class TestTools:
|
|
| 281 |
# Check that the user was updated via MCP
|
| 282 |
async with Client(fastmcp_openapi_server) as client:
|
| 283 |
user_response = await client.read_resource("resource://get_user_users/1")
|
| 284 |
-
|
| 285 |
-
response_text = user_response[0].text
|
| 286 |
user = json.loads(response_text)
|
| 287 |
assert user == expected_data
|
| 288 |
|
|
@@ -305,9 +295,7 @@ class TestTools:
|
|
| 305 |
)
|
| 306 |
async with Client(mcp_server) as client:
|
| 307 |
tool_response = await client.call_tool("get_users_users_get", {})
|
| 308 |
-
assert
|
| 309 |
-
assert isinstance(tool_response[0], TextContent)
|
| 310 |
-
assert json.loads(tool_response[0].text) == [
|
| 311 |
user.model_dump()
|
| 312 |
for user in sorted(users_db.values(), key=lambda x: x.id)
|
| 313 |
]
|
|
@@ -341,8 +329,7 @@ class TestResources:
|
|
| 341 |
resource_response = await client.read_resource(
|
| 342 |
"resource://get_users_users_get"
|
| 343 |
)
|
| 344 |
-
|
| 345 |
-
response_text = resource_response[0].text
|
| 346 |
resource = json.loads(response_text)
|
| 347 |
assert resource == json_users
|
| 348 |
response = await api_client.get("/users")
|
|
@@ -369,8 +356,7 @@ class TestResources:
|
|
| 369 |
"""Test reading a resource that returns a string."""
|
| 370 |
async with Client(fastmcp_openapi_server) as client:
|
| 371 |
resource_response = await client.read_resource("resource://ping_ping_get")
|
| 372 |
-
assert
|
| 373 |
-
assert resource_response[0].text == "pong"
|
| 374 |
|
| 375 |
|
| 376 |
class TestResourceTemplates:
|
|
@@ -407,8 +393,7 @@ class TestResourceTemplates:
|
|
| 407 |
resource_response = await client.read_resource(
|
| 408 |
f"resource://get_user_users/{user_id}"
|
| 409 |
)
|
| 410 |
-
|
| 411 |
-
response_text = resource_response[0].text
|
| 412 |
resource = json.loads(response_text)
|
| 413 |
|
| 414 |
assert resource == users_db[user_id].model_dump()
|
|
@@ -430,8 +415,7 @@ class TestResourceTemplates:
|
|
| 430 |
resource_response = await client.read_resource(
|
| 431 |
f"resource://get_user_active_state_users/{is_active}/{user_id}"
|
| 432 |
)
|
| 433 |
-
|
| 434 |
-
response_text = resource_response[0].text
|
| 435 |
resource = json.loads(response_text)
|
| 436 |
|
| 437 |
assert resource == users_db[user_id].model_dump()
|
|
@@ -681,8 +665,7 @@ class TestOpenAPI30Compatibility:
|
|
| 681 |
"""Test reading a resource from an OpenAPI 3.0 server."""
|
| 682 |
async with Client(openapi_30_server) as client:
|
| 683 |
resource_response = await client.read_resource("resource://listProducts")
|
| 684 |
-
|
| 685 |
-
response_text = resource_response[0].text
|
| 686 |
content = json.loads(response_text)
|
| 687 |
assert len(content) == 2
|
| 688 |
assert content[0]["name"] == "Product 1"
|
|
@@ -692,8 +675,7 @@ class TestOpenAPI30Compatibility:
|
|
| 692 |
"""Test reading a resource from template from an OpenAPI 3.0 server."""
|
| 693 |
async with Client(openapi_30_server) as client:
|
| 694 |
resource_response = await client.read_resource("resource://getProduct/p1")
|
| 695 |
-
|
| 696 |
-
response_text = resource_response[0].text
|
| 697 |
content = json.loads(response_text)
|
| 698 |
assert content["id"] == "p1"
|
| 699 |
assert content["name"] == "Product 1"
|
|
@@ -707,8 +689,7 @@ class TestOpenAPI30Compatibility:
|
|
| 707 |
)
|
| 708 |
# Result should be a text content
|
| 709 |
assert len(result) == 1
|
| 710 |
-
|
| 711 |
-
product = json.loads(result[0].text)
|
| 712 |
assert product["id"] == "p3"
|
| 713 |
assert product["name"] == "New Product"
|
| 714 |
assert product["price"] == 39.99
|
|
@@ -857,8 +838,7 @@ class TestOpenAPI31Compatibility:
|
|
| 857 |
"""Test reading a resource from an OpenAPI 3.1 server."""
|
| 858 |
async with Client(openapi_31_server) as client:
|
| 859 |
resource_response = await client.read_resource("resource://listOrders")
|
| 860 |
-
|
| 861 |
-
response_text = resource_response[0].text
|
| 862 |
content = json.loads(response_text)
|
| 863 |
assert len(content) == 2
|
| 864 |
assert content[0]["customer"] == "Alice"
|
|
@@ -868,8 +848,7 @@ class TestOpenAPI31Compatibility:
|
|
| 868 |
"""Test reading a resource from template from an OpenAPI 3.1 server."""
|
| 869 |
async with Client(openapi_31_server) as client:
|
| 870 |
resource_response = await client.read_resource("resource://getOrder/o1")
|
| 871 |
-
|
| 872 |
-
response_text = resource_response[0].text
|
| 873 |
content = json.loads(response_text)
|
| 874 |
assert content["id"] == "o1"
|
| 875 |
assert content["customer"] == "Alice"
|
|
@@ -883,8 +862,7 @@ class TestOpenAPI31Compatibility:
|
|
| 883 |
)
|
| 884 |
# Result should be a text content
|
| 885 |
assert len(result) == 1
|
| 886 |
-
|
| 887 |
-
order = json.loads(result[0].text)
|
| 888 |
assert order["id"] == "o3"
|
| 889 |
assert order["customer"] == "Charlie"
|
| 890 |
assert order["items"] == ["item4", "item5"]
|
|
|
|
| 9 |
from fastapi import FastAPI, HTTPException, Response
|
| 10 |
from fastapi.responses import PlainTextResponse
|
| 11 |
from httpx import ASGITransport, AsyncClient
|
| 12 |
+
from mcp.types import BlobResourceContents
|
| 13 |
from pydantic import BaseModel, TypeAdapter
|
| 14 |
from pydantic.networks import AnyUrl
|
| 15 |
|
|
|
|
| 234 |
"create_user_users_post", {"name": "David", "active": False}
|
| 235 |
)
|
| 236 |
|
| 237 |
+
response_data = json.loads(tool_response[0].text) # type: ignore[attr-defined]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
expected_user = User(id=4, name="David", active=False).model_dump()
|
| 239 |
assert response_data == expected_user
|
| 240 |
|
|
|
|
| 245 |
# Check that the user was created via MCP
|
| 246 |
async with Client(fastmcp_openapi_server) as client:
|
| 247 |
user_response = await client.read_resource("resource://get_user_users/4")
|
| 248 |
+
response_text = user_response[0].text # type: ignore[attr-defined]
|
|
|
|
| 249 |
user = json.loads(response_text)
|
| 250 |
assert user == expected_user
|
| 251 |
|
|
|
|
| 261 |
{"user_id": 1, "name": "XYZ"},
|
| 262 |
)
|
| 263 |
|
| 264 |
+
response_data = json.loads(tool_response[0].text) # type: ignore[attr-defined]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
expected_data = dict(id=1, name="XYZ", active=True)
|
| 266 |
assert response_data == expected_data
|
| 267 |
|
|
|
|
| 272 |
# Check that the user was updated via MCP
|
| 273 |
async with Client(fastmcp_openapi_server) as client:
|
| 274 |
user_response = await client.read_resource("resource://get_user_users/1")
|
| 275 |
+
response_text = user_response[0].text # type: ignore[attr-defined]
|
|
|
|
| 276 |
user = json.loads(response_text)
|
| 277 |
assert user == expected_data
|
| 278 |
|
|
|
|
| 295 |
)
|
| 296 |
async with Client(mcp_server) as client:
|
| 297 |
tool_response = await client.call_tool("get_users_users_get", {})
|
| 298 |
+
assert json.loads(tool_response[0].text) == [ # type: ignore[attr-defined]
|
|
|
|
|
|
|
| 299 |
user.model_dump()
|
| 300 |
for user in sorted(users_db.values(), key=lambda x: x.id)
|
| 301 |
]
|
|
|
|
| 329 |
resource_response = await client.read_resource(
|
| 330 |
"resource://get_users_users_get"
|
| 331 |
)
|
| 332 |
+
response_text = resource_response[0].text # type: ignore[attr-defined]
|
|
|
|
| 333 |
resource = json.loads(response_text)
|
| 334 |
assert resource == json_users
|
| 335 |
response = await api_client.get("/users")
|
|
|
|
| 356 |
"""Test reading a resource that returns a string."""
|
| 357 |
async with Client(fastmcp_openapi_server) as client:
|
| 358 |
resource_response = await client.read_resource("resource://ping_ping_get")
|
| 359 |
+
assert resource_response[0].text == "pong" # type: ignore[attr-defined]
|
|
|
|
| 360 |
|
| 361 |
|
| 362 |
class TestResourceTemplates:
|
|
|
|
| 393 |
resource_response = await client.read_resource(
|
| 394 |
f"resource://get_user_users/{user_id}"
|
| 395 |
)
|
| 396 |
+
response_text = resource_response[0].text # type: ignore[attr-defined]
|
|
|
|
| 397 |
resource = json.loads(response_text)
|
| 398 |
|
| 399 |
assert resource == users_db[user_id].model_dump()
|
|
|
|
| 415 |
resource_response = await client.read_resource(
|
| 416 |
f"resource://get_user_active_state_users/{is_active}/{user_id}"
|
| 417 |
)
|
| 418 |
+
response_text = resource_response[0].text # type: ignore[attr-defined]
|
|
|
|
| 419 |
resource = json.loads(response_text)
|
| 420 |
|
| 421 |
assert resource == users_db[user_id].model_dump()
|
|
|
|
| 665 |
"""Test reading a resource from an OpenAPI 3.0 server."""
|
| 666 |
async with Client(openapi_30_server) as client:
|
| 667 |
resource_response = await client.read_resource("resource://listProducts")
|
| 668 |
+
response_text = resource_response[0].text # type: ignore[attr-defined]
|
|
|
|
| 669 |
content = json.loads(response_text)
|
| 670 |
assert len(content) == 2
|
| 671 |
assert content[0]["name"] == "Product 1"
|
|
|
|
| 675 |
"""Test reading a resource from template from an OpenAPI 3.0 server."""
|
| 676 |
async with Client(openapi_30_server) as client:
|
| 677 |
resource_response = await client.read_resource("resource://getProduct/p1")
|
| 678 |
+
response_text = resource_response[0].text # type: ignore[attr-defined]
|
|
|
|
| 679 |
content = json.loads(response_text)
|
| 680 |
assert content["id"] == "p1"
|
| 681 |
assert content["name"] == "Product 1"
|
|
|
|
| 689 |
)
|
| 690 |
# Result should be a text content
|
| 691 |
assert len(result) == 1
|
| 692 |
+
product = json.loads(result[0].text) # type: ignore[attr-defined]
|
|
|
|
| 693 |
assert product["id"] == "p3"
|
| 694 |
assert product["name"] == "New Product"
|
| 695 |
assert product["price"] == 39.99
|
|
|
|
| 838 |
"""Test reading a resource from an OpenAPI 3.1 server."""
|
| 839 |
async with Client(openapi_31_server) as client:
|
| 840 |
resource_response = await client.read_resource("resource://listOrders")
|
| 841 |
+
response_text = resource_response[0].text # type: ignore[attr-defined]
|
|
|
|
| 842 |
content = json.loads(response_text)
|
| 843 |
assert len(content) == 2
|
| 844 |
assert content[0]["customer"] == "Alice"
|
|
|
|
| 848 |
"""Test reading a resource from template from an OpenAPI 3.1 server."""
|
| 849 |
async with Client(openapi_31_server) as client:
|
| 850 |
resource_response = await client.read_resource("resource://getOrder/o1")
|
| 851 |
+
response_text = resource_response[0].text # type: ignore[attr-defined]
|
|
|
|
| 852 |
content = json.loads(response_text)
|
| 853 |
assert content["id"] == "o1"
|
| 854 |
assert content["customer"] == "Alice"
|
|
|
|
| 862 |
)
|
| 863 |
# Result should be a text content
|
| 864 |
assert len(result) == 1
|
| 865 |
+
order = json.loads(result[0].text) # type: ignore[attr-dict]
|
|
|
|
| 866 |
assert order["id"] == "o3"
|
| 867 |
assert order["customer"] == "Charlie"
|
| 868 |
assert order["items"] == ["item4", "item5"]
|
tests/server/test_import_server.py
CHANGED
|
@@ -1,8 +1,6 @@
|
|
| 1 |
import json
|
| 2 |
from urllib.parse import quote
|
| 3 |
|
| 4 |
-
from mcp.types import TextContent, TextResourceContents
|
| 5 |
-
|
| 6 |
from fastmcp.client.client import Client
|
| 7 |
from fastmcp.server.server import FastMCP
|
| 8 |
|
|
@@ -223,8 +221,7 @@ async def test_call_imported_custom_named_tool():
|
|
| 223 |
|
| 224 |
async with Client(main_app) as client:
|
| 225 |
result = await client.call_tool("api_get_data", {"query": "test"})
|
| 226 |
-
assert
|
| 227 |
-
assert result[0].text == "Data for query: test"
|
| 228 |
|
| 229 |
|
| 230 |
async def test_first_level_importing_with_custom_name():
|
|
@@ -278,8 +275,7 @@ async def test_call_nested_imported_tool():
|
|
| 278 |
result = await main_app._tool_manager.call_tool(
|
| 279 |
"service_provider_compute", {"input": 21}
|
| 280 |
)
|
| 281 |
-
assert
|
| 282 |
-
assert result[0].text == "42"
|
| 283 |
|
| 284 |
|
| 285 |
async def test_import_with_proxy_tools():
|
|
@@ -302,8 +298,7 @@ async def test_import_with_proxy_tools():
|
|
| 302 |
await main_app.import_server("api", proxy_app)
|
| 303 |
|
| 304 |
result = await main_app._mcp_call_tool("api_get_data", {"query": "test"})
|
| 305 |
-
assert
|
| 306 |
-
assert result[0].text == "Data for query: test"
|
| 307 |
|
| 308 |
|
| 309 |
async def test_import_with_proxy_prompts():
|
|
@@ -326,8 +321,7 @@ async def test_import_with_proxy_prompts():
|
|
| 326 |
await main_app.import_server("api", proxy_app)
|
| 327 |
|
| 328 |
result = await main_app._mcp_get_prompt("api_greeting", {"name": "World"})
|
| 329 |
-
assert
|
| 330 |
-
assert result.messages[0].content.text == "Hello, World from API!"
|
| 331 |
assert result.description == "Example greeting prompt."
|
| 332 |
|
| 333 |
|
|
@@ -356,8 +350,7 @@ async def test_import_with_proxy_resources():
|
|
| 356 |
# Access the resource through the main app with the prefixed key
|
| 357 |
async with Client(main_app) as client:
|
| 358 |
result = await client.read_resource("config://api/settings")
|
| 359 |
-
|
| 360 |
-
content = json.loads(result[0].text)
|
| 361 |
assert content["api_key"] == "12345"
|
| 362 |
assert content["base_url"] == "https://api.example.com"
|
| 363 |
|
|
@@ -387,8 +380,7 @@ async def test_import_with_proxy_resource_templates():
|
|
| 387 |
quoted_email = quote("john@example.com", safe="")
|
| 388 |
async with Client(main_app) as client:
|
| 389 |
result = await client.read_resource(f"user://api/{quoted_name}/{quoted_email}")
|
| 390 |
-
|
| 391 |
-
content = json.loads(result[0].text)
|
| 392 |
assert content["name"] == "John Doe"
|
| 393 |
assert content["email"] == "john@example.com"
|
| 394 |
|
|
|
|
| 1 |
import json
|
| 2 |
from urllib.parse import quote
|
| 3 |
|
|
|
|
|
|
|
| 4 |
from fastmcp.client.client import Client
|
| 5 |
from fastmcp.server.server import FastMCP
|
| 6 |
|
|
|
|
| 221 |
|
| 222 |
async with Client(main_app) as client:
|
| 223 |
result = await client.call_tool("api_get_data", {"query": "test"})
|
| 224 |
+
assert result[0].text == "Data for query: test" # type: ignore[attr-defined]
|
|
|
|
| 225 |
|
| 226 |
|
| 227 |
async def test_first_level_importing_with_custom_name():
|
|
|
|
| 275 |
result = await main_app._tool_manager.call_tool(
|
| 276 |
"service_provider_compute", {"input": 21}
|
| 277 |
)
|
| 278 |
+
assert result[0].text == "42" # type: ignore[attr-defined]
|
|
|
|
| 279 |
|
| 280 |
|
| 281 |
async def test_import_with_proxy_tools():
|
|
|
|
| 298 |
await main_app.import_server("api", proxy_app)
|
| 299 |
|
| 300 |
result = await main_app._mcp_call_tool("api_get_data", {"query": "test"})
|
| 301 |
+
assert result[0].text == "Data for query: test" # type: ignore[attr-defined]
|
|
|
|
| 302 |
|
| 303 |
|
| 304 |
async def test_import_with_proxy_prompts():
|
|
|
|
| 321 |
await main_app.import_server("api", proxy_app)
|
| 322 |
|
| 323 |
result = await main_app._mcp_get_prompt("api_greeting", {"name": "World"})
|
| 324 |
+
assert result.messages[0].content.text == "Hello, World from API!" # type: ignore[attr-defined]
|
|
|
|
| 325 |
assert result.description == "Example greeting prompt."
|
| 326 |
|
| 327 |
|
|
|
|
| 350 |
# Access the resource through the main app with the prefixed key
|
| 351 |
async with Client(main_app) as client:
|
| 352 |
result = await client.read_resource("config://api/settings")
|
| 353 |
+
content = json.loads(result[0].text) # type: ignore[attr-defined]
|
|
|
|
| 354 |
assert content["api_key"] == "12345"
|
| 355 |
assert content["base_url"] == "https://api.example.com"
|
| 356 |
|
|
|
|
| 380 |
quoted_email = quote("john@example.com", safe="")
|
| 381 |
async with Client(main_app) as client:
|
| 382 |
result = await client.read_resource(f"user://api/{quoted_name}/{quoted_email}")
|
| 383 |
+
content = json.loads(result[0].text) # type: ignore[attr-defined]
|
|
|
|
| 384 |
assert content["name"] == "John Doe"
|
| 385 |
assert content["email"] == "john@example.com"
|
| 386 |
|
tests/server/test_lifespan.py
DELETED
|
@@ -1,396 +0,0 @@
|
|
| 1 |
-
"""Tests for lifespan functionality in both low-level and FastMCP servers."""
|
| 2 |
-
|
| 3 |
-
import os
|
| 4 |
-
import sys
|
| 5 |
-
import traceback
|
| 6 |
-
from collections.abc import AsyncIterator
|
| 7 |
-
from contextlib import asynccontextmanager
|
| 8 |
-
from pathlib import Path
|
| 9 |
-
|
| 10 |
-
import anyio
|
| 11 |
-
import httpx
|
| 12 |
-
import uvicorn
|
| 13 |
-
from mcp.server.lowlevel.server import NotificationOptions, Server
|
| 14 |
-
from mcp.server.models import InitializationOptions
|
| 15 |
-
from mcp.shared.message import SessionMessage
|
| 16 |
-
from mcp.types import (
|
| 17 |
-
ClientCapabilities,
|
| 18 |
-
Implementation,
|
| 19 |
-
InitializeRequestParams,
|
| 20 |
-
JSONRPCMessage,
|
| 21 |
-
JSONRPCNotification,
|
| 22 |
-
JSONRPCRequest,
|
| 23 |
-
)
|
| 24 |
-
from pydantic import TypeAdapter
|
| 25 |
-
from starlette.applications import Starlette
|
| 26 |
-
from starlette.routing import Mount
|
| 27 |
-
|
| 28 |
-
from fastmcp import Context, FastMCP
|
| 29 |
-
from fastmcp.utilities.tests import run_server_in_process
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
async def test_lowlevel_server_lifespan():
|
| 33 |
-
"""Test that lifespan works in low-level server."""
|
| 34 |
-
|
| 35 |
-
@asynccontextmanager
|
| 36 |
-
async def test_lifespan(server: Server) -> AsyncIterator[dict[str, bool]]:
|
| 37 |
-
"""Test lifespan context that tracks startup/shutdown."""
|
| 38 |
-
context = {"started": False, "shutdown": False}
|
| 39 |
-
try:
|
| 40 |
-
context["started"] = True
|
| 41 |
-
yield context
|
| 42 |
-
finally:
|
| 43 |
-
context["shutdown"] = True
|
| 44 |
-
|
| 45 |
-
server = Server("test", lifespan=test_lifespan)
|
| 46 |
-
|
| 47 |
-
# Create memory streams for testing
|
| 48 |
-
send_stream1, receive_stream1 = anyio.create_memory_object_stream(100)
|
| 49 |
-
send_stream2, receive_stream2 = anyio.create_memory_object_stream(100)
|
| 50 |
-
|
| 51 |
-
# Create a tool that accesses lifespan context
|
| 52 |
-
@server.call_tool()
|
| 53 |
-
async def check_lifespan(name: str, arguments: dict) -> list:
|
| 54 |
-
ctx = server.request_context
|
| 55 |
-
assert isinstance(ctx.lifespan_context, dict)
|
| 56 |
-
assert ctx.lifespan_context["started"]
|
| 57 |
-
assert not ctx.lifespan_context["shutdown"]
|
| 58 |
-
return [{"type": "text", "text": "true"}]
|
| 59 |
-
|
| 60 |
-
# Run server in background task
|
| 61 |
-
async with (
|
| 62 |
-
anyio.create_task_group() as tg,
|
| 63 |
-
send_stream1,
|
| 64 |
-
receive_stream1,
|
| 65 |
-
send_stream2,
|
| 66 |
-
receive_stream2,
|
| 67 |
-
):
|
| 68 |
-
|
| 69 |
-
async def run_server():
|
| 70 |
-
await server.run(
|
| 71 |
-
receive_stream1,
|
| 72 |
-
send_stream2,
|
| 73 |
-
InitializationOptions(
|
| 74 |
-
server_name="test",
|
| 75 |
-
server_version="0.1.0",
|
| 76 |
-
capabilities=server.get_capabilities(
|
| 77 |
-
notification_options=NotificationOptions(),
|
| 78 |
-
experimental_capabilities={},
|
| 79 |
-
),
|
| 80 |
-
),
|
| 81 |
-
raise_exceptions=True,
|
| 82 |
-
)
|
| 83 |
-
|
| 84 |
-
tg.start_soon(run_server)
|
| 85 |
-
|
| 86 |
-
# Initialize the server
|
| 87 |
-
params = InitializeRequestParams(
|
| 88 |
-
protocolVersion="2024-11-05",
|
| 89 |
-
capabilities=ClientCapabilities(),
|
| 90 |
-
clientInfo=Implementation(name="test-client", version="0.1.0"),
|
| 91 |
-
)
|
| 92 |
-
await send_stream1.send(
|
| 93 |
-
SessionMessage(
|
| 94 |
-
JSONRPCMessage(
|
| 95 |
-
root=JSONRPCRequest(
|
| 96 |
-
jsonrpc="2.0",
|
| 97 |
-
id=1,
|
| 98 |
-
method="initialize",
|
| 99 |
-
params=TypeAdapter(InitializeRequestParams).dump_python(params),
|
| 100 |
-
)
|
| 101 |
-
)
|
| 102 |
-
)
|
| 103 |
-
)
|
| 104 |
-
response = await receive_stream2.receive()
|
| 105 |
-
response = response.message
|
| 106 |
-
|
| 107 |
-
# Send initialized notification
|
| 108 |
-
await send_stream1.send(
|
| 109 |
-
SessionMessage(
|
| 110 |
-
JSONRPCMessage(
|
| 111 |
-
root=JSONRPCNotification(
|
| 112 |
-
jsonrpc="2.0",
|
| 113 |
-
method="notifications/initialized",
|
| 114 |
-
)
|
| 115 |
-
)
|
| 116 |
-
)
|
| 117 |
-
)
|
| 118 |
-
|
| 119 |
-
# Call the tool to verify lifespan context
|
| 120 |
-
await send_stream1.send(
|
| 121 |
-
SessionMessage(
|
| 122 |
-
JSONRPCMessage(
|
| 123 |
-
root=JSONRPCRequest(
|
| 124 |
-
jsonrpc="2.0",
|
| 125 |
-
id=2,
|
| 126 |
-
method="tools/call",
|
| 127 |
-
params={"name": "check_lifespan", "arguments": {}},
|
| 128 |
-
)
|
| 129 |
-
)
|
| 130 |
-
)
|
| 131 |
-
)
|
| 132 |
-
|
| 133 |
-
# Get response and verify
|
| 134 |
-
response = await receive_stream2.receive()
|
| 135 |
-
response = response.message
|
| 136 |
-
assert response.root.result["content"][0]["text"] == "true"
|
| 137 |
-
|
| 138 |
-
# Cancel server task
|
| 139 |
-
tg.cancel_scope.cancel()
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
async def test_fastmcp_server_lifespan():
|
| 143 |
-
"""Test that lifespan works in FastMCP server."""
|
| 144 |
-
|
| 145 |
-
@asynccontextmanager
|
| 146 |
-
async def test_lifespan(server: FastMCP) -> AsyncIterator[dict]:
|
| 147 |
-
"""Test lifespan context that tracks startup/shutdown."""
|
| 148 |
-
context = {"started": False, "shutdown": False}
|
| 149 |
-
try:
|
| 150 |
-
context["started"] = True
|
| 151 |
-
yield context
|
| 152 |
-
finally:
|
| 153 |
-
context["shutdown"] = True
|
| 154 |
-
|
| 155 |
-
server = FastMCP("test", lifespan=test_lifespan)
|
| 156 |
-
|
| 157 |
-
# Create memory streams for testing
|
| 158 |
-
send_stream1, receive_stream1 = anyio.create_memory_object_stream(100)
|
| 159 |
-
send_stream2, receive_stream2 = anyio.create_memory_object_stream(100)
|
| 160 |
-
|
| 161 |
-
# Add a tool that checks lifespan context
|
| 162 |
-
@server.tool()
|
| 163 |
-
def check_lifespan(ctx: Context) -> bool:
|
| 164 |
-
"""Tool that checks lifespan context."""
|
| 165 |
-
assert isinstance(ctx.request_context.lifespan_context, dict)
|
| 166 |
-
assert ctx.request_context.lifespan_context["started"]
|
| 167 |
-
assert not ctx.request_context.lifespan_context["shutdown"]
|
| 168 |
-
return True
|
| 169 |
-
|
| 170 |
-
# Run server in background task
|
| 171 |
-
async with (
|
| 172 |
-
anyio.create_task_group() as tg,
|
| 173 |
-
send_stream1,
|
| 174 |
-
receive_stream1,
|
| 175 |
-
send_stream2,
|
| 176 |
-
receive_stream2,
|
| 177 |
-
):
|
| 178 |
-
|
| 179 |
-
async def run_server():
|
| 180 |
-
await server._mcp_server.run(
|
| 181 |
-
receive_stream1,
|
| 182 |
-
send_stream2,
|
| 183 |
-
server._mcp_server.create_initialization_options(),
|
| 184 |
-
raise_exceptions=True,
|
| 185 |
-
)
|
| 186 |
-
|
| 187 |
-
tg.start_soon(run_server)
|
| 188 |
-
|
| 189 |
-
# Initialize the server
|
| 190 |
-
params = InitializeRequestParams(
|
| 191 |
-
protocolVersion="2024-11-05",
|
| 192 |
-
capabilities=ClientCapabilities(),
|
| 193 |
-
clientInfo=Implementation(name="test-client", version="0.1.0"),
|
| 194 |
-
)
|
| 195 |
-
await send_stream1.send(
|
| 196 |
-
SessionMessage(
|
| 197 |
-
JSONRPCMessage(
|
| 198 |
-
root=JSONRPCRequest(
|
| 199 |
-
jsonrpc="2.0",
|
| 200 |
-
id=1,
|
| 201 |
-
method="initialize",
|
| 202 |
-
params=TypeAdapter(InitializeRequestParams).dump_python(params),
|
| 203 |
-
)
|
| 204 |
-
)
|
| 205 |
-
)
|
| 206 |
-
)
|
| 207 |
-
response = await receive_stream2.receive()
|
| 208 |
-
response = response.message
|
| 209 |
-
|
| 210 |
-
# Send initialized notification
|
| 211 |
-
await send_stream1.send(
|
| 212 |
-
SessionMessage(
|
| 213 |
-
JSONRPCMessage(
|
| 214 |
-
root=JSONRPCNotification(
|
| 215 |
-
jsonrpc="2.0",
|
| 216 |
-
method="notifications/initialized",
|
| 217 |
-
)
|
| 218 |
-
)
|
| 219 |
-
)
|
| 220 |
-
)
|
| 221 |
-
|
| 222 |
-
# Call the tool to verify lifespan context
|
| 223 |
-
await send_stream1.send(
|
| 224 |
-
SessionMessage(
|
| 225 |
-
JSONRPCMessage(
|
| 226 |
-
root=JSONRPCRequest(
|
| 227 |
-
jsonrpc="2.0",
|
| 228 |
-
id=2,
|
| 229 |
-
method="tools/call",
|
| 230 |
-
params={"name": "check_lifespan", "arguments": {}},
|
| 231 |
-
)
|
| 232 |
-
)
|
| 233 |
-
)
|
| 234 |
-
)
|
| 235 |
-
|
| 236 |
-
# Get response and verify
|
| 237 |
-
response = await receive_stream2.receive()
|
| 238 |
-
response = response.message
|
| 239 |
-
assert response.root.result["content"][0]["text"] == "true"
|
| 240 |
-
|
| 241 |
-
# Cancel server task
|
| 242 |
-
tg.cancel_scope.cancel()
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
def run_server_with_incorrect_lifespan_setup(
|
| 246 |
-
host: str, port: int, server_log_file_path: str
|
| 247 |
-
) -> None:
|
| 248 |
-
os.makedirs(os.path.dirname(server_log_file_path), exist_ok=True)
|
| 249 |
-
|
| 250 |
-
CUSTOM_LOGGING_CONFIG = {
|
| 251 |
-
"version": 1,
|
| 252 |
-
"disable_existing_loggers": False,
|
| 253 |
-
"formatters": {
|
| 254 |
-
"default": {
|
| 255 |
-
"()": "uvicorn.logging.DefaultFormatter",
|
| 256 |
-
"fmt": "%(levelprefix)s %(asctime)s [%(name)s] %(message)s",
|
| 257 |
-
"datefmt": "%Y-%m-%d %H:%M:%S",
|
| 258 |
-
"use_colors": False,
|
| 259 |
-
},
|
| 260 |
-
"access": {
|
| 261 |
-
"()": "uvicorn.logging.AccessFormatter",
|
| 262 |
-
"fmt": '%(levelprefix)s %(asctime)s [%(name)s] %(client_addr)s - "%(request_line)s" %(status_code)s',
|
| 263 |
-
"datefmt": "%Y-%m-%d %H:%M:%S",
|
| 264 |
-
"use_colors": False,
|
| 265 |
-
},
|
| 266 |
-
},
|
| 267 |
-
"handlers": {
|
| 268 |
-
"file_default": {
|
| 269 |
-
"formatter": "default",
|
| 270 |
-
"class": "logging.FileHandler",
|
| 271 |
-
"filename": server_log_file_path,
|
| 272 |
-
"mode": "w",
|
| 273 |
-
},
|
| 274 |
-
"file_access": {
|
| 275 |
-
"formatter": "access",
|
| 276 |
-
"class": "logging.FileHandler",
|
| 277 |
-
"filename": server_log_file_path,
|
| 278 |
-
"mode": "a",
|
| 279 |
-
},
|
| 280 |
-
},
|
| 281 |
-
"loggers": {
|
| 282 |
-
"uvicorn": { # Catches uvicorn root logs
|
| 283 |
-
"handlers": ["file_default"],
|
| 284 |
-
"level": "DEBUG",
|
| 285 |
-
"propagate": False,
|
| 286 |
-
},
|
| 287 |
-
"uvicorn.error": {
|
| 288 |
-
"handlers": ["file_default"],
|
| 289 |
-
"level": "DEBUG",
|
| 290 |
-
"propagate": False,
|
| 291 |
-
},
|
| 292 |
-
"uvicorn.access": {
|
| 293 |
-
"handlers": ["file_access"],
|
| 294 |
-
"level": "INFO",
|
| 295 |
-
"propagate": False,
|
| 296 |
-
},
|
| 297 |
-
},
|
| 298 |
-
"root": {
|
| 299 |
-
"handlers": ["file_default"],
|
| 300 |
-
"level": "DEBUG",
|
| 301 |
-
},
|
| 302 |
-
}
|
| 303 |
-
|
| 304 |
-
try:
|
| 305 |
-
mcp = FastMCP()
|
| 306 |
-
|
| 307 |
-
@mcp.tool("ping_tool", "A simple ping tool for the test server")
|
| 308 |
-
def ping_tool() -> str:
|
| 309 |
-
return "pong"
|
| 310 |
-
|
| 311 |
-
mcp_asgi_app = mcp.http_app(transport="streamable-http")
|
| 312 |
-
|
| 313 |
-
parent_app = Starlette(
|
| 314 |
-
routes=[Mount("/mounted_mcp", app=mcp_asgi_app)],
|
| 315 |
-
)
|
| 316 |
-
|
| 317 |
-
uvicorn.run(
|
| 318 |
-
parent_app,
|
| 319 |
-
host=host,
|
| 320 |
-
port=port,
|
| 321 |
-
log_config=CUSTOM_LOGGING_CONFIG,
|
| 322 |
-
log_level=None,
|
| 323 |
-
)
|
| 324 |
-
sys.exit(0)
|
| 325 |
-
except Exception as e_outer:
|
| 326 |
-
with open(server_log_file_path, "a") as f_fallback:
|
| 327 |
-
f_fallback.write(
|
| 328 |
-
"--- FALLBACK EXCEPTION IN SERVER RUNNER (PRE-UVICORN) ---\n"
|
| 329 |
-
)
|
| 330 |
-
f_fallback.write(f"{type(e_outer).__name__}: {e_outer}\n")
|
| 331 |
-
f_fallback.write(traceback.format_exc())
|
| 332 |
-
sys.exit(1)
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
async def test_missing_lifespan_logs_informative_error(tmp_path: Path):
|
| 336 |
-
server_log_file = tmp_path / "server.log"
|
| 337 |
-
|
| 338 |
-
with run_server_in_process(
|
| 339 |
-
run_server_with_incorrect_lifespan_setup, str(server_log_file)
|
| 340 |
-
) as server_url:
|
| 341 |
-
full_mcp_path = server_url + "/mounted_mcp/mcp/"
|
| 342 |
-
|
| 343 |
-
client_triggered_error = False
|
| 344 |
-
response_status = -1
|
| 345 |
-
response_body = ""
|
| 346 |
-
try:
|
| 347 |
-
async with httpx.AsyncClient(timeout=10) as client:
|
| 348 |
-
response = await client.post(
|
| 349 |
-
full_mcp_path,
|
| 350 |
-
json={"id": 1, "method": "list_tools", "jsonrpc": "2.0"},
|
| 351 |
-
)
|
| 352 |
-
response_status = response.status_code
|
| 353 |
-
response_body = response.text
|
| 354 |
-
if response.status_code == 500:
|
| 355 |
-
client_triggered_error = True
|
| 356 |
-
else:
|
| 357 |
-
print(
|
| 358 |
-
f"Client received unexpected status code: {response.status_code} "
|
| 359 |
-
f"Response: {response_body[:500]}"
|
| 360 |
-
)
|
| 361 |
-
except httpx.RequestError as e:
|
| 362 |
-
print(f"Client request failed with RequestError: {e}")
|
| 363 |
-
client_triggered_error = True
|
| 364 |
-
|
| 365 |
-
assert client_triggered_error, (
|
| 366 |
-
f"Client request did not result in a 500 error or a request error. "
|
| 367 |
-
f"Status: {response_status}, Body: {response_body[:500]}"
|
| 368 |
-
)
|
| 369 |
-
|
| 370 |
-
assert server_log_file.exists(), (
|
| 371 |
-
f"Server log file was not created at {server_log_file}"
|
| 372 |
-
)
|
| 373 |
-
log_content = server_log_file.read_text()
|
| 374 |
-
|
| 375 |
-
print(f"--- Captured Server Log Content ({server_log_file}) ---")
|
| 376 |
-
print(log_content)
|
| 377 |
-
print("--- End Server Log Content ---")
|
| 378 |
-
|
| 379 |
-
# Core assertions for the enhanced error message
|
| 380 |
-
assert (
|
| 381 |
-
"FastMCP's StreamableHTTPSessionManager task group was not initialized"
|
| 382 |
-
in log_content
|
| 383 |
-
)
|
| 384 |
-
assert "lifespan=mcp_app.lifespan" in log_content
|
| 385 |
-
assert "gofastmcp.com/deployment/asgi" in log_content
|
| 386 |
-
assert "Original error: Task group is not initialized" in log_content
|
| 387 |
-
|
| 388 |
-
# Check for Uvicorn's own error logging wrapper for the request
|
| 389 |
-
assert "ERROR" in log_content # General check for ERROR level logs
|
| 390 |
-
assert "Exception in ASGI application" in log_content
|
| 391 |
-
|
| 392 |
-
# Sanity checks for server operation and logging setup
|
| 393 |
-
assert "Uvicorn running on" in log_content
|
| 394 |
-
assert (
|
| 395 |
-
"--- FALLBACK EXCEPTION IN SERVER RUNNER (PRE-UVICORN) ---" not in log_content
|
| 396 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/server/test_logging.py
CHANGED
|
@@ -2,6 +2,7 @@ import asyncio
|
|
| 2 |
import logging
|
| 3 |
from unittest.mock import AsyncMock, Mock, patch
|
| 4 |
|
|
|
|
| 5 |
import pytest
|
| 6 |
|
| 7 |
from fastmcp.server.server import FastMCP
|
|
@@ -27,7 +28,7 @@ async def test_uvicorn_logging_default_level(
|
|
| 27 |
"""Tests that FastMCP passes log_level to uvicorn.Config if no log_config is given."""
|
| 28 |
mock_server_instance = AsyncMock()
|
| 29 |
mock_uvicorn_server_constructor.return_value = mock_server_instance
|
| 30 |
-
serve_finished_event =
|
| 31 |
mock_server_instance.serve.side_effect = serve_finished_event.wait
|
| 32 |
|
| 33 |
test_log_level = "warning"
|
|
@@ -63,7 +64,7 @@ async def test_uvicorn_logging_with_custom_log_config(
|
|
| 63 |
"""Tests that FastMCP passes log_config to uvicorn.Config and not log_level."""
|
| 64 |
mock_server_instance = AsyncMock()
|
| 65 |
mock_uvicorn_server_constructor.return_value = mock_server_instance
|
| 66 |
-
serve_finished_event =
|
| 67 |
mock_server_instance.serve.side_effect = serve_finished_event.wait
|
| 68 |
|
| 69 |
sample_log_config = {
|
|
@@ -123,7 +124,7 @@ async def test_uvicorn_logging_custom_log_config_overrides_log_level_param(
|
|
| 123 |
"""Tests log_config precedence if log_level is also passed to run_http_async."""
|
| 124 |
mock_server_instance = AsyncMock()
|
| 125 |
mock_uvicorn_server_constructor.return_value = mock_server_instance
|
| 126 |
-
serve_finished_event =
|
| 127 |
mock_server_instance.serve.side_effect = serve_finished_event.wait
|
| 128 |
|
| 129 |
sample_log_config = {
|
|
|
|
| 2 |
import logging
|
| 3 |
from unittest.mock import AsyncMock, Mock, patch
|
| 4 |
|
| 5 |
+
import anyio
|
| 6 |
import pytest
|
| 7 |
|
| 8 |
from fastmcp.server.server import FastMCP
|
|
|
|
| 28 |
"""Tests that FastMCP passes log_level to uvicorn.Config if no log_config is given."""
|
| 29 |
mock_server_instance = AsyncMock()
|
| 30 |
mock_uvicorn_server_constructor.return_value = mock_server_instance
|
| 31 |
+
serve_finished_event = anyio.Event()
|
| 32 |
mock_server_instance.serve.side_effect = serve_finished_event.wait
|
| 33 |
|
| 34 |
test_log_level = "warning"
|
|
|
|
| 64 |
"""Tests that FastMCP passes log_config to uvicorn.Config and not log_level."""
|
| 65 |
mock_server_instance = AsyncMock()
|
| 66 |
mock_uvicorn_server_constructor.return_value = mock_server_instance
|
| 67 |
+
serve_finished_event = anyio.Event()
|
| 68 |
mock_server_instance.serve.side_effect = serve_finished_event.wait
|
| 69 |
|
| 70 |
sample_log_config = {
|
|
|
|
| 124 |
"""Tests log_config precedence if log_level is also passed to run_http_async."""
|
| 125 |
mock_server_instance = AsyncMock()
|
| 126 |
mock_uvicorn_server_constructor.return_value = mock_server_instance
|
| 127 |
+
serve_finished_event = anyio.Event()
|
| 128 |
mock_server_instance.serve.side_effect = serve_finished_event.wait
|
| 129 |
|
| 130 |
sample_log_config = {
|
tests/server/test_mount.py
CHANGED
|
@@ -3,8 +3,6 @@ import sys
|
|
| 3 |
from contextlib import asynccontextmanager
|
| 4 |
|
| 5 |
import pytest
|
| 6 |
-
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
| 7 |
-
from mcp.types import TextContent, TextResourceContents
|
| 8 |
|
| 9 |
from fastmcp import FastMCP
|
| 10 |
from fastmcp.client import Client
|
|
@@ -36,8 +34,7 @@ class TestBasicMount:
|
|
| 36 |
|
| 37 |
async with Client(main_app) as client:
|
| 38 |
result = await client.call_tool("sub_sub_tool", {})
|
| 39 |
-
assert
|
| 40 |
-
assert result[0].text == "This is from the sub app"
|
| 41 |
|
| 42 |
async def test_mount_with_custom_separator(self):
|
| 43 |
"""Test mounting with a custom tool separator (deprecated but still supported)."""
|
|
@@ -57,8 +54,7 @@ class TestBasicMount:
|
|
| 57 |
|
| 58 |
# Call the tool
|
| 59 |
result = await main_app._mcp_call_tool("sub_greet", {"name": "World"})
|
| 60 |
-
assert
|
| 61 |
-
assert result[0].text == "Hello, World!"
|
| 62 |
|
| 63 |
async def test_mount_invalid_resource_prefix(self):
|
| 64 |
main_app = FastMCP("MainApp")
|
|
@@ -147,12 +143,10 @@ class TestMultipleServerMount:
|
|
| 147 |
|
| 148 |
# Call tools from both mounted servers
|
| 149 |
result1 = await main_app._mcp_call_tool("weather_get_forecast", {})
|
| 150 |
-
assert
|
| 151 |
-
assert result1[0].text == "Weather forecast"
|
| 152 |
|
| 153 |
result2 = await main_app._mcp_call_tool("news_get_headlines", {})
|
| 154 |
-
assert
|
| 155 |
-
assert result2[0].text == "News headlines"
|
| 156 |
|
| 157 |
async def test_mount_same_prefix(self):
|
| 158 |
"""Test that mounting with the same prefix replaces the previous mount."""
|
|
@@ -227,8 +221,7 @@ class TestMultipleServerMount:
|
|
| 227 |
|
| 228 |
# Test calling a tool
|
| 229 |
result = await client.call_tool("working_working_tool", {})
|
| 230 |
-
assert
|
| 231 |
-
assert result[0].text == "Working tool"
|
| 232 |
|
| 233 |
# Test resources
|
| 234 |
resources = await client.list_resources()
|
|
@@ -284,8 +277,7 @@ class TestDynamicChanges:
|
|
| 284 |
|
| 285 |
# Call the dynamically added tool
|
| 286 |
result = await main_app._mcp_call_tool("sub_dynamic_tool", {})
|
| 287 |
-
assert
|
| 288 |
-
assert result[0].text == "Added after mounting"
|
| 289 |
|
| 290 |
async def test_removing_tool_after_mounting(self):
|
| 291 |
"""Test that tools removed from mounted servers are no longer accessible."""
|
|
@@ -335,8 +327,7 @@ class TestResourcesAndTemplates:
|
|
| 335 |
# Check that resource can be accessed
|
| 336 |
async with Client(main_app) as client:
|
| 337 |
result = await client.read_resource("data://data/users")
|
| 338 |
-
assert
|
| 339 |
-
assert json.loads(result[0].text) == ["user1", "user2"]
|
| 340 |
|
| 341 |
async def test_mount_with_resource_templates(self):
|
| 342 |
"""Test mounting a server with resource templates."""
|
|
@@ -357,8 +348,7 @@ class TestResourcesAndTemplates:
|
|
| 357 |
# Check template instantiation
|
| 358 |
async with Client(main_app) as client:
|
| 359 |
result = await client.read_resource("users://api/123/profile")
|
| 360 |
-
|
| 361 |
-
profile = json.loads(result[0].text)
|
| 362 |
assert profile["id"] == "123"
|
| 363 |
assert profile["name"] == "User 123"
|
| 364 |
|
|
@@ -382,8 +372,7 @@ class TestResourcesAndTemplates:
|
|
| 382 |
# Check access to the resource
|
| 383 |
async with Client(main_app) as client:
|
| 384 |
result = await client.read_resource("data://data/config")
|
| 385 |
-
|
| 386 |
-
config = json.loads(result[0].text)
|
| 387 |
assert config["version"] == "1.0"
|
| 388 |
|
| 389 |
|
|
@@ -461,8 +450,7 @@ class TestProxyServer:
|
|
| 461 |
|
| 462 |
# Call the tool
|
| 463 |
result = await main_app._mcp_call_tool("proxy_get_data", {"query": "test"})
|
| 464 |
-
assert
|
| 465 |
-
assert result[0].text == "Data for test"
|
| 466 |
|
| 467 |
async def test_dynamically_adding_to_proxied_server(self):
|
| 468 |
"""Test that changes to the original server are reflected in the mounted proxy."""
|
|
@@ -489,8 +477,7 @@ class TestProxyServer:
|
|
| 489 |
|
| 490 |
# Call the tool
|
| 491 |
result = await main_app._mcp_call_tool("proxy_dynamic_data", {})
|
| 492 |
-
assert
|
| 493 |
-
assert result[0].text == "Dynamic data"
|
| 494 |
|
| 495 |
async def test_proxy_server_with_resources(self):
|
| 496 |
"""Test mounting a proxy server with resources."""
|
|
@@ -512,8 +499,7 @@ class TestProxyServer:
|
|
| 512 |
|
| 513 |
# Resource should be accessible through main app
|
| 514 |
result = await main_app._mcp_read_resource("config://proxy/settings")
|
| 515 |
-
|
| 516 |
-
config = json.loads(result[0].content)
|
| 517 |
assert config["api_key"] == "12345"
|
| 518 |
|
| 519 |
async def test_proxy_server_with_prompts(self):
|
|
|
|
| 3 |
from contextlib import asynccontextmanager
|
| 4 |
|
| 5 |
import pytest
|
|
|
|
|
|
|
| 6 |
|
| 7 |
from fastmcp import FastMCP
|
| 8 |
from fastmcp.client import Client
|
|
|
|
| 34 |
|
| 35 |
async with Client(main_app) as client:
|
| 36 |
result = await client.call_tool("sub_sub_tool", {})
|
| 37 |
+
assert result[0].text == "This is from the sub app" # type: ignore[attr-defined]
|
|
|
|
| 38 |
|
| 39 |
async def test_mount_with_custom_separator(self):
|
| 40 |
"""Test mounting with a custom tool separator (deprecated but still supported)."""
|
|
|
|
| 54 |
|
| 55 |
# Call the tool
|
| 56 |
result = await main_app._mcp_call_tool("sub_greet", {"name": "World"})
|
| 57 |
+
assert result[0].text == "Hello, World!" # type: ignore[attr-defined]
|
|
|
|
| 58 |
|
| 59 |
async def test_mount_invalid_resource_prefix(self):
|
| 60 |
main_app = FastMCP("MainApp")
|
|
|
|
| 143 |
|
| 144 |
# Call tools from both mounted servers
|
| 145 |
result1 = await main_app._mcp_call_tool("weather_get_forecast", {})
|
| 146 |
+
assert result1[0].text == "Weather forecast" # type: ignore[attr-defined]
|
|
|
|
| 147 |
|
| 148 |
result2 = await main_app._mcp_call_tool("news_get_headlines", {})
|
| 149 |
+
assert result2[0].text == "News headlines" # type: ignore[attr-defined]
|
|
|
|
| 150 |
|
| 151 |
async def test_mount_same_prefix(self):
|
| 152 |
"""Test that mounting with the same prefix replaces the previous mount."""
|
|
|
|
| 221 |
|
| 222 |
# Test calling a tool
|
| 223 |
result = await client.call_tool("working_working_tool", {})
|
| 224 |
+
assert result[0].text == "Working tool" # type: ignore[attr-defined]
|
|
|
|
| 225 |
|
| 226 |
# Test resources
|
| 227 |
resources = await client.list_resources()
|
|
|
|
| 277 |
|
| 278 |
# Call the dynamically added tool
|
| 279 |
result = await main_app._mcp_call_tool("sub_dynamic_tool", {})
|
| 280 |
+
assert result[0].text == "Added after mounting" # type: ignore[attr-defined]
|
|
|
|
| 281 |
|
| 282 |
async def test_removing_tool_after_mounting(self):
|
| 283 |
"""Test that tools removed from mounted servers are no longer accessible."""
|
|
|
|
| 327 |
# Check that resource can be accessed
|
| 328 |
async with Client(main_app) as client:
|
| 329 |
result = await client.read_resource("data://data/users")
|
| 330 |
+
assert json.loads(result[0].text) == ["user1", "user2"] # type: ignore[attr-defined]
|
|
|
|
| 331 |
|
| 332 |
async def test_mount_with_resource_templates(self):
|
| 333 |
"""Test mounting a server with resource templates."""
|
|
|
|
| 348 |
# Check template instantiation
|
| 349 |
async with Client(main_app) as client:
|
| 350 |
result = await client.read_resource("users://api/123/profile")
|
| 351 |
+
profile = json.loads(result[0].text) # type: ignore
|
|
|
|
| 352 |
assert profile["id"] == "123"
|
| 353 |
assert profile["name"] == "User 123"
|
| 354 |
|
|
|
|
| 372 |
# Check access to the resource
|
| 373 |
async with Client(main_app) as client:
|
| 374 |
result = await client.read_resource("data://data/config")
|
| 375 |
+
config = json.loads(result[0].text) # type: ignore[attr-defined]
|
|
|
|
| 376 |
assert config["version"] == "1.0"
|
| 377 |
|
| 378 |
|
|
|
|
| 450 |
|
| 451 |
# Call the tool
|
| 452 |
result = await main_app._mcp_call_tool("proxy_get_data", {"query": "test"})
|
| 453 |
+
assert result[0].text == "Data for test" # type: ignore[attr-defined]
|
|
|
|
| 454 |
|
| 455 |
async def test_dynamically_adding_to_proxied_server(self):
|
| 456 |
"""Test that changes to the original server are reflected in the mounted proxy."""
|
|
|
|
| 477 |
|
| 478 |
# Call the tool
|
| 479 |
result = await main_app._mcp_call_tool("proxy_dynamic_data", {})
|
| 480 |
+
assert result[0].text == "Dynamic data" # type: ignore[attr-defined]
|
|
|
|
| 481 |
|
| 482 |
async def test_proxy_server_with_resources(self):
|
| 483 |
"""Test mounting a proxy server with resources."""
|
|
|
|
| 499 |
|
| 500 |
# Resource should be accessible through main app
|
| 501 |
result = await main_app._mcp_read_resource("config://proxy/settings")
|
| 502 |
+
config = json.loads(result[0].content) # type: ignore[attr-defined]
|
|
|
|
| 503 |
assert config["api_key"] == "12345"
|
| 504 |
|
| 505 |
async def test_proxy_server_with_prompts(self):
|
tests/server/test_proxy.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
import json
|
| 2 |
from typing import Any
|
| 3 |
|
| 4 |
-
import mcp.types
|
| 5 |
import pytest
|
|
|
|
| 6 |
from dirty_equals import Contains
|
| 7 |
from mcp import McpError
|
| 8 |
|
|
@@ -89,16 +89,14 @@ async def test_as_proxy_with_server(fastmcp_server):
|
|
| 89 |
"""FastMCP.as_proxy should accept a FastMCP instance."""
|
| 90 |
proxy = FastMCP.as_proxy(fastmcp_server)
|
| 91 |
result = await proxy._mcp_call_tool("greet", {"name": "Test"})
|
| 92 |
-
assert
|
| 93 |
-
assert result[0].text == "Hello, Test!"
|
| 94 |
|
| 95 |
|
| 96 |
async def test_as_proxy_with_transport(fastmcp_server):
|
| 97 |
"""FastMCP.as_proxy should accept a ClientTransport."""
|
| 98 |
proxy = FastMCP.as_proxy(FastMCPTransport(fastmcp_server))
|
| 99 |
result = await proxy._mcp_call_tool("greet", {"name": "Test"})
|
| 100 |
-
assert
|
| 101 |
-
assert result[0].text == "Hello, Test!"
|
| 102 |
|
| 103 |
|
| 104 |
def test_as_proxy_with_url():
|
|
@@ -137,9 +135,7 @@ class TestTools:
|
|
| 137 |
async def test_call_tool_calls_tool(self, proxy_server):
|
| 138 |
async with Client(proxy_server) as client:
|
| 139 |
proxy_result = await client.call_tool("add", {"a": 1, "b": 2})
|
| 140 |
-
|
| 141 |
-
assert isinstance(proxy_result[0], mcp.types.TextContent)
|
| 142 |
-
assert proxy_result[0].text == "3"
|
| 143 |
|
| 144 |
async def test_error_tool_raises_error(self, proxy_server):
|
| 145 |
with pytest.raises(ToolError, match=""):
|
|
@@ -163,8 +159,7 @@ class TestResources:
|
|
| 163 |
async def test_read_resource(self, proxy_server: FastMCPProxy):
|
| 164 |
async with Client(proxy_server) as client:
|
| 165 |
result = await client.read_resource("resource://wave")
|
| 166 |
-
assert
|
| 167 |
-
assert result[0].text == "👋"
|
| 168 |
|
| 169 |
async def test_read_resource_same_as_original(self, fastmcp_server, proxy_server):
|
| 170 |
async with Client(fastmcp_server) as client:
|
|
@@ -176,8 +171,7 @@ class TestResources:
|
|
| 176 |
async def test_read_json_resource(self, proxy_server: FastMCPProxy):
|
| 177 |
async with Client(proxy_server) as client:
|
| 178 |
result = await client.read_resource("data://users")
|
| 179 |
-
assert
|
| 180 |
-
assert json.loads(result[0].text) == USERS
|
| 181 |
|
| 182 |
async def test_read_resource_returns_none_if_not_found(self, proxy_server):
|
| 183 |
with pytest.raises(McpError, match="Unknown resource: resource://nonexistent"):
|
|
@@ -201,8 +195,7 @@ class TestResourceTemplates:
|
|
| 201 |
async def test_read_resource_template(self, proxy_server: FastMCPProxy, id: int):
|
| 202 |
async with Client(proxy_server) as client:
|
| 203 |
result = await client.read_resource(f"data://user/{id}")
|
| 204 |
-
assert
|
| 205 |
-
assert json.loads(result[0].text) == USERS[id - 1]
|
| 206 |
|
| 207 |
async def test_read_resource_template_same_as_original(
|
| 208 |
self, fastmcp_server, proxy_server
|
|
@@ -238,7 +231,28 @@ class TestPrompts:
|
|
| 238 |
async def test_render_prompt_calls_prompt(self, proxy_server):
|
| 239 |
async with Client(proxy_server) as client:
|
| 240 |
result = await client.get_prompt("welcome", {"name": "Alice"})
|
| 241 |
-
assert isinstance(result.messages[0], mcp.types.PromptMessage)
|
| 242 |
assert result.messages[0].role == "user"
|
| 243 |
-
assert
|
| 244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import json
|
| 2 |
from typing import Any
|
| 3 |
|
|
|
|
| 4 |
import pytest
|
| 5 |
+
from anyio import create_task_group
|
| 6 |
from dirty_equals import Contains
|
| 7 |
from mcp import McpError
|
| 8 |
|
|
|
|
| 89 |
"""FastMCP.as_proxy should accept a FastMCP instance."""
|
| 90 |
proxy = FastMCP.as_proxy(fastmcp_server)
|
| 91 |
result = await proxy._mcp_call_tool("greet", {"name": "Test"})
|
| 92 |
+
assert result[0].text == "Hello, Test!" # type: ignore[attr-defined]
|
|
|
|
| 93 |
|
| 94 |
|
| 95 |
async def test_as_proxy_with_transport(fastmcp_server):
|
| 96 |
"""FastMCP.as_proxy should accept a ClientTransport."""
|
| 97 |
proxy = FastMCP.as_proxy(FastMCPTransport(fastmcp_server))
|
| 98 |
result = await proxy._mcp_call_tool("greet", {"name": "Test"})
|
| 99 |
+
assert result[0].text == "Hello, Test!" # type: ignore[attr-defined]
|
|
|
|
| 100 |
|
| 101 |
|
| 102 |
def test_as_proxy_with_url():
|
|
|
|
| 135 |
async def test_call_tool_calls_tool(self, proxy_server):
|
| 136 |
async with Client(proxy_server) as client:
|
| 137 |
proxy_result = await client.call_tool("add", {"a": 1, "b": 2})
|
| 138 |
+
assert proxy_result[0].text == "3" # type: ignore[attr-defined]
|
|
|
|
|
|
|
| 139 |
|
| 140 |
async def test_error_tool_raises_error(self, proxy_server):
|
| 141 |
with pytest.raises(ToolError, match=""):
|
|
|
|
| 159 |
async def test_read_resource(self, proxy_server: FastMCPProxy):
|
| 160 |
async with Client(proxy_server) as client:
|
| 161 |
result = await client.read_resource("resource://wave")
|
| 162 |
+
assert result[0].text == "👋" # type: ignore[attr-defined]
|
|
|
|
| 163 |
|
| 164 |
async def test_read_resource_same_as_original(self, fastmcp_server, proxy_server):
|
| 165 |
async with Client(fastmcp_server) as client:
|
|
|
|
| 171 |
async def test_read_json_resource(self, proxy_server: FastMCPProxy):
|
| 172 |
async with Client(proxy_server) as client:
|
| 173 |
result = await client.read_resource("data://users")
|
| 174 |
+
assert json.loads(result[0].text) == USERS # type: ignore[attr-defined]
|
|
|
|
| 175 |
|
| 176 |
async def test_read_resource_returns_none_if_not_found(self, proxy_server):
|
| 177 |
with pytest.raises(McpError, match="Unknown resource: resource://nonexistent"):
|
|
|
|
| 195 |
async def test_read_resource_template(self, proxy_server: FastMCPProxy, id: int):
|
| 196 |
async with Client(proxy_server) as client:
|
| 197 |
result = await client.read_resource(f"data://user/{id}")
|
| 198 |
+
assert json.loads(result[0].text) == USERS[id - 1] # type: ignore[attr-defined]
|
|
|
|
| 199 |
|
| 200 |
async def test_read_resource_template_same_as_original(
|
| 201 |
self, fastmcp_server, proxy_server
|
|
|
|
| 231 |
async def test_render_prompt_calls_prompt(self, proxy_server):
|
| 232 |
async with Client(proxy_server) as client:
|
| 233 |
result = await client.get_prompt("welcome", {"name": "Alice"})
|
|
|
|
| 234 |
assert result.messages[0].role == "user"
|
| 235 |
+
assert result.messages[0].content.text == "Welcome to FastMCP, Alice!" # type: ignore[attr-defined]
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
async def test_proxy_handles_multiple_concurrent_tasks_correctly(
|
| 239 |
+
proxy_server: FastMCPProxy,
|
| 240 |
+
):
|
| 241 |
+
results = {}
|
| 242 |
+
|
| 243 |
+
async def get_and_store(name, coro):
|
| 244 |
+
results[name] = await coro()
|
| 245 |
+
|
| 246 |
+
async with create_task_group() as tg:
|
| 247 |
+
tg.start_soon(get_and_store, "prompts", proxy_server.get_prompts)
|
| 248 |
+
tg.start_soon(get_and_store, "resources", proxy_server.get_resources)
|
| 249 |
+
tg.start_soon(get_and_store, "tools", proxy_server.get_tools)
|
| 250 |
+
|
| 251 |
+
assert list(results) == Contains("resources", "prompts", "tools")
|
| 252 |
+
assert list(results["prompts"]) == Contains("welcome")
|
| 253 |
+
assert [r.name for r in results["resources"].values()] == Contains(
|
| 254 |
+
"data://users", "resource://wave"
|
| 255 |
+
)
|
| 256 |
+
assert list(results["tools"]) == Contains(
|
| 257 |
+
"greet", "add", "error_tool", "tool_without_description"
|
| 258 |
+
)
|
tests/server/test_server.py
CHANGED
|
@@ -2,10 +2,6 @@ from typing import Annotated
|
|
| 2 |
|
| 3 |
import pytest
|
| 4 |
from mcp import McpError
|
| 5 |
-
from mcp.types import (
|
| 6 |
-
TextContent,
|
| 7 |
-
TextResourceContents,
|
| 8 |
-
)
|
| 9 |
from pydantic import Field
|
| 10 |
|
| 11 |
from fastmcp import Client, FastMCP
|
|
@@ -16,6 +12,7 @@ from fastmcp.server.server import (
|
|
| 16 |
has_resource_prefix,
|
| 17 |
remove_resource_prefix,
|
| 18 |
)
|
|
|
|
| 19 |
|
| 20 |
|
| 21 |
class TestCreateServer:
|
|
@@ -48,8 +45,7 @@ class TestCreateServer:
|
|
| 48 |
result = await client.call_tool("hello_world", {})
|
| 49 |
assert len(result) == 1
|
| 50 |
content = result[0]
|
| 51 |
-
assert
|
| 52 |
-
assert "¡Hola, 世界! 👋" == content.text
|
| 53 |
|
| 54 |
|
| 55 |
class TestTools:
|
|
@@ -98,6 +94,24 @@ class TestTools:
|
|
| 98 |
with pytest.raises(NotFoundError, match="Unknown tool: adder"):
|
| 99 |
await mcp._mcp_call_tool("adder", {"a": 1, "b": 2})
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
|
| 102 |
class TestToolDecorator:
|
| 103 |
async def test_no_tools_before_decorator(self):
|
|
@@ -114,8 +128,7 @@ class TestToolDecorator:
|
|
| 114 |
return x + y
|
| 115 |
|
| 116 |
result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
|
| 117 |
-
assert
|
| 118 |
-
assert result[0].text == "3"
|
| 119 |
|
| 120 |
async def test_tool_decorator_incorrect_usage(self):
|
| 121 |
mcp = FastMCP()
|
|
@@ -134,8 +147,7 @@ class TestToolDecorator:
|
|
| 134 |
return x + y
|
| 135 |
|
| 136 |
result = await mcp._mcp_call_tool("custom-add", {"x": 1, "y": 2})
|
| 137 |
-
assert
|
| 138 |
-
assert result[0].text == "3"
|
| 139 |
|
| 140 |
async def test_tool_decorator_with_description(self):
|
| 141 |
mcp = FastMCP()
|
|
@@ -163,8 +175,7 @@ class TestToolDecorator:
|
|
| 163 |
obj = MyClass(10)
|
| 164 |
mcp.add_tool(obj.add)
|
| 165 |
result = await mcp._mcp_call_tool("add", {"y": 2})
|
| 166 |
-
assert
|
| 167 |
-
assert result[0].text == "12"
|
| 168 |
|
| 169 |
async def test_tool_decorator_classmethod(self):
|
| 170 |
mcp = FastMCP()
|
|
@@ -178,8 +189,7 @@ class TestToolDecorator:
|
|
| 178 |
|
| 179 |
mcp.add_tool(MyClass.add)
|
| 180 |
result = await mcp._mcp_call_tool("add", {"y": 2})
|
| 181 |
-
assert
|
| 182 |
-
assert result[0].text == "12"
|
| 183 |
|
| 184 |
async def test_tool_decorator_staticmethod(self):
|
| 185 |
mcp = FastMCP()
|
|
@@ -191,8 +201,7 @@ class TestToolDecorator:
|
|
| 191 |
return x + y
|
| 192 |
|
| 193 |
result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
|
| 194 |
-
assert
|
| 195 |
-
assert result[0].text == "3"
|
| 196 |
|
| 197 |
async def test_tool_decorator_async_function(self):
|
| 198 |
mcp = FastMCP()
|
|
@@ -202,8 +211,7 @@ class TestToolDecorator:
|
|
| 202 |
return x + y
|
| 203 |
|
| 204 |
result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
|
| 205 |
-
assert
|
| 206 |
-
assert result[0].text == "3"
|
| 207 |
|
| 208 |
async def test_tool_decorator_classmethod_async_function(self):
|
| 209 |
mcp = FastMCP()
|
|
@@ -217,8 +225,7 @@ class TestToolDecorator:
|
|
| 217 |
|
| 218 |
mcp.add_tool(MyClass.add)
|
| 219 |
result = await mcp._mcp_call_tool("add", {"y": 2})
|
| 220 |
-
assert
|
| 221 |
-
assert result[0].text == "12"
|
| 222 |
|
| 223 |
async def test_tool_decorator_staticmethod_async_function(self):
|
| 224 |
mcp = FastMCP()
|
|
@@ -230,8 +237,7 @@ class TestToolDecorator:
|
|
| 230 |
|
| 231 |
mcp.add_tool(MyClass.add)
|
| 232 |
result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
|
| 233 |
-
assert
|
| 234 |
-
assert result[0].text == "3"
|
| 235 |
|
| 236 |
async def test_tool_decorator_with_tags(self):
|
| 237 |
"""Test that the tool decorator properly sets tags."""
|
|
@@ -262,8 +268,7 @@ class TestToolDecorator:
|
|
| 262 |
|
| 263 |
# Call the tool by its custom name
|
| 264 |
result = await mcp._mcp_call_tool("custom_multiply", {"a": 5, "b": 3})
|
| 265 |
-
assert
|
| 266 |
-
assert result[0].text == "15"
|
| 267 |
|
| 268 |
# Original name should not be registered
|
| 269 |
assert "multiply" not in tools
|
|
@@ -316,8 +321,7 @@ class TestResourceDecorator:
|
|
| 316 |
|
| 317 |
async with Client(mcp) as client:
|
| 318 |
result = await client.read_resource("resource://data")
|
| 319 |
-
assert
|
| 320 |
-
assert result[0].text == "Hello, world!"
|
| 321 |
|
| 322 |
async def test_resource_decorator_incorrect_usage(self):
|
| 323 |
mcp = FastMCP()
|
|
@@ -344,8 +348,7 @@ class TestResourceDecorator:
|
|
| 344 |
|
| 345 |
async with Client(mcp) as client:
|
| 346 |
result = await client.read_resource("resource://data")
|
| 347 |
-
assert
|
| 348 |
-
assert result[0].text == "Hello, world!"
|
| 349 |
|
| 350 |
async def test_resource_decorator_with_description(self):
|
| 351 |
mcp = FastMCP()
|
|
@@ -389,8 +392,7 @@ class TestResourceDecorator:
|
|
| 389 |
|
| 390 |
async with Client(mcp) as client:
|
| 391 |
result = await client.read_resource("resource://data")
|
| 392 |
-
assert
|
| 393 |
-
assert result[0].text == "My prefix: Hello, world!"
|
| 394 |
|
| 395 |
async def test_resource_decorator_classmethod(self):
|
| 396 |
mcp = FastMCP()
|
|
@@ -408,8 +410,7 @@ class TestResourceDecorator:
|
|
| 408 |
|
| 409 |
async with Client(mcp) as client:
|
| 410 |
result = await client.read_resource("resource://data")
|
| 411 |
-
assert
|
| 412 |
-
assert result[0].text == "Class prefix: Hello, world!"
|
| 413 |
|
| 414 |
async def test_resource_decorator_staticmethod(self):
|
| 415 |
mcp = FastMCP()
|
|
@@ -422,8 +423,7 @@ class TestResourceDecorator:
|
|
| 422 |
|
| 423 |
async with Client(mcp) as client:
|
| 424 |
result = await client.read_resource("resource://data")
|
| 425 |
-
assert
|
| 426 |
-
assert result[0].text == "Static Hello, world!"
|
| 427 |
|
| 428 |
async def test_resource_decorator_async_function(self):
|
| 429 |
mcp = FastMCP()
|
|
@@ -434,8 +434,7 @@ class TestResourceDecorator:
|
|
| 434 |
|
| 435 |
async with Client(mcp) as client:
|
| 436 |
result = await client.read_resource("resource://data")
|
| 437 |
-
assert
|
| 438 |
-
assert result[0].text == "Async Hello, world!"
|
| 439 |
|
| 440 |
|
| 441 |
class TestTemplateDecorator:
|
|
@@ -454,8 +453,7 @@ class TestTemplateDecorator:
|
|
| 454 |
|
| 455 |
async with Client(mcp) as client:
|
| 456 |
result = await client.read_resource("resource://test/data")
|
| 457 |
-
assert
|
| 458 |
-
assert result[0].text == "Data for test"
|
| 459 |
|
| 460 |
async def test_template_decorator_incorrect_usage(self):
|
| 461 |
mcp = FastMCP()
|
|
@@ -482,8 +480,7 @@ class TestTemplateDecorator:
|
|
| 482 |
|
| 483 |
async with Client(mcp) as client:
|
| 484 |
result = await client.read_resource("resource://test/data")
|
| 485 |
-
assert
|
| 486 |
-
assert result[0].text == "Data for test"
|
| 487 |
|
| 488 |
async def test_template_decorator_with_description(self):
|
| 489 |
mcp = FastMCP()
|
|
@@ -514,8 +511,7 @@ class TestTemplateDecorator:
|
|
| 514 |
|
| 515 |
async with Client(mcp) as client:
|
| 516 |
result = await client.read_resource("resource://test/data")
|
| 517 |
-
assert
|
| 518 |
-
assert result[0].text == "My prefix: Data for test"
|
| 519 |
|
| 520 |
async def test_template_decorator_classmethod(self):
|
| 521 |
mcp = FastMCP()
|
|
@@ -535,8 +531,7 @@ class TestTemplateDecorator:
|
|
| 535 |
|
| 536 |
async with Client(mcp) as client:
|
| 537 |
result = await client.read_resource("resource://test/data")
|
| 538 |
-
assert
|
| 539 |
-
assert result[0].text == "Class prefix: Data for test"
|
| 540 |
|
| 541 |
async def test_template_decorator_staticmethod(self):
|
| 542 |
mcp = FastMCP()
|
|
@@ -549,8 +544,7 @@ class TestTemplateDecorator:
|
|
| 549 |
|
| 550 |
async with Client(mcp) as client:
|
| 551 |
result = await client.read_resource("resource://test/data")
|
| 552 |
-
assert
|
| 553 |
-
assert result[0].text == "Static Data for test"
|
| 554 |
|
| 555 |
async def test_template_decorator_async_function(self):
|
| 556 |
mcp = FastMCP()
|
|
@@ -561,8 +555,7 @@ class TestTemplateDecorator:
|
|
| 561 |
|
| 562 |
async with Client(mcp) as client:
|
| 563 |
result = await client.read_resource("resource://test/data")
|
| 564 |
-
assert
|
| 565 |
-
assert result[0].text == "Async Data for test"
|
| 566 |
|
| 567 |
async def test_template_decorator_with_tags(self):
|
| 568 |
"""Test that the template decorator properly sets tags."""
|
|
@@ -603,8 +596,7 @@ class TestPromptDecorator:
|
|
| 603 |
assert prompt.name == "fn"
|
| 604 |
# Don't compare functions directly since validate_call wraps them
|
| 605 |
content = await prompt.render()
|
| 606 |
-
assert
|
| 607 |
-
assert content[0].content.text == "Hello, world!"
|
| 608 |
|
| 609 |
async def test_prompt_decorator_incorrect_usage(self):
|
| 610 |
mcp = FastMCP()
|
|
@@ -629,8 +621,7 @@ class TestPromptDecorator:
|
|
| 629 |
prompt = prompts_dict["custom_name"]
|
| 630 |
assert prompt.name == "custom_name"
|
| 631 |
content = await prompt.render()
|
| 632 |
-
assert
|
| 633 |
-
assert content[0].content.text == "Hello, world!"
|
| 634 |
|
| 635 |
async def test_prompt_decorator_with_description(self):
|
| 636 |
mcp = FastMCP()
|
|
@@ -644,8 +635,7 @@ class TestPromptDecorator:
|
|
| 644 |
prompt = prompts_dict["fn"]
|
| 645 |
assert prompt.description == "A custom description"
|
| 646 |
content = await prompt.render()
|
| 647 |
-
assert
|
| 648 |
-
assert content[0].content.text == "Hello, world!"
|
| 649 |
|
| 650 |
async def test_prompt_decorator_with_parameters(self):
|
| 651 |
mcp = FastMCP()
|
|
@@ -668,16 +658,14 @@ class TestPromptDecorator:
|
|
| 668 |
result = await client.get_prompt("test_prompt", {"name": "World"})
|
| 669 |
assert len(result.messages) == 1
|
| 670 |
message = result.messages[0]
|
| 671 |
-
assert
|
| 672 |
-
assert message.content.text == "Hello, World!"
|
| 673 |
|
| 674 |
result = await client.get_prompt(
|
| 675 |
"test_prompt", {"name": "World", "greeting": "Hi"}
|
| 676 |
)
|
| 677 |
assert len(result.messages) == 1
|
| 678 |
message = result.messages[0]
|
| 679 |
-
assert
|
| 680 |
-
assert message.content.text == "Hi, World!"
|
| 681 |
|
| 682 |
async def test_prompt_decorator_instance_method(self):
|
| 683 |
mcp = FastMCP()
|
|
@@ -696,8 +684,7 @@ class TestPromptDecorator:
|
|
| 696 |
result = await client.get_prompt("test_prompt")
|
| 697 |
assert len(result.messages) == 1
|
| 698 |
message = result.messages[0]
|
| 699 |
-
assert
|
| 700 |
-
assert message.content.text == "My prefix: Hello, world!"
|
| 701 |
|
| 702 |
async def test_prompt_decorator_classmethod(self):
|
| 703 |
mcp = FastMCP()
|
|
@@ -715,8 +702,7 @@ class TestPromptDecorator:
|
|
| 715 |
result = await client.get_prompt("test_prompt")
|
| 716 |
assert len(result.messages) == 1
|
| 717 |
message = result.messages[0]
|
| 718 |
-
assert
|
| 719 |
-
assert message.content.text == "Class prefix: Hello, world!"
|
| 720 |
|
| 721 |
async def test_prompt_decorator_staticmethod(self):
|
| 722 |
mcp = FastMCP()
|
|
@@ -731,8 +717,7 @@ class TestPromptDecorator:
|
|
| 731 |
result = await client.get_prompt("test_prompt")
|
| 732 |
assert len(result.messages) == 1
|
| 733 |
message = result.messages[0]
|
| 734 |
-
assert
|
| 735 |
-
assert message.content.text == "Static Hello, world!"
|
| 736 |
|
| 737 |
async def test_prompt_decorator_async_function(self):
|
| 738 |
mcp = FastMCP()
|
|
@@ -745,8 +730,7 @@ class TestPromptDecorator:
|
|
| 745 |
result = await client.get_prompt("test_prompt")
|
| 746 |
assert len(result.messages) == 1
|
| 747 |
message = result.messages[0]
|
| 748 |
-
assert
|
| 749 |
-
assert message.content.text == "Async Hello, world!"
|
| 750 |
|
| 751 |
async def test_prompt_decorator_with_tags(self):
|
| 752 |
"""Test that the prompt decorator properly sets tags."""
|
|
@@ -943,20 +927,17 @@ class TestResourcePrefixMounting:
|
|
| 943 |
async with Client(main_server) as client:
|
| 944 |
# Regular resource
|
| 945 |
result = await client.read_resource("resource://prefix/test-resource")
|
| 946 |
-
assert
|
| 947 |
-
assert result[0].text == "Resource content"
|
| 948 |
|
| 949 |
# Absolute path resource
|
| 950 |
result = await client.read_resource("resource://prefix//absolute/path")
|
| 951 |
-
assert
|
| 952 |
-
assert result[0].text == "Absolute resource content"
|
| 953 |
|
| 954 |
# Template resource
|
| 955 |
result = await client.read_resource(
|
| 956 |
"resource://prefix/param-value/template"
|
| 957 |
)
|
| 958 |
-
assert
|
| 959 |
-
assert result[0].text == "Template resource with param-value"
|
| 960 |
|
| 961 |
@pytest.mark.parametrize(
|
| 962 |
"uri,prefix,expected_match,expected_strip",
|
|
@@ -1032,15 +1013,12 @@ class TestResourcePrefixMounting:
|
|
| 1032 |
# Verify we can access the resources
|
| 1033 |
async with Client(target_server) as client:
|
| 1034 |
result = await client.read_resource("resource://imported/test-resource")
|
| 1035 |
-
assert
|
| 1036 |
-
assert result[0].text == "Resource content"
|
| 1037 |
|
| 1038 |
result = await client.read_resource("resource://imported//absolute/path")
|
| 1039 |
-
assert
|
| 1040 |
-
assert result[0].text == "Absolute resource content"
|
| 1041 |
|
| 1042 |
result = await client.read_resource(
|
| 1043 |
"resource://imported/param-value/template"
|
| 1044 |
)
|
| 1045 |
-
assert
|
| 1046 |
-
assert result[0].text == "Template resource with param-value"
|
|
|
|
| 2 |
|
| 3 |
import pytest
|
| 4 |
from mcp import McpError
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
from pydantic import Field
|
| 6 |
|
| 7 |
from fastmcp import Client, FastMCP
|
|
|
|
| 12 |
has_resource_prefix,
|
| 13 |
remove_resource_prefix,
|
| 14 |
)
|
| 15 |
+
from fastmcp.tools.tool import Tool
|
| 16 |
|
| 17 |
|
| 18 |
class TestCreateServer:
|
|
|
|
| 45 |
result = await client.call_tool("hello_world", {})
|
| 46 |
assert len(result) == 1
|
| 47 |
content = result[0]
|
| 48 |
+
assert content.text == "¡Hola, 世界! 👋" # type: ignore[attr-defined]
|
|
|
|
| 49 |
|
| 50 |
|
| 51 |
class TestTools:
|
|
|
|
| 94 |
with pytest.raises(NotFoundError, match="Unknown tool: adder"):
|
| 95 |
await mcp._mcp_call_tool("adder", {"a": 1, "b": 2})
|
| 96 |
|
| 97 |
+
async def test_add_tool_at_init(self):
|
| 98 |
+
def f(x: int) -> int:
|
| 99 |
+
return x + 1
|
| 100 |
+
|
| 101 |
+
def g(x: int) -> int:
|
| 102 |
+
"""add two to a number"""
|
| 103 |
+
return x + 2
|
| 104 |
+
|
| 105 |
+
g_tool = Tool.from_function(g, name="g-tool")
|
| 106 |
+
|
| 107 |
+
mcp = FastMCP(tools=[f, g_tool])
|
| 108 |
+
|
| 109 |
+
tools = await mcp.get_tools()
|
| 110 |
+
assert len(tools) == 2
|
| 111 |
+
assert tools["f"].name == "f"
|
| 112 |
+
assert tools["g-tool"].name == "g-tool"
|
| 113 |
+
assert tools["g-tool"].description == "add two to a number"
|
| 114 |
+
|
| 115 |
|
| 116 |
class TestToolDecorator:
|
| 117 |
async def test_no_tools_before_decorator(self):
|
|
|
|
| 128 |
return x + y
|
| 129 |
|
| 130 |
result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
|
| 131 |
+
assert result[0].text == "3" # type: ignore[attr-defined]
|
|
|
|
| 132 |
|
| 133 |
async def test_tool_decorator_incorrect_usage(self):
|
| 134 |
mcp = FastMCP()
|
|
|
|
| 147 |
return x + y
|
| 148 |
|
| 149 |
result = await mcp._mcp_call_tool("custom-add", {"x": 1, "y": 2})
|
| 150 |
+
assert result[0].text == "3" # type: ignore[attr-defined]
|
|
|
|
| 151 |
|
| 152 |
async def test_tool_decorator_with_description(self):
|
| 153 |
mcp = FastMCP()
|
|
|
|
| 175 |
obj = MyClass(10)
|
| 176 |
mcp.add_tool(obj.add)
|
| 177 |
result = await mcp._mcp_call_tool("add", {"y": 2})
|
| 178 |
+
assert result[0].text == "12" # type: ignore[attr-defined]
|
|
|
|
| 179 |
|
| 180 |
async def test_tool_decorator_classmethod(self):
|
| 181 |
mcp = FastMCP()
|
|
|
|
| 189 |
|
| 190 |
mcp.add_tool(MyClass.add)
|
| 191 |
result = await mcp._mcp_call_tool("add", {"y": 2})
|
| 192 |
+
assert result[0].text == "12" # type: ignore[attr-defined]
|
|
|
|
| 193 |
|
| 194 |
async def test_tool_decorator_staticmethod(self):
|
| 195 |
mcp = FastMCP()
|
|
|
|
| 201 |
return x + y
|
| 202 |
|
| 203 |
result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
|
| 204 |
+
assert result[0].text == "3" # type: ignore[attr-defined]
|
|
|
|
| 205 |
|
| 206 |
async def test_tool_decorator_async_function(self):
|
| 207 |
mcp = FastMCP()
|
|
|
|
| 211 |
return x + y
|
| 212 |
|
| 213 |
result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
|
| 214 |
+
assert result[0].text == "3" # type: ignore[attr-defined]
|
|
|
|
| 215 |
|
| 216 |
async def test_tool_decorator_classmethod_async_function(self):
|
| 217 |
mcp = FastMCP()
|
|
|
|
| 225 |
|
| 226 |
mcp.add_tool(MyClass.add)
|
| 227 |
result = await mcp._mcp_call_tool("add", {"y": 2})
|
| 228 |
+
assert result[0].text == "12" # type: ignore[attr-defined]
|
|
|
|
| 229 |
|
| 230 |
async def test_tool_decorator_staticmethod_async_function(self):
|
| 231 |
mcp = FastMCP()
|
|
|
|
| 237 |
|
| 238 |
mcp.add_tool(MyClass.add)
|
| 239 |
result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
|
| 240 |
+
assert result[0].text == "3" # type: ignore[attr-defined]
|
|
|
|
| 241 |
|
| 242 |
async def test_tool_decorator_with_tags(self):
|
| 243 |
"""Test that the tool decorator properly sets tags."""
|
|
|
|
| 268 |
|
| 269 |
# Call the tool by its custom name
|
| 270 |
result = await mcp._mcp_call_tool("custom_multiply", {"a": 5, "b": 3})
|
| 271 |
+
assert result[0].text == "15" # type: ignore[attr-defined]
|
|
|
|
| 272 |
|
| 273 |
# Original name should not be registered
|
| 274 |
assert "multiply" not in tools
|
|
|
|
| 321 |
|
| 322 |
async with Client(mcp) as client:
|
| 323 |
result = await client.read_resource("resource://data")
|
| 324 |
+
assert result[0].text == "Hello, world!" # type: ignore[attr-defined]
|
|
|
|
| 325 |
|
| 326 |
async def test_resource_decorator_incorrect_usage(self):
|
| 327 |
mcp = FastMCP()
|
|
|
|
| 348 |
|
| 349 |
async with Client(mcp) as client:
|
| 350 |
result = await client.read_resource("resource://data")
|
| 351 |
+
assert result[0].text == "Hello, world!" # type: ignore[attr-defined]
|
|
|
|
| 352 |
|
| 353 |
async def test_resource_decorator_with_description(self):
|
| 354 |
mcp = FastMCP()
|
|
|
|
| 392 |
|
| 393 |
async with Client(mcp) as client:
|
| 394 |
result = await client.read_resource("resource://data")
|
| 395 |
+
assert result[0].text == "My prefix: Hello, world!" # type: ignore[attr-defined]
|
|
|
|
| 396 |
|
| 397 |
async def test_resource_decorator_classmethod(self):
|
| 398 |
mcp = FastMCP()
|
|
|
|
| 410 |
|
| 411 |
async with Client(mcp) as client:
|
| 412 |
result = await client.read_resource("resource://data")
|
| 413 |
+
assert result[0].text == "Class prefix: Hello, world!" # type: ignore[attr-defined]
|
|
|
|
| 414 |
|
| 415 |
async def test_resource_decorator_staticmethod(self):
|
| 416 |
mcp = FastMCP()
|
|
|
|
| 423 |
|
| 424 |
async with Client(mcp) as client:
|
| 425 |
result = await client.read_resource("resource://data")
|
| 426 |
+
assert result[0].text == "Static Hello, world!" # type: ignore[attr-defined]
|
|
|
|
| 427 |
|
| 428 |
async def test_resource_decorator_async_function(self):
|
| 429 |
mcp = FastMCP()
|
|
|
|
| 434 |
|
| 435 |
async with Client(mcp) as client:
|
| 436 |
result = await client.read_resource("resource://data")
|
| 437 |
+
assert result[0].text == "Async Hello, world!" # type: ignore[attr-defined]
|
|
|
|
| 438 |
|
| 439 |
|
| 440 |
class TestTemplateDecorator:
|
|
|
|
| 453 |
|
| 454 |
async with Client(mcp) as client:
|
| 455 |
result = await client.read_resource("resource://test/data")
|
| 456 |
+
assert result[0].text == "Data for test" # type: ignore[attr-defined]
|
|
|
|
| 457 |
|
| 458 |
async def test_template_decorator_incorrect_usage(self):
|
| 459 |
mcp = FastMCP()
|
|
|
|
| 480 |
|
| 481 |
async with Client(mcp) as client:
|
| 482 |
result = await client.read_resource("resource://test/data")
|
| 483 |
+
assert result[0].text == "Data for test" # type: ignore[attr-defined]
|
|
|
|
| 484 |
|
| 485 |
async def test_template_decorator_with_description(self):
|
| 486 |
mcp = FastMCP()
|
|
|
|
| 511 |
|
| 512 |
async with Client(mcp) as client:
|
| 513 |
result = await client.read_resource("resource://test/data")
|
| 514 |
+
assert result[0].text == "My prefix: Data for test" # type: ignore[attr-defined]
|
|
|
|
| 515 |
|
| 516 |
async def test_template_decorator_classmethod(self):
|
| 517 |
mcp = FastMCP()
|
|
|
|
| 531 |
|
| 532 |
async with Client(mcp) as client:
|
| 533 |
result = await client.read_resource("resource://test/data")
|
| 534 |
+
assert result[0].text == "Class prefix: Data for test" # type: ignore[attr-defined]
|
|
|
|
| 535 |
|
| 536 |
async def test_template_decorator_staticmethod(self):
|
| 537 |
mcp = FastMCP()
|
|
|
|
| 544 |
|
| 545 |
async with Client(mcp) as client:
|
| 546 |
result = await client.read_resource("resource://test/data")
|
| 547 |
+
assert result[0].text == "Static Data for test" # type: ignore[attr-defined]
|
|
|
|
| 548 |
|
| 549 |
async def test_template_decorator_async_function(self):
|
| 550 |
mcp = FastMCP()
|
|
|
|
| 555 |
|
| 556 |
async with Client(mcp) as client:
|
| 557 |
result = await client.read_resource("resource://test/data")
|
| 558 |
+
assert result[0].text == "Async Data for test" # type: ignore[attr-defined]
|
|
|
|
| 559 |
|
| 560 |
async def test_template_decorator_with_tags(self):
|
| 561 |
"""Test that the template decorator properly sets tags."""
|
|
|
|
| 596 |
assert prompt.name == "fn"
|
| 597 |
# Don't compare functions directly since validate_call wraps them
|
| 598 |
content = await prompt.render()
|
| 599 |
+
assert content[0].content.text == "Hello, world!" # type: ignore[attr-defined]
|
|
|
|
| 600 |
|
| 601 |
async def test_prompt_decorator_incorrect_usage(self):
|
| 602 |
mcp = FastMCP()
|
|
|
|
| 621 |
prompt = prompts_dict["custom_name"]
|
| 622 |
assert prompt.name == "custom_name"
|
| 623 |
content = await prompt.render()
|
| 624 |
+
assert content[0].content.text == "Hello, world!" # type: ignore[attr-defined]
|
|
|
|
| 625 |
|
| 626 |
async def test_prompt_decorator_with_description(self):
|
| 627 |
mcp = FastMCP()
|
|
|
|
| 635 |
prompt = prompts_dict["fn"]
|
| 636 |
assert prompt.description == "A custom description"
|
| 637 |
content = await prompt.render()
|
| 638 |
+
assert content[0].content.text == "Hello, world!" # type: ignore[attr-defined]
|
|
|
|
| 639 |
|
| 640 |
async def test_prompt_decorator_with_parameters(self):
|
| 641 |
mcp = FastMCP()
|
|
|
|
| 658 |
result = await client.get_prompt("test_prompt", {"name": "World"})
|
| 659 |
assert len(result.messages) == 1
|
| 660 |
message = result.messages[0]
|
| 661 |
+
assert message.content.text == "Hello, World!" # type: ignore[attr-defined]
|
|
|
|
| 662 |
|
| 663 |
result = await client.get_prompt(
|
| 664 |
"test_prompt", {"name": "World", "greeting": "Hi"}
|
| 665 |
)
|
| 666 |
assert len(result.messages) == 1
|
| 667 |
message = result.messages[0]
|
| 668 |
+
assert message.content.text == "Hi, World!" # type: ignore[attr-defined]
|
|
|
|
| 669 |
|
| 670 |
async def test_prompt_decorator_instance_method(self):
|
| 671 |
mcp = FastMCP()
|
|
|
|
| 684 |
result = await client.get_prompt("test_prompt")
|
| 685 |
assert len(result.messages) == 1
|
| 686 |
message = result.messages[0]
|
| 687 |
+
assert message.content.text == "My prefix: Hello, world!" # type: ignore[attr-defined]
|
|
|
|
| 688 |
|
| 689 |
async def test_prompt_decorator_classmethod(self):
|
| 690 |
mcp = FastMCP()
|
|
|
|
| 702 |
result = await client.get_prompt("test_prompt")
|
| 703 |
assert len(result.messages) == 1
|
| 704 |
message = result.messages[0]
|
| 705 |
+
assert message.content.text == "Class prefix: Hello, world!" # type: ignore[attr-defined]
|
|
|
|
| 706 |
|
| 707 |
async def test_prompt_decorator_staticmethod(self):
|
| 708 |
mcp = FastMCP()
|
|
|
|
| 717 |
result = await client.get_prompt("test_prompt")
|
| 718 |
assert len(result.messages) == 1
|
| 719 |
message = result.messages[0]
|
| 720 |
+
assert message.content.text == "Static Hello, world!" # type: ignore[attr-defined]
|
|
|
|
| 721 |
|
| 722 |
async def test_prompt_decorator_async_function(self):
|
| 723 |
mcp = FastMCP()
|
|
|
|
| 730 |
result = await client.get_prompt("test_prompt")
|
| 731 |
assert len(result.messages) == 1
|
| 732 |
message = result.messages[0]
|
| 733 |
+
assert message.content.text == "Async Hello, world!" # type: ignore[attr-defined]
|
|
|
|
| 734 |
|
| 735 |
async def test_prompt_decorator_with_tags(self):
|
| 736 |
"""Test that the prompt decorator properly sets tags."""
|
|
|
|
| 927 |
async with Client(main_server) as client:
|
| 928 |
# Regular resource
|
| 929 |
result = await client.read_resource("resource://prefix/test-resource")
|
| 930 |
+
assert result[0].text == "Resource content" # type: ignore[attr-defined]
|
|
|
|
| 931 |
|
| 932 |
# Absolute path resource
|
| 933 |
result = await client.read_resource("resource://prefix//absolute/path")
|
| 934 |
+
assert result[0].text == "Absolute resource content" # type: ignore[attr-defined]
|
|
|
|
| 935 |
|
| 936 |
# Template resource
|
| 937 |
result = await client.read_resource(
|
| 938 |
"resource://prefix/param-value/template"
|
| 939 |
)
|
| 940 |
+
assert result[0].text == "Template resource with param-value" # type: ignore[attr-defined]
|
|
|
|
| 941 |
|
| 942 |
@pytest.mark.parametrize(
|
| 943 |
"uri,prefix,expected_match,expected_strip",
|
|
|
|
| 1013 |
# Verify we can access the resources
|
| 1014 |
async with Client(target_server) as client:
|
| 1015 |
result = await client.read_resource("resource://imported/test-resource")
|
| 1016 |
+
assert result[0].text == "Resource content" # type: ignore[attr-defined]
|
|
|
|
| 1017 |
|
| 1018 |
result = await client.read_resource("resource://imported//absolute/path")
|
| 1019 |
+
assert result[0].text == "Absolute resource content" # type: ignore[attr-defined]
|
|
|
|
| 1020 |
|
| 1021 |
result = await client.read_resource(
|
| 1022 |
"resource://imported/param-value/template"
|
| 1023 |
)
|
| 1024 |
+
assert result[0].text == "Template resource with param-value" # type: ignore[attr-defined]
|
|
|
tests/server/test_server_interactions.py
CHANGED
|
@@ -10,7 +10,6 @@ import pydantic_core
|
|
| 10 |
import pytest
|
| 11 |
from mcp import McpError
|
| 12 |
from mcp.types import (
|
| 13 |
-
BlobResourceContents,
|
| 14 |
ImageContent,
|
| 15 |
TextContent,
|
| 16 |
TextResourceContents,
|
|
@@ -77,14 +76,12 @@ class TestTools:
|
|
| 77 |
async def test_call_tool(self, tool_server: FastMCP):
|
| 78 |
async with Client(tool_server) as client:
|
| 79 |
result = await client.call_tool("add", {"x": 1, "y": 2})
|
| 80 |
-
assert
|
| 81 |
-
assert result[0].text == "3"
|
| 82 |
|
| 83 |
async def test_call_tool_as_client(self, tool_server: FastMCP):
|
| 84 |
async with Client(tool_server) as client:
|
| 85 |
result = await client.call_tool("add", {"x": 1, "y": 2})
|
| 86 |
-
assert
|
| 87 |
-
assert result[0].text == "3"
|
| 88 |
|
| 89 |
async def test_call_tool_error(self, tool_server: FastMCP):
|
| 90 |
async with Client(tool_server) as client:
|
|
@@ -113,8 +110,7 @@ class TestTools:
|
|
| 113 |
async def test_tool_returns_list(self, tool_server: FastMCP):
|
| 114 |
async with Client(tool_server) as client:
|
| 115 |
result = await client.call_tool("list_tool", {})
|
| 116 |
-
assert
|
| 117 |
-
assert result[0].text == '[\n "x",\n 2\n]'
|
| 118 |
|
| 119 |
|
| 120 |
class TestToolReturnTypes:
|
|
@@ -127,8 +123,7 @@ class TestToolReturnTypes:
|
|
| 127 |
|
| 128 |
async with Client(mcp) as client:
|
| 129 |
result = await client.call_tool("string_tool", {})
|
| 130 |
-
assert
|
| 131 |
-
assert result[0].text == "Hello, world!"
|
| 132 |
|
| 133 |
async def test_bytes(self, tmp_path: Path):
|
| 134 |
mcp = FastMCP()
|
|
@@ -139,8 +134,7 @@ class TestToolReturnTypes:
|
|
| 139 |
|
| 140 |
async with Client(mcp) as client:
|
| 141 |
result = await client.call_tool("bytes_tool", {})
|
| 142 |
-
assert
|
| 143 |
-
assert result[0].text == '"Hello, world!"'
|
| 144 |
|
| 145 |
async def test_uuid(self):
|
| 146 |
mcp = FastMCP()
|
|
@@ -153,8 +147,7 @@ class TestToolReturnTypes:
|
|
| 153 |
|
| 154 |
async with Client(mcp) as client:
|
| 155 |
result = await client.call_tool("uuid_tool", {})
|
| 156 |
-
assert
|
| 157 |
-
assert result[0].text == pydantic_core.to_json(test_uuid).decode()
|
| 158 |
|
| 159 |
async def test_path(self):
|
| 160 |
mcp = FastMCP()
|
|
@@ -167,8 +160,7 @@ class TestToolReturnTypes:
|
|
| 167 |
|
| 168 |
async with Client(mcp) as client:
|
| 169 |
result = await client.call_tool("path_tool", {})
|
| 170 |
-
assert
|
| 171 |
-
assert result[0].text == pydantic_core.to_json(test_path).decode()
|
| 172 |
|
| 173 |
async def test_datetime(self):
|
| 174 |
mcp = FastMCP()
|
|
@@ -181,8 +173,7 @@ class TestToolReturnTypes:
|
|
| 181 |
|
| 182 |
async with Client(mcp) as client:
|
| 183 |
result = await client.call_tool("datetime_tool", {})
|
| 184 |
-
assert
|
| 185 |
-
assert result[0].text == pydantic_core.to_json(dt).decode()
|
| 186 |
|
| 187 |
async def test_image(self, tmp_path: Path):
|
| 188 |
mcp = FastMCP()
|
|
@@ -337,8 +328,7 @@ class TestToolParameters:
|
|
| 337 |
async with Client(mcp) as client:
|
| 338 |
# String with integer value should be coerced to int
|
| 339 |
result = await client.call_tool("add_one", {"x": "42"})
|
| 340 |
-
assert
|
| 341 |
-
assert result[0].text == "43"
|
| 342 |
|
| 343 |
async def test_tool_bool_coercion(self):
|
| 344 |
"""Test string-to-bool type coercion."""
|
|
@@ -351,12 +341,10 @@ class TestToolParameters:
|
|
| 351 |
async with Client(mcp) as client:
|
| 352 |
# String with boolean value should be coerced to bool
|
| 353 |
result = await client.call_tool("toggle", {"flag": "true"})
|
| 354 |
-
assert
|
| 355 |
-
assert result[0].text == "false"
|
| 356 |
|
| 357 |
result = await client.call_tool("toggle", {"flag": "false"})
|
| 358 |
-
assert
|
| 359 |
-
assert result[0].text == "true"
|
| 360 |
|
| 361 |
async def test_annotated_field_validation(self):
|
| 362 |
mcp = FastMCP()
|
|
@@ -411,8 +399,7 @@ class TestToolParameters:
|
|
| 411 |
|
| 412 |
async with Client(mcp) as client:
|
| 413 |
result = await client.call_tool("analyze", {"x": "a"})
|
| 414 |
-
assert
|
| 415 |
-
assert result[0].text == "a"
|
| 416 |
|
| 417 |
async def test_enum_type_validation_error(self):
|
| 418 |
mcp = FastMCP()
|
|
@@ -444,8 +431,7 @@ class TestToolParameters:
|
|
| 444 |
|
| 445 |
async with Client(mcp) as client:
|
| 446 |
result = await client.call_tool("analyze", {"x": "red"})
|
| 447 |
-
assert
|
| 448 |
-
assert result[0].text == "red"
|
| 449 |
|
| 450 |
async def test_union_type_validation(self):
|
| 451 |
mcp = FastMCP()
|
|
@@ -456,12 +442,10 @@ class TestToolParameters:
|
|
| 456 |
|
| 457 |
async with Client(mcp) as client:
|
| 458 |
result = await client.call_tool("analyze", {"x": 1})
|
| 459 |
-
assert
|
| 460 |
-
assert result[0].text == "1"
|
| 461 |
|
| 462 |
result = await client.call_tool("analyze", {"x": 1.0})
|
| 463 |
-
assert
|
| 464 |
-
assert result[0].text == "1.0"
|
| 465 |
|
| 466 |
with pytest.raises(ToolError, match="Error calling tool 'analyze'"):
|
| 467 |
await client.call_tool("analyze", {"x": "not a number"})
|
|
@@ -479,8 +463,7 @@ class TestToolParameters:
|
|
| 479 |
|
| 480 |
async with Client(mcp) as client:
|
| 481 |
result = await client.call_tool("send_path", {"path": str(test_path)})
|
| 482 |
-
assert
|
| 483 |
-
assert result[0].text == str(test_path)
|
| 484 |
|
| 485 |
async def test_path_type_error(self):
|
| 486 |
mcp = FastMCP()
|
|
@@ -505,8 +488,7 @@ class TestToolParameters:
|
|
| 505 |
|
| 506 |
async with Client(mcp) as client:
|
| 507 |
result = await client.call_tool("send_uuid", {"x": test_uuid})
|
| 508 |
-
assert
|
| 509 |
-
assert result[0].text == str(test_uuid)
|
| 510 |
|
| 511 |
async def test_uuid_type_error(self):
|
| 512 |
mcp = FastMCP()
|
|
@@ -530,8 +512,7 @@ class TestToolParameters:
|
|
| 530 |
|
| 531 |
async with Client(mcp) as client:
|
| 532 |
result = await client.call_tool("send_datetime", {"x": dt})
|
| 533 |
-
assert
|
| 534 |
-
assert result[0].text == dt.isoformat()
|
| 535 |
|
| 536 |
async def test_datetime_type_parse_string(self):
|
| 537 |
mcp = FastMCP()
|
|
@@ -544,8 +525,7 @@ class TestToolParameters:
|
|
| 544 |
result = await client.call_tool(
|
| 545 |
"send_datetime", {"x": "2021-01-01T00:00:00"}
|
| 546 |
)
|
| 547 |
-
assert
|
| 548 |
-
assert result[0].text == "2021-01-01T00:00:00"
|
| 549 |
|
| 550 |
async def test_datetime_type_error(self):
|
| 551 |
mcp = FastMCP()
|
|
@@ -567,8 +547,7 @@ class TestToolParameters:
|
|
| 567 |
|
| 568 |
async with Client(mcp) as client:
|
| 569 |
result = await client.call_tool("send_date", {"x": datetime.date.today()})
|
| 570 |
-
assert
|
| 571 |
-
assert result[0].text == datetime.date.today().isoformat()
|
| 572 |
|
| 573 |
async def test_date_type_parse_string(self):
|
| 574 |
mcp = FastMCP()
|
|
@@ -579,8 +558,7 @@ class TestToolParameters:
|
|
| 579 |
|
| 580 |
async with Client(mcp) as client:
|
| 581 |
result = await client.call_tool("send_date", {"x": "2021-01-01"})
|
| 582 |
-
assert
|
| 583 |
-
assert result[0].text == "2021-01-01"
|
| 584 |
|
| 585 |
async def test_timedelta_type(self):
|
| 586 |
mcp = FastMCP()
|
|
@@ -593,8 +571,7 @@ class TestToolParameters:
|
|
| 593 |
result = await client.call_tool(
|
| 594 |
"send_timedelta", {"x": datetime.timedelta(days=1)}
|
| 595 |
)
|
| 596 |
-
assert
|
| 597 |
-
assert result[0].text == "1 day, 0:00:00"
|
| 598 |
|
| 599 |
async def test_timedelta_type_parse_int(self):
|
| 600 |
mcp = FastMCP()
|
|
@@ -605,8 +582,7 @@ class TestToolParameters:
|
|
| 605 |
|
| 606 |
async with Client(mcp) as client:
|
| 607 |
result = await client.call_tool("send_timedelta", {"x": 1000})
|
| 608 |
-
assert
|
| 609 |
-
assert result[0].text == "0:16:40"
|
| 610 |
|
| 611 |
|
| 612 |
class TestToolContextInjection:
|
|
@@ -639,7 +615,7 @@ class TestToolContextInjection:
|
|
| 639 |
result = await client.call_tool("tool_with_context", {"x": 42})
|
| 640 |
assert len(result) == 1
|
| 641 |
content = result[0]
|
| 642 |
-
assert
|
| 643 |
|
| 644 |
async def test_async_context(self):
|
| 645 |
"""Test that context works in async functions."""
|
|
@@ -654,8 +630,7 @@ class TestToolContextInjection:
|
|
| 654 |
result = await client.call_tool("async_tool", {"x": 42})
|
| 655 |
assert len(result) == 1
|
| 656 |
content = result[0]
|
| 657 |
-
assert
|
| 658 |
-
assert content.text == "Async request 2: 42"
|
| 659 |
|
| 660 |
async def test_optional_context(self):
|
| 661 |
"""Test that context is optional."""
|
|
@@ -669,8 +644,7 @@ class TestToolContextInjection:
|
|
| 669 |
result = await client.call_tool("no_context", {"x": 21})
|
| 670 |
assert len(result) == 1
|
| 671 |
content = result[0]
|
| 672 |
-
assert
|
| 673 |
-
assert content.text == "42"
|
| 674 |
|
| 675 |
async def test_context_resource_access(self):
|
| 676 |
"""Test that context can access resources."""
|
|
@@ -692,8 +666,7 @@ class TestToolContextInjection:
|
|
| 692 |
result = await client.call_tool("tool_with_resource", {})
|
| 693 |
assert len(result) == 1
|
| 694 |
content = result[0]
|
| 695 |
-
assert
|
| 696 |
-
assert "Read resource: resource data" in content.text
|
| 697 |
|
| 698 |
async def test_tool_decorator_with_tags(self):
|
| 699 |
"""Test that the tool decorator properly sets tags."""
|
|
@@ -721,8 +694,7 @@ class TestToolContextInjection:
|
|
| 721 |
|
| 722 |
async with Client(mcp) as client:
|
| 723 |
result = await client.call_tool("MyTool", {"x": 2})
|
| 724 |
-
assert
|
| 725 |
-
assert result[0].text == "4"
|
| 726 |
|
| 727 |
|
| 728 |
class TestResource:
|
|
@@ -739,8 +711,7 @@ class TestResource:
|
|
| 739 |
|
| 740 |
async with Client(mcp) as client:
|
| 741 |
result = await client.read_resource(AnyUrl("resource://test"))
|
| 742 |
-
assert
|
| 743 |
-
assert result[0].text == "Hello, world!"
|
| 744 |
|
| 745 |
async def test_binary_resource(self):
|
| 746 |
mcp = FastMCP()
|
|
@@ -758,8 +729,7 @@ class TestResource:
|
|
| 758 |
|
| 759 |
async with Client(mcp) as client:
|
| 760 |
result = await client.read_resource(AnyUrl("resource://binary"))
|
| 761 |
-
assert
|
| 762 |
-
assert result[0].blob == base64.b64encode(b"Binary data").decode()
|
| 763 |
|
| 764 |
async def test_file_resource_text(self, tmp_path: Path):
|
| 765 |
mcp = FastMCP()
|
|
@@ -775,8 +745,7 @@ class TestResource:
|
|
| 775 |
|
| 776 |
async with Client(mcp) as client:
|
| 777 |
result = await client.read_resource(AnyUrl("file://test.txt"))
|
| 778 |
-
assert
|
| 779 |
-
assert result[0].text == "Hello from file!"
|
| 780 |
|
| 781 |
async def test_file_resource_binary(self, tmp_path: Path):
|
| 782 |
mcp = FastMCP()
|
|
@@ -795,8 +764,7 @@ class TestResource:
|
|
| 795 |
|
| 796 |
async with Client(mcp) as client:
|
| 797 |
result = await client.read_resource(AnyUrl("file://test.bin"))
|
| 798 |
-
assert
|
| 799 |
-
assert result[0].blob == base64.b64encode(b"Binary file data").decode()
|
| 800 |
|
| 801 |
|
| 802 |
class TestResourceContext:
|
|
@@ -810,8 +778,7 @@ class TestResourceContext:
|
|
| 810 |
|
| 811 |
async with Client(mcp) as client:
|
| 812 |
result = await client.read_resource(AnyUrl("resource://test"))
|
| 813 |
-
assert
|
| 814 |
-
assert result[0].text == "2"
|
| 815 |
|
| 816 |
|
| 817 |
class TestResourceTemplates:
|
|
@@ -860,8 +827,7 @@ class TestResourceTemplates:
|
|
| 860 |
|
| 861 |
async with Client(mcp) as client:
|
| 862 |
result = await client.read_resource(AnyUrl("resource://test/data"))
|
| 863 |
-
assert
|
| 864 |
-
assert result[0].text == "Data for test"
|
| 865 |
|
| 866 |
async def test_resource_mismatched_params(self):
|
| 867 |
"""Test that mismatched parameters raise an error"""
|
|
@@ -888,8 +854,7 @@ class TestResourceTemplates:
|
|
| 888 |
result = await client.read_resource(
|
| 889 |
AnyUrl("resource://cursor/fastmcp/data")
|
| 890 |
)
|
| 891 |
-
assert
|
| 892 |
-
assert result[0].text == "Data for cursor/fastmcp"
|
| 893 |
|
| 894 |
async def test_resource_multiple_mismatched_params(self):
|
| 895 |
"""Test that mismatched parameters raise an error"""
|
|
@@ -913,8 +878,7 @@ class TestResourceTemplates:
|
|
| 913 |
|
| 914 |
async with Client(mcp) as client:
|
| 915 |
result = await client.read_resource(AnyUrl("resource://static"))
|
| 916 |
-
assert
|
| 917 |
-
assert result[0].text == "Static data"
|
| 918 |
|
| 919 |
async def test_template_with_varkwargs(self):
|
| 920 |
"""Test that a template can have **kwargs."""
|
|
@@ -926,8 +890,7 @@ class TestResourceTemplates:
|
|
| 926 |
|
| 927 |
async with Client(mcp) as client:
|
| 928 |
result = await client.read_resource(AnyUrl("test://1/2/3"))
|
| 929 |
-
assert
|
| 930 |
-
assert result[0].text == "6"
|
| 931 |
|
| 932 |
async def test_template_with_default_params(self):
|
| 933 |
"""Test that a template can have default parameters."""
|
|
@@ -946,13 +909,11 @@ class TestResourceTemplates:
|
|
| 946 |
# Call the template and verify it uses the default value
|
| 947 |
async with Client(mcp) as client:
|
| 948 |
result = await client.read_resource(AnyUrl("math://add/5"))
|
| 949 |
-
assert
|
| 950 |
-
assert result[0].text == "15" # 5 + default 10
|
| 951 |
|
| 952 |
# Can also call with explicit params
|
| 953 |
result2 = await client.read_resource(AnyUrl("math://add/7"))
|
| 954 |
-
assert
|
| 955 |
-
assert result2[0].text == "17" # 7 + default 10
|
| 956 |
|
| 957 |
async def test_template_to_resource_conversion(self):
|
| 958 |
"""Test that a template can be converted to a resource."""
|
|
@@ -971,8 +932,7 @@ class TestResourceTemplates:
|
|
| 971 |
# When accessed, should create a concrete resource
|
| 972 |
async with Client(mcp) as client:
|
| 973 |
result = await client.read_resource(AnyUrl("resource://test/data"))
|
| 974 |
-
assert
|
| 975 |
-
assert result[0].text == "Data for test"
|
| 976 |
|
| 977 |
async def test_stacked_resource_template_decorators(self):
|
| 978 |
"""Test that resource template decorators can be stacked."""
|
|
@@ -1011,15 +971,15 @@ class TestResourceTemplates:
|
|
| 1011 |
email_result = await client.read_resource(
|
| 1012 |
AnyUrl("users://email/user@example.com")
|
| 1013 |
)
|
| 1014 |
-
assert
|
| 1015 |
-
email_data = json.loads(email_result[0].text)
|
| 1016 |
assert email_data["lookup"] == "email"
|
| 1017 |
assert email_data["email"] == "user@example.com"
|
| 1018 |
|
| 1019 |
# Test lookup by name
|
| 1020 |
name_result = await client.read_resource(AnyUrl("users://name/John"))
|
| 1021 |
-
assert
|
| 1022 |
-
name_data = json.loads(name_result[0].text)
|
| 1023 |
assert name_data["lookup"] == "name"
|
| 1024 |
assert name_data["name"] == "John"
|
| 1025 |
assert name_data["email"] == "dummy@example.com"
|
|
@@ -1044,8 +1004,7 @@ class TestResourceTemplates:
|
|
| 1044 |
|
| 1045 |
async with Client(mcp) as client:
|
| 1046 |
result = await client.read_resource(AnyUrl("resource://test/data"))
|
| 1047 |
-
assert
|
| 1048 |
-
assert result[0].text == "Template resource: test/data"
|
| 1049 |
|
| 1050 |
async def test_templates_match_in_order_of_definition(self):
|
| 1051 |
"""
|
|
@@ -1065,12 +1024,10 @@ class TestResourceTemplates:
|
|
| 1065 |
|
| 1066 |
async with Client(mcp) as client:
|
| 1067 |
result = await client.read_resource(AnyUrl("resource://a/b/c"))
|
| 1068 |
-
assert
|
| 1069 |
-
assert result[0].text == "Template resource 1: a/b/c"
|
| 1070 |
|
| 1071 |
result = await client.read_resource(AnyUrl("resource://a/b"))
|
| 1072 |
-
assert
|
| 1073 |
-
assert result[0].text == "Template resource 1: a/b"
|
| 1074 |
|
| 1075 |
async def test_templates_shadow_each_other_reorder(self):
|
| 1076 |
"""
|
|
@@ -1089,12 +1046,10 @@ class TestResourceTemplates:
|
|
| 1089 |
|
| 1090 |
async with Client(mcp) as client:
|
| 1091 |
result = await client.read_resource(AnyUrl("resource://a/b/c"))
|
| 1092 |
-
assert
|
| 1093 |
-
assert result[0].text == "Template resource 2: a/b/c"
|
| 1094 |
|
| 1095 |
result = await client.read_resource(AnyUrl("resource://a/b"))
|
| 1096 |
-
assert
|
| 1097 |
-
assert result[0].text == "Template resource 1: a/b"
|
| 1098 |
|
| 1099 |
|
| 1100 |
class TestResourceTemplateContext:
|
|
@@ -1108,8 +1063,7 @@ class TestResourceTemplateContext:
|
|
| 1108 |
|
| 1109 |
async with Client(mcp) as client:
|
| 1110 |
result = await client.read_resource(AnyUrl("resource://test"))
|
| 1111 |
-
assert
|
| 1112 |
-
assert result[0].text.startswith("Resource template: test 2")
|
| 1113 |
|
| 1114 |
async def test_resource_template_context_with_callable_object(self):
|
| 1115 |
mcp = FastMCP()
|
|
@@ -1122,8 +1076,7 @@ class TestResourceTemplateContext:
|
|
| 1122 |
|
| 1123 |
async with Client(mcp) as client:
|
| 1124 |
result = await client.read_resource(AnyUrl("resource://test"))
|
| 1125 |
-
assert
|
| 1126 |
-
assert result[0].text.startswith("Resource template: test 2")
|
| 1127 |
|
| 1128 |
|
| 1129 |
class TestPrompts:
|
|
@@ -1143,8 +1096,7 @@ class TestPrompts:
|
|
| 1143 |
assert prompt.name == "fn"
|
| 1144 |
# Don't compare functions directly since validate_call wraps them
|
| 1145 |
content = await prompt.render()
|
| 1146 |
-
assert
|
| 1147 |
-
assert content[0].content.text == "Hello, world!"
|
| 1148 |
|
| 1149 |
async def test_prompt_decorator_with_name(self):
|
| 1150 |
"""Test prompt decorator with custom name."""
|
|
@@ -1159,8 +1111,7 @@ class TestPrompts:
|
|
| 1159 |
prompt = prompts_dict["custom_name"]
|
| 1160 |
assert prompt.name == "custom_name"
|
| 1161 |
content = await prompt.render()
|
| 1162 |
-
assert
|
| 1163 |
-
assert content[0].content.text == "Hello, world!"
|
| 1164 |
|
| 1165 |
async def test_prompt_decorator_with_description(self):
|
| 1166 |
"""Test prompt decorator with custom description."""
|
|
@@ -1175,8 +1126,7 @@ class TestPrompts:
|
|
| 1175 |
prompt = prompts_dict["fn"]
|
| 1176 |
assert prompt.description == "A custom description"
|
| 1177 |
content = await prompt.render()
|
| 1178 |
-
assert
|
| 1179 |
-
assert content[0].content.text == "Hello, world!"
|
| 1180 |
|
| 1181 |
def test_prompt_decorator_error(self):
|
| 1182 |
"""Test error when decorator is used incorrectly."""
|
|
@@ -1224,8 +1174,7 @@ class TestPrompts:
|
|
| 1224 |
message = result.messages[0]
|
| 1225 |
assert message.role == "user"
|
| 1226 |
content = message.content
|
| 1227 |
-
assert
|
| 1228 |
-
assert content.text == "Hello, World!"
|
| 1229 |
|
| 1230 |
async def test_get_prompt_with_resource(self):
|
| 1231 |
"""Test getting a prompt that returns resource content."""
|
|
@@ -1249,10 +1198,10 @@ class TestPrompts:
|
|
| 1249 |
result = await client.get_prompt("fn")
|
| 1250 |
assert result.messages[0].role == "user"
|
| 1251 |
content = result.messages[0].content
|
| 1252 |
-
assert isinstance(content, EmbeddedResource)
|
| 1253 |
resource = content.resource
|
| 1254 |
-
assert isinstance(resource, TextResourceContents)
|
| 1255 |
-
assert resource.text == "File contents"
|
| 1256 |
assert resource.mimeType == "text/plain"
|
| 1257 |
|
| 1258 |
async def test_get_unknown_prompt(self):
|
|
@@ -1342,5 +1291,4 @@ class TestPromptContext:
|
|
| 1342 |
assert len(result.messages) == 1
|
| 1343 |
message = result.messages[0]
|
| 1344 |
assert message.role == "user"
|
| 1345 |
-
assert
|
| 1346 |
-
assert message.content.text == "Hello, World! 2"
|
|
|
|
| 10 |
import pytest
|
| 11 |
from mcp import McpError
|
| 12 |
from mcp.types import (
|
|
|
|
| 13 |
ImageContent,
|
| 14 |
TextContent,
|
| 15 |
TextResourceContents,
|
|
|
|
| 76 |
async def test_call_tool(self, tool_server: FastMCP):
|
| 77 |
async with Client(tool_server) as client:
|
| 78 |
result = await client.call_tool("add", {"x": 1, "y": 2})
|
| 79 |
+
assert result[0].text == "3" # type: ignore[attr-defined]
|
|
|
|
| 80 |
|
| 81 |
async def test_call_tool_as_client(self, tool_server: FastMCP):
|
| 82 |
async with Client(tool_server) as client:
|
| 83 |
result = await client.call_tool("add", {"x": 1, "y": 2})
|
| 84 |
+
assert result[0].text == "3" # type: ignore[attr-defined]
|
|
|
|
| 85 |
|
| 86 |
async def test_call_tool_error(self, tool_server: FastMCP):
|
| 87 |
async with Client(tool_server) as client:
|
|
|
|
| 110 |
async def test_tool_returns_list(self, tool_server: FastMCP):
|
| 111 |
async with Client(tool_server) as client:
|
| 112 |
result = await client.call_tool("list_tool", {})
|
| 113 |
+
assert result[0].text == '[\n "x",\n 2\n]' # type: ignore[attr-defined]
|
|
|
|
| 114 |
|
| 115 |
|
| 116 |
class TestToolReturnTypes:
|
|
|
|
| 123 |
|
| 124 |
async with Client(mcp) as client:
|
| 125 |
result = await client.call_tool("string_tool", {})
|
| 126 |
+
assert result[0].text == "Hello, world!" # type: ignore[attr-defined]
|
|
|
|
| 127 |
|
| 128 |
async def test_bytes(self, tmp_path: Path):
|
| 129 |
mcp = FastMCP()
|
|
|
|
| 134 |
|
| 135 |
async with Client(mcp) as client:
|
| 136 |
result = await client.call_tool("bytes_tool", {})
|
| 137 |
+
assert result[0].text == '"Hello, world!"' # type: ignore[attr-defined]
|
|
|
|
| 138 |
|
| 139 |
async def test_uuid(self):
|
| 140 |
mcp = FastMCP()
|
|
|
|
| 147 |
|
| 148 |
async with Client(mcp) as client:
|
| 149 |
result = await client.call_tool("uuid_tool", {})
|
| 150 |
+
assert result[0].text == pydantic_core.to_json(test_uuid).decode() # type: ignore[attr-defined]
|
|
|
|
| 151 |
|
| 152 |
async def test_path(self):
|
| 153 |
mcp = FastMCP()
|
|
|
|
| 160 |
|
| 161 |
async with Client(mcp) as client:
|
| 162 |
result = await client.call_tool("path_tool", {})
|
| 163 |
+
assert result[0].text == pydantic_core.to_json(test_path).decode() # type: ignore[attr-defined]
|
|
|
|
| 164 |
|
| 165 |
async def test_datetime(self):
|
| 166 |
mcp = FastMCP()
|
|
|
|
| 173 |
|
| 174 |
async with Client(mcp) as client:
|
| 175 |
result = await client.call_tool("datetime_tool", {})
|
| 176 |
+
assert result[0].text == pydantic_core.to_json(dt).decode() # type: ignore[attr-defined]
|
|
|
|
| 177 |
|
| 178 |
async def test_image(self, tmp_path: Path):
|
| 179 |
mcp = FastMCP()
|
|
|
|
| 328 |
async with Client(mcp) as client:
|
| 329 |
# String with integer value should be coerced to int
|
| 330 |
result = await client.call_tool("add_one", {"x": "42"})
|
| 331 |
+
assert result[0].text == "43" # type: ignore[attr-defined]
|
|
|
|
| 332 |
|
| 333 |
async def test_tool_bool_coercion(self):
|
| 334 |
"""Test string-to-bool type coercion."""
|
|
|
|
| 341 |
async with Client(mcp) as client:
|
| 342 |
# String with boolean value should be coerced to bool
|
| 343 |
result = await client.call_tool("toggle", {"flag": "true"})
|
| 344 |
+
assert result[0].text == "false" # type: ignore[attr-defined]
|
|
|
|
| 345 |
|
| 346 |
result = await client.call_tool("toggle", {"flag": "false"})
|
| 347 |
+
assert result[0].text == "true" # type: ignore[attr-defined]
|
|
|
|
| 348 |
|
| 349 |
async def test_annotated_field_validation(self):
|
| 350 |
mcp = FastMCP()
|
|
|
|
| 399 |
|
| 400 |
async with Client(mcp) as client:
|
| 401 |
result = await client.call_tool("analyze", {"x": "a"})
|
| 402 |
+
assert result[0].text == "a" # type: ignore[attr-defined]
|
|
|
|
| 403 |
|
| 404 |
async def test_enum_type_validation_error(self):
|
| 405 |
mcp = FastMCP()
|
|
|
|
| 431 |
|
| 432 |
async with Client(mcp) as client:
|
| 433 |
result = await client.call_tool("analyze", {"x": "red"})
|
| 434 |
+
assert result[0].text == "red" # type: ignore[attr-defined]
|
|
|
|
| 435 |
|
| 436 |
async def test_union_type_validation(self):
|
| 437 |
mcp = FastMCP()
|
|
|
|
| 442 |
|
| 443 |
async with Client(mcp) as client:
|
| 444 |
result = await client.call_tool("analyze", {"x": 1})
|
| 445 |
+
assert result[0].text == "1" # type: ignore[attr-defined]
|
|
|
|
| 446 |
|
| 447 |
result = await client.call_tool("analyze", {"x": 1.0})
|
| 448 |
+
assert result[0].text == "1.0" # type: ignore[attr-defined]
|
|
|
|
| 449 |
|
| 450 |
with pytest.raises(ToolError, match="Error calling tool 'analyze'"):
|
| 451 |
await client.call_tool("analyze", {"x": "not a number"})
|
|
|
|
| 463 |
|
| 464 |
async with Client(mcp) as client:
|
| 465 |
result = await client.call_tool("send_path", {"path": str(test_path)})
|
| 466 |
+
assert result[0].text == str(test_path) # type: ignore[attr-defined]
|
|
|
|
| 467 |
|
| 468 |
async def test_path_type_error(self):
|
| 469 |
mcp = FastMCP()
|
|
|
|
| 488 |
|
| 489 |
async with Client(mcp) as client:
|
| 490 |
result = await client.call_tool("send_uuid", {"x": test_uuid})
|
| 491 |
+
assert result[0].text == str(test_uuid) # type: ignore[attr-defined]
|
|
|
|
| 492 |
|
| 493 |
async def test_uuid_type_error(self):
|
| 494 |
mcp = FastMCP()
|
|
|
|
| 512 |
|
| 513 |
async with Client(mcp) as client:
|
| 514 |
result = await client.call_tool("send_datetime", {"x": dt})
|
| 515 |
+
assert result[0].text == dt.isoformat() # type: ignore[attr-defined]
|
|
|
|
| 516 |
|
| 517 |
async def test_datetime_type_parse_string(self):
|
| 518 |
mcp = FastMCP()
|
|
|
|
| 525 |
result = await client.call_tool(
|
| 526 |
"send_datetime", {"x": "2021-01-01T00:00:00"}
|
| 527 |
)
|
| 528 |
+
assert result[0].text == "2021-01-01T00:00:00" # type: ignore[attr-defined]
|
|
|
|
| 529 |
|
| 530 |
async def test_datetime_type_error(self):
|
| 531 |
mcp = FastMCP()
|
|
|
|
| 547 |
|
| 548 |
async with Client(mcp) as client:
|
| 549 |
result = await client.call_tool("send_date", {"x": datetime.date.today()})
|
| 550 |
+
assert result[0].text == datetime.date.today().isoformat() # type: ignore[attr-defined]
|
|
|
|
| 551 |
|
| 552 |
async def test_date_type_parse_string(self):
|
| 553 |
mcp = FastMCP()
|
|
|
|
| 558 |
|
| 559 |
async with Client(mcp) as client:
|
| 560 |
result = await client.call_tool("send_date", {"x": "2021-01-01"})
|
| 561 |
+
assert result[0].text == "2021-01-01" # type: ignore[attr-defined]
|
|
|
|
| 562 |
|
| 563 |
async def test_timedelta_type(self):
|
| 564 |
mcp = FastMCP()
|
|
|
|
| 571 |
result = await client.call_tool(
|
| 572 |
"send_timedelta", {"x": datetime.timedelta(days=1)}
|
| 573 |
)
|
| 574 |
+
assert result[0].text == "1 day, 0:00:00" # type: ignore[attr-defined]
|
|
|
|
| 575 |
|
| 576 |
async def test_timedelta_type_parse_int(self):
|
| 577 |
mcp = FastMCP()
|
|
|
|
| 582 |
|
| 583 |
async with Client(mcp) as client:
|
| 584 |
result = await client.call_tool("send_timedelta", {"x": 1000})
|
| 585 |
+
assert result[0].text == "0:16:40" # type: ignore[attr-defined]
|
|
|
|
| 586 |
|
| 587 |
|
| 588 |
class TestToolContextInjection:
|
|
|
|
| 615 |
result = await client.call_tool("tool_with_context", {"x": 42})
|
| 616 |
assert len(result) == 1
|
| 617 |
content = result[0]
|
| 618 |
+
assert content.text == "2" # type: ignore[attr-defined]
|
| 619 |
|
| 620 |
async def test_async_context(self):
|
| 621 |
"""Test that context works in async functions."""
|
|
|
|
| 630 |
result = await client.call_tool("async_tool", {"x": 42})
|
| 631 |
assert len(result) == 1
|
| 632 |
content = result[0]
|
| 633 |
+
assert content.text == "Async request 2: 42" # type: ignore[attr-defined]
|
|
|
|
| 634 |
|
| 635 |
async def test_optional_context(self):
|
| 636 |
"""Test that context is optional."""
|
|
|
|
| 644 |
result = await client.call_tool("no_context", {"x": 21})
|
| 645 |
assert len(result) == 1
|
| 646 |
content = result[0]
|
| 647 |
+
assert content.text == "42" # type: ignore[attr-defined]
|
|
|
|
| 648 |
|
| 649 |
async def test_context_resource_access(self):
|
| 650 |
"""Test that context can access resources."""
|
|
|
|
| 666 |
result = await client.call_tool("tool_with_resource", {})
|
| 667 |
assert len(result) == 1
|
| 668 |
content = result[0]
|
| 669 |
+
assert "Read resource: resource data" in content.text # type: ignore[attr-defined]
|
|
|
|
| 670 |
|
| 671 |
async def test_tool_decorator_with_tags(self):
|
| 672 |
"""Test that the tool decorator properly sets tags."""
|
|
|
|
| 694 |
|
| 695 |
async with Client(mcp) as client:
|
| 696 |
result = await client.call_tool("MyTool", {"x": 2})
|
| 697 |
+
assert result[0].text == "4" # type: ignore[attr-defined]
|
|
|
|
| 698 |
|
| 699 |
|
| 700 |
class TestResource:
|
|
|
|
| 711 |
|
| 712 |
async with Client(mcp) as client:
|
| 713 |
result = await client.read_resource(AnyUrl("resource://test"))
|
| 714 |
+
assert result[0].text == "Hello, world!" # type: ignore[attr-defined]
|
|
|
|
| 715 |
|
| 716 |
async def test_binary_resource(self):
|
| 717 |
mcp = FastMCP()
|
|
|
|
| 729 |
|
| 730 |
async with Client(mcp) as client:
|
| 731 |
result = await client.read_resource(AnyUrl("resource://binary"))
|
| 732 |
+
assert result[0].blob == base64.b64encode(b"Binary data").decode() # type: ignore[attr-defined]
|
|
|
|
| 733 |
|
| 734 |
async def test_file_resource_text(self, tmp_path: Path):
|
| 735 |
mcp = FastMCP()
|
|
|
|
| 745 |
|
| 746 |
async with Client(mcp) as client:
|
| 747 |
result = await client.read_resource(AnyUrl("file://test.txt"))
|
| 748 |
+
assert result[0].text == "Hello from file!" # type: ignore[attr-defined]
|
|
|
|
| 749 |
|
| 750 |
async def test_file_resource_binary(self, tmp_path: Path):
|
| 751 |
mcp = FastMCP()
|
|
|
|
| 764 |
|
| 765 |
async with Client(mcp) as client:
|
| 766 |
result = await client.read_resource(AnyUrl("file://test.bin"))
|
| 767 |
+
assert result[0].blob == base64.b64encode(b"Binary file data").decode() # type: ignore[attr-defined]
|
|
|
|
| 768 |
|
| 769 |
|
| 770 |
class TestResourceContext:
|
|
|
|
| 778 |
|
| 779 |
async with Client(mcp) as client:
|
| 780 |
result = await client.read_resource(AnyUrl("resource://test"))
|
| 781 |
+
assert result[0].text == "2" # type: ignore[attr-defined]
|
|
|
|
| 782 |
|
| 783 |
|
| 784 |
class TestResourceTemplates:
|
|
|
|
| 827 |
|
| 828 |
async with Client(mcp) as client:
|
| 829 |
result = await client.read_resource(AnyUrl("resource://test/data"))
|
| 830 |
+
assert result[0].text == "Data for test" # type: ignore[attr-defined]
|
|
|
|
| 831 |
|
| 832 |
async def test_resource_mismatched_params(self):
|
| 833 |
"""Test that mismatched parameters raise an error"""
|
|
|
|
| 854 |
result = await client.read_resource(
|
| 855 |
AnyUrl("resource://cursor/fastmcp/data")
|
| 856 |
)
|
| 857 |
+
assert result[0].text == "Data for cursor/fastmcp" # type: ignore[attr-defined]
|
|
|
|
| 858 |
|
| 859 |
async def test_resource_multiple_mismatched_params(self):
|
| 860 |
"""Test that mismatched parameters raise an error"""
|
|
|
|
| 878 |
|
| 879 |
async with Client(mcp) as client:
|
| 880 |
result = await client.read_resource(AnyUrl("resource://static"))
|
| 881 |
+
assert result[0].text == "Static data" # type: ignore[attr-defined]
|
|
|
|
| 882 |
|
| 883 |
async def test_template_with_varkwargs(self):
|
| 884 |
"""Test that a template can have **kwargs."""
|
|
|
|
| 890 |
|
| 891 |
async with Client(mcp) as client:
|
| 892 |
result = await client.read_resource(AnyUrl("test://1/2/3"))
|
| 893 |
+
assert result[0].text == "6" # type: ignore[attr-defined]
|
|
|
|
| 894 |
|
| 895 |
async def test_template_with_default_params(self):
|
| 896 |
"""Test that a template can have default parameters."""
|
|
|
|
| 909 |
# Call the template and verify it uses the default value
|
| 910 |
async with Client(mcp) as client:
|
| 911 |
result = await client.read_resource(AnyUrl("math://add/5"))
|
| 912 |
+
assert result[0].text == "15" # type: ignore[attr-defined]
|
|
|
|
| 913 |
|
| 914 |
# Can also call with explicit params
|
| 915 |
result2 = await client.read_resource(AnyUrl("math://add/7"))
|
| 916 |
+
assert result2[0].text == "17" # type: ignore[attr-defined]
|
|
|
|
| 917 |
|
| 918 |
async def test_template_to_resource_conversion(self):
|
| 919 |
"""Test that a template can be converted to a resource."""
|
|
|
|
| 932 |
# When accessed, should create a concrete resource
|
| 933 |
async with Client(mcp) as client:
|
| 934 |
result = await client.read_resource(AnyUrl("resource://test/data"))
|
| 935 |
+
assert result[0].text == "Data for test" # type: ignore[attr-defined]
|
|
|
|
| 936 |
|
| 937 |
async def test_stacked_resource_template_decorators(self):
|
| 938 |
"""Test that resource template decorators can be stacked."""
|
|
|
|
| 971 |
email_result = await client.read_resource(
|
| 972 |
AnyUrl("users://email/user@example.com")
|
| 973 |
)
|
| 974 |
+
assert email_result[0].text # type: ignore[attr-defined]
|
| 975 |
+
email_data = json.loads(email_result[0].text) # type: ignore[attr-defined]
|
| 976 |
assert email_data["lookup"] == "email"
|
| 977 |
assert email_data["email"] == "user@example.com"
|
| 978 |
|
| 979 |
# Test lookup by name
|
| 980 |
name_result = await client.read_resource(AnyUrl("users://name/John"))
|
| 981 |
+
assert name_result[0].text # type: ignore[attr-defined]
|
| 982 |
+
name_data = json.loads(name_result[0].text) # type: ignore[attr-defined]
|
| 983 |
assert name_data["lookup"] == "name"
|
| 984 |
assert name_data["name"] == "John"
|
| 985 |
assert name_data["email"] == "dummy@example.com"
|
|
|
|
| 1004 |
|
| 1005 |
async with Client(mcp) as client:
|
| 1006 |
result = await client.read_resource(AnyUrl("resource://test/data"))
|
| 1007 |
+
assert result[0].text == "Template resource: test/data" # type: ignore[attr-defined]
|
|
|
|
| 1008 |
|
| 1009 |
async def test_templates_match_in_order_of_definition(self):
|
| 1010 |
"""
|
|
|
|
| 1024 |
|
| 1025 |
async with Client(mcp) as client:
|
| 1026 |
result = await client.read_resource(AnyUrl("resource://a/b/c"))
|
| 1027 |
+
assert result[0].text == "Template resource 1: a/b/c" # type: ignore[attr-defined]
|
|
|
|
| 1028 |
|
| 1029 |
result = await client.read_resource(AnyUrl("resource://a/b"))
|
| 1030 |
+
assert result[0].text == "Template resource 1: a/b" # type: ignore[attr-defined]
|
|
|
|
| 1031 |
|
| 1032 |
async def test_templates_shadow_each_other_reorder(self):
|
| 1033 |
"""
|
|
|
|
| 1046 |
|
| 1047 |
async with Client(mcp) as client:
|
| 1048 |
result = await client.read_resource(AnyUrl("resource://a/b/c"))
|
| 1049 |
+
assert result[0].text == "Template resource 2: a/b/c" # type: ignore[attr-defined]
|
|
|
|
| 1050 |
|
| 1051 |
result = await client.read_resource(AnyUrl("resource://a/b"))
|
| 1052 |
+
assert result[0].text == "Template resource 1: a/b" # type: ignore[attr-defined]
|
|
|
|
| 1053 |
|
| 1054 |
|
| 1055 |
class TestResourceTemplateContext:
|
|
|
|
| 1063 |
|
| 1064 |
async with Client(mcp) as client:
|
| 1065 |
result = await client.read_resource(AnyUrl("resource://test"))
|
| 1066 |
+
assert result[0].text.startswith("Resource template: test 2") # type: ignore[attr-defined]
|
|
|
|
| 1067 |
|
| 1068 |
async def test_resource_template_context_with_callable_object(self):
|
| 1069 |
mcp = FastMCP()
|
|
|
|
| 1076 |
|
| 1077 |
async with Client(mcp) as client:
|
| 1078 |
result = await client.read_resource(AnyUrl("resource://test"))
|
| 1079 |
+
assert result[0].text.startswith("Resource template: test 2") # type: ignore[attr-defined]
|
|
|
|
| 1080 |
|
| 1081 |
|
| 1082 |
class TestPrompts:
|
|
|
|
| 1096 |
assert prompt.name == "fn"
|
| 1097 |
# Don't compare functions directly since validate_call wraps them
|
| 1098 |
content = await prompt.render()
|
| 1099 |
+
assert content[0].content.text == "Hello, world!" # type: ignore[attr-defined]
|
|
|
|
| 1100 |
|
| 1101 |
async def test_prompt_decorator_with_name(self):
|
| 1102 |
"""Test prompt decorator with custom name."""
|
|
|
|
| 1111 |
prompt = prompts_dict["custom_name"]
|
| 1112 |
assert prompt.name == "custom_name"
|
| 1113 |
content = await prompt.render()
|
| 1114 |
+
assert content[0].content.text == "Hello, world!" # type: ignore[attr-defined]
|
|
|
|
| 1115 |
|
| 1116 |
async def test_prompt_decorator_with_description(self):
|
| 1117 |
"""Test prompt decorator with custom description."""
|
|
|
|
| 1126 |
prompt = prompts_dict["fn"]
|
| 1127 |
assert prompt.description == "A custom description"
|
| 1128 |
content = await prompt.render()
|
| 1129 |
+
assert content[0].content.text == "Hello, world!" # type: ignore[attr-defined]
|
|
|
|
| 1130 |
|
| 1131 |
def test_prompt_decorator_error(self):
|
| 1132 |
"""Test error when decorator is used incorrectly."""
|
|
|
|
| 1174 |
message = result.messages[0]
|
| 1175 |
assert message.role == "user"
|
| 1176 |
content = message.content
|
| 1177 |
+
assert content.text == "Hello, World!" # type: ignore[attr-defined]
|
|
|
|
| 1178 |
|
| 1179 |
async def test_get_prompt_with_resource(self):
|
| 1180 |
"""Test getting a prompt that returns resource content."""
|
|
|
|
| 1198 |
result = await client.get_prompt("fn")
|
| 1199 |
assert result.messages[0].role == "user"
|
| 1200 |
content = result.messages[0].content
|
| 1201 |
+
assert isinstance(content, EmbeddedResource) # type: ignore[attr-defined]
|
| 1202 |
resource = content.resource
|
| 1203 |
+
assert isinstance(resource, TextResourceContents) # type: ignore[attr-defined]
|
| 1204 |
+
assert resource.text == "File contents" # type: ignore[attr-defined]
|
| 1205 |
assert resource.mimeType == "text/plain"
|
| 1206 |
|
| 1207 |
async def test_get_unknown_prompt(self):
|
|
|
|
| 1291 |
assert len(result.messages) == 1
|
| 1292 |
message = result.messages[0]
|
| 1293 |
assert message.role == "user"
|
| 1294 |
+
assert message.content.text == "Hello, World! 2" # type: ignore[attr-defined]
|
|
|
tests/server/test_tool_annotations.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
from typing import Any
|
| 2 |
|
| 3 |
-
from mcp.types import
|
| 4 |
|
| 5 |
from fastmcp import Client, FastMCP
|
| 6 |
|
|
@@ -212,8 +212,7 @@ async def test_tool_functionality_with_annotations():
|
|
| 212 |
"create_item", {"name": "test_item", "value": 42}
|
| 213 |
)
|
| 214 |
assert len(result) == 1
|
| 215 |
-
assert isinstance(result[0], TextContent)
|
| 216 |
|
| 217 |
# The result should contain the expected JSON
|
| 218 |
-
assert '"name": "test_item"' in result[0].text
|
| 219 |
-
assert '"value": 42' in result[0].text
|
|
|
|
| 1 |
from typing import Any
|
| 2 |
|
| 3 |
+
from mcp.types import ToolAnnotations
|
| 4 |
|
| 5 |
from fastmcp import Client, FastMCP
|
| 6 |
|
|
|
|
| 212 |
"create_item", {"name": "test_item", "value": 42}
|
| 213 |
)
|
| 214 |
assert len(result) == 1
|
|
|
|
| 215 |
|
| 216 |
# The result should contain the expected JSON
|
| 217 |
+
assert '"name": "test_item"' in result[0].text # type: ignore[attr-defined]
|
| 218 |
+
assert '"value": 42' in result[0].text # type: ignore[attr-defined]
|
tests/test_examples.py
CHANGED
|
@@ -1,10 +1,5 @@
|
|
| 1 |
"""Tests for example servers"""
|
| 2 |
|
| 3 |
-
from mcp.types import (
|
| 4 |
-
PromptMessage,
|
| 5 |
-
TextContent,
|
| 6 |
-
TextResourceContents,
|
| 7 |
-
)
|
| 8 |
from pydantic import AnyUrl
|
| 9 |
|
| 10 |
from fastmcp import Client
|
|
@@ -17,8 +12,7 @@ async def test_simple_echo():
|
|
| 17 |
async with Client(mcp) as client:
|
| 18 |
result = await client.call_tool("echo", {"text": "hello"})
|
| 19 |
assert len(result) == 1
|
| 20 |
-
assert
|
| 21 |
-
assert result[0].text == "hello"
|
| 22 |
|
| 23 |
|
| 24 |
async def test_complex_inputs():
|
|
@@ -31,8 +25,7 @@ async def test_complex_inputs():
|
|
| 31 |
"name_shrimp", {"tank": tank, "extra_names": ["charlie"]}
|
| 32 |
)
|
| 33 |
assert len(result) == 1
|
| 34 |
-
assert
|
| 35 |
-
assert result[0].text == '[\n "bob",\n "alice",\n "charlie"\n]'
|
| 36 |
|
| 37 |
|
| 38 |
async def test_desktop(monkeypatch):
|
|
@@ -43,15 +36,12 @@ async def test_desktop(monkeypatch):
|
|
| 43 |
# Test the add function
|
| 44 |
result = await client.call_tool("add", {"a": 1, "b": 2})
|
| 45 |
assert len(result) == 1
|
| 46 |
-
assert
|
| 47 |
-
assert result[0].text == "3"
|
| 48 |
|
| 49 |
async with Client(mcp) as client:
|
| 50 |
result = await client.read_resource(AnyUrl("greeting://rooter12"))
|
| 51 |
assert len(result) == 1
|
| 52 |
-
assert
|
| 53 |
-
assert isinstance(result[0].text, str)
|
| 54 |
-
assert result[0].text == "Hello, rooter12!"
|
| 55 |
|
| 56 |
|
| 57 |
async def test_echo():
|
|
@@ -61,27 +51,19 @@ async def test_echo():
|
|
| 61 |
async with Client(mcp) as client:
|
| 62 |
result = await client.call_tool("echo_tool", {"text": "hello"})
|
| 63 |
assert len(result) == 1
|
| 64 |
-
assert
|
| 65 |
-
assert result[0].text == "hello"
|
| 66 |
|
| 67 |
async with Client(mcp) as client:
|
| 68 |
result = await client.read_resource(AnyUrl("echo://static"))
|
| 69 |
assert len(result) == 1
|
| 70 |
-
assert
|
| 71 |
-
assert isinstance(result[0].text, str)
|
| 72 |
-
assert result[0].text == "Echo!"
|
| 73 |
|
| 74 |
async with Client(mcp) as client:
|
| 75 |
result = await client.read_resource(AnyUrl("echo://server42"))
|
| 76 |
assert len(result) == 1
|
| 77 |
-
assert
|
| 78 |
-
assert isinstance(result[0].text, str)
|
| 79 |
-
assert result[0].text == "Echo: server42"
|
| 80 |
|
| 81 |
async with Client(mcp) as client:
|
| 82 |
result = await client.get_prompt("echo", {"text": "hello"})
|
| 83 |
assert len(result.messages) == 1
|
| 84 |
-
assert
|
| 85 |
-
assert isinstance(result.messages[0].content, TextContent)
|
| 86 |
-
assert isinstance(result.messages[0].content.text, str)
|
| 87 |
-
assert result.messages[0].content.text == "hello"
|
|
|
|
| 1 |
"""Tests for example servers"""
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
from pydantic import AnyUrl
|
| 4 |
|
| 5 |
from fastmcp import Client
|
|
|
|
| 12 |
async with Client(mcp) as client:
|
| 13 |
result = await client.call_tool("echo", {"text": "hello"})
|
| 14 |
assert len(result) == 1
|
| 15 |
+
assert result[0].text == "hello" # type: ignore[attr-defined]
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
async def test_complex_inputs():
|
|
|
|
| 25 |
"name_shrimp", {"tank": tank, "extra_names": ["charlie"]}
|
| 26 |
)
|
| 27 |
assert len(result) == 1
|
| 28 |
+
assert result[0].text == '[\n "bob",\n "alice",\n "charlie"\n]' # type: ignore[attr-defined]
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
async def test_desktop(monkeypatch):
|
|
|
|
| 36 |
# Test the add function
|
| 37 |
result = await client.call_tool("add", {"a": 1, "b": 2})
|
| 38 |
assert len(result) == 1
|
| 39 |
+
assert result[0].text == "3" # type: ignore[attr-defined]
|
|
|
|
| 40 |
|
| 41 |
async with Client(mcp) as client:
|
| 42 |
result = await client.read_resource(AnyUrl("greeting://rooter12"))
|
| 43 |
assert len(result) == 1
|
| 44 |
+
assert result[0].text == "Hello, rooter12!" # type: ignore[attr-defined]
|
|
|
|
|
|
|
| 45 |
|
| 46 |
|
| 47 |
async def test_echo():
|
|
|
|
| 51 |
async with Client(mcp) as client:
|
| 52 |
result = await client.call_tool("echo_tool", {"text": "hello"})
|
| 53 |
assert len(result) == 1
|
| 54 |
+
assert result[0].text == "hello" # type: ignore[attr-defined]
|
|
|
|
| 55 |
|
| 56 |
async with Client(mcp) as client:
|
| 57 |
result = await client.read_resource(AnyUrl("echo://static"))
|
| 58 |
assert len(result) == 1
|
| 59 |
+
assert result[0].text == "Echo!" # type: ignore[attr-defined]
|
|
|
|
|
|
|
| 60 |
|
| 61 |
async with Client(mcp) as client:
|
| 62 |
result = await client.read_resource(AnyUrl("echo://server42"))
|
| 63 |
assert len(result) == 1
|
| 64 |
+
assert result[0].text == "Echo: server42" # type: ignore[attr-defined]
|
|
|
|
|
|
|
| 65 |
|
| 66 |
async with Client(mcp) as client:
|
| 67 |
result = await client.get_prompt("echo", {"text": "hello"})
|
| 68 |
assert len(result.messages) == 1
|
| 69 |
+
assert result.messages[0].content.text == "hello" # type: ignore[attr-defined]
|
|
|
|
|
|
|
|
|
tests/tools/test_tool.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import pytest
|
| 2 |
-
from mcp.types import ImageContent
|
| 3 |
from pydantic import BaseModel
|
| 4 |
|
| 5 |
from fastmcp import FastMCP, Image
|
|
@@ -209,9 +209,7 @@ class TestLegacyToolJsonParsing:
|
|
| 209 |
|
| 210 |
# Run the tool which will do JSON parsing
|
| 211 |
result = await tool.run(json_args)
|
| 212 |
-
assert
|
| 213 |
-
assert isinstance(result[0], TextContent)
|
| 214 |
-
assert result[0].text == "1-a,b,c"
|
| 215 |
|
| 216 |
async def test_str_vs_list_str(self):
|
| 217 |
"""Test handling of string vs list[str] type annotations."""
|
|
@@ -223,23 +221,17 @@ class TestLegacyToolJsonParsing:
|
|
| 223 |
|
| 224 |
# Test regular string input (should remain a string)
|
| 225 |
result = await tool.run({"str_or_list": "hello"})
|
| 226 |
-
assert
|
| 227 |
-
assert isinstance(result[0], TextContent)
|
| 228 |
-
assert result[0].text == "hello"
|
| 229 |
|
| 230 |
# Test JSON string input (should be parsed as a string)
|
| 231 |
result = await tool.run({"str_or_list": '"hello"'})
|
| 232 |
-
assert
|
| 233 |
-
assert isinstance(result[0], TextContent)
|
| 234 |
-
assert result[0].text == "hello"
|
| 235 |
|
| 236 |
# Test JSON list input (should be parsed as a list)
|
| 237 |
result = await tool.run({"str_or_list": '["hello", "world"]'})
|
| 238 |
-
assert len(result) == 1
|
| 239 |
-
assert isinstance(result[0], TextContent)
|
| 240 |
|
| 241 |
# The exact formatting might vary, so we just check that it contains the key elements
|
| 242 |
-
text_without_whitespace = result[0].text.replace(" ", "").replace("\n", "")
|
| 243 |
assert "hello" in text_without_whitespace
|
| 244 |
assert "world" in text_without_whitespace
|
| 245 |
assert "[" in text_without_whitespace
|
|
@@ -256,9 +248,7 @@ class TestLegacyToolJsonParsing:
|
|
| 256 |
# Invalid JSON should remain a string
|
| 257 |
invalid_json = "{'nice to meet you': 'hello', 'goodbye': 5}"
|
| 258 |
result = await tool.run({"string": invalid_json})
|
| 259 |
-
assert
|
| 260 |
-
assert isinstance(result[0], TextContent)
|
| 261 |
-
assert result[0].text == invalid_json
|
| 262 |
|
| 263 |
async def test_keep_str_union_as_str(self):
|
| 264 |
"""Test that string arguments are kept as strings when parsing would create an invalid value"""
|
|
@@ -273,9 +263,7 @@ class TestLegacyToolJsonParsing:
|
|
| 273 |
# Invalid JSON for the union type should remain a string
|
| 274 |
invalid_json = "{'nice to meet you': 'hello', 'goodbye': 5}"
|
| 275 |
result = await tool.run({"string": invalid_json})
|
| 276 |
-
assert
|
| 277 |
-
assert isinstance(result[0], TextContent)
|
| 278 |
-
assert result[0].text == invalid_json
|
| 279 |
|
| 280 |
async def test_complex_type_validation(self):
|
| 281 |
"""Test that parsed JSON is validated against complex types"""
|
|
@@ -292,11 +280,9 @@ class TestLegacyToolJsonParsing:
|
|
| 292 |
# Valid JSON for the model
|
| 293 |
valid_json = '{"x": 1, "y": {"1": "hello"}}'
|
| 294 |
result = await tool.run({"data": valid_json})
|
| 295 |
-
assert
|
| 296 |
-
assert
|
| 297 |
-
assert '"
|
| 298 |
-
assert '"y": {' in result[0].text
|
| 299 |
-
assert '"1": "hello"' in result[0].text
|
| 300 |
|
| 301 |
# Invalid JSON for the model (y has string keys, not int keys)
|
| 302 |
# Should throw a validation error
|
|
@@ -317,8 +303,7 @@ class TestLegacyToolJsonParsing:
|
|
| 317 |
result = await client.call_tool(
|
| 318 |
"process_list", {"items": "[1, 2, 3, 4, 5]"}
|
| 319 |
)
|
| 320 |
-
assert
|
| 321 |
-
assert result[0].text == "15"
|
| 322 |
|
| 323 |
async def test_tool_list_coercion_error(self):
|
| 324 |
"""Test that a list coercion error is raised if the input is not a valid list."""
|
|
@@ -348,8 +333,7 @@ class TestLegacyToolJsonParsing:
|
|
| 348 |
result = await client.call_tool(
|
| 349 |
"process_dict", {"data": '{"a": 1, "b": "2", "c": 3}'}
|
| 350 |
)
|
| 351 |
-
assert
|
| 352 |
-
assert result[0].text == "6"
|
| 353 |
|
| 354 |
async def test_tool_set_coercion(self):
|
| 355 |
"""Test JSON string to set type coercion."""
|
|
@@ -362,8 +346,7 @@ class TestLegacyToolJsonParsing:
|
|
| 362 |
|
| 363 |
async with Client(mcp) as client:
|
| 364 |
result = await client.call_tool("process_set", {"items": "[1, 2, 3, 4, 5]"})
|
| 365 |
-
assert
|
| 366 |
-
assert result[0].text == "15"
|
| 367 |
|
| 368 |
async def test_tool_tuple_coercion(self):
|
| 369 |
"""Test JSON string to tuple type coercion."""
|
|
@@ -376,5 +359,4 @@ class TestLegacyToolJsonParsing:
|
|
| 376 |
|
| 377 |
async with Client(mcp) as client:
|
| 378 |
result = await client.call_tool("process_tuple", {"items": '["1", "two"]'})
|
| 379 |
-
assert
|
| 380 |
-
assert result[0].text == "4"
|
|
|
|
| 1 |
import pytest
|
| 2 |
+
from mcp.types import ImageContent
|
| 3 |
from pydantic import BaseModel
|
| 4 |
|
| 5 |
from fastmcp import FastMCP, Image
|
|
|
|
| 209 |
|
| 210 |
# Run the tool which will do JSON parsing
|
| 211 |
result = await tool.run(json_args)
|
| 212 |
+
assert result[0].text == "1-a,b,c" # type: ignore[attr-dict]
|
|
|
|
|
|
|
| 213 |
|
| 214 |
async def test_str_vs_list_str(self):
|
| 215 |
"""Test handling of string vs list[str] type annotations."""
|
|
|
|
| 221 |
|
| 222 |
# Test regular string input (should remain a string)
|
| 223 |
result = await tool.run({"str_or_list": "hello"})
|
| 224 |
+
assert result[0].text == "hello" # type: ignore[attr-dict]
|
|
|
|
|
|
|
| 225 |
|
| 226 |
# Test JSON string input (should be parsed as a string)
|
| 227 |
result = await tool.run({"str_or_list": '"hello"'})
|
| 228 |
+
assert result[0].text == "hello" # type: ignore[attr-dict]
|
|
|
|
|
|
|
| 229 |
|
| 230 |
# Test JSON list input (should be parsed as a list)
|
| 231 |
result = await tool.run({"str_or_list": '["hello", "world"]'})
|
|
|
|
|
|
|
| 232 |
|
| 233 |
# The exact formatting might vary, so we just check that it contains the key elements
|
| 234 |
+
text_without_whitespace = result[0].text.replace(" ", "").replace("\n", "") # type: ignore[attr-dict]
|
| 235 |
assert "hello" in text_without_whitespace
|
| 236 |
assert "world" in text_without_whitespace
|
| 237 |
assert "[" in text_without_whitespace
|
|
|
|
| 248 |
# Invalid JSON should remain a string
|
| 249 |
invalid_json = "{'nice to meet you': 'hello', 'goodbye': 5}"
|
| 250 |
result = await tool.run({"string": invalid_json})
|
| 251 |
+
assert result[0].text == invalid_json # type: ignore[attr-dict]
|
|
|
|
|
|
|
| 252 |
|
| 253 |
async def test_keep_str_union_as_str(self):
|
| 254 |
"""Test that string arguments are kept as strings when parsing would create an invalid value"""
|
|
|
|
| 263 |
# Invalid JSON for the union type should remain a string
|
| 264 |
invalid_json = "{'nice to meet you': 'hello', 'goodbye': 5}"
|
| 265 |
result = await tool.run({"string": invalid_json})
|
| 266 |
+
assert result[0].text == invalid_json # type: ignore[attr-dict]
|
|
|
|
|
|
|
| 267 |
|
| 268 |
async def test_complex_type_validation(self):
|
| 269 |
"""Test that parsed JSON is validated against complex types"""
|
|
|
|
| 280 |
# Valid JSON for the model
|
| 281 |
valid_json = '{"x": 1, "y": {"1": "hello"}}'
|
| 282 |
result = await tool.run({"data": valid_json})
|
| 283 |
+
assert '"x": 1' in result[0].text # type: ignore[attr-dict]
|
| 284 |
+
assert '"y": {' in result[0].text # type: ignore[attr-dict]
|
| 285 |
+
assert '"1": "hello"' in result[0].text # type: ignore[attr-dict]
|
|
|
|
|
|
|
| 286 |
|
| 287 |
# Invalid JSON for the model (y has string keys, not int keys)
|
| 288 |
# Should throw a validation error
|
|
|
|
| 303 |
result = await client.call_tool(
|
| 304 |
"process_list", {"items": "[1, 2, 3, 4, 5]"}
|
| 305 |
)
|
| 306 |
+
assert result[0].text == "15" # type: ignore[attr-dict]
|
|
|
|
| 307 |
|
| 308 |
async def test_tool_list_coercion_error(self):
|
| 309 |
"""Test that a list coercion error is raised if the input is not a valid list."""
|
|
|
|
| 333 |
result = await client.call_tool(
|
| 334 |
"process_dict", {"data": '{"a": 1, "b": "2", "c": 3}'}
|
| 335 |
)
|
| 336 |
+
assert result[0].text == "6" # type: ignore[attr-dict]
|
|
|
|
| 337 |
|
| 338 |
async def test_tool_set_coercion(self):
|
| 339 |
"""Test JSON string to set type coercion."""
|
|
|
|
| 346 |
|
| 347 |
async with Client(mcp) as client:
|
| 348 |
result = await client.call_tool("process_set", {"items": "[1, 2, 3, 4, 5]"})
|
| 349 |
+
assert result[0].text == "15" # type: ignore[attr-dict]
|
|
|
|
| 350 |
|
| 351 |
async def test_tool_tuple_coercion(self):
|
| 352 |
"""Test JSON string to tuple type coercion."""
|
|
|
|
| 359 |
|
| 360 |
async with Client(mcp) as client:
|
| 361 |
result = await client.call_tool("process_tuple", {"items": '["1", "two"]'})
|
| 362 |
+
assert result[0].text == "4" # type: ignore[attr-dict]
|
|
|
tests/tools/test_tool_manager.py
CHANGED
|
@@ -5,7 +5,7 @@ from typing import Annotated, Any
|
|
| 5 |
|
| 6 |
import pydantic_core
|
| 7 |
import pytest
|
| 8 |
-
from mcp.types import ImageContent
|
| 9 |
from pydantic import BaseModel
|
| 10 |
|
| 11 |
from fastmcp import Context, FastMCP, Image
|
|
@@ -318,13 +318,8 @@ class TestCallTools:
|
|
| 318 |
manager = ToolManager()
|
| 319 |
manager.add_tool_from_fn(add)
|
| 320 |
result = await manager.call_tool("add", {"a": 1, "b": 2})
|
| 321 |
-
assert isinstance(result, list)
|
| 322 |
-
assert len(result) == 1
|
| 323 |
-
from mcp.types import TextContent
|
| 324 |
|
| 325 |
-
assert
|
| 326 |
-
assert result[0].text == "3"
|
| 327 |
-
assert json.loads(result[0].text) == 3
|
| 328 |
|
| 329 |
async def test_call_async_tool(self):
|
| 330 |
async def double(n: int) -> int:
|
|
@@ -334,12 +329,7 @@ class TestCallTools:
|
|
| 334 |
manager = ToolManager()
|
| 335 |
manager.add_tool_from_fn(double)
|
| 336 |
result = await manager.call_tool("double", {"n": 5})
|
| 337 |
-
assert
|
| 338 |
-
assert len(result) == 1
|
| 339 |
-
|
| 340 |
-
assert isinstance(result[0], TextContent)
|
| 341 |
-
assert result[0].text == "10"
|
| 342 |
-
assert json.loads(result[0].text) == 10
|
| 343 |
|
| 344 |
async def test_call_tool_callable_object(self):
|
| 345 |
class Adder:
|
|
@@ -352,11 +342,7 @@ class TestCallTools:
|
|
| 352 |
manager = ToolManager()
|
| 353 |
manager.add_tool_from_fn(Adder())
|
| 354 |
result = await manager.call_tool("Adder", {"x": 1, "y": 2})
|
| 355 |
-
assert
|
| 356 |
-
assert len(result) == 1
|
| 357 |
-
assert isinstance(result[0], TextContent)
|
| 358 |
-
assert result[0].text == "3"
|
| 359 |
-
assert json.loads(result[0].text) == 3
|
| 360 |
|
| 361 |
async def test_call_tool_callable_object_async(self):
|
| 362 |
class Adder:
|
|
@@ -369,11 +355,7 @@ class TestCallTools:
|
|
| 369 |
manager = ToolManager()
|
| 370 |
manager.add_tool_from_fn(Adder())
|
| 371 |
result = await manager.call_tool("Adder", {"x": 1, "y": 2})
|
| 372 |
-
assert
|
| 373 |
-
assert len(result) == 1
|
| 374 |
-
assert isinstance(result[0], TextContent)
|
| 375 |
-
assert result[0].text == "3"
|
| 376 |
-
assert json.loads(result[0].text) == 3
|
| 377 |
|
| 378 |
async def test_call_tool_with_default_args(self):
|
| 379 |
def add(a: int, b: int = 1) -> int:
|
|
@@ -383,12 +365,8 @@ class TestCallTools:
|
|
| 383 |
manager = ToolManager()
|
| 384 |
manager.add_tool_from_fn(add)
|
| 385 |
result = await manager.call_tool("add", {"a": 1})
|
| 386 |
-
assert isinstance(result, list)
|
| 387 |
-
assert len(result) == 1
|
| 388 |
|
| 389 |
-
assert
|
| 390 |
-
assert result[0].text == "2"
|
| 391 |
-
assert json.loads(result[0].text) == 2
|
| 392 |
|
| 393 |
async def test_call_tool_with_missing_args(self):
|
| 394 |
def add(a: int, b: int) -> int:
|
|
@@ -413,11 +391,7 @@ class TestCallTools:
|
|
| 413 |
manager.add_tool_from_fn(sum_vals)
|
| 414 |
|
| 415 |
result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]})
|
| 416 |
-
assert
|
| 417 |
-
assert len(result) == 1
|
| 418 |
-
assert isinstance(result[0], TextContent)
|
| 419 |
-
assert result[0].text == "6"
|
| 420 |
-
assert json.loads(result[0].text) == 6
|
| 421 |
|
| 422 |
async def test_call_tool_with_list_int_input_legacy_behavior(self):
|
| 423 |
"""Legacy behavior -- parse a stringified JSON object"""
|
|
@@ -431,11 +405,7 @@ class TestCallTools:
|
|
| 431 |
|
| 432 |
with temporary_settings(tool_attempt_parse_json_args=True):
|
| 433 |
result = await manager.call_tool("sum_vals", {"vals": "[1, 2, 3]"})
|
| 434 |
-
assert
|
| 435 |
-
assert len(result) == 1
|
| 436 |
-
assert isinstance(result[0], TextContent)
|
| 437 |
-
assert result[0].text == "6"
|
| 438 |
-
assert json.loads(result[0].text) == 6
|
| 439 |
|
| 440 |
async def test_call_tool_with_list_str_or_str_input(self):
|
| 441 |
def concat_strs(vals: list[str] | str) -> str:
|
|
@@ -446,16 +416,10 @@ class TestCallTools:
|
|
| 446 |
|
| 447 |
# Try both with plain python object and with JSON list
|
| 448 |
result = await manager.call_tool("concat_strs", {"vals": ["a", "b", "c"]})
|
| 449 |
-
assert
|
| 450 |
-
assert len(result) == 1
|
| 451 |
-
assert isinstance(result[0], TextContent)
|
| 452 |
-
assert result[0].text == "abc"
|
| 453 |
|
| 454 |
result = await manager.call_tool("concat_strs", {"vals": "a"})
|
| 455 |
-
assert
|
| 456 |
-
assert len(result) == 1
|
| 457 |
-
assert isinstance(result[0], TextContent)
|
| 458 |
-
assert result[0].text == "a"
|
| 459 |
|
| 460 |
async def test_call_tool_with_list_str_or_str_input_legacy_behavior(self):
|
| 461 |
"""Legacy behavior -- parse a stringified JSON object"""
|
|
@@ -468,16 +432,10 @@ class TestCallTools:
|
|
| 468 |
|
| 469 |
with temporary_settings(tool_attempt_parse_json_args=True):
|
| 470 |
result = await manager.call_tool("concat_strs", {"vals": '["a", "b", "c"]'})
|
| 471 |
-
assert
|
| 472 |
-
assert len(result) == 1
|
| 473 |
-
assert isinstance(result[0], TextContent)
|
| 474 |
-
assert result[0].text == "abc"
|
| 475 |
|
| 476 |
result = await manager.call_tool("concat_strs", {"vals": '"a"'})
|
| 477 |
-
assert
|
| 478 |
-
assert len(result) == 1
|
| 479 |
-
assert isinstance(result[0], TextContent)
|
| 480 |
-
assert result[0].text == "a"
|
| 481 |
|
| 482 |
async def test_call_tool_with_complex_model(self):
|
| 483 |
class MyShrimpTank(BaseModel):
|
|
@@ -507,10 +465,7 @@ class TestCallTools:
|
|
| 507 |
},
|
| 508 |
)
|
| 509 |
|
| 510 |
-
assert
|
| 511 |
-
assert len(result) == 1
|
| 512 |
-
assert isinstance(result[0], TextContent)
|
| 513 |
-
assert result[0].text == '[\n "rex",\n "gertrude"\n]'
|
| 514 |
|
| 515 |
async def test_call_tool_with_custom_serializer(self):
|
| 516 |
"""Test that a custom serializer provided to FastMCP is used by tools."""
|
|
@@ -530,10 +485,7 @@ class TestCallTools:
|
|
| 530 |
manager.add_tool_from_fn(get_data)
|
| 531 |
|
| 532 |
result = await manager.call_tool("get_data", {})
|
| 533 |
-
assert
|
| 534 |
-
assert len(result) == 1
|
| 535 |
-
assert isinstance(result[0], TextContent)
|
| 536 |
-
assert result[0].text == 'CUSTOM:{"key": "value", "number": 123}'
|
| 537 |
|
| 538 |
async def test_call_tool_with_list_result_custom_serializer(self):
|
| 539 |
"""Test that a custom serializer provided to FastMCP is used by tools that return lists."""
|
|
@@ -555,12 +507,9 @@ class TestCallTools:
|
|
| 555 |
manager.add_tool_from_fn(get_data)
|
| 556 |
|
| 557 |
result = await manager.call_tool("get_data", {})
|
| 558 |
-
assert isinstance(result, list)
|
| 559 |
-
assert len(result) == 1
|
| 560 |
-
assert isinstance(result[0], TextContent)
|
| 561 |
assert (
|
| 562 |
-
result[0].text
|
| 563 |
-
== 'CUSTOM:[{"key": "value", "number": 123}, {"key": "value2", "number": 456}]'
|
| 564 |
)
|
| 565 |
|
| 566 |
async def test_custom_serializer_fallback_on_error(self):
|
|
@@ -580,10 +529,7 @@ class TestCallTools:
|
|
| 580 |
manager.add_tool_from_fn(get_data)
|
| 581 |
|
| 582 |
result = await manager.call_tool("get_data", {})
|
| 583 |
-
assert
|
| 584 |
-
assert len(result) == 1
|
| 585 |
-
assert isinstance(result[0], TextContent)
|
| 586 |
-
assert result[0].text == pydantic_core.to_json(uuid_result).decode()
|
| 587 |
|
| 588 |
|
| 589 |
class TestToolSchema:
|
|
@@ -648,10 +594,7 @@ class TestContextHandling:
|
|
| 648 |
|
| 649 |
with context:
|
| 650 |
result = await manager.call_tool("tool_with_context", {"x": 42})
|
| 651 |
-
assert
|
| 652 |
-
assert len(result) == 1
|
| 653 |
-
assert isinstance(result[0], TextContent)
|
| 654 |
-
assert result[0].text == "42"
|
| 655 |
|
| 656 |
async def test_context_injection_async(self):
|
| 657 |
"""Test that context is properly injected in async tools."""
|
|
@@ -668,14 +611,10 @@ class TestContextHandling:
|
|
| 668 |
|
| 669 |
with context:
|
| 670 |
result = await manager.call_tool("async_tool", {"x": 42})
|
| 671 |
-
assert
|
| 672 |
-
assert len(result) == 1
|
| 673 |
-
assert isinstance(result[0], TextContent)
|
| 674 |
-
assert result[0].text == "42"
|
| 675 |
|
| 676 |
async def test_context_optional(self):
|
| 677 |
"""Test that context is optional when calling tools."""
|
| 678 |
-
from mcp.types import TextContent
|
| 679 |
|
| 680 |
def tool_with_context(x: int, ctx: Context | None) -> int:
|
| 681 |
return x
|
|
@@ -689,10 +628,7 @@ class TestContextHandling:
|
|
| 689 |
|
| 690 |
with context:
|
| 691 |
result = await manager.call_tool("tool_with_context", {"x": 42})
|
| 692 |
-
assert
|
| 693 |
-
assert len(result) == 1
|
| 694 |
-
assert isinstance(result[0], TextContent)
|
| 695 |
-
assert result[0].text == "42"
|
| 696 |
|
| 697 |
def test_parameterized_context_parameter_detection(self):
|
| 698 |
"""Test that context parameters are properly detected in
|
|
@@ -782,7 +718,6 @@ class TestCustomToolNames:
|
|
| 782 |
|
| 783 |
async def test_call_tool_with_custom_name(self):
|
| 784 |
"""Test calling a tool added with a custom name."""
|
| 785 |
-
from mcp.types import TextContent
|
| 786 |
|
| 787 |
def multiply(a: int, b: int) -> int:
|
| 788 |
"""Multiply two numbers."""
|
|
@@ -793,11 +728,7 @@ class TestCustomToolNames:
|
|
| 793 |
|
| 794 |
# Tool should be callable by its custom name
|
| 795 |
result = await manager.call_tool("custom_multiply", {"a": 5, "b": 3})
|
| 796 |
-
assert
|
| 797 |
-
assert len(result) == 1
|
| 798 |
-
assert isinstance(result[0], TextContent)
|
| 799 |
-
assert result[0].text == "15"
|
| 800 |
-
assert json.loads(result[0].text) == 15
|
| 801 |
|
| 802 |
# Original name should not be registered
|
| 803 |
with pytest.raises(NotFoundError, match="Unknown tool: multiply"):
|
|
|
|
| 5 |
|
| 6 |
import pydantic_core
|
| 7 |
import pytest
|
| 8 |
+
from mcp.types import ImageContent
|
| 9 |
from pydantic import BaseModel
|
| 10 |
|
| 11 |
from fastmcp import Context, FastMCP, Image
|
|
|
|
| 318 |
manager = ToolManager()
|
| 319 |
manager.add_tool_from_fn(add)
|
| 320 |
result = await manager.call_tool("add", {"a": 1, "b": 2})
|
|
|
|
|
|
|
|
|
|
| 321 |
|
| 322 |
+
assert result[0].text == "3" # type: ignore[attr-defined]
|
|
|
|
|
|
|
| 323 |
|
| 324 |
async def test_call_async_tool(self):
|
| 325 |
async def double(n: int) -> int:
|
|
|
|
| 329 |
manager = ToolManager()
|
| 330 |
manager.add_tool_from_fn(double)
|
| 331 |
result = await manager.call_tool("double", {"n": 5})
|
| 332 |
+
assert result[0].text == "10" # type: ignore[attr-defined]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
|
| 334 |
async def test_call_tool_callable_object(self):
|
| 335 |
class Adder:
|
|
|
|
| 342 |
manager = ToolManager()
|
| 343 |
manager.add_tool_from_fn(Adder())
|
| 344 |
result = await manager.call_tool("Adder", {"x": 1, "y": 2})
|
| 345 |
+
assert result[0].text == "3" # type: ignore[attr-defined]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
|
| 347 |
async def test_call_tool_callable_object_async(self):
|
| 348 |
class Adder:
|
|
|
|
| 355 |
manager = ToolManager()
|
| 356 |
manager.add_tool_from_fn(Adder())
|
| 357 |
result = await manager.call_tool("Adder", {"x": 1, "y": 2})
|
| 358 |
+
assert result[0].text == "3" # type: ignore[attr-defined]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
|
| 360 |
async def test_call_tool_with_default_args(self):
|
| 361 |
def add(a: int, b: int = 1) -> int:
|
|
|
|
| 365 |
manager = ToolManager()
|
| 366 |
manager.add_tool_from_fn(add)
|
| 367 |
result = await manager.call_tool("add", {"a": 1})
|
|
|
|
|
|
|
| 368 |
|
| 369 |
+
assert result[0].text == "2" # type: ignore[attr-defined]
|
|
|
|
|
|
|
| 370 |
|
| 371 |
async def test_call_tool_with_missing_args(self):
|
| 372 |
def add(a: int, b: int) -> int:
|
|
|
|
| 391 |
manager.add_tool_from_fn(sum_vals)
|
| 392 |
|
| 393 |
result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]})
|
| 394 |
+
assert result[0].text == "6" # type: ignore[attr-defined]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 395 |
|
| 396 |
async def test_call_tool_with_list_int_input_legacy_behavior(self):
|
| 397 |
"""Legacy behavior -- parse a stringified JSON object"""
|
|
|
|
| 405 |
|
| 406 |
with temporary_settings(tool_attempt_parse_json_args=True):
|
| 407 |
result = await manager.call_tool("sum_vals", {"vals": "[1, 2, 3]"})
|
| 408 |
+
assert result[0].text == "6" # type: ignore[attr-defined]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
|
| 410 |
async def test_call_tool_with_list_str_or_str_input(self):
|
| 411 |
def concat_strs(vals: list[str] | str) -> str:
|
|
|
|
| 416 |
|
| 417 |
# Try both with plain python object and with JSON list
|
| 418 |
result = await manager.call_tool("concat_strs", {"vals": ["a", "b", "c"]})
|
| 419 |
+
assert result[0].text == "abc" # type: ignore[attr-defined]
|
|
|
|
|
|
|
|
|
|
| 420 |
|
| 421 |
result = await manager.call_tool("concat_strs", {"vals": "a"})
|
| 422 |
+
assert result[0].text == "a" # type: ignore[attr-defined]
|
|
|
|
|
|
|
|
|
|
| 423 |
|
| 424 |
async def test_call_tool_with_list_str_or_str_input_legacy_behavior(self):
|
| 425 |
"""Legacy behavior -- parse a stringified JSON object"""
|
|
|
|
| 432 |
|
| 433 |
with temporary_settings(tool_attempt_parse_json_args=True):
|
| 434 |
result = await manager.call_tool("concat_strs", {"vals": '["a", "b", "c"]'})
|
| 435 |
+
assert result[0].text == "abc" # type: ignore[attr-defined]
|
|
|
|
|
|
|
|
|
|
| 436 |
|
| 437 |
result = await manager.call_tool("concat_strs", {"vals": '"a"'})
|
| 438 |
+
assert result[0].text == "a" # type: ignore[attr-defined]
|
|
|
|
|
|
|
|
|
|
| 439 |
|
| 440 |
async def test_call_tool_with_complex_model(self):
|
| 441 |
class MyShrimpTank(BaseModel):
|
|
|
|
| 465 |
},
|
| 466 |
)
|
| 467 |
|
| 468 |
+
assert result[0].text == '[\n "rex",\n "gertrude"\n]' # type: ignore[attr-defined]
|
|
|
|
|
|
|
|
|
|
| 469 |
|
| 470 |
async def test_call_tool_with_custom_serializer(self):
|
| 471 |
"""Test that a custom serializer provided to FastMCP is used by tools."""
|
|
|
|
| 485 |
manager.add_tool_from_fn(get_data)
|
| 486 |
|
| 487 |
result = await manager.call_tool("get_data", {})
|
| 488 |
+
assert result[0].text == 'CUSTOM:{"key": "value", "number": 123}' # type: ignore[attr-defined]
|
|
|
|
|
|
|
|
|
|
| 489 |
|
| 490 |
async def test_call_tool_with_list_result_custom_serializer(self):
|
| 491 |
"""Test that a custom serializer provided to FastMCP is used by tools that return lists."""
|
|
|
|
| 507 |
manager.add_tool_from_fn(get_data)
|
| 508 |
|
| 509 |
result = await manager.call_tool("get_data", {})
|
|
|
|
|
|
|
|
|
|
| 510 |
assert (
|
| 511 |
+
result[0].text # type: ignore[attr-defined]
|
| 512 |
+
== 'CUSTOM:[{"key": "value", "number": 123}, {"key": "value2", "number": 456}]' # type: ignore[attr-defined]
|
| 513 |
)
|
| 514 |
|
| 515 |
async def test_custom_serializer_fallback_on_error(self):
|
|
|
|
| 529 |
manager.add_tool_from_fn(get_data)
|
| 530 |
|
| 531 |
result = await manager.call_tool("get_data", {})
|
| 532 |
+
assert result[0].text == pydantic_core.to_json(uuid_result).decode() # type: ignore[attr-defined]
|
|
|
|
|
|
|
|
|
|
| 533 |
|
| 534 |
|
| 535 |
class TestToolSchema:
|
|
|
|
| 594 |
|
| 595 |
with context:
|
| 596 |
result = await manager.call_tool("tool_with_context", {"x": 42})
|
| 597 |
+
assert result[0].text == "42" # type: ignore[attr-defined]
|
|
|
|
|
|
|
|
|
|
| 598 |
|
| 599 |
async def test_context_injection_async(self):
|
| 600 |
"""Test that context is properly injected in async tools."""
|
|
|
|
| 611 |
|
| 612 |
with context:
|
| 613 |
result = await manager.call_tool("async_tool", {"x": 42})
|
| 614 |
+
assert result[0].text == "42" # type: ignore[attr-defined]
|
|
|
|
|
|
|
|
|
|
| 615 |
|
| 616 |
async def test_context_optional(self):
|
| 617 |
"""Test that context is optional when calling tools."""
|
|
|
|
| 618 |
|
| 619 |
def tool_with_context(x: int, ctx: Context | None) -> int:
|
| 620 |
return x
|
|
|
|
| 628 |
|
| 629 |
with context:
|
| 630 |
result = await manager.call_tool("tool_with_context", {"x": 42})
|
| 631 |
+
assert result[0].text == "42" # type: ignore[attr-defined]
|
|
|
|
|
|
|
|
|
|
| 632 |
|
| 633 |
def test_parameterized_context_parameter_detection(self):
|
| 634 |
"""Test that context parameters are properly detected in
|
|
|
|
| 718 |
|
| 719 |
async def test_call_tool_with_custom_name(self):
|
| 720 |
"""Test calling a tool added with a custom name."""
|
|
|
|
| 721 |
|
| 722 |
def multiply(a: int, b: int) -> int:
|
| 723 |
"""Multiply two numbers."""
|
|
|
|
| 728 |
|
| 729 |
# Tool should be callable by its custom name
|
| 730 |
result = await manager.call_tool("custom_multiply", {"a": 5, "b": 3})
|
| 731 |
+
assert result[0].text == "15" # type: ignore[attr-defined]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 732 |
|
| 733 |
# Original name should not be registered
|
| 734 |
with pytest.raises(NotFoundError, match="Unknown tool: multiply"):
|
tests/utilities/test_mcp_config.py
CHANGED
|
@@ -1,8 +1,6 @@
|
|
| 1 |
import inspect
|
| 2 |
from pathlib import Path
|
| 3 |
|
| 4 |
-
from mcp.types import TextContent
|
| 5 |
-
|
| 6 |
from fastmcp.client.client import Client
|
| 7 |
from fastmcp.client.transports import (
|
| 8 |
SSETransport,
|
|
@@ -136,7 +134,5 @@ async def test_multi_client(tmp_path: Path):
|
|
| 136 |
|
| 137 |
result_1 = await client.call_tool("test_1_add", {"a": 1, "b": 2})
|
| 138 |
result_2 = await client.call_tool("test_2_add", {"a": 1, "b": 2})
|
| 139 |
-
assert
|
| 140 |
-
assert
|
| 141 |
-
assert isinstance(result_2[0], TextContent)
|
| 142 |
-
assert result_2[0].text == "3"
|
|
|
|
| 1 |
import inspect
|
| 2 |
from pathlib import Path
|
| 3 |
|
|
|
|
|
|
|
| 4 |
from fastmcp.client.client import Client
|
| 5 |
from fastmcp.client.transports import (
|
| 6 |
SSETransport,
|
|
|
|
| 134 |
|
| 135 |
result_1 = await client.call_tool("test_1_add", {"a": 1, "b": 2})
|
| 136 |
result_2 = await client.call_tool("test_2_add", {"a": 1, "b": 2})
|
| 137 |
+
assert result_1[0].text == "3" # type: ignore[attr-dict]
|
| 138 |
+
assert result_2[0].text == "3" # type: ignore[attr-dict]
|
|
|
|
|
|