diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index c201aca74..04f35da3e 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -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 diff --git a/docs/patterns/openapi.mdx b/docs/patterns/openapi.mdx index fb2524446..0934cec8e 100644 --- a/docs/patterns/openapi.mdx +++ b/docs/patterns/openapi.mdx @@ -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 diff --git a/src/fastmcp/server/http.py b/src/fastmcp/server/http.py index 8655b2d64..2a9cced00 100644 --- a/src/fastmcp/server/http.py +++ b/src/fastmcp/server/http.py @@ -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( diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py index e8e4e21c6..687790653 100644 --- a/src/fastmcp/server/openapi.py +++ b/src/fastmcp/server/openapi.py @@ -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) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index d8c86eb65..1edbcac4d 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -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 diff --git a/src/fastmcp/utilities/tests.py b/src/fastmcp/utilities/tests.py index 39bd6b9c7..0d64a1292 100644 --- a/src/fastmcp/utilities/tests.py +++ b/src/fastmcp/utilities/tests.py @@ -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") diff --git a/tests/contrib/test_bulk_tool_caller.py b/tests/contrib/test_bulk_tool_caller.py index 855341912..b6f86c927 100644 --- a/tests/contrib/test_bulk_tool_caller.py +++ b/tests/contrib/test_bulk_tool_caller.py @@ -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 = [ diff --git a/tests/server/test_auth_integration.py b/tests/server/test_auth_integration.py index fb0330dcf..f64bd0843 100644 --- a/tests/server/test_auth_integration.py +++ b/tests/server/test_auth_integration.py @@ -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 ): diff --git a/tests/server/test_http_middleware.py b/tests/server/test_http_middleware.py index e48dc127b..7a1ab22f4 100644 --- a/tests/server/test_http_middleware.py +++ b/tests/server/test_http_middleware.py @@ -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") diff --git a/tests/server/test_lifespan.py b/tests/server/test_lifespan.py index 8b11ca825..ad041bbf9 100644 --- a/tests/server/test_lifespan.py +++ b/tests/server/test_lifespan.py @@ -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 + ) diff --git a/tests/server/test_openapi.py b/tests/server/test_openapi.py index e92d33b48..7bd7d5d34 100644 --- a/tests/server/test_openapi.py +++ b/tests/server/test_openapi.py @@ -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 + ) + ], + ) diff --git a/tests/server/test_openapi_path_parameters.py b/tests/server/test_openapi_path_parameters.py index e8b6121e5..9940594a8 100644 --- a/tests/server/test_openapi_path_parameters.py +++ b/tests/server/test_openapi_path_parameters.py @@ -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) diff --git a/tests/test_deprecated.py b/tests/test_deprecated.py index ecb63bc8e..65f6fc71d 100644 --- a/tests/test_deprecated.py +++ b/tests/test_deprecated.py @@ -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") diff --git a/tests/test_examples.py b/tests/test_examples.py index 1f68390d8..fcee6c521 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -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