"""Test cases for MCP tool loading and Git-based MCP servers.""" import asyncio import json import tempfile from pathlib import Path import pytest from mini_agent.tools.mcp_loader import ( MCPServerConnection, MCPTimeoutConfig, _determine_connection_type, cleanup_mcp_connections, get_mcp_timeout_config, load_mcp_tools_async, set_mcp_timeout_config, ) @pytest.fixture(scope="module") def mcp_config(): """Read MCP configuration.""" mcp_config_path = Path("mini_agent/config/mcp.json") with open(mcp_config_path, encoding="utf-8") as f: return json.load(f) # ============================================================================= # Connection Type Detection Tests # ============================================================================= class TestDetermineConnectionType: """Tests for _determine_connection_type function.""" def test_stdio_with_command_only(self): """STDIO is default when only command is specified.""" config = {"command": "npx", "args": ["-y", "some-server"]} assert _determine_connection_type(config) == "stdio" def test_stdio_explicit_type(self): """Explicit type=stdio should return stdio.""" config = {"command": "npx", "type": "stdio"} assert _determine_connection_type(config) == "stdio" def test_url_defaults_to_streamable_http(self): """URL without explicit type should default to streamable_http.""" config = {"url": "https://mcp.example.com/mcp"} assert _determine_connection_type(config) == "streamable_http" def test_sse_explicit_type(self): """Explicit type=sse should return sse.""" config = {"url": "https://mcp.example.com/sse", "type": "sse"} assert _determine_connection_type(config) == "sse" def test_http_explicit_type(self): """Explicit type=http should return http.""" config = {"url": "https://mcp.example.com/http", "type": "http"} assert _determine_connection_type(config) == "http" def test_streamable_http_explicit_type(self): """Explicit type=streamable_http should return streamable_http.""" config = {"url": "https://mcp.example.com/mcp", "type": "streamable_http"} assert _determine_connection_type(config) == "streamable_http" def test_case_insensitive_type(self): """Type should be case insensitive.""" config = {"url": "https://mcp.example.com/sse", "type": "SSE"} assert _determine_connection_type(config) == "sse" def test_empty_config_defaults_to_stdio(self): """Empty config should default to stdio.""" config = {} assert _determine_connection_type(config) == "stdio" def test_unknown_type_with_url_defaults_to_streamable_http(self): """Unknown type with URL should default to streamable_http.""" config = {"url": "https://mcp.example.com/mcp", "type": "unknown"} assert _determine_connection_type(config) == "streamable_http" # ============================================================================= # MCPServerConnection Initialization Tests # ============================================================================= class TestMCPServerConnectionInit: """Tests for MCPServerConnection initialization.""" def test_stdio_connection_init(self): """Test STDIO connection initialization.""" conn = MCPServerConnection( name="test-stdio", connection_type="stdio", command="npx", args=["-y", "test-server"], env={"API_KEY": "test"}, ) assert conn.name == "test-stdio" assert conn.connection_type == "stdio" assert conn.command == "npx" assert conn.args == ["-y", "test-server"] assert conn.env == {"API_KEY": "test"} assert conn.url is None def test_url_connection_init(self): """Test URL-based connection initialization.""" conn = MCPServerConnection( name="test-url", connection_type="streamable_http", url="https://mcp.example.com/mcp", headers={"Authorization": "Bearer token"}, ) assert conn.name == "test-url" assert conn.connection_type == "streamable_http" assert conn.url == "https://mcp.example.com/mcp" assert conn.headers == {"Authorization": "Bearer token"} assert conn.command is None def test_sse_connection_init(self): """Test SSE connection initialization.""" conn = MCPServerConnection( name="test-sse", connection_type="sse", url="https://mcp.example.com/sse", ) assert conn.name == "test-sse" assert conn.connection_type == "sse" assert conn.url == "https://mcp.example.com/sse" def test_default_values(self): """Test default values for optional parameters.""" conn = MCPServerConnection(name="test-default") assert conn.connection_type == "stdio" assert conn.args == [] assert conn.env == {} assert conn.headers == {} def test_timeout_overrides(self): """Test per-server timeout override initialization.""" conn = MCPServerConnection( name="test-timeout", connection_type="sse", url="https://mcp.example.com/sse", connect_timeout=15.0, execute_timeout=90.0, sse_read_timeout=180.0, ) assert conn.connect_timeout == 15.0 assert conn.execute_timeout == 90.0 assert conn.sse_read_timeout == 180.0 # ============================================================================= # Timeout Configuration Tests # ============================================================================= class TestMCPTimeoutConfig: """Tests for MCP timeout configuration.""" def test_default_timeout_config(self): """Test default timeout configuration values.""" config = MCPTimeoutConfig() assert config.connect_timeout == 10.0 assert config.execute_timeout == 60.0 assert config.sse_read_timeout == 120.0 def test_custom_timeout_config(self): """Test custom timeout configuration values.""" config = MCPTimeoutConfig( connect_timeout=5.0, execute_timeout=30.0, sse_read_timeout=60.0, ) assert config.connect_timeout == 5.0 assert config.execute_timeout == 30.0 assert config.sse_read_timeout == 60.0 def test_set_global_timeout_config(self): """Test setting global timeout configuration.""" # Save original config original = get_mcp_timeout_config() original_connect = original.connect_timeout original_execute = original.execute_timeout try: # Set new values set_mcp_timeout_config(connect_timeout=20.0, execute_timeout=120.0) config = get_mcp_timeout_config() assert config.connect_timeout == 20.0 assert config.execute_timeout == 120.0 finally: # Restore original values set_mcp_timeout_config( connect_timeout=original_connect, execute_timeout=original_execute, ) def test_partial_timeout_config_update(self): """Test partial update of timeout configuration.""" original = get_mcp_timeout_config() original_connect = original.connect_timeout original_execute = original.execute_timeout original_sse = original.sse_read_timeout try: # Only update connect_timeout set_mcp_timeout_config(connect_timeout=25.0) config = get_mcp_timeout_config() assert config.connect_timeout == 25.0 # Other values should remain unchanged from previous test state finally: set_mcp_timeout_config( connect_timeout=original_connect, execute_timeout=original_execute, sse_read_timeout=original_sse, ) class TestMCPServerConnectionTimeout: """Tests for MCPServerConnection timeout behavior.""" def test_get_effective_connect_timeout_with_override(self): """Test getting effective connect timeout with per-server override.""" conn = MCPServerConnection( name="test", connection_type="sse", url="https://example.com", connect_timeout=20.0, ) assert conn._get_connect_timeout() == 20.0 def test_get_effective_connect_timeout_without_override(self): """Test getting effective connect timeout using global default.""" conn = MCPServerConnection( name="test", connection_type="sse", url="https://example.com", ) # Should use global default global_config = get_mcp_timeout_config() assert conn._get_connect_timeout() == global_config.connect_timeout def test_get_effective_execute_timeout_with_override(self): """Test getting effective execute timeout with per-server override.""" conn = MCPServerConnection( name="test", connection_type="sse", url="https://example.com", execute_timeout=180.0, ) assert conn._get_execute_timeout() == 180.0 # ============================================================================= # URL-based Config Loading Tests # ============================================================================= @pytest.mark.asyncio async def test_url_config_validation(): """Test that URL-based config without url is rejected.""" with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: config = { "mcpServers": { "broken-sse": { "type": "sse", # Missing "url" field } } } json.dump(config, f) f.flush() try: tools = await load_mcp_tools_async(f.name) # Should return empty list (server skipped due to missing url) assert tools == [] finally: await cleanup_mcp_connections() Path(f.name).unlink() @pytest.mark.asyncio async def test_stdio_config_validation(): """Test that STDIO config without command is rejected.""" with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: config = { "mcpServers": { "broken-stdio": { "type": "stdio", # Missing "command" field } } } json.dump(config, f) f.flush() try: tools = await load_mcp_tools_async(f.name) # Should return empty list (server skipped due to missing command) assert tools == [] finally: await cleanup_mcp_connections() Path(f.name).unlink() @pytest.mark.asyncio async def test_mixed_config_loading(): """Test loading config with both STDIO and URL-based servers.""" with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: config = { "mcpServers": { "stdio-server": {"command": "npx", "args": ["-y", "nonexistent-server"], "disabled": True}, "url-server": {"url": "https://mcp.nonexistent.example.com/mcp", "disabled": True}, "sse-server": {"url": "https://sse.nonexistent.example.com/sse", "type": "sse", "disabled": True}, } } json.dump(config, f) f.flush() try: # All servers are disabled, should return empty but not error tools = await load_mcp_tools_async(f.name) assert tools == [] finally: await cleanup_mcp_connections() Path(f.name).unlink() @pytest.mark.asyncio async def test_mcp_tools_loading(): """Test loading MCP tools from mcp.json.""" print("\n=== Testing MCP Tool Loading ===") try: # Load MCP tools tools = await load_mcp_tools_async("mini_agent/config/mcp.json") print(f"Loaded {len(tools)} MCP tools") # Display loaded tools if tools: for tool in tools: desc = tool.description[:60] if len(tool.description) > 60 else tool.description print(f" - {tool.name}: {desc}") # Test should pass even if no tools loaded (e.g., no mcp.json or no Node.js) assert isinstance(tools, list), "Should return a list of tools" print("โœ… MCP tools loading test passed") finally: # Cleanup MCP connections await cleanup_mcp_connections() @pytest.mark.asyncio async def test_git_mcp_loading(mcp_config): """Test loading MCP Server from Git repository (minimax_search).""" print("\n" + "=" * 70) print("Testing: Loading MiniMax Search MCP Server from Git repository") print("=" * 70) git_url = mcp_config["mcpServers"]["minimax_search"]["args"][1] print(f"\n๐Ÿ“ Git repository: {git_url}") print("โณ Cloning and installing...\n") try: # Load MCP tools tools = await load_mcp_tools_async("mini_agent/config/mcp.json") print("\nโœ… Loaded successfully!") print("\n๐Ÿ“Š Statistics:") print(f" โ€ข Total tools loaded: {len(tools)}") # Verify tools list is not empty assert isinstance(tools, list), "Should return a list of tools" if tools: print("\n๐Ÿ”ง Available tools:") for tool in tools: desc = tool.description[:80] + "..." if len(tool.description) > 80 else tool.description print(f" โ€ข {tool.name}") print(f" {desc}") # Verify expected tools from minimax_search expected_tools = ["search", "parallel_search", "browse"] loaded_tool_names = [t.name for t in tools] print("\n๐Ÿ” Function verification:") found_count = 0 for expected in expected_tools: if expected in loaded_tool_names: print(f" โœ… {expected} - OK") found_count += 1 else: print(f" โŒ {expected} - Missing") # If no expected tools found, minimax_search connection failed if found_count == 0: print("\nโš ๏ธ Warning: minimax_search MCP Server connection failed") print("This may be due to SSH key authentication requirements or network issues") pytest.skip("minimax_search MCP Server connection failed, skipping test") # Assert all expected tools exist missing_tools = [t for t in expected_tools if t not in loaded_tool_names] assert len(missing_tools) == 0, f"Missing tools: {missing_tools}" print("\n" + "=" * 70) print("โœ… All tests passed! MCP Server loaded from Git repository successfully!") print("=" * 70) finally: # Cleanup MCP connections print("\n๐Ÿงน Cleaning up MCP connections...") await cleanup_mcp_connections() @pytest.mark.asyncio async def test_git_mcp_tool_availability(): """Test Git MCP tool availability.""" print("\n=== Testing Git MCP Tool Availability ===") try: tools = await load_mcp_tools_async("mini_agent/config/mcp.json") if not tools: pytest.skip("No MCP tools loaded") return # Find search tool search_tool = None for tool in tools: if "search" in tool.name.lower(): search_tool = tool break assert search_tool is not None, "Should contain search-related tools" print(f"โœ… Found search tool: {search_tool.name}") finally: await cleanup_mcp_connections() @pytest.mark.asyncio async def test_mcp_tool_execution(): """Test executing an MCP tool if available (memory server).""" print("\n=== Testing MCP Tool Execution ===") try: tools = await load_mcp_tools_async("mini_agent/config/mcp.json") if not tools: print("โš ๏ธ No MCP tools loaded, skipping execution test") pytest.skip("No MCP tools available") return # Try to find and test create_entities (from memory server) create_tool = None for tool in tools: if tool.name == "create_entities": create_tool = tool break if create_tool: print(f"Testing: {create_tool.name}") try: result = await create_tool.execute( entities=[ { "name": "test_entity", "entityType": "test", "observations": ["Test observation for pytest"], } ] ) assert result.success, f"Tool execution should succeed: {result.error}" print(f"โœ… Tool execution successful: {result.content[:100]}") except Exception as e: pytest.fail(f"Tool execution failed: {e}") else: print("โš ๏ธ create_entities tool not found, skipping execution test") pytest.skip("create_entities tool not available") finally: await cleanup_mcp_connections() @pytest.mark.asyncio async def test_connection_timeout_on_unreachable_server(): """Test that connection to unreachable server times out properly.""" print("\n=== Testing Connection Timeout ===") # Set a short timeout for testing original = get_mcp_timeout_config() original_connect = original.connect_timeout try: set_mcp_timeout_config(connect_timeout=2.0) conn = MCPServerConnection( name="unreachable-test", connection_type="streamable_http", url="https://10.255.255.1:9999/mcp", # Non-routable IP, will timeout ) import time start = time.time() success = await conn.connect() elapsed = time.time() - start assert success is False, "Connection to unreachable server should fail" # Should timeout within reasonable time (connect_timeout + some overhead) assert elapsed < 10.0, f"Should timeout quickly, but took {elapsed:.1f}s" print(f"โœ… Connection timed out as expected in {elapsed:.1f}s") finally: set_mcp_timeout_config(connect_timeout=original_connect) await cleanup_mcp_connections() @pytest.mark.asyncio async def test_per_server_timeout_override_in_config(): """Test that per-server timeout overrides from config are respected.""" print("\n=== Testing Per-Server Timeout Override ===") with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: config = { "mcpServers": { "fast-server": { "url": "https://10.255.255.1:9999/mcp", "connect_timeout": 1.0, # Very short timeout "execute_timeout": 30.0, } } } json.dump(config, f) f.flush() try: import time start = time.time() tools = await load_mcp_tools_async(f.name) elapsed = time.time() - start # Should fail due to unreachable server assert tools == [] # Should respect the short 1.0s connect_timeout assert elapsed < 5.0, f"Should use per-server timeout, but took {elapsed:.1f}s" print(f"โœ… Per-server timeout override worked, failed in {elapsed:.1f}s") finally: await cleanup_mcp_connections() Path(f.name).unlink() async def main(): """Run all MCP tests.""" print("=" * 80) print("Running MCP Integration Tests") print("=" * 80) print("\nNote: These tests require Node.js and will use MCP servers defined in mcp.json") print("Tests will pass even if MCP is not configured.\n") await test_mcp_tools_loading() await test_mcp_tool_execution() await test_connection_timeout_on_unreachable_server() print("\n" + "=" * 80) print("MCP tests completed! โœ…") print("=" * 80) if __name__ == "__main__": asyncio.run(main())