Fix timeout not propagating to proxy clients in multi-server MCPConfig (#2809)

This commit is contained in:
Jeremiah Lowin 2026-01-09 20:01:16 -05:00 committed by GitHub
commit 401b315786
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 210 additions and 87 deletions

View file

@ -1110,9 +1110,8 @@ class TestInferTransport:
}
transport = infer_transport(config)
assert isinstance(transport, MCPConfigTransport)
assert isinstance(transport.transport, FastMCPTransport)
# 3 providers: LocalProvider (always first) + 2 mounted MCP servers
assert len(cast(FastMCP, transport.transport.server)._providers) == 3
# Multi-server configs create composite server at connect time
assert len(transport.config.mcpServers) == 2
def test_infer_fastmcp_server(self, fastmcp_server):
"""FastMCP server instances should infer to FastMCPTransport."""

View file

@ -6,12 +6,16 @@ import os
import sys
import tempfile
from collections.abc import AsyncGenerator
from datetime import timedelta
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, patch
import psutil
import pytest
from mcp.types import TextContent
from fastmcp import FastMCP
from fastmcp.client.auth.bearer import BearerAuth
from fastmcp.client.auth.oauth import OAuthClientProvider
from fastmcp.client.client import Client
@ -774,6 +778,127 @@ async def test_multi_client_with_elicitation(tmp_path: Path):
assert result.data == 42
async def test_multi_server_config_transport(tmp_path: Path):
"""
Tests that MCPConfigTransport properly handles multi-server configurations.
Related to https://github.com/jlowin/fastmcp/issues/2802 - verifies the
refactored architecture creates composite servers correctly.
"""
server_script = inspect.cleandoc("""
from fastmcp import FastMCP
mcp = FastMCP()
@mcp.tool
def greet(name: str) -> str:
return f"Hello, {name}!"
if __name__ == '__main__':
mcp.run()
""")
script_path = tmp_path / "greet_server.py"
script_path.write_text(server_script)
config = {
"mcpServers": {
"server1": {
"command": "python",
"args": [str(script_path)],
},
"server2": {
"command": "python",
"args": [str(script_path)],
},
}
}
# Create client with multiple servers
client = Client(config)
assert isinstance(client.transport, MCPConfigTransport)
# Verify both servers are accessible via prefixed tool names
async with client:
tools = await client.list_tools()
tool_names = [t.name for t in tools]
assert "server1_greet" in tool_names
assert "server2_greet" in tool_names
# Call tools on both servers
result1 = await client.call_tool("server1_greet", {"name": "World"})
assert isinstance(result1.content[0], TextContent)
assert "Hello, World!" in result1.content[0].text
result2 = await client.call_tool("server2_greet", {"name": "FastMCP"})
assert isinstance(result2.content[0], TextContent)
assert "Hello, FastMCP!" in result2.content[0].text
async def test_multi_server_timeout_propagation():
"""Test that timeout is correctly propagated to proxy clients in multi-server configs."""
# Create a config with multiple servers
config = MCPConfig(
mcpServers={
"server1": StdioMCPServer(command="echo", args=["test"]),
"server2": StdioMCPServer(command="echo", args=["test"]),
}
)
transport = MCPConfigTransport(config)
timeout = timedelta(seconds=42)
# Patch _create_proxy to verify timeout is passed correctly
with (
patch("fastmcp.client.transports.FastMCP.as_proxy") as mock_as_proxy,
patch.object(
transport, "_create_proxy", wraps=transport._create_proxy
) as mock_create_proxy,
):
# Make as_proxy return a mock FastMCP
mock_proxy = FastMCP(name="MockProxy")
mock_as_proxy.return_value = mock_proxy
# Mock connect_session on FastMCPTransport to avoid actual connection
with patch(
"fastmcp.client.transports.FastMCPTransport.connect_session"
) as mock_connect:
mock_session = AsyncMock()
mock_connect.return_value.__aenter__ = AsyncMock(return_value=mock_session)
mock_connect.return_value.__aexit__ = AsyncMock(return_value=None)
async with transport.connect_session(read_timeout_seconds=timeout):
pass
# Verify _create_proxy was called with the timeout for each server
assert mock_create_proxy.call_count == 2
for call in mock_create_proxy.call_args_list:
_, kwargs = call.args, call.kwargs if call.kwargs else {}
# Third positional arg is timeout
call_timeout = call[0][2] if len(call[0]) > 2 else kwargs.get("timeout")
assert call_timeout == timeout, (
f"Expected timeout {timeout}, got {call_timeout}"
)
async def test_single_server_config_transport():
"""Test that single-server configs delegate directly without creating a composite."""
config = MCPConfig(
mcpServers={
"only_server": StdioMCPServer(command="echo", args=["test"]),
}
)
transport = MCPConfigTransport(config)
# Single server should have transport created eagerly (not at connect time)
assert hasattr(transport, "transport")
assert isinstance(transport.transport, StdioTransport)
# _transports should already contain the single transport
assert len(transport._transports) == 1
def sample_tool_fn(arg1: int, arg2: str) -> str:
return f"Hello, world! {arg1} {arg2}"