mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
rm slop markers
This commit is contained in:
parent
3d220a7724
commit
7f46580c14
10 changed files with 171 additions and 202 deletions
50
.github/workflows/run-integration-tests.yml
vendored
50
.github/workflows/run-integration-tests.yml
vendored
|
|
@ -1,50 +0,0 @@
|
|||
name: Run integration tests
|
||||
|
||||
env:
|
||||
# enable colored output
|
||||
PY_COLORS: 1
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
paths:
|
||||
- "src/**"
|
||||
- "integration_tests/**"
|
||||
- "uv.lock"
|
||||
- "pyproject.toml"
|
||||
- ".github/workflows/**"
|
||||
|
||||
# run on all pull requests because these checks are required and will block merges otherwise
|
||||
pull_request:
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
run_tests:
|
||||
name: "Run tests: Python ${{ matrix.python-version }} on ${{ matrix.os }}"
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
python-version: ["3.10"]
|
||||
fail-fast: false
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: "uv.lock"
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install FastMCP
|
||||
run: uv sync --locked
|
||||
|
||||
- name: Run tests
|
||||
run: uv run pytest integration_tests
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
PYTHON_EXE = sys.executable
|
||||
|
||||
SERVER_CODE = """
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool("dummy_tool", "A simple dummy tool for the test server")
|
||||
def add(a: int, b: int) -> int:
|
||||
return a + b
|
||||
|
||||
app = FastAPI() # Intentionally no lifespan=mcp.lifespan
|
||||
app.mount("/", mcp.http_app(transport="streamable-http"))
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8080, log_config=None)
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.timeout(20)
|
||||
def test_server_shows_informative_error_on_stderr():
|
||||
"""
|
||||
Runs a minimal FastMCP+FastAPI server (that omits lifespan wiring)
|
||||
as a subprocess, triggers the error via an HTTP request, and then checks
|
||||
if the server's stderr contains the specific informative error message for issue #518.
|
||||
"""
|
||||
process = None
|
||||
captured_stderr = ""
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".py", delete=False
|
||||
) as tmp_script:
|
||||
tmp_script.write(SERVER_CODE)
|
||||
tmp_script_path = tmp_script.name
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
[PYTHON_EXE, "-u", tmp_script_path],
|
||||
stderr=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
text=True,
|
||||
universal_newlines=True,
|
||||
)
|
||||
|
||||
time.sleep(3)
|
||||
|
||||
if process.poll() is None:
|
||||
try:
|
||||
with httpx.Client(timeout=5.0) as client:
|
||||
# The mounted FastMCP app is at root, its internal default path is /mcp
|
||||
client.get("http://localhost:8080/mcp/")
|
||||
except httpx.RequestError:
|
||||
pass
|
||||
time.sleep(1)
|
||||
|
||||
finally:
|
||||
if process:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
_, captured_stderr = process.communicate(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
_, captured_stderr = process.communicate()
|
||||
|
||||
if os.path.exists(tmp_script_path):
|
||||
os.unlink(tmp_script_path)
|
||||
|
||||
assert captured_stderr is not None, "stderr should have been captured"
|
||||
normalized_stderr = captured_stderr.replace("\r\n", "\n").replace("\r", "\n")
|
||||
|
||||
assert (
|
||||
"FastMCP's StreamableHTTPSessionManager task group was not initialized"
|
||||
in normalized_stderr
|
||||
)
|
||||
assert "lifespan=mcp_app.lifespan" in normalized_stderr
|
||||
assert "gofastmcp.com/deployment/asgi" in normalized_stderr
|
||||
assert "Original error: Task group is not initialized" in normalized_stderr
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue