|
|
"""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) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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.""" |
|
|
|
|
|
original = get_mcp_timeout_config() |
|
|
original_connect = original.connect_timeout |
|
|
original_execute = original.execute_timeout |
|
|
|
|
|
try: |
|
|
|
|
|
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: |
|
|
|
|
|
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: |
|
|
|
|
|
set_mcp_timeout_config(connect_timeout=25.0) |
|
|
config = get_mcp_timeout_config() |
|
|
assert config.connect_timeout == 25.0 |
|
|
|
|
|
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", |
|
|
) |
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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", |
|
|
|
|
|
} |
|
|
} |
|
|
} |
|
|
json.dump(config, f) |
|
|
f.flush() |
|
|
|
|
|
try: |
|
|
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_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", |
|
|
|
|
|
} |
|
|
} |
|
|
} |
|
|
json.dump(config, f) |
|
|
f.flush() |
|
|
|
|
|
try: |
|
|
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_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: |
|
|
|
|
|
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: |
|
|
|
|
|
tools = await load_mcp_tools_async("mini_agent/config/mcp.json") |
|
|
|
|
|
print(f"Loaded {len(tools)} MCP tools") |
|
|
|
|
|
|
|
|
if tools: |
|
|
for tool in tools: |
|
|
desc = tool.description[:60] if len(tool.description) > 60 else tool.description |
|
|
print(f" - {tool.name}: {desc}") |
|
|
|
|
|
|
|
|
assert isinstance(tools, list), "Should return a list of tools" |
|
|
print("✅ MCP tools loading test passed") |
|
|
|
|
|
finally: |
|
|
|
|
|
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: |
|
|
|
|
|
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)}") |
|
|
|
|
|
|
|
|
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}") |
|
|
|
|
|
|
|
|
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 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") |
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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 ===") |
|
|
|
|
|
|
|
|
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", |
|
|
) |
|
|
|
|
|
import time |
|
|
|
|
|
start = time.time() |
|
|
success = await conn.connect() |
|
|
elapsed = time.time() - start |
|
|
|
|
|
assert success is False, "Connection to unreachable server should fail" |
|
|
|
|
|
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, |
|
|
"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 |
|
|
|
|
|
|
|
|
assert tools == [] |
|
|
|
|
|
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()) |
|
|
|