Skip to content

Commit 3321644

Browse files
authored
Replace subprocess tests with in-process async servers (#2006)
* Use anyio as testing backend * Remove asyncio markers * Update streamable http tests * Replace all subprocess tests * Replace anyio task groups with asyncio context managers in tests - Convert run_server_async from anyio task group pattern to asyncio.create_task with async context manager - Remove task_group fixture from conftest - Update all test fixtures to use async with run_server_async pattern - Remove TaskGroup imports from all test files - Tests now work with pytest-asyncio instead of pytest-anyio * Update test_github_provider_integration.py
1 parent 39aebcf commit 3321644

28 files changed

Lines changed: 411 additions & 358 deletions

docs/development/tests.mdx

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,53 @@ async def test_database_tool():
297297

298298
### Testing Network Transports
299299

300-
While in-memory testing covers most unit testing needs, you'll occasionally need to test actual network transports. Use the `run_server_in_process` utility to spawn a server in a separate process for testing:
300+
While in-memory testing covers most unit testing needs, you'll occasionally need to test actual network transports like HTTP or SSE. FastMCP provides two approaches: in-process async servers using AnyIO task groups (preferred), and separate subprocess servers (for special cases).
301+
302+
#### In-Process Network Testing (Preferred)
303+
304+
For most network transport tests, use `run_server_async` with AnyIO task groups. This runs the server as a task in the same process, providing fast, deterministic tests with full debugger support:
305+
306+
```python
307+
import pytest
308+
from anyio.abc import TaskGroup
309+
from fastmcp import FastMCP, Client
310+
from fastmcp.client.transports import StreamableHttpTransport
311+
from fastmcp.utilities.tests import run_server_async
312+
313+
def create_test_server() -> FastMCP:
314+
"""Create a test server instance."""
315+
server = FastMCP("TestServer")
316+
317+
@server.tool
318+
def greet(name: str) -> str:
319+
return f"Hello, {name}!"
320+
321+
return server
322+
323+
@pytest.fixture
324+
async def http_server(task_group: TaskGroup) -> str:
325+
"""Start server in-process using task group."""
326+
server = create_test_server()
327+
url = await run_server_async(task_group, server, transport="http")
328+
return url
329+
330+
async def test_http_transport(http_server: str):
331+
"""Test actual HTTP transport behavior."""
332+
async with Client(
333+
transport=StreamableHttpTransport(http_server)
334+
) as client:
335+
result = await client.ping()
336+
assert result is True
337+
338+
greeting = await client.call_tool("greet", {"name": "World"})
339+
assert greeting.data == "Hello, World!"
340+
```
341+
342+
The `task_group` fixture is provided globally by `conftest.py` and automatically handles server lifecycle and cleanup. This approach is faster than subprocess-based testing and provides better error messages.
343+
344+
#### Subprocess Testing (Special Cases)
345+
346+
For tests that require complete process isolation (like STDIO transport or testing subprocess behavior), use `run_server_in_process`:
301347

302348
```python
303349
import pytest
@@ -328,12 +374,9 @@ async def test_http_transport(http_server: str):
328374
) as client:
329375
result = await client.ping()
330376
assert result is True
331-
332-
greeting = await client.call_tool("greet", {"name": "World"})
333-
assert greeting.data == "Hello, World!"
334377
```
335378

336-
The `run_server_in_process` utility handles server lifecycle, port allocation, and cleanup automatically. This pattern is essential for testing transport-specific behavior like timeouts, headers, and authentication. Note that FastMCP often uses the `client_process` marker to isolate tests that spawn processes, as they can create contention in CI.
379+
The `run_server_in_process` utility handles server lifecycle, port allocation, and cleanup automatically. Use this only when subprocess isolation is truly necessary, as it's slower and harder to debug than in-process testing. FastMCP uses the `client_process` marker to isolate these tests in CI.
337380

338381
### Documentation Testing
339382

pyproject.toml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ dependencies = [
1717
"openapi-core>=0.19.5",
1818
"py-key-value-aio[disk,memory]>=0.2.2,<0.3.0",
1919
"websockets>=15.0.1",
20+
"pytest-asyncio>=1.2.0",
2021
]
2122

2223
requires-python = ">=3.10"
@@ -59,7 +60,6 @@ dev = [
5960
"pyinstrument>=5.0.2",
6061
"pyperclip>=1.9.0",
6162
"pytest>=8.3.3",
62-
"pytest-asyncio>=0.23.5",
6363
"pytest-cov>=6.1.1",
6464
"pytest-env>=1.1.5",
6565
"pytest-flakefinder",
@@ -99,8 +99,6 @@ fallback-version = "0.0.0"
9999

100100
[tool.pytest.ini_options]
101101
asyncio_mode = "auto"
102-
asyncio_default_fixture_loop_scope = "session"
103-
asyncio_default_test_loop_scope = "session"
104102
# filterwarnings = ["error::DeprecationWarning"]
105103
timeout = 5
106104
env = [

src/fastmcp/server/context.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import logging
77
import warnings
88
import weakref
9-
from asyncio.locks import Lock
109
from collections.abc import Generator, Mapping, Sequence
1110
from contextlib import contextmanager
1211
from contextvars import ContextVar, Token
@@ -15,6 +14,7 @@
1514
from logging import Logger
1615
from typing import Any, Literal, cast, get_origin, overload
1716

17+
import anyio
1818
from mcp import LoggingLevel, ServerSession
1919
from mcp.server.lowlevel.helper_types import ReadResourceContents
2020
from mcp.server.lowlevel.server import request_ctx
@@ -61,7 +61,7 @@
6161

6262
T = TypeVar("T", default=Any)
6363
_current_context: ContextVar[Context | None] = ContextVar("context", default=None) # type: ignore[assignment]
64-
_flush_lock: Lock = asyncio.Lock()
64+
_flush_lock = anyio.Lock()
6565

6666

6767
@dataclass

src/fastmcp/server/middleware/rate_limiting.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
"""Rate limiting middleware for protecting FastMCP servers from abuse."""
22

3-
import asyncio
43
import time
54
from collections import defaultdict, deque
65
from collections.abc import Callable
76
from typing import Any
87

8+
import anyio
99
from mcp import McpError
1010
from mcp.types import ErrorData
1111

@@ -33,7 +33,7 @@ def __init__(self, capacity: int, refill_rate: float):
3333
self.refill_rate = refill_rate
3434
self.tokens = capacity
3535
self.last_refill = time.time()
36-
self._lock = asyncio.Lock()
36+
self._lock = anyio.Lock()
3737

3838
async def consume(self, tokens: int = 1) -> bool:
3939
"""Try to consume tokens from the bucket.
@@ -71,7 +71,7 @@ def __init__(self, max_requests: int, window_seconds: int):
7171
self.max_requests = max_requests
7272
self.window_seconds = window_seconds
7373
self.requests = deque()
74-
self._lock = asyncio.Lock()
74+
self._lock = anyio.Lock()
7575

7676
async def is_allowed(self) -> bool:
7777
"""Check if a request is allowed."""

src/fastmcp/utilities/tests.py

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
import multiprocessing
66
import socket
77
import time
8-
from collections.abc import Callable, Generator
9-
from contextlib import contextmanager
8+
from collections.abc import AsyncGenerator, Callable, Generator
9+
from contextlib import asynccontextmanager, contextmanager
1010
from typing import TYPE_CHECKING, Any, Literal
1111
from urllib.parse import parse_qs, urlparse
1212

@@ -140,6 +140,88 @@ def run_server_in_process(
140140
raise RuntimeError("Server process failed to terminate even after kill")
141141

142142

143+
@asynccontextmanager
144+
async def run_server_async(
145+
server: FastMCP,
146+
port: int | None = None,
147+
transport: Literal["http", "streamable-http", "sse"] = "http",
148+
path: str = "/mcp",
149+
host: str = "127.0.0.1",
150+
) -> AsyncGenerator[str, None]:
151+
"""
152+
Start a FastMCP server as an asyncio task for in-process async testing.
153+
154+
This is the recommended way to test FastMCP servers. It runs the server
155+
as an async task in the same process, eliminating subprocess coordination,
156+
sleeps, and cleanup issues.
157+
158+
Args:
159+
server: FastMCP server instance
160+
port: Port to bind to (default: find available port)
161+
transport: Transport type ("http", "streamable-http", or "sse")
162+
path: URL path for the server (default: "/mcp")
163+
host: Host to bind to (default: "127.0.0.1")
164+
165+
Yields:
166+
Server URL string
167+
168+
Example:
169+
```python
170+
import pytest
171+
from fastmcp import FastMCP, Client
172+
from fastmcp.client.transports import StreamableHttpTransport
173+
from fastmcp.utilities.tests import run_server_async
174+
175+
@pytest.fixture
176+
async def server():
177+
mcp = FastMCP("test")
178+
179+
@mcp.tool()
180+
def greet(name: str) -> str:
181+
return f"Hello, {name}!"
182+
183+
async with run_server_async(mcp) as url:
184+
yield url
185+
186+
async def test_greet(server: str):
187+
async with Client(StreamableHttpTransport(server)) as client:
188+
result = await client.call_tool("greet", {"name": "World"})
189+
assert result.content[0].text == "Hello, World!"
190+
```
191+
"""
192+
import asyncio
193+
194+
if port is None:
195+
port = find_available_port()
196+
197+
# Wait a tiny bit for the port to be released if it was just used
198+
await asyncio.sleep(0.01)
199+
200+
# Start server as a background task
201+
server_task = asyncio.create_task(
202+
server.run_http_async(
203+
host=host,
204+
port=port,
205+
transport=transport,
206+
path=path,
207+
show_banner=False,
208+
)
209+
)
210+
211+
# Give the server a moment to start
212+
await asyncio.sleep(0.1)
213+
214+
try:
215+
yield f"http://{host}:{port}{path}"
216+
finally:
217+
# Cleanup: cancel the task
218+
server_task.cancel()
219+
try:
220+
await server_task
221+
except asyncio.CancelledError:
222+
pass
223+
224+
143225
@contextmanager
144226
def caplog_for_fastmcp(caplog):
145227
"""Context manager to capture logs from FastMCP loggers even when propagation is disabled."""

tests/cli/test_mcp_server_config_integration.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,6 @@ def test_detect_test_fastmcp_json(self, tmp_path):
8484
class TestConfigWithClient:
8585
"""Test fastmcp.json configuration with client connections."""
8686

87-
@pytest.mark.asyncio
8887
async def test_config_server_with_client(self, server_with_config):
8988
"""Test that a server loaded from config works with a client."""
9089
# Load the config

tests/cli/test_server_args.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
class TestServerArguments:
1212
"""Test passing arguments to servers."""
1313

14-
@pytest.mark.asyncio
1514
async def test_server_with_argparse(self, tmp_path):
1615
"""Test a server that uses argparse with command line arguments."""
1716
server_file = tmp_path / "argparse_server.py"
@@ -53,7 +52,6 @@ def get_config() -> dict:
5352
tools = await server.get_tools()
5453
assert "get_config" in tools
5554

56-
@pytest.mark.asyncio
5755
async def test_server_with_no_args(self, tmp_path):
5856
"""Test a server that uses argparse with no arguments (defaults)."""
5957
server_file = tmp_path / "default_server.py"
@@ -79,7 +77,6 @@ async def test_server_with_no_args(self, tmp_path):
7977

8078
assert server.name == "DefaultName"
8179

82-
@pytest.mark.asyncio
8380
async def test_server_with_sys_argv_access(self, tmp_path):
8481
"""Test a server that directly accesses sys.argv."""
8582
server_file = tmp_path / "sysargv_server.py"
@@ -112,7 +109,6 @@ async def test_server_with_sys_argv_access(self, tmp_path):
112109

113110
assert server.name == "DirectServer"
114111

115-
@pytest.mark.asyncio
116112
async def test_config_server_example(self):
117113
"""Test the actual config_server.py example."""
118114
# Find the examples directory

tests/client/auth/test_oauth_client.py

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from collections.abc import Generator
21
from urllib.parse import urlparse
32

43
import httpx
@@ -9,7 +8,8 @@
98
from fastmcp.server.auth.auth import ClientRegistrationOptions
109
from fastmcp.server.auth.providers.in_memory import InMemoryOAuthProvider
1110
from fastmcp.server.server import FastMCP
12-
from fastmcp.utilities.tests import HeadlessOAuth, run_server_in_process
11+
from fastmcp.utilities.http import find_available_port
12+
from fastmcp.utilities.tests import HeadlessOAuth, run_server_async
1313

1414

1515
def fastmcp_server(issuer_url: str):
@@ -35,31 +35,27 @@ def get_test_resource() -> str:
3535
return server
3636

3737

38-
def run_server(host: str, port: int, **kwargs) -> None:
39-
fastmcp_server(f"http://{host}:{port}").run(host=host, port=port, **kwargs)
40-
41-
4238
@pytest.fixture
43-
def streamable_http_server() -> Generator[str, None, None]:
44-
with run_server_in_process(run_server, transport="http") as url:
45-
yield f"{url}/mcp"
39+
async def streamable_http_server():
40+
"""Start OAuth-enabled server."""
41+
port = find_available_port()
42+
server = fastmcp_server(f"http://127.0.0.1:{port}")
43+
async with run_server_async(server, port=port, transport="http") as url:
44+
yield url
4645

4746

48-
@pytest.fixture()
47+
@pytest.fixture
4948
def client_unauthorized(streamable_http_server: str) -> Client:
5049
return Client(transport=StreamableHttpTransport(streamable_http_server))
5150

5251

53-
@pytest.fixture()
54-
def client_with_headless_oauth(
55-
streamable_http_server: str,
56-
) -> Generator[Client, None, None]:
52+
@pytest.fixture
53+
def client_with_headless_oauth(streamable_http_server: str) -> Client:
5754
"""Client with headless OAuth that bypasses browser interaction."""
58-
client = Client(
55+
return Client(
5956
transport=StreamableHttpTransport(streamable_http_server),
6057
auth=HeadlessOAuth(mcp_url=streamable_http_server),
6158
)
62-
yield client
6359

6460

6561
async def test_unauthorized(client_unauthorized: Client):

0 commit comments

Comments
 (0)