|
19 | 19 | from key_value.aio.stores.memory import MemoryStore |
20 | 20 | from pydantic import SecretStr |
21 | 21 |
|
| 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 |
22 | 26 | from openhands.sdk.mcp import create_mcp_tools |
| 27 | +from openhands.sdk.mcp.client import MCPClient |
23 | 28 | from openhands.sdk.mcp.config import ( |
24 | 29 | MCPApiKeyAuthCredential, |
25 | 30 | MCPBasicAuthCredential, |
|
32 | 37 | ) |
33 | 38 | from openhands.sdk.mcp.exceptions import MCPError, MCPTimeoutError |
34 | 39 | from openhands.sdk.mcp.utils import _prepare_mcp_config |
| 40 | +from openhands.sdk.testing import TestLLM |
35 | 41 |
|
36 | 42 |
|
37 | 43 | logger = logging.getLogger(__name__) |
@@ -649,6 +655,170 @@ def test_create_mcp_tools_connection_to_nonexistent_server(): |
649 | 655 | pass # Expected connection errors are acceptable |
650 | 656 |
|
651 | 657 |
|
| 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 | + |
652 | 822 | def test_create_mcp_tools_stdio_server(): |
653 | 823 | """Test creating MCP tools from a native server map.""" |
654 | 824 | mcp_config = stdio_fetch_mcp_config() |
|
0 commit comments