Skip to content

Commit c6f677c

Browse files
fix(sdk): tolerate unavailable MCP servers
Treat MCP connection failures as optional by default while retaining an explicit strict mode, diagnostic server targets, and cleanup that preserves the original error. Add regressions for mixed-server discovery and LocalConversation startup. Co-authored-by: openhands <openhands@all-hands.dev>
1 parent df2ea8f commit c6f677c

3 files changed

Lines changed: 225 additions & 9 deletions

File tree

openhands-sdk/openhands/sdk/mcp/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ def sync_close(self) -> None:
103103
if hasattr(self, "close") and inspect.iscoroutinefunction(self.close):
104104
try:
105105
self._executor.run_async(self.close, timeout=10.0)
106-
except Exception:
106+
except BaseException: # noqa: BLE001 - cleanup must never mask the original error
107107
pass # Ignore close errors during cleanup
108108

109109
# Always cleanup the executor

openhands-sdk/openhands/sdk/mcp/utils.py

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@
2222
enabled_mcp_servers,
2323
to_fastmcp_mcp_config,
2424
)
25-
from openhands.sdk.mcp.exceptions import MCPTimeoutError
25+
from openhands.sdk.mcp.exceptions import MCPError, MCPTimeoutError
2626
from openhands.sdk.mcp.tool import MCPToolDefinition
27+
from openhands.sdk.utils.redact import redact_url_params
2728

2829

2930
logger = get_logger(__name__)
@@ -46,6 +47,7 @@ def create_tools(
4647
mcp_config: dict[str, MCPServer],
4748
timeout: float = 30.0,
4849
*,
50+
strict: bool = False,
4951
on_tools_changed: ToolsChangedCallback | None = None,
5052
on_tools_reconciled: ToolsReconciledCallback | None = None,
5153
) -> MCPClient: ...
@@ -59,12 +61,14 @@ def create_tools(
5961
mcp_config: dict[str, MCPServer],
6062
timeout: float = 30.0,
6163
*,
64+
strict: bool = False,
6265
on_tools_changed: ToolsChangedCallback | None = None,
6366
on_tools_reconciled: ToolsReconciledCallback | None = None,
6467
) -> MCPClient:
6568
return create_mcp_tools(
6669
mcp_config,
6770
timeout,
71+
strict=strict,
6872
on_tools_changed=on_tools_changed,
6973
on_tools_reconciled=on_tools_reconciled,
7074
)
@@ -312,10 +316,43 @@ async def _refresh_tools(self) -> None:
312316
)
313317

314318

319+
def _server_target(name: str, server: MCPServer) -> str:
320+
if server.url is not None:
321+
return f"{name!r} at {redact_url_params(server.url)!r}"
322+
if server.command is not None:
323+
return f"{name!r} using command {server.command!r}"
324+
return repr(name)
325+
326+
327+
def _connection_failure_message(
328+
mcp_config: Mapping[str, MCPServer], error: BaseException
329+
) -> str:
330+
targets = ", ".join(
331+
_server_target(name, server) for name, server in mcp_config.items()
332+
)
333+
detail = str(error.__cause__ or error)
334+
return (
335+
f"Failed to connect to MCP server(s): {targets}. {detail}\n"
336+
"Possible solutions:\n"
337+
" 1. Check if the MCP server is running and responding\n"
338+
" 2. Verify network connectivity to the MCP server"
339+
)
340+
341+
342+
def _close_client_quietly(client: MCPClient) -> None:
343+
try:
344+
client.sync_close()
345+
except BaseException as close_error: # noqa: BLE001 - cleanup must not mask the original error
346+
logger.debug(
347+
"Failed to close MCP client during error cleanup", exc_info=close_error
348+
)
349+
350+
315351
def create_mcp_tools(
316352
mcp_config: dict[str, MCPServer],
317353
timeout: float = 30.0,
318354
*,
355+
strict: bool = False,
319356
on_tools_changed: ToolsChangedCallback | None = None,
320357
on_tools_reconciled: ToolsReconciledCallback | None = None,
321358
mcp_oauth_token_storage: AsyncKeyValue | None = None,
@@ -330,6 +367,10 @@ def create_mcp_tools(
330367
# use tool
331368
# Connection automatically closed
332369
370+
By default, an unavailable server is logged and skipped so an optional
371+
MCP server cannot prevent the agent from starting. Pass ``strict=True``
372+
to preserve fail-fast behavior for configurations that require MCP.
373+
333374
The client subscribes to ``notifications/tools/list_changed`` and
334375
reconciles its tool list whenever the server signals a change. When
335376
``on_tools_changed`` is provided, the client invokes it with newly added
@@ -365,7 +406,7 @@ def create_mcp_tools(
365406
_connect_and_list_tools, timeout=timeout, client=client
366407
)
367408
except TimeoutError as e:
368-
client.sync_close()
409+
_close_client_quietly(client)
369410
# Extract server names from config for better error message
370411
server_names = (
371412
list(config.mcpServers.keys()) if config.mcpServers else ["unknown"]
@@ -381,13 +422,18 @@ def create_mcp_tools(
381422
raise MCPTimeoutError(
382423
error_msg, timeout=timeout, config=config.model_dump()
383424
) from e
425+
except (MCPError, ConnectionError) as e:
426+
error_msg = _connection_failure_message(mcp_config, e)
427+
_close_client_quietly(client)
428+
if strict:
429+
raise MCPError(error_msg) from e
430+
logger.warning(
431+
"%s. Continuing without MCP tools; pass strict=True to fail fast.",
432+
error_msg,
433+
)
434+
return client
384435
except BaseException:
385-
try:
386-
client.sync_close()
387-
except Exception as close_exc:
388-
logger.warning(
389-
"Failed to close MCP client during error cleanup", exc_info=close_exc
390-
)
436+
_close_client_quietly(client)
391437
raise
392438

393439
logger.info("Created %d MCP tools", len(client.tools))

tests/sdk/mcp/test_create_mcp_tool.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@
1919
from key_value.aio.stores.memory import MemoryStore
2020
from pydantic import SecretStr
2121

22+
from openhands.sdk.agent import Agent
23+
from openhands.sdk.conversation.impl.local_conversation import LocalConversation
24+
from openhands.sdk.conversation.state import ConversationExecutionStatus
25+
from openhands.sdk.llm import Message, TextContent
2226
from openhands.sdk.mcp import create_mcp_tools
27+
from openhands.sdk.mcp.client import MCPClient
2328
from openhands.sdk.mcp.config import (
2429
MCPApiKeyAuthCredential,
2530
MCPBasicAuthCredential,
@@ -32,6 +37,7 @@
3237
)
3338
from openhands.sdk.mcp.exceptions import MCPError, MCPTimeoutError
3439
from openhands.sdk.mcp.utils import _prepare_mcp_config
40+
from openhands.sdk.testing import TestLLM
3541

3642

3743
logger = logging.getLogger(__name__)
@@ -649,6 +655,170 @@ def test_create_mcp_tools_connection_to_nonexistent_server():
649655
pass # Expected connection errors are acceptable
650656

651657

658+
def test_unreachable_server_is_skipped_with_diagnostic_warning(caplog):
659+
"""An unavailable optional server must not prevent tool creation."""
660+
config = native_mcp_config(
661+
{
662+
"mcpServers": {
663+
"broken": {
664+
"transport": "http",
665+
"url": "http://127.0.0.1:59999/mcp?api_key=secret",
666+
}
667+
}
668+
}
669+
)
670+
671+
with caplog.at_level(logging.WARNING):
672+
tools = create_mcp_tools(config, timeout=5.0)
673+
674+
assert len(tools) == 0
675+
assert "broken" in caplog.text
676+
assert "http://127.0.0.1:59999/mcp?api_key=%3Credacted%3E" in caplog.text
677+
assert "secret" not in caplog.text
678+
assert "Possible solutions" in caplog.text
679+
assert "strict=True" in caplog.text
680+
681+
682+
def test_unreachable_server_can_fail_fast_in_strict_mode():
683+
"""Strict mode retains the opt-in fail-fast behavior with context."""
684+
config = native_mcp_config(
685+
{
686+
"mcpServers": {
687+
"broken": {
688+
"transport": "http",
689+
"url": "http://127.0.0.1:59999/mcp",
690+
}
691+
}
692+
}
693+
)
694+
695+
with pytest.raises(MCPError) as exc_info:
696+
create_mcp_tools(config, timeout=5.0, strict=True)
697+
698+
assert "broken" in str(exc_info.value)
699+
assert "http://127.0.0.1:59999/mcp" in str(exc_info.value)
700+
assert exc_info.value.__cause__ is not None
701+
assert exc_info.value.__cause__.__cause__ is not None
702+
703+
704+
def test_reachable_server_tools_survive_unreachable_server(
705+
http_mcp_server: MCPTestServer,
706+
):
707+
"""A failed optional server must not discard tools from a healthy server."""
708+
config = native_mcp_config(
709+
{
710+
"mcpServers": {
711+
"healthy": {
712+
"transport": "http",
713+
"url": f"http://127.0.0.1:{http_mcp_server.port}/mcp",
714+
},
715+
"broken": {
716+
"transport": "http",
717+
"url": "http://127.0.0.1:59999/mcp",
718+
},
719+
}
720+
}
721+
)
722+
723+
tools = create_mcp_tools(config, timeout=10.0)
724+
725+
assert {tool.name for tool in tools} == {"healthy_greet", "healthy_add_numbers"}
726+
727+
728+
def test_local_conversation_runs_with_unreachable_mcp_server(tmp_path: Path, caplog):
729+
"""An unavailable MCP source must not block the agent's LLM path."""
730+
llm = TestLLM.from_messages(
731+
[Message(role="assistant", content=[TextContent(text="done")])]
732+
)
733+
agent = Agent(
734+
llm=llm,
735+
tools=[],
736+
include_default_tools=[],
737+
mcp_config=native_mcp_config(
738+
{
739+
"mcpServers": {
740+
"broken": {
741+
"transport": "http",
742+
"url": "http://127.0.0.1:59999/mcp",
743+
}
744+
}
745+
}
746+
),
747+
)
748+
conversation = LocalConversation(
749+
agent=agent,
750+
workspace=str(tmp_path),
751+
visualizer=None,
752+
)
753+
754+
try:
755+
with caplog.at_level(logging.WARNING):
756+
conversation.send_message("hello")
757+
conversation.run()
758+
finally:
759+
conversation.close()
760+
761+
assert conversation.state.execution_status == ConversationExecutionStatus.FINISHED
762+
assert llm.call_count == 1
763+
assert "broken" in caplog.text
764+
765+
766+
def test_cleanup_failure_does_not_mask_connection_failure():
767+
"""Cleanup errors must not replace the original MCP connection error."""
768+
config = native_mcp_config(
769+
{
770+
"mcpServers": {
771+
"broken": {
772+
"transport": "http",
773+
"url": "http://127.0.0.1:59999/mcp",
774+
}
775+
}
776+
}
777+
)
778+
779+
with patch("openhands.sdk.mcp.utils.MCPClient") as mock_client_class:
780+
mock_client = MagicMock()
781+
mock_client_class.return_value = mock_client
782+
mock_client.call_async_from_sync.side_effect = MCPError(
783+
"MCP Connection Failure"
784+
)
785+
mock_client.sync_close.side_effect = BaseException("cleanup failed")
786+
787+
with pytest.raises(MCPError, match="broken") as exc_info:
788+
create_mcp_tools(config, timeout=5.0, strict=True)
789+
790+
assert "MCP Connection Failure" in str(exc_info.value.__cause__)
791+
792+
793+
def test_sync_close_suppresses_base_exception_from_async_close():
794+
"""Client cleanup must handle cancellation-style BaseException values."""
795+
client = MCPClient(
796+
FastMCPConfig.model_validate(
797+
{
798+
"mcpServers": {
799+
"server": {
800+
"transport": "http",
801+
"url": "http://127.0.0.1:59999/mcp",
802+
}
803+
}
804+
}
805+
)
806+
)
807+
with (
808+
patch.object(
809+
client._executor,
810+
"run_async",
811+
side_effect=BaseException("cleanup failed"),
812+
) as run_async,
813+
patch.object(client._executor, "close") as executor_close,
814+
):
815+
client.sync_close()
816+
817+
run_async.assert_called_once()
818+
executor_close.assert_called_once()
819+
assert client._closed is True
820+
821+
652822
def test_create_mcp_tools_stdio_server():
653823
"""Test creating MCP tools from a native server map."""
654824
mcp_config = stdio_fetch_mcp_config()

0 commit comments

Comments
 (0)