mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-20 20:44:17 +02:00
Graceful degradation for multi-server proxy setup (#3546)
* Graceful degradation for multi-server proxy setup * Add comment for broad except, parameterize test ordering
This commit is contained in:
parent
734b93b999
commit
02d55dec32
2 changed files with 165 additions and 11 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue