import os from unittest.mock import AsyncMock, MagicMock, patch import pytest from pydantic import ValidationError from openhands.controller.agent import Agent from openhands.core.config import OpenHandsConfig, load_from_env from openhands.core.config.mcp_config import ( MCPConfig, MCPSHTTPServerConfig, MCPSSEServerConfig, MCPStdioServerConfig, ) from openhands.llm.llm_registry import LLMRegistry from openhands.server.services.conversation_stats import ConversationStats from openhands.server.session.conversation_init_data import ConversationInitData from openhands.server.session.session import Session from openhands.storage.memory import InMemoryFileStore def test_valid_sse_config(): """Test a valid SSE configuration.""" config = MCPConfig( sse_servers=[ MCPSSEServerConfig(url='http://server1:8080'), MCPSSEServerConfig(url='http://server2:8080'), ] ) config.validate_servers() # Should not raise any exception def test_empty_sse_config(): """Test SSE configuration with empty servers list.""" config = MCPConfig(sse_servers=[]) config.validate_servers() def test_invalid_sse_url(): """Test SSE configuration with invalid URL format.""" with pytest.raises(ValidationError) as exc_info: MCPSSEServerConfig(url='not_a_url') assert 'URL must include a scheme' in str(exc_info.value) def test_duplicate_sse_urls(): """Test SSE configuration with duplicate server URLs.""" config = MCPConfig( sse_servers=[ MCPSSEServerConfig(url='http://server1:8080'), MCPSSEServerConfig(url='http://server1:8080'), ] ) with pytest.raises(ValueError) as exc_info: config.validate_servers() assert 'Duplicate MCP server URLs are not allowed' in str(exc_info.value) def test_from_toml_section_valid(): """Test creating config from valid TOML section.""" data = { 'sse_servers': ['http://server1:8080'], } result = MCPConfig.from_toml_section(data) assert 'mcp' in result assert len(result['mcp'].sse_servers) == 1 assert result['mcp'].sse_servers[0].url == 'http://server1:8080' def test_from_toml_section_invalid_sse(): """Test creating config from TOML section with invalid SSE URL.""" data = { 'sse_servers': ['not_a_url'], } with pytest.raises(ValueError) as exc_info: MCPConfig.from_toml_section(data) assert 'URL must include a scheme' in str(exc_info.value) def test_complex_urls(): """Test SSE configuration with complex URLs.""" config = MCPConfig( sse_servers=[ MCPSSEServerConfig(url='https://user:pass@server1:8080/path?query=1'), MCPSSEServerConfig(url='wss://server2:8443/ws'), MCPSSEServerConfig(url='http://subdomain.example.com:9090'), ] ) config.validate_servers() # Should not raise any exception def test_mcp_sse_server_config_with_api_key(): """Test MCPSSEServerConfig with API key.""" config = MCPSSEServerConfig(url='http://server1:8080', api_key='test-api-key') assert config.url == 'http://server1:8080' assert config.api_key == 'test-api-key' def test_mcp_sse_server_config_without_api_key(): """Test MCPSSEServerConfig without API key.""" config = MCPSSEServerConfig(url='http://server1:8080') assert config.url == 'http://server1:8080' assert config.api_key is None def test_mcp_stdio_server_config_basic(): """Test basic MCPStdioServerConfig.""" config = MCPStdioServerConfig(name='test-server', command='python') assert config.name == 'test-server' assert config.command == 'python' assert config.args == [] assert config.env == {} def test_mcp_stdio_server_config_with_args_and_env(): """Test MCPStdioServerConfig with args and env.""" config = MCPStdioServerConfig( name='test-server', command='python', args=['-m', 'server'], env={'DEBUG': 'true', 'PORT': '8080'}, ) assert config.name == 'test-server' assert config.command == 'python' assert config.args == ['-m', 'server'] assert config.env == {'DEBUG': 'true', 'PORT': '8080'} def test_mcp_config_with_stdio_servers(): """Test MCPConfig with stdio servers.""" stdio_server = MCPStdioServerConfig( name='test-server', command='python', args=['-m', 'server'], env={'DEBUG': 'true'}, ) config = MCPConfig(stdio_servers=[stdio_server]) assert len(config.stdio_servers) == 1 assert config.stdio_servers[0].name == 'test-server' assert config.stdio_servers[0].command == 'python' assert config.stdio_servers[0].args == ['-m', 'server'] assert config.stdio_servers[0].env == {'DEBUG': 'true'} def test_from_toml_section_with_stdio_servers(): """Test creating config from TOML section with stdio servers.""" data = { 'sse_servers': ['http://server1:8080'], 'stdio_servers': [ { 'name': 'test-server', 'command': 'python', 'args': ['-m', 'server'], 'env': {'DEBUG': 'true'}, } ], } result = MCPConfig.from_toml_section(data) assert 'mcp' in result assert len(result['mcp'].sse_servers) == 1 assert result['mcp'].sse_servers[0].url == 'http://server1:8080' assert len(result['mcp'].stdio_servers) == 1 assert result['mcp'].stdio_servers[0].name == 'test-server' assert result['mcp'].stdio_servers[0].command == 'python' assert result['mcp'].stdio_servers[0].args == ['-m', 'server'] assert result['mcp'].stdio_servers[0].env == {'DEBUG': 'true'} def test_mcp_config_with_both_server_types(): """Test MCPConfig with both SSE and stdio servers.""" sse_server = MCPSSEServerConfig(url='http://server1:8080', api_key='test-api-key') stdio_server = MCPStdioServerConfig( name='test-server', command='python', args=['-m', 'server'], env={'DEBUG': 'true'}, ) config = MCPConfig(sse_servers=[sse_server], stdio_servers=[stdio_server]) assert len(config.sse_servers) == 1 assert config.sse_servers[0].url == 'http://server1:8080' assert config.sse_servers[0].api_key == 'test-api-key' assert len(config.stdio_servers) == 1 assert config.stdio_servers[0].name == 'test-server' assert config.stdio_servers[0].command == 'python' def test_mcp_config_model_validation_error(): """Test MCPConfig validation error with invalid data.""" with pytest.raises(ValidationError): # Missing required 'url' field MCPSSEServerConfig() with pytest.raises(ValidationError): # Missing required 'name' and 'command' fields MCPStdioServerConfig() def test_mcp_config_extra_fields_forbidden(): """Test that extra fields are forbidden in MCPConfig.""" with pytest.raises(ValidationError): MCPConfig(extra_field='value') # Note: The nested models don't have 'extra': 'forbid' set, so they allow extra fields # We're only testing the main MCPConfig class here def test_stdio_server_equality_with_different_env_order(): """Test that MCPStdioServerConfig equality works with env in different order but respects arg order.""" # Test 1: Same args, different env order server1 = MCPStdioServerConfig( name='test-server', command='python', args=['--verbose', '--debug', '--port=8080'], env={'DEBUG': 'true', 'PORT': '8080'}, ) server2 = MCPStdioServerConfig( name='test-server', command='python', args=['--verbose', '--debug', '--port=8080'], # Same order env={'PORT': '8080', 'DEBUG': 'true'}, # Different order ) # Should be equal because env is compared as a set assert server1 == server2 # Test 2: Different args order server3 = MCPStdioServerConfig( name='test-server', command='python', args=['--debug', '--port=8080', '--verbose'], # Different order env={'DEBUG': 'true', 'PORT': '8080'}, ) # Should NOT be equal because args order matters assert server1 != server3 # Test 3: Different arg value server4 = MCPStdioServerConfig( name='test-server', command='python', args=['--verbose', '--debug', '--port=9090'], # Different port env={'DEBUG': 'true', 'PORT': '8080'}, ) # Should not be equal assert server1 != server4 # Test 4: Different env value server5 = MCPStdioServerConfig( name='test-server', command='python', args=['--verbose', '--debug', '--port=8080'], env={'DEBUG': 'true', 'PORT': '9090'}, # Different port ) # Should not be equal assert server1 != server5 def test_mcp_stdio_server_args_parsing_basic(): """Test MCPStdioServerConfig args parsing with basic shell-like format.""" # Test basic space-separated parsing config = MCPStdioServerConfig( name='test-server', command='python', args='arg1 arg2 arg3' ) assert config.args == ['arg1', 'arg2', 'arg3'] # Test single argument config = MCPStdioServerConfig( name='test-server', command='python', args='single-arg' ) assert config.args == ['single-arg'] def test_mcp_stdio_server_args_parsing_invalid_quotes(): """Test MCPStdioServerConfig args parsing with invalid quotes.""" # Test unmatched quotes with pytest.raises(ValidationError) as exc_info: MCPStdioServerConfig( name='test-server', command='python', args='--config "unmatched quote' ) assert 'Invalid argument format' in str(exc_info.value) def test_env_var_mcp_shttp_server_config(monkeypatch): """Test creating MCPSHTTPServerConfig from environment variables.""" # Set environment variables for MCP HTTP server monkeypatch.setenv( 'MCP_SHTTP_SERVERS', '[{"url": "http://env-server:8080", "api_key": "env-api-key"}]', ) # Create a config object config = OpenHandsConfig() # Load from environment load_from_env(config, os.environ) # Convert dictionary servers to proper server config objects by creating a new MCPConfig # This triggers the model validator which automatically converts dict servers to proper objects config.mcp = MCPConfig( sse_servers=config.mcp.sse_servers, stdio_servers=config.mcp.stdio_servers, shttp_servers=config.mcp.shttp_servers, ) # Check that the HTTP server was added assert len(config.mcp.shttp_servers) == 1 # Access the first server server = config.mcp.shttp_servers[0] # Verify it's a proper server config object assert isinstance(server, MCPSHTTPServerConfig) assert server.url == 'http://env-server:8080' assert server.api_key == 'env-api-key' # Now let's create a proper MCPConfig with the values from the environment mcp_config = MCPConfig(shttp_servers=config.mcp.shttp_servers) # Verify that the MCPSHTTPServerConfig objects are created correctly assert len(mcp_config.shttp_servers) == 1 assert isinstance(mcp_config.shttp_servers[0], MCPSHTTPServerConfig) assert mcp_config.shttp_servers[0].url == 'http://env-server:8080' assert mcp_config.shttp_servers[0].api_key == 'env-api-key' def test_env_var_mcp_shttp_server_config_with_toml(monkeypatch, tmp_path): """Test creating MCPSHTTPServerConfig from environment variables with TOML config.""" # Create a TOML file with some MCP configuration toml_file = tmp_path / 'config.toml' with open(toml_file, 'w', encoding='utf-8') as f: f.write(""" [mcp] sse_servers = ["http://toml-server:8080"] shttp_servers = [ { url = "http://toml-http-server:8080", api_key = "toml-api-key" } ] """) # Set environment variables for MCP HTTP server to override TOML monkeypatch.setenv( 'MCP_SHTTP_SERVERS', '[{"url": "http://env-server:8080", "api_key": "env-api-key"}]', ) # Create a config object config = OpenHandsConfig() # Load from TOML first from openhands.core.config import load_from_toml load_from_toml(config, str(toml_file)) # Verify TOML values were loaded assert len(config.mcp.shttp_servers) == 1 assert isinstance(config.mcp.shttp_servers[0], MCPSHTTPServerConfig) assert config.mcp.shttp_servers[0].url == 'http://toml-http-server:8080' assert config.mcp.shttp_servers[0].api_key == 'toml-api-key' # Now load from environment, which should override TOML load_from_env(config, os.environ) # Check that the environment values override the TOML values assert len(config.mcp.shttp_servers) == 1 # The values should now be from the environment server = config.mcp.shttp_servers[0] assert isinstance(server, MCPSHTTPServerConfig) assert server.url == 'http://env-server:8080' assert server.api_key == 'env-api-key' def test_env_var_mcp_shttp_servers_with_python_str_representation(monkeypatch): """Test creating MCPSHTTPServerConfig from environment variables using Python string representation.""" # Create a Python list of dictionaries mcp_shttp_servers = [ {'url': 'https://example.com/mcp/mcp', 'api_key': 'test-api-key'} ] # Set environment variable with the string representation of the Python list monkeypatch.setenv('MCP_SHTTP_SERVERS', str(mcp_shttp_servers)) # Create a config object config = OpenHandsConfig() # Load from environment load_from_env(config, os.environ) # Check that the HTTP server was added assert len(config.mcp.shttp_servers) == 1 # Access the first server server = config.mcp.shttp_servers[0] # Check that it's a dict with the expected keys assert isinstance(server, MCPSHTTPServerConfig) assert server.url == 'https://example.com/mcp/mcp' assert server.api_key == 'test-api-key' @pytest.mark.asyncio async def test_session_preserves_env_mcp_config(monkeypatch): """Test that Session preserves MCP configuration from environment variables.""" # Set environment variables for MCP HTTP server monkeypatch.setenv( 'MCP_SHTTP_SERVERS', '[{"url": "http://env-server:8080", "api_key": "env-api-key"}]', ) # Also set MCP_HOST to prevent the default server from being added monkeypatch.setenv('MCP_HOST', 'dummy') # Create a config object and load from environment config = OpenHandsConfig() load_from_env(config, os.environ) # Verify the environment variables were loaded into the config assert config.mcp_host == 'dummy' assert len(config.mcp.shttp_servers) == 1 # If it's already a proper server config object, just verify it assert isinstance(config.mcp.shttp_servers[0], MCPSHTTPServerConfig) assert config.mcp.shttp_servers[0].url == 'http://env-server:8080' assert config.mcp.shttp_servers[0].api_key == 'env-api-key' # Create a session with the config session = Session( sid='test-sid', file_store=InMemoryFileStore({}), config=config, sio=AsyncMock(), llm_registry=LLMRegistry(config=OpenHandsConfig()), conversation_stats=ConversationStats(None, 'test-sid', None), ) # Create empty settings settings = ConversationInitData() # Mock the Agent.get_cls method to avoid AgentNotRegisteredError mock_agent_cls = MagicMock() mock_agent_instance = MagicMock() mock_agent_cls.return_value = mock_agent_instance # Initialize the agent (this is where the MCP config would be reset) with ( patch.object(session.agent_session, 'start', AsyncMock()), patch.object(Agent, 'get_cls', return_value=mock_agent_cls), ): await session.initialize_agent(settings, None, None) # Verify that the MCP configuration was preserved assert len(session.config.mcp.shttp_servers) >= 0 # Clean up await session.close()