fastmcp/tests/server/proxy/test_proxy_stdio_race_fix.py
marvin-context-protocol[bot] 9cd457727d Fix stdio proxy race condition in concurrent connections
Resolves race condition where multiple parallel requests through FastMCP
proxy to stdio servers would fail with 'Received request before
initialization was complete' errors.

The issue occurred because each proxy operation created a new client that
tried to initialize simultaneously. Added _SynchronizedClientContext to
serialize stdio client initialization using asyncio semaphore while
preserving performance for non-stdio transports.

- Added _SynchronizedClientContext async context manager
- Enhanced ProxyManagerMixin with synchronized client context
- Updated all proxy managers to use synchronized context for stdio
- Added comprehensive tests with 20+ parallel operations
- All existing tests continue to pass

Co-authored-by: William Easton <strawgate@users.noreply.github.com>
2025-08-25 18:25:32 +00:00

128 lines
4.2 KiB
Python

"""Test for stdio proxy race condition fix."""
import asyncio
import inspect
import tempfile
from pathlib import Path
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.client.transports import PythonStdioTransport
class TestProxyStdioRaceFix:
"""Test that the proxy stdio race condition is fixed."""
async def test_proxy_parallel_calls_no_race_condition(self):
"""Test that parallel calls through stdio proxy don't fail with race conditions."""
# Create a temporary script for the backend server
server_script = inspect.cleandoc("""
from fastmcp import FastMCP
mcp = FastMCP()
@mcp.tool
def add(a: int, b: int) -> int:
return a + b
if __name__ == '__main__':
mcp.run()
""")
with tempfile.TemporaryDirectory() as tmp_dir:
script_path = Path(tmp_dir) / "test.py"
script_path.write_text(server_script)
# Set up the backend client (stdio transport)
backend_client = Client(
transport=PythonStdioTransport(script_path=script_path)
)
# Create proxy server
proxy = FastMCP.as_proxy(backend=backend_client, name="test_parallel_calls")
# Create client that connects to the proxy
client = Client(transport=proxy)
# Test with enough parallel calls to trigger race condition
count = 20
tasks = [client.list_tools() for _ in range(count)]
async with backend_client, client:
results = await asyncio.gather(*tasks, return_exceptions=True)
# All calls should succeed
exceptions = [result for result in results if isinstance(result, Exception)]
successes = [
result for result in results if not isinstance(result, Exception)
]
assert len(exceptions) == 0, (
f"Got {len(exceptions)} exceptions: {exceptions}"
)
assert len(successes) == count
assert all(
len(result) == 1 for result in successes
) # Each should have 1 tool (add)
async def test_proxy_parallel_tool_calls_no_race_condition(self):
"""Test that parallel tool calls through stdio proxy don't fail with race conditions."""
# Create a temporary script for the backend server
server_script = inspect.cleandoc("""
from fastmcp import FastMCP
mcp = FastMCP()
@mcp.tool
def add(a: int, b: int) -> int:
return a + b
if __name__ == '__main__':
mcp.run()
""")
with tempfile.TemporaryDirectory() as tmp_dir:
script_path = Path(tmp_dir) / "test.py"
script_path.write_text(server_script)
# Set up the backend client (stdio transport)
backend_client = Client(
transport=PythonStdioTransport(script_path=script_path)
)
# Create proxy server
proxy = FastMCP.as_proxy(backend=backend_client, name="test_parallel_calls")
# Create client that connects to the proxy
client = Client(transport=proxy)
# Test with parallel tool calls
count = 15
tasks = [
client.call_tool("add", {"a": i, "b": i + 1}) for i in range(count)
]
async with backend_client, client:
results = await asyncio.gather(*tasks, return_exceptions=True)
# All calls should succeed
exceptions = [result for result in results if isinstance(result, Exception)]
successes = [
result for result in results if not isinstance(result, Exception)
]
assert len(exceptions) == 0, (
f"Got {len(exceptions)} exceptions: {exceptions}"
)
assert len(successes) == count
# Verify results are correct
for i, result in enumerate(successes):
expected = i + (i + 1) # a + b where a=i, b=i+1
assert result.data == expected, (
f"Expected {expected}, got {result.data}"
)