From 02d55dec325f56fa7730e2fb53fd16bd87f38fd2 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:25:56 -0400 Subject: [PATCH] Graceful degradation for multi-server proxy setup (#3546) * Graceful degradation for multi-server proxy setup * Add comment for broad except, parameterize test ordering --- src/fastmcp/client/transports/config.py | 29 +++-- tests/test_mcp_config.py | 147 ++++++++++++++++++++++++ 2 files changed, 165 insertions(+), 11 deletions(-) diff --git a/src/fastmcp/client/transports/config.py b/src/fastmcp/client/transports/config.py index c8d5a8a40..cd1d59cfa 100644 --- a/src/fastmcp/client/transports/config.py +++ b/src/fastmcp/client/transports/config.py @@ -17,6 +17,9 @@ from fastmcp.mcp_config import ( TransformingStdioMCPServer, ) from fastmcp.server.server import FastMCP, create_proxy +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) class MCPConfigTransport(ClientTransport): @@ -104,21 +107,25 @@ class MCPConfigTransport(ClientTransport): await t.close() self._transports = [] - try: - for name, server_config in self.config.mcpServers.items(): + for name, server_config in self.config.mcpServers.items(): + try: transport, _client, proxy = await self._create_proxy( name, server_config, timeout, stack ) - self._transports.append(transport) - composite.mount( - proxy, namespace=name if self.name_as_prefix else None + except Exception: # Broad catch is intentional: failure modes + # are diverse (OSError, TimeoutError, RuntimeError, etc.) + # and the whole point is to skip any server that can't connect. + logger.warning( + "Failed to connect to MCP server %r, skipping", + name, + exc_info=True, ) - except Exception: - # Clean up any transports created before the failure - for t in self._transports: - await t.close() - self._transports = [] - raise + continue + self._transports.append(transport) + composite.mount(proxy, namespace=name if self.name_as_prefix else None) + + if not self._transports: + raise ConnectionError("All MCP servers failed to connect") async with FastMCPTransport(mcp=composite).connect_session( **session_kwargs diff --git a/tests/test_mcp_config.py b/tests/test_mcp_config.py index d8b0547dd..97274b1e5 100644 --- a/tests/test_mcp_config.py +++ b/tests/test_mcp_config.py @@ -1007,6 +1007,153 @@ async def test_single_server_config_transport(): assert len(transport._transports) == 1 +@pytest.mark.parametrize( + "server_order", + [ + {"good_server": True, "bad_server": False}, + {"bad_server": False, "good_server": True}, + ], + ids=["good_first", "bad_first"], +) +async def test_multi_server_partial_failure(tmp_path: Path, server_order: dict): + """When one server fails to connect, the others should still work.""" + 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() + """) + + script_path = tmp_path / "test.py" + script_path.write_text(server_script) + + servers = {} + for name, is_good in server_order.items(): + if is_good: + servers[name] = { + "command": "python", + "args": [str(script_path)], + } + else: + servers[name] = { + "command": "this-command-does-not-exist-anywhere", + "args": [], + } + + client = Client({"mcpServers": servers}) + async with client: + tools = await client.list_tools() + tool_names = [t.name for t in tools] + assert "good_server_add" in tool_names + assert len(tools) == 1 + + +async def test_multi_server_partial_failure_logs_warning(tmp_path: Path, caplog): + """A warning should be logged when a server fails to connect.""" + 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() + """) + + script_path = tmp_path / "test.py" + script_path.write_text(server_script) + + config = { + "mcpServers": { + "good_server": { + "command": "python", + "args": [str(script_path)], + }, + "bad_server": { + "command": "this-command-does-not-exist-anywhere", + "args": [], + }, + } + } + + with caplog.at_level(logging.WARNING): + async with Client(config): + pass + + warning_records = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and "bad_server" in r.message + ] + assert len(warning_records) == 1 + + +async def test_multi_server_all_fail(): + """When all servers fail to connect, a ConnectionError should be raised.""" + config = MCPConfig( + mcpServers={ + "bad_1": StdioMCPServer( + command="this-command-does-not-exist-anywhere", + args=[], + ), + "bad_2": StdioMCPServer( + command="this-other-command-does-not-exist-either", + args=[], + ), + } + ) + + transport = MCPConfigTransport(config) + with pytest.raises(ConnectionError, match="All MCP servers failed to connect"): + async with transport.connect_session(): + pass + + +async def test_multi_server_partial_failure_cleanup(tmp_path: Path): + """Transports for failed servers should not leak into _transports.""" + server_script = inspect.cleandoc(""" + from fastmcp import FastMCP + + mcp = FastMCP() + + @mcp.tool + def ping() -> str: + return "pong" + + if __name__ == '__main__': + mcp.run() + """) + + script_path = tmp_path / "test.py" + script_path.write_text(server_script) + + config = { + "mcpServers": { + "working": { + "command": "python", + "args": [str(script_path)], + }, + "broken": { + "command": "this-command-does-not-exist-anywhere", + "args": [], + }, + } + } + + transport = MCPConfigTransport(config) + async with transport.connect_session(): + assert len(transport._transports) == 1 + + def sample_tool_fn(arg1: int, arg2: str) -> str: return f"Hello, world! {arg1} {arg2}"