Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 43 additions & 16 deletions cecli/mcp/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,17 +160,45 @@ async def connect_server(self, name: str) -> bool:
self._server_tools[server.name] = get_local_tool_schemas()
return True

try:
session = await server.connect()
tools = await experimental_mcp_client.load_mcp_tools(session=session, format="openai")
self._server_tools[server.name] = tools
self._connected_servers.add(server)
self._log_verbose(f"Connected to MCP server: {name}")
return True
except (Exception, asyncio.CancelledError) as e:
if server.name != "unnamed-server":
self._log_error(f"Failed to connect to MCP server {name}: {e}")
return False
# Retry with exponential backoff for transient connection failures.
# Note: This also fixes a latent bug where asyncio.CancelledError was
# silently caught and treated as a connection failure. CancelledError is
# now re-raised to properly propagate cancellation.
# When io is None (e.g., during from_servers before IO is assigned),
# _log_warning and _log_error silently return — retries still happen
# but with no user-visible feedback. This is intentional.
max_retries = 3
delay = 1.0
backoff = 2.0
max_delay = 30.0

for attempt in range(1, max_retries + 1):
try:
session = await server.connect()
tools = await experimental_mcp_client.load_mcp_tools(
session=session, format="openai"
)
self._server_tools[server.name] = tools
self._connected_servers.add(server)
self._log_verbose(f"Connected to MCP server: {name}")
return True
except asyncio.CancelledError:
raise
except Exception as e:
if attempt < max_retries:
self._log_warning(
f"Connection attempt {attempt} failed for {name}, "
f"retrying in {delay}s... ({e})"
)
await asyncio.sleep(delay)
delay = min(delay * backoff, max_delay)
else:
if server.name != "unnamed-server":
self._log_error(
f"Failed to connect to MCP server {name} "
f"after {max_retries} attempts: {e}"
)
return False

async def disconnect_server(self, name: str) -> bool:
"""
Expand Down Expand Up @@ -281,11 +309,10 @@ async def add_server_with_retry(
success = await mcp_manager.add_server(server, connect=False)
return (server, success)

for _attempt in range(max_retries):
success = await mcp_manager.add_server(server, connect=True)
if success:
return (server, True)
return (server, False)
# connect_server now has built-in retry logic, so we only need
# a single call here — no separate retry loop needed.
success = await mcp_manager.add_server(server, connect=True)
return (server, success)

tasks = []
for server in servers:
Expand Down
8 changes: 6 additions & 2 deletions tests/mcp/test_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,11 +149,15 @@ async def test_connect_server_failure(self, mock_server, mock_io):
manager = McpServerManager(servers=[mock_server], io=mock_io)
mock_server.connect.side_effect = Exception("Connection failed")

result = await manager.connect_server("test-server")
with patch("asyncio.sleep"):
result = await manager.connect_server("test-server")

assert result is False
mock_server.connect.assert_called_once()
assert mock_server.connect.call_count == 3 # 1 initial + 2 retries
assert mock_io.tool_warning.call_count == 2 # warnings for attempts 1 and 2
mock_io.tool_error.assert_called_once()
error_msg = mock_io.tool_error.call_args[0][0]
assert "after 3 attempts" in error_msg
assert mock_server not in manager._connected_servers

@pytest.mark.asyncio
Expand Down
Loading
Loading