Merge branch 'config-dicts' of https://github.com/jlowin/fastmcp into config-dicts

This commit is contained in:
Jeremiah Lowin 2025-05-20 17:46:36 -04:00
commit 885f2fc4a1
14 changed files with 572 additions and 82 deletions

View file

@ -44,7 +44,7 @@ jobs:
python-version: ${{ matrix.python-version }}
- name: Install FastMCP
run: uv sync --dev --locked
run: uv sync --locked
- name: Run tests
run: uv run pytest
run: uv run pytest tests

View file

@ -61,19 +61,27 @@ Internally, FastMCP uses a priority-ordered set of `RouteMap` objects to determi
# Simplified version of the actual mapping rules
DEFAULT_ROUTE_MAPPINGS = [
# GET with path parameters -> ResourceTemplate
RouteMap(methods=["GET"], pattern=r".*\{.*\}.*",
route_type=RouteType.RESOURCE_TEMPLATE),
RouteMap(
methods=["GET"],
pattern=r".*\{.*\}.*",
route_type=RouteType.RESOURCE_TEMPLATE,
),
# GET without path parameters -> Resource
RouteMap(methods=["GET"], pattern=r".*",
route_type=RouteType.RESOURCE),
RouteMap(
methods=["GET"],
pattern=r".*",
route_type=RouteType.RESOURCE,
),
# All other methods -> Tool
RouteMap(methods=["POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
pattern=r".*", route_type=RouteType.TOOL),
RouteMap(
methods="*",
pattern=r".*",
route_type=RouteType.TOOL,
),
]
```
### Custom Route Maps
Users can add custom route maps to override the default mapping behavior. User-supplied route maps are always applied first, before the default route maps.
@ -97,6 +105,35 @@ mcp = await FastMCP.from_openapi(
)
```
### All Routes as Tools
When building AI agent backends, it's often useful to treat all routes as callable tools regardless of their HTTP method. You can use the `all_routes_as_tools` parameter to automatically map every route to a Tool:
```python
# Make all endpoints tools, regardless of HTTP method
mcp = FastMCP.from_openapi(
openapi_spec=spec,
client=api_client,
all_routes_as_tools=True
)
```
This is equivalent to defining a single route map that matches all routes:
```python
# Same effect as all_routes_as_tools=True
mcp = FastMCP.from_openapi(
openapi_spec=spec,
client=api_client,
route_maps=[
RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL)
]
)
```
Note that `all_routes_as_tools` and `route_maps` cannot be used together - if you need more complex mapping rules, use `route_maps` instead.
## How It Works
1. FastMCP parses your OpenAPI spec to extract routes and schemas

View file

@ -306,7 +306,29 @@ def create_streamable_http_app(
async def handle_streamable_http(
scope: Scope, receive: Receive, send: Send
) -> None:
await session_manager.handle_request(scope, receive, send)
try:
await session_manager.handle_request(scope, receive, send)
except RuntimeError as e:
if str(e) == "Task group is not initialized. Make sure to use run().":
logger.error(
f"Original RuntimeError from mcp library: {e}", exc_info=True
)
new_error_message = (
"FastMCP's StreamableHTTPSessionManager task group was not initialized. "
"This commonly occurs when the FastMCP application's lifespan is not "
"passed to the parent ASGI application (e.g., FastAPI or Starlette). "
"Please ensure you are setting `lifespan=mcp_app.lifespan` in your "
"parent app's constructor, where `mcp_app` is the application instance "
"returned by `fastmcp_instance.http_app()`. \\n"
"For more details, see the FastMCP ASGI integration documentation: "
"https://gofastmcp.com/deployment/asgi"
)
# Raise a new RuntimeError that includes the original error's message
# for full context, but leads with the more helpful guidance.
raise RuntimeError(f"{new_error_message}\\nOriginal error: {e}") from e
else:
# Re-raise other RuntimeErrors if they don't match the specific message
raise
# Get auth middleware and routes
auth_middleware, auth_routes, required_scopes = setup_auth_middleware_and_routes(

View file

@ -47,7 +47,7 @@ class RouteType(enum.Enum):
class RouteMap:
"""Mapping configuration for HTTP routes to FastMCP component types."""
methods: list[HttpMethod]
methods: list[HttpMethod] | Literal["*"]
pattern: Pattern[str] | str
route_type: RouteType
@ -86,7 +86,7 @@ def _determine_route_type(
# Check mappings in priority order (first match wins)
for route_map in mappings:
# Check if the HTTP method matches
if route.method in route_map.methods:
if route_map.methods == "*" or route.method in route_map.methods:
# Handle both string patterns and compiled Pattern objects
if isinstance(route_map.pattern, Pattern):
pattern_matches = route_map.pattern.search(route.path)

View file

@ -62,7 +62,7 @@ from fastmcp.utilities.logging import get_logger
if TYPE_CHECKING:
from fastmcp.client import Client
from fastmcp.client.transports import ClientTransport
from fastmcp.server.openapi import FastMCPOpenAPI
from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap
from fastmcp.server.proxy import FastMCPProxy
logger = get_logger(__name__)
@ -1082,24 +1082,59 @@ class FastMCP(Generic[LifespanResultT]):
@classmethod
def from_openapi(
cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, **settings: Any
cls,
openapi_spec: dict[str, Any],
client: httpx.AsyncClient,
route_maps: list[RouteMap] | None = None,
all_routes_as_tools: bool = False,
**settings: Any,
) -> FastMCPOpenAPI:
"""
Create a FastMCP server from an OpenAPI specification.
"""
from .openapi import FastMCPOpenAPI
from .openapi import FastMCPOpenAPI, RouteMap, RouteType
return FastMCPOpenAPI(openapi_spec=openapi_spec, client=client, **settings)
if all_routes_as_tools and route_maps:
raise ValueError("Cannot specify both all_routes_as_tools and route_maps")
elif all_routes_as_tools:
route_maps = [
RouteMap(
methods="*",
pattern=r".*",
route_type=RouteType.TOOL,
)
]
return FastMCPOpenAPI(
openapi_spec=openapi_spec,
client=client,
route_maps=route_maps,
**settings,
)
@classmethod
def from_fastapi(
cls, app: Any, name: str | None = None, **settings: Any
cls,
app: Any,
name: str | None = None,
route_maps: list[RouteMap] | None = None,
all_routes_as_tools: bool = False,
**settings: Any,
) -> FastMCPOpenAPI:
"""
Create a FastMCP server from a FastAPI application.
"""
from .openapi import FastMCPOpenAPI
from .openapi import FastMCPOpenAPI, RouteMap, RouteType
if all_routes_as_tools and route_maps:
raise ValueError("Cannot specify both all_routes_as_tools and route_maps")
elif all_routes_as_tools:
route_maps = [
RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL)
]
client = httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://fastapi"
@ -1108,7 +1143,11 @@ class FastMCP(Generic[LifespanResultT]):
name = name or app.title
return FastMCPOpenAPI(
openapi_spec=app.openapi(), client=client, name=name, **settings
openapi_spec=app.openapi(),
client=client,
name=name,
route_maps=route_maps,
**settings,
)
@classmethod

View file

@ -71,7 +71,7 @@ def _run_server(mcp_server: FastMCP, transport: Literal["sse"], port: int) -> No
@contextmanager
def run_server_in_process(
server_fn: Callable[[str, int], None], *args
server_fn: Callable[..., None], *args
) -> Generator[str, None, None]:
"""
Context manager that runs a Starlette app in a separate process and returns the
@ -109,7 +109,11 @@ def run_server_in_process(
yield f"http://{host}:{port}"
proc.kill()
proc.join(timeout=2)
proc.terminate()
proc.join(timeout=5)
if proc.is_alive():
raise RuntimeError("Server process failed to terminate")
# If it's still alive, then force kill it
proc.kill()
proc.join(timeout=2)
if proc.is_alive():
raise RuntimeError("Server process failed to terminate even after kill")

View file

@ -85,7 +85,6 @@ ERROR_TOOL_NAME = "error_tool"
NO_RETURN_TOOL_NAME = "no_return_tool"
@pytest.mark.asyncio
async def test_call_tool_bulk_single_success(bulk_caller_live: BulkToolCaller):
"""Test single successful call via call_tool_bulk using echo_tool."""
tool_arguments = [{"arg1": "value1"}]
@ -98,7 +97,6 @@ async def test_call_tool_bulk_single_success(bulk_caller_live: BulkToolCaller):
assert result == expected_result
@pytest.mark.asyncio
async def test_call_tool_bulk_multiple_success(bulk_caller_live: BulkToolCaller):
"""Test multiple successful calls via call_tool_bulk using echo_tool."""
tool_arguments = [{"arg1": "value1"}, {"arg1": "value2"}]
@ -110,7 +108,6 @@ async def test_call_tool_bulk_multiple_success(bulk_caller_live: BulkToolCaller)
assert results == expected_results
@pytest.mark.asyncio
async def test_call_tool_bulk_error_stops(bulk_caller_live: BulkToolCaller):
"""Test call_tool_bulk stops on first error using error_tool."""
tool_arguments = [{"arg1": "error_value"}, {"arg1": "value2"}]
@ -125,7 +122,6 @@ async def test_call_tool_bulk_error_stops(bulk_caller_live: BulkToolCaller):
assert result == expected_result
@pytest.mark.asyncio
async def test_call_tool_bulk_error_continues(bulk_caller_live: BulkToolCaller):
"""Test call_tool_bulk continues on error using error_tool and echo_tool."""
tool_arguments = [{"arg1": "error_value"}, {"arg1": "success_value"}]
@ -148,7 +144,6 @@ async def test_call_tool_bulk_error_continues(bulk_caller_live: BulkToolCaller):
assert success_result == expected_success_result
@pytest.mark.asyncio
async def test_call_tools_bulk_single_success(bulk_caller_live: BulkToolCaller):
"""Test single successful call via call_tools_bulk using echo_tool."""
tool_calls = [CallToolRequest(tool=ECHO_TOOL_NAME, arguments={"arg1": "value1"})]
@ -161,7 +156,6 @@ async def test_call_tools_bulk_single_success(bulk_caller_live: BulkToolCaller):
assert result == expected_result
@pytest.mark.asyncio
async def test_call_tools_bulk_multiple_success(bulk_caller_live: BulkToolCaller):
"""Test multiple successful calls via call_tools_bulk with different tools."""
tool_calls = [
@ -181,7 +175,6 @@ async def test_call_tools_bulk_multiple_success(bulk_caller_live: BulkToolCaller
assert results == expected_results
@pytest.mark.asyncio
async def test_call_tools_bulk_error_stops(bulk_caller_live: BulkToolCaller):
"""Test call_tools_bulk stops on first error using error_tool."""
tool_calls = [
@ -199,7 +192,6 @@ async def test_call_tools_bulk_error_stops(bulk_caller_live: BulkToolCaller):
assert result == expected_result
@pytest.mark.asyncio
async def test_call_tools_bulk_error_continues(bulk_caller_live: BulkToolCaller):
"""Test call_tools_bulk continues on error using error_tool and echo_tool."""
tool_calls = [

View file

@ -341,7 +341,6 @@ async def tokens(test_client, registered_client, auth_code, pkce_challenge, requ
class TestAuthEndpoints:
@pytest.mark.anyio
async def test_metadata_endpoint(self, test_client: httpx.AsyncClient):
"""Test the OAuth 2.0 metadata endpoint."""
print("Sending request to metadata endpoint")
@ -370,7 +369,6 @@ class TestAuthEndpoints:
]
assert metadata["service_documentation"] == "https://docs.example.com/"
@pytest.mark.anyio
async def test_token_validation_error(self, test_client: httpx.AsyncClient):
"""Test token endpoint error - validation error."""
# Missing required fields
@ -387,7 +385,6 @@ class TestAuthEndpoints:
"error_description" in error_response
) # Contains validation error messages
@pytest.mark.anyio
async def test_token_invalid_auth_code(
self, test_client, registered_client, pkce_challenge
):
@ -414,7 +411,6 @@ class TestAuthEndpoints:
"authorization code does not exist" in error_response["error_description"]
)
@pytest.mark.anyio
async def test_token_expired_auth_code(
self,
test_client,
@ -459,7 +455,6 @@ class TestAuthEndpoints:
"authorization code has expired" in error_response["error_description"]
)
@pytest.mark.anyio
@pytest.mark.parametrize(
"registered_client",
[
@ -494,7 +489,6 @@ class TestAuthEndpoints:
assert error_response["error"] == "invalid_request"
assert "redirect_uri did not match" in error_response["error_description"]
@pytest.mark.anyio
async def test_token_code_verifier_mismatch(
self, test_client, registered_client, auth_code
):
@ -517,7 +511,6 @@ class TestAuthEndpoints:
assert error_response["error"] == "invalid_grant"
assert "incorrect code_verifier" in error_response["error_description"]
@pytest.mark.anyio
async def test_token_invalid_refresh_token(self, test_client, registered_client):
"""Test token endpoint error - refresh token does not exist."""
# Try to use a non-existent refresh token
@ -535,7 +528,6 @@ class TestAuthEndpoints:
assert error_response["error"] == "invalid_grant"
assert "refresh token does not exist" in error_response["error_description"]
@pytest.mark.anyio
async def test_token_expired_refresh_token(
self,
test_client,
@ -586,7 +578,6 @@ class TestAuthEndpoints:
assert error_response["error"] == "invalid_grant"
assert "refresh token has expired" in error_response["error_description"]
@pytest.mark.anyio
async def test_token_invalid_scope(
self, test_client, registered_client, auth_code, pkce_challenge
):
@ -624,7 +615,6 @@ class TestAuthEndpoints:
assert error_response["error"] == "invalid_scope"
assert "cannot request scope" in error_response["error_description"]
@pytest.mark.anyio
async def test_client_registration(
self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider
):
@ -652,7 +642,6 @@ class TestAuthEndpoints:
# client_info["client_id"]
# ) is not None
@pytest.mark.anyio
async def test_client_registration_missing_required_fields(
self, test_client: httpx.AsyncClient
):
@ -673,7 +662,6 @@ class TestAuthEndpoints:
assert error_data["error"] == "invalid_client_metadata"
assert error_data["error_description"] == "redirect_uris: Field required"
@pytest.mark.anyio
async def test_client_registration_invalid_uri(
self, test_client: httpx.AsyncClient
):
@ -696,7 +684,6 @@ class TestAuthEndpoints:
"redirect_uris.0: Input should be a valid URL, relative URL without a base"
)
@pytest.mark.anyio
async def test_client_registration_empty_redirect_uris(
self, test_client: httpx.AsyncClient
):
@ -719,7 +706,6 @@ class TestAuthEndpoints:
== "redirect_uris: List should have at least 1 item after validation, not 0"
)
@pytest.mark.anyio
async def test_authorize_form_post(
self,
test_client: httpx.AsyncClient,
@ -763,7 +749,6 @@ class TestAuthEndpoints:
assert "code" in query_params
assert query_params["state"][0] == "test_form_state"
@pytest.mark.anyio
async def test_authorization_get(
self,
test_client: httpx.AsyncClient,
@ -878,7 +863,6 @@ class TestAuthEndpoints:
is None
)
@pytest.mark.anyio
async def test_revoke_invalid_token(self, test_client, registered_client):
"""Test revoking an invalid token."""
response = await test_client.post(
@ -892,7 +876,6 @@ class TestAuthEndpoints:
# per RFC, this should return 200 even if the token is invalid
assert response.status_code == 200
@pytest.mark.anyio
async def test_revoke_with_malformed_token(self, test_client, registered_client):
response = await test_client.post(
"/revoke",
@ -908,7 +891,6 @@ class TestAuthEndpoints:
assert error_response["error"] == "invalid_request"
assert "token_type_hint" in error_response["error_description"]
@pytest.mark.anyio
async def test_client_registration_disallowed_scopes(
self, test_client: httpx.AsyncClient
):
@ -930,7 +912,6 @@ class TestAuthEndpoints:
assert "scope" in error_data["error_description"]
assert "admin" in error_data["error_description"]
@pytest.mark.anyio
async def test_client_registration_default_scopes(
self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider
):
@ -959,7 +940,6 @@ class TestAuthEndpoints:
# Check that default scopes were applied
assert registered_client.scope == "read write"
@pytest.mark.anyio
async def test_client_registration_invalid_grant_type(
self, test_client: httpx.AsyncClient
):
@ -986,7 +966,6 @@ class TestAuthEndpoints:
class TestAuthorizeEndpointErrors:
"""Test error handling in the OAuth authorization endpoint."""
@pytest.mark.anyio
async def test_authorize_missing_client_id(
self, test_client: httpx.AsyncClient, pkce_challenge
):
@ -1012,7 +991,6 @@ class TestAuthorizeEndpointErrors:
# The response should include an error message about missing client_id
assert "client_id" in response.text.lower()
@pytest.mark.anyio
async def test_authorize_invalid_client_id(
self, test_client: httpx.AsyncClient, pkce_challenge
):
@ -1038,7 +1016,6 @@ class TestAuthorizeEndpointErrors:
# The response should include an error message about invalid client_id
assert "client" in response.text.lower()
@pytest.mark.anyio
async def test_authorize_missing_redirect_uri(
self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
):
@ -1064,7 +1041,6 @@ class TestAuthorizeEndpointErrors:
redirect_url = response.headers["location"]
assert redirect_url.startswith("https://client.example.com/callback")
@pytest.mark.anyio
async def test_authorize_invalid_redirect_uri(
self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
):
@ -1092,7 +1068,6 @@ class TestAuthorizeEndpointErrors:
# The response should include an error message about redirect_uri mismatch
assert "redirect" in response.text.lower()
@pytest.mark.anyio
@pytest.mark.parametrize(
"registered_client",
[
@ -1130,7 +1105,6 @@ class TestAuthorizeEndpointErrors:
# The response should include an error message about missing redirect_uri
assert "redirect_uri" in response.text.lower()
@pytest.mark.anyio
async def test_authorize_unsupported_response_type(
self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
):
@ -1164,7 +1138,6 @@ class TestAuthorizeEndpointErrors:
assert "state" in query_params
assert query_params["state"][0] == "test_state"
@pytest.mark.anyio
async def test_authorize_missing_response_type(
self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
):
@ -1197,7 +1170,6 @@ class TestAuthorizeEndpointErrors:
assert "state" in query_params
assert query_params["state"][0] == "test_state"
@pytest.mark.anyio
async def test_authorize_missing_pkce_challenge(
self, test_client: httpx.AsyncClient, registered_client
):
@ -1228,7 +1200,6 @@ class TestAuthorizeEndpointErrors:
assert "state" in query_params
assert query_params["state"][0] == "test_state"
@pytest.mark.anyio
async def test_authorize_invalid_scope(
self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
):

View file

@ -4,7 +4,6 @@ from collections.abc import Callable
from typing import Any
import httpx
import pytest
from httpx import ASGITransport
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
@ -51,7 +50,6 @@ async def endpoint_handler(request: Request):
return JSONResponse({"message": "Hello, world!"})
@pytest.mark.asyncio
async def test_sse_app_with_custom_middleware():
"""Test that custom middleware works with SSE app."""
server = FastMCP(name="TestServer")
@ -82,7 +80,6 @@ async def test_sse_app_with_custom_middleware():
assert response.headers["X-Custom-Header"] == "test-value"
@pytest.mark.asyncio
async def test_streamable_http_app_with_custom_middleware():
"""Test that custom middleware works with StreamableHTTP app."""
server = FastMCP(name="TestServer")
@ -113,7 +110,6 @@ async def test_streamable_http_app_with_custom_middleware():
assert response.headers["X-Custom-Header"] == "test-value"
@pytest.mark.asyncio
async def test_create_sse_app_with_custom_middleware():
"""Test that custom middleware works with create_sse_app function."""
server = FastMCP(name="TestServer")
@ -149,7 +145,6 @@ async def test_create_sse_app_with_custom_middleware():
assert data["state"]["modified_by"] == "middleware"
@pytest.mark.asyncio
async def test_create_streamable_http_app_with_custom_middleware():
"""Test that custom middleware works with create_streamable_http_app function."""
server = FastMCP(name="TestServer")
@ -184,7 +179,6 @@ async def test_create_streamable_http_app_with_custom_middleware():
assert data["state"]["modified_by"] == "middleware"
@pytest.mark.asyncio
async def test_multiple_middleware_ordering():
"""Test that multiple middleware are applied in the correct order."""
server = FastMCP(name="TestServer")

View file

@ -1,10 +1,15 @@
"""Tests for lifespan functionality in both low-level and FastMCP servers."""
import os
import sys
import traceback
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
import anyio
import pytest
import httpx
import uvicorn
from mcp.server.lowlevel.server import NotificationOptions, Server
from mcp.server.models import InitializationOptions
from mcp.shared.message import SessionMessage
@ -17,11 +22,13 @@ from mcp.types import (
JSONRPCRequest,
)
from pydantic import TypeAdapter
from starlette.applications import Starlette
from starlette.routing import Mount
from fastmcp import Context, FastMCP
from fastmcp.utilities.tests import run_server_in_process
@pytest.mark.anyio
async def test_lowlevel_server_lifespan():
"""Test that lifespan works in low-level server."""
@ -132,7 +139,6 @@ async def test_lowlevel_server_lifespan():
tg.cancel_scope.cancel()
@pytest.mark.anyio
async def test_fastmcp_server_lifespan():
"""Test that lifespan works in FastMCP server."""
@ -234,3 +240,157 @@ async def test_fastmcp_server_lifespan():
# Cancel server task
tg.cancel_scope.cancel()
def run_server_with_incorrect_lifespan_setup(
host: str, port: int, server_log_file_path: str
) -> None:
os.makedirs(os.path.dirname(server_log_file_path), exist_ok=True)
CUSTOM_LOGGING_CONFIG = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {
"()": "uvicorn.logging.DefaultFormatter",
"fmt": "%(levelprefix)s %(asctime)s [%(name)s] %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
"use_colors": False,
},
"access": {
"()": "uvicorn.logging.AccessFormatter",
"fmt": '%(levelprefix)s %(asctime)s [%(name)s] %(client_addr)s - "%(request_line)s" %(status_code)s',
"datefmt": "%Y-%m-%d %H:%M:%S",
"use_colors": False,
},
},
"handlers": {
"file_default": {
"formatter": "default",
"class": "logging.FileHandler",
"filename": server_log_file_path,
"mode": "w",
},
"file_access": {
"formatter": "access",
"class": "logging.FileHandler",
"filename": server_log_file_path,
"mode": "a",
},
},
"loggers": {
"uvicorn": { # Catches uvicorn root logs
"handlers": ["file_default"],
"level": "DEBUG",
"propagate": False,
},
"uvicorn.error": {
"handlers": ["file_default"],
"level": "DEBUG",
"propagate": False,
},
"uvicorn.access": {
"handlers": ["file_access"],
"level": "INFO",
"propagate": False,
},
},
"root": {
"handlers": ["file_default"],
"level": "DEBUG",
},
}
try:
mcp = FastMCP()
@mcp.tool("ping_tool", "A simple ping tool for the test server")
def ping_tool() -> str:
return "pong"
mcp_asgi_app = mcp.http_app(transport="streamable-http")
parent_app = Starlette(
routes=[Mount("/mounted_mcp", app=mcp_asgi_app)],
)
uvicorn.run(
parent_app,
host=host,
port=port,
log_config=CUSTOM_LOGGING_CONFIG,
log_level=None,
)
sys.exit(0)
except Exception as e_outer:
with open(server_log_file_path, "a") as f_fallback:
f_fallback.write(
"--- FALLBACK EXCEPTION IN SERVER RUNNER (PRE-UVICORN) ---\n"
)
f_fallback.write(f"{type(e_outer).__name__}: {e_outer}\n")
f_fallback.write(traceback.format_exc())
sys.exit(1)
async def test_missing_lifespan_logs_informative_error(tmp_path: Path):
server_log_file = tmp_path / "server.log"
with run_server_in_process(
run_server_with_incorrect_lifespan_setup, str(server_log_file)
) as server_url:
full_mcp_path = server_url + "/mounted_mcp/mcp/"
client_triggered_error = False
response_status = -1
response_body = ""
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.post(
full_mcp_path,
json={"id": 1, "method": "list_tools", "jsonrpc": "2.0"},
)
response_status = response.status_code
response_body = response.text
if response.status_code == 500:
client_triggered_error = True
else:
print(
f"Client received unexpected status code: {response.status_code} "
f"Response: {response_body[:500]}"
)
except httpx.RequestError as e:
print(f"Client request failed with RequestError: {e}")
client_triggered_error = True
assert client_triggered_error, (
f"Client request did not result in a 500 error or a request error. "
f"Status: {response_status}, Body: {response_body[:500]}"
)
assert server_log_file.exists(), (
f"Server log file was not created at {server_log_file}"
)
log_content = server_log_file.read_text()
print(f"--- Captured Server Log Content ({server_log_file}) ---")
print(log_content)
print("--- End Server Log Content ---")
# Core assertions for the enhanced error message
assert (
"FastMCP's StreamableHTTPSessionManager task group was not initialized"
in log_content
)
assert "lifespan=mcp_app.lifespan" in log_content
assert "gofastmcp.com/deployment/asgi" in log_content
assert "Original error: Task group is not initialized" in log_content
# Check for Uvicorn's own error logging wrapper for the request
assert "ERROR" in log_content # General check for ERROR level logs
assert "Exception in ASGI application" in log_content
# Sanity checks for server operation and logging setup
assert "Uvicorn running on" in log_content
assert (
"--- FALLBACK EXCEPTION IN SERVER RUNNER (PRE-UVICORN) ---" not in log_content
)

View file

@ -1890,3 +1890,287 @@ class TestEnumHandling:
assert "enum" in enum_def
assert enum_def["enum"] == ["foo", "bar", "baz"]
assert enum_def["type"] == "string"
class TestRouteMapWildcard:
"""Tests for wildcard RouteMap methods functionality."""
@pytest.fixture
def basic_openapi_spec(self) -> dict:
"""Create a minimal OpenAPI spec with different HTTP methods."""
return {
"openapi": "3.1.0",
"info": {"title": "Test API", "version": "1.0.0"},
"paths": {
"/users": {
"get": {
"operationId": "getUsers",
"responses": {"200": {"description": "Success"}},
},
"post": {
"operationId": "createUser",
"responses": {"201": {"description": "Created"}},
},
},
"/posts": {
"get": {
"operationId": "getPosts",
"responses": {"200": {"description": "Success"}},
},
"post": {
"operationId": "createPost",
"responses": {"201": {"description": "Created"}},
},
},
},
}
@pytest.fixture
async def mock_basic_client(self) -> httpx.AsyncClient:
"""Create a simple mock client."""
async def _responder(request):
return httpx.Response(200, json={"status": "ok"})
transport = httpx.MockTransport(_responder)
return httpx.AsyncClient(transport=transport, base_url="http://test")
async def test_wildcard_matches_all_methods(
self, basic_openapi_spec, mock_basic_client
):
"""Test that a RouteMap with methods='*' matches all HTTP methods."""
# Create a single route map with wildcard method
route_maps = [RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL)]
mcp = FastMCPOpenAPI(
openapi_spec=basic_openapi_spec,
client=mock_basic_client,
route_maps=route_maps,
)
# All operations should be mapped to tools
tools = mcp._tool_manager.list_tools()
tool_names = {tool.name for tool in tools}
# Check that all operations were mapped as tools
expected_tools = {"getUsers", "createUser", "getPosts", "createPost"}
assert tool_names == expected_tools
# No resources or templates should be created
resources = mcp._resource_manager.get_resources()
templates = mcp._resource_manager.get_templates()
assert len(resources) == 0
assert len(templates) == 0
async def test_priority_specific_over_wildcard(
self, basic_openapi_spec, mock_basic_client
):
"""Test that specific method maps take priority over wildcard."""
# Create route maps with specific method first, then wildcard
route_maps = [
# GET operations should be mapped to resources
RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE),
# All other operations should be mapped to tools
RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL),
]
mcp = FastMCPOpenAPI(
openapi_spec=basic_openapi_spec,
client=mock_basic_client,
route_maps=route_maps,
)
# Check GET operations went to resources
resources = mcp._resource_manager.get_resources()
resource_names = {r.name for r in resources.values()}
assert "getUsers" in resource_names
assert "getPosts" in resource_names
assert len(resources) == 2
# Check other operations went to tools
tools = mcp._tool_manager.list_tools()
tool_names = {tool.name for tool in tools}
assert "createUser" in tool_names
assert "createPost" in tool_names
assert len(tools) == 2
async def test_priority_wildcard_first(self, basic_openapi_spec, mock_basic_client):
"""Test that when wildcard is first, it matches everything."""
# Create route maps with wildcard first, then specific methods
route_maps = [
# Wildcard first matches everything
RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL),
# This should never be reached
RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE),
]
mcp = FastMCPOpenAPI(
openapi_spec=basic_openapi_spec,
client=mock_basic_client,
route_maps=route_maps,
)
# All operations should be tools
tools = mcp._tool_manager.list_tools()
assert len(tools) == 4
# No resources should be created
resources = mcp._resource_manager.get_resources()
assert len(resources) == 0
async def test_wildcard_with_specific_paths(
self, basic_openapi_spec, mock_basic_client
):
"""Test wildcard methods combined with specific path patterns."""
route_maps = [
# All methods on /users path -> Resources
RouteMap(methods="*", pattern=r".*/users$", route_type=RouteType.RESOURCE),
# All methods on /posts path -> Tools
RouteMap(methods="*", pattern=r".*/posts$", route_type=RouteType.TOOL),
]
mcp = FastMCPOpenAPI(
openapi_spec=basic_openapi_spec,
client=mock_basic_client,
route_maps=route_maps,
)
# Check /users operations went to resources
resources = mcp._resource_manager.get_resources()
resource_names = {r.name for r in resources.values()}
assert "getUsers" in resource_names
assert "createUser" in resource_names
assert len(resources) == 2
# Check /posts operations went to tools
tools = mcp._tool_manager.list_tools()
tool_names = {tool.name for tool in tools}
assert "getPosts" in tool_names
assert "createPost" in tool_names
assert len(tools) == 2
class TestAllRoutesAsTools:
"""Tests for the all_routes_as_tools parameter in FastMCP class methods."""
@pytest.fixture
def simple_api_spec(self) -> dict:
"""A simple OpenAPI spec with both GET and POST methods."""
return {
"openapi": "3.1.0",
"info": {"title": "Test API", "version": "1.0.0"},
"paths": {
"/items": {
"get": {
"operationId": "getItems",
"responses": {"200": {"description": "Success"}},
},
"post": {
"operationId": "createItem",
"responses": {"201": {"description": "Created"}},
},
},
},
}
@pytest.fixture
async def mock_client(self) -> httpx.AsyncClient:
"""Simple mock client for testing."""
async def _responder(request):
return httpx.Response(200, json={"result": "ok"})
transport = httpx.MockTransport(_responder)
return httpx.AsyncClient(transport=transport, base_url="http://test")
async def test_from_openapi_all_routes_as_tools(self, simple_api_spec, mock_client):
"""Test FastMCP.from_openapi with all_routes_as_tools=True."""
# Create server with all routes as tools
server = FastMCP.from_openapi(
openapi_spec=simple_api_spec, client=mock_client, all_routes_as_tools=True
)
# All operations (GET and POST) should be mapped to tools
tools = server._tool_manager.list_tools()
tool_names = {t.name for t in tools}
assert "getItems" in tool_names
assert "createItem" in tool_names
assert len(tools) == 2
# No resources or templates should be created
resources = server._resource_manager.get_resources()
templates = server._resource_manager.get_templates()
assert len(resources) == 0
assert len(templates) == 0
async def test_from_openapi_all_routes_as_tools_conflicting_args(
self, simple_api_spec, mock_client
):
"""Test FastMCP.from_openapi raises error when both route_maps and all_routes_as_tools are provided."""
# Try to create server with conflicting args
with pytest.raises(
ValueError, match="Cannot specify both all_routes_as_tools and route_maps"
):
FastMCP.from_openapi(
openapi_spec=simple_api_spec,
client=mock_client,
all_routes_as_tools=True,
route_maps=[
RouteMap(
methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE
)
],
)
async def test_from_fastapi_all_routes_as_tools(self):
"""Test FastMCP.from_fastapi with all_routes_as_tools=True."""
# Create a simple FastAPI app
app = FastAPI(title="Test FastAPI")
@app.get("/items")
async def get_items():
return [{"id": 1, "name": "Item 1"}]
@app.post("/items")
async def create_item(item: dict):
return {"id": 2, **item}
# Create server with all routes as tools
server = FastMCP.from_fastapi(app=app, all_routes_as_tools=True)
# Both GET and POST operations should be mapped to tools
tools = server._tool_manager.list_tools()
# Get tool names from the generated operation IDs
tool_names = {t.name for t in tools}
# Check that both routes were mapped to tools
# The exact names depend on FastAPI's operation ID generation
assert len(tools) == 2
assert any("get" in name.lower() for name in tool_names)
assert any("post" in name.lower() for name in tool_names)
# No resources or templates should be created
resources = server._resource_manager.get_resources()
templates = server._resource_manager.get_templates()
assert len(resources) == 0
assert len(templates) == 0
async def test_from_fastapi_all_routes_as_tools_conflicting_args(self):
"""Test FastMCP.from_fastapi raises error when both route_maps and all_routes_as_tools are provided."""
app = FastAPI(title="Test FastAPI")
# Try to create server with conflicting args
with pytest.raises(
ValueError, match="Cannot specify both all_routes_as_tools and route_maps"
):
FastMCP.from_fastapi(
app=app,
all_routes_as_tools=True,
route_maps=[
RouteMap(
methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE
)
],
)

View file

@ -87,7 +87,6 @@ async def test_fastmcp_from_openapi(array_path_spec, mock_client):
assert "test-operation" in tool_names
@pytest.mark.asyncio
async def test_array_path_parameter_handling(mock_client):
"""Test how array path parameters are handled."""
# Create a simple route with array path parameter
@ -158,7 +157,6 @@ async def test_array_path_parameter_handling(mock_client):
)
@pytest.mark.asyncio
async def test_integration_array_path_parameter(array_path_spec, mock_client):
"""Integration test for array path parameters."""
# Create FastMCP from the spec
@ -192,7 +190,6 @@ async def test_integration_array_path_parameter(array_path_spec, mock_client):
)
@pytest.mark.asyncio
async def test_complex_nested_array_path_parameter(mock_client):
"""Test handling of complex nested array path parameters."""
# Create a route with a path parameter that contains nested objects in an array
@ -262,7 +259,6 @@ async def test_complex_nested_array_path_parameter(mock_client):
assert "{" not in called_url, "The URL should not contain Python object syntax"
@pytest.mark.asyncio
async def test_array_query_param_with_fastapi():
"""Test array query parameters using FastAPI and FastMCP.from_fastapi integration."""
# Create a FastAPI app with a route that has an array query parameter
@ -323,7 +319,6 @@ async def test_array_query_param_with_fastapi():
assert result_data == {"selected": ["monday", "tuesday"]}
@pytest.mark.asyncio
async def test_array_query_parameter_format(mock_client):
"""Test that array query parameters are formatted as comma-separated values when explode=False."""
# Create a route with array query parameter
@ -394,7 +389,6 @@ async def test_array_query_parameter_format(mock_client):
)
@pytest.mark.asyncio
async def test_array_query_parameter_exploded_format(mock_client):
"""Test that array query parameters are formatted as separate parameters when explode=True."""
# Create a route with array query parameter with explode=True (default)

View file

@ -40,7 +40,6 @@ def test_streamable_http_app_deprecation_warning():
assert isinstance(app, Starlette)
@pytest.mark.asyncio
async def test_run_sse_async_deprecation_warning():
"""Test that run_sse_async raises a deprecation warning."""
server = FastMCP("TestServer")
@ -58,7 +57,6 @@ async def test_run_sse_async_deprecation_warning():
assert call_kwargs.get("transport") == "sse"
@pytest.mark.asyncio
async def test_run_streamable_http_async_deprecation_warning():
"""Test that run_streamable_http_async raises a deprecation warning."""
server = FastMCP("TestServer")

View file

@ -1,6 +1,5 @@
"""Tests for example servers"""
import pytest
from mcp.types import (
PromptMessage,
TextContent,
@ -11,7 +10,6 @@ from pydantic import AnyUrl
from fastmcp import Client
@pytest.mark.anyio
async def test_simple_echo():
"""Test the simple echo server"""
from examples.simple_echo import mcp
@ -23,7 +21,6 @@ async def test_simple_echo():
assert result[0].text == "hello"
@pytest.mark.anyio
async def test_complex_inputs():
"""Test the complex inputs server"""
from examples.complex_inputs import mcp
@ -38,7 +35,6 @@ async def test_complex_inputs():
assert result[0].text == '[\n "bob",\n "alice",\n "charlie"\n]'
@pytest.mark.anyio
async def test_desktop(monkeypatch):
"""Test the desktop server"""
from examples.desktop import mcp
@ -58,7 +54,6 @@ async def test_desktop(monkeypatch):
assert result[0].text == "Hello, rooter12!"
@pytest.mark.anyio
async def test_echo():
"""Test the echo server"""
from examples.echo import mcp