diff --git a/openhands/app_server/app_conversation/live_status_app_conversation_service.py b/openhands/app_server/app_conversation/live_status_app_conversation_service.py index dd4b2dd499..e7641ad487 100644 --- a/openhands/app_server/app_conversation/live_status_app_conversation_service.py +++ b/openhands/app_server/app_conversation/live_status_app_conversation_service.py @@ -585,6 +585,204 @@ class LiveStatusAppConversationService(AppConversationServiceBase): return secrets + def _configure_llm(self, user: UserInfo, llm_model: str | None) -> LLM: + """Configure LLM settings. + + Args: + user: User information containing LLM preferences + llm_model: Optional specific model to use, falls back to user default + + Returns: + Configured LLM instance + """ + model = llm_model or user.llm_model + base_url = user.llm_base_url + if model and model.startswith('openhands/'): + base_url = user.llm_base_url or self.openhands_provider_base_url + + return LLM( + model=model, + base_url=base_url, + api_key=user.llm_api_key, + usage_id='agent', + ) + + async def _get_tavily_api_key(self, user: UserInfo) -> str | None: + """Get Tavily search API key, prioritizing user's key over service key. + + Args: + user: User information + + Returns: + Tavily API key if available, None otherwise + """ + # Get the actual API key values, prioritizing user's key over service key + user_search_key = None + if user.search_api_key: + key_value = user.search_api_key.get_secret_value() + if key_value and key_value.strip(): + user_search_key = key_value + + service_tavily_key = None + if self.tavily_api_key: + # tavily_api_key is already a string (extracted in the factory method) + if self.tavily_api_key.strip(): + service_tavily_key = self.tavily_api_key + + return user_search_key or service_tavily_key + + async def _add_system_mcp_servers( + self, mcp_servers: dict[str, Any], user: UserInfo + ) -> None: + """Add system-generated MCP servers (default OpenHands server and Tavily). + + Args: + mcp_servers: Dictionary to add servers to + user: User information for API keys + """ + if not self.web_url: + return + + # Add default OpenHands MCP server + mcp_url = f'{self.web_url}/mcp/mcp' + mcp_servers['default'] = {'url': mcp_url} + + # Add API key if available + mcp_api_key = await self.user_context.get_mcp_api_key() + if mcp_api_key: + mcp_servers['default']['headers'] = { + 'X-Session-API-Key': mcp_api_key, + } + + # Add Tavily search if API key is available + tavily_api_key = await self._get_tavily_api_key(user) + if tavily_api_key: + _logger.info('Adding search engine to MCP config') + mcp_servers['tavily'] = { + 'url': f'https://mcp.tavily.com/mcp/?tavilyApiKey={tavily_api_key}' + } + else: + _logger.info('No search engine API key found, skipping search engine') + + def _add_custom_sse_servers( + self, mcp_servers: dict[str, Any], sse_servers: list + ) -> None: + """Add custom SSE MCP servers from user configuration. + + Args: + mcp_servers: Dictionary to add servers to + sse_servers: List of SSE server configurations + """ + for sse_server in sse_servers: + server_config = { + 'url': sse_server.url, + 'transport': 'sse', + } + if sse_server.api_key: + server_config['headers'] = { + 'Authorization': f'Bearer {sse_server.api_key}' + } + + # Generate unique server name using UUID + # TODO: Let the users specify the server name + server_name = f'sse_{uuid4().hex[:8]}' + mcp_servers[server_name] = server_config + _logger.debug( + f'Added custom SSE server: {server_name} for {sse_server.url}' + ) + + def _add_custom_shttp_servers( + self, mcp_servers: dict[str, Any], shttp_servers: list + ) -> None: + """Add custom SHTTP MCP servers from user configuration. + + Args: + mcp_servers: Dictionary to add servers to + shttp_servers: List of SHTTP server configurations + """ + for shttp_server in shttp_servers: + server_config = { + 'url': shttp_server.url, + 'transport': 'streamable-http', + } + if shttp_server.api_key: + server_config['headers'] = { + 'Authorization': f'Bearer {shttp_server.api_key}' + } + if shttp_server.timeout: + server_config['timeout'] = shttp_server.timeout + + # Generate unique server name using UUID + # TODO: Let the users specify the server name + server_name = f'shttp_{uuid4().hex[:8]}' + mcp_servers[server_name] = server_config + _logger.debug( + f'Added custom SHTTP server: {server_name} for {shttp_server.url}' + ) + + def _add_custom_stdio_servers( + self, mcp_servers: dict[str, Any], stdio_servers: list + ) -> None: + """Add custom STDIO MCP servers from user configuration. + + Args: + mcp_servers: Dictionary to add servers to + stdio_servers: List of STDIO server configurations + """ + for stdio_server in stdio_servers: + server_config = { + 'command': stdio_server.command, + 'args': stdio_server.args, + } + if stdio_server.env: + server_config['env'] = stdio_server.env + + # STDIO servers have an explicit name field + mcp_servers[stdio_server.name] = server_config + _logger.debug(f'Added custom STDIO server: {stdio_server.name}') + + def _merge_custom_mcp_config( + self, mcp_servers: dict[str, Any], user: UserInfo + ) -> None: + """Merge custom MCP configuration from user settings. + + Args: + mcp_servers: Dictionary to add servers to + user: User information containing custom MCP config + """ + if not user.mcp_config: + return + + try: + sse_count = len(user.mcp_config.sse_servers) + shttp_count = len(user.mcp_config.shttp_servers) + stdio_count = len(user.mcp_config.stdio_servers) + + _logger.info( + f'Loading custom MCP config from user settings: ' + f'{sse_count} SSE, {shttp_count} SHTTP, {stdio_count} STDIO servers' + ) + + # Add each type of custom server + self._add_custom_sse_servers(mcp_servers, user.mcp_config.sse_servers) + self._add_custom_shttp_servers(mcp_servers, user.mcp_config.shttp_servers) + self._add_custom_stdio_servers(mcp_servers, user.mcp_config.stdio_servers) + + _logger.info( + f'Successfully merged custom MCP config: added {sse_count} SSE, ' + f'{shttp_count} SHTTP, and {stdio_count} STDIO servers' + ) + + except Exception as e: + _logger.error( + f'Error loading custom MCP config from user settings: {e}', + exc_info=True, + ) + # Continue with system config only, don't fail conversation startup + _logger.warning( + 'Continuing with system-generated MCP config only due to custom config error' + ) + async def _configure_llm_and_mcp( self, user: UserInfo, llm_model: str | None ) -> tuple[LLM, dict]: @@ -598,56 +796,20 @@ class LiveStatusAppConversationService(AppConversationServiceBase): Tuple of (configured LLM instance, MCP config dictionary) """ # Configure LLM - model = llm_model or user.llm_model - base_url = user.llm_base_url - if model and model.startswith('openhands/'): - base_url = user.llm_base_url or self.openhands_provider_base_url - llm = LLM( - model=model, - base_url=base_url, - api_key=user.llm_api_key, - usage_id='agent', - ) + llm = self._configure_llm(user, llm_model) - # Configure MCP - mcp_config: dict[str, Any] = {} - if self.web_url: - mcp_url = f'{self.web_url}/mcp/mcp' - mcp_config = { - 'default': { - 'url': mcp_url, - } - } + # Configure MCP - SDK expects format: {'mcpServers': {'server_name': {...}}} + mcp_servers: dict[str, Any] = {} - # Add API key if available - mcp_api_key = await self.user_context.get_mcp_api_key() - if mcp_api_key: - mcp_config['default']['headers'] = { - 'X-Session-API-Key': mcp_api_key, - } + # Add system-generated servers (default + tavily) + await self._add_system_mcp_servers(mcp_servers, user) - # Get the actual API key values, prioritizing user's key over service key - user_search_key = None - if user.search_api_key: - key_value = user.search_api_key.get_secret_value() - if key_value and key_value.strip(): - user_search_key = key_value + # Merge custom servers from user settings + self._merge_custom_mcp_config(mcp_servers, user) - service_tavily_key = None - if self.tavily_api_key: - # tavily_api_key is already a string (extracted in the factory method) - if self.tavily_api_key.strip(): - service_tavily_key = self.tavily_api_key - - tavily_api_key = user_search_key or service_tavily_key - - if tavily_api_key: - _logger.info('Adding search engine to MCP config') - mcp_config['tavily'] = { - 'url': f'https://mcp.tavily.com/mcp/?tavilyApiKey={tavily_api_key}' - } - else: - _logger.info('No search engine API key found, skipping search engine') + # Wrap in the mcpServers structure required by the SDK + mcp_config = {'mcpServers': mcp_servers} if mcp_servers else {} + _logger.info(f'Final MCP configuration: {mcp_config}') return llm, mcp_config diff --git a/tests/unit/app_server/test_live_status_app_conversation_service.py b/tests/unit/app_server/test_live_status_app_conversation_service.py index 273d79ca25..6a9821b9f3 100644 --- a/tests/unit/app_server/test_live_status_app_conversation_service.py +++ b/tests/unit/app_server/test_live_status_app_conversation_service.py @@ -68,6 +68,7 @@ class TestLiveStatusAppConversationService: self.mock_user.search_api_key = None # Default to None self.mock_user.condenser_max_size = None # Default to None self.mock_user.llm_base_url = 'https://api.openai.com/v1' + self.mock_user.mcp_config = None # Default to None to avoid error handling path # Mock sandbox self.mock_sandbox = Mock(spec=SandboxInfo) @@ -239,9 +240,16 @@ class TestLiveStatusAppConversationService: assert llm.api_key.get_secret_value() == self.mock_user.llm_api_key assert llm.usage_id == 'agent' - assert 'default' in mcp_config - assert mcp_config['default']['url'] == 'https://test.example.com/mcp/mcp' - assert mcp_config['default']['headers']['X-Session-API-Key'] == 'mcp_api_key' + assert 'mcpServers' in mcp_config + assert 'default' in mcp_config['mcpServers'] + assert ( + mcp_config['mcpServers']['default']['url'] + == 'https://test.example.com/mcp/mcp' + ) + assert ( + mcp_config['mcpServers']['default']['headers']['X-Session-API-Key'] + == 'mcp_api_key' + ) @pytest.mark.asyncio async def test_configure_llm_and_mcp_openhands_model_prefers_user_base_url(self): @@ -320,8 +328,9 @@ class TestLiveStatusAppConversationService: # Assert assert llm.model == self.mock_user.llm_model - assert 'default' in mcp_config - assert 'headers' not in mcp_config['default'] + assert 'mcpServers' in mcp_config + assert 'default' in mcp_config['mcpServers'] + assert 'headers' not in mcp_config['mcpServers']['default'] @pytest.mark.asyncio async def test_configure_llm_and_mcp_without_web_url(self): @@ -354,10 +363,11 @@ class TestLiveStatusAppConversationService: # Assert assert isinstance(llm, LLM) - assert 'default' in mcp_config - assert 'tavily' in mcp_config + assert 'mcpServers' in mcp_config + assert 'default' in mcp_config['mcpServers'] + assert 'tavily' in mcp_config['mcpServers'] assert ( - mcp_config['tavily']['url'] + mcp_config['mcpServers']['tavily']['url'] == 'https://mcp.tavily.com/mcp/?tavilyApiKey=user_search_key' ) @@ -375,10 +385,11 @@ class TestLiveStatusAppConversationService: # Assert assert isinstance(llm, LLM) - assert 'default' in mcp_config - assert 'tavily' in mcp_config + assert 'mcpServers' in mcp_config + assert 'default' in mcp_config['mcpServers'] + assert 'tavily' in mcp_config['mcpServers'] assert ( - mcp_config['tavily']['url'] + mcp_config['mcpServers']['tavily']['url'] == 'https://mcp.tavily.com/mcp/?tavilyApiKey=env_tavily_key' ) @@ -399,9 +410,10 @@ class TestLiveStatusAppConversationService: # Assert assert isinstance(llm, LLM) - assert 'tavily' in mcp_config + assert 'mcpServers' in mcp_config + assert 'tavily' in mcp_config['mcpServers'] assert ( - mcp_config['tavily']['url'] + mcp_config['mcpServers']['tavily']['url'] == 'https://mcp.tavily.com/mcp/?tavilyApiKey=user_search_key' ) @@ -420,8 +432,9 @@ class TestLiveStatusAppConversationService: # Assert assert isinstance(llm, LLM) - assert 'default' in mcp_config - assert 'tavily' not in mcp_config + assert 'mcpServers' in mcp_config + assert 'default' in mcp_config['mcpServers'] + assert 'tavily' not in mcp_config['mcpServers'] @pytest.mark.asyncio async def test_configure_llm_and_mcp_saas_mode_no_tavily_without_user_key(self): @@ -443,8 +456,9 @@ class TestLiveStatusAppConversationService: # Assert assert isinstance(llm, LLM) - assert 'default' in mcp_config - assert 'tavily' not in mcp_config + assert 'mcpServers' in mcp_config + assert 'default' in mcp_config['mcpServers'] + assert 'tavily' not in mcp_config['mcpServers'] @pytest.mark.asyncio async def test_configure_llm_and_mcp_saas_mode_with_user_search_key(self): @@ -467,10 +481,11 @@ class TestLiveStatusAppConversationService: # Assert assert isinstance(llm, LLM) - assert 'default' in mcp_config - assert 'tavily' in mcp_config + assert 'mcpServers' in mcp_config + assert 'default' in mcp_config['mcpServers'] + assert 'tavily' in mcp_config['mcpServers'] assert ( - mcp_config['tavily']['url'] + mcp_config['mcpServers']['tavily']['url'] == 'https://mcp.tavily.com/mcp/?tavilyApiKey=user_search_key' ) @@ -491,10 +506,11 @@ class TestLiveStatusAppConversationService: # Assert assert isinstance(llm, LLM) - assert 'tavily' in mcp_config + assert 'mcpServers' in mcp_config + assert 'tavily' in mcp_config['mcpServers'] # Should fall back to env key since user key is empty assert ( - mcp_config['tavily']['url'] + mcp_config['mcpServers']['tavily']['url'] == 'https://mcp.tavily.com/mcp/?tavilyApiKey=env_tavily_key' ) @@ -515,10 +531,11 @@ class TestLiveStatusAppConversationService: # Assert assert isinstance(llm, LLM) - assert 'tavily' in mcp_config + assert 'mcpServers' in mcp_config + assert 'tavily' in mcp_config['mcpServers'] # Should fall back to env key since user key is whitespace only assert ( - mcp_config['tavily']['url'] + mcp_config['mcpServers']['tavily']['url'] == 'https://mcp.tavily.com/mcp/?tavilyApiKey=env_tavily_key' ) @@ -824,3 +841,404 @@ class TestLiveStatusAppConversationService: secrets=mock_secrets, ) self.service._finalize_conversation_request.assert_called_once() + + @pytest.mark.asyncio + async def test_configure_llm_and_mcp_with_custom_sse_servers(self): + """Test _configure_llm_and_mcp merges custom SSE servers with UUID-based names.""" + # Arrange + + from openhands.core.config.mcp_config import MCPConfig, MCPSSEServerConfig + + self.mock_user.mcp_config = MCPConfig( + sse_servers=[ + MCPSSEServerConfig(url='https://linear.app/sse', api_key='linear_key'), + MCPSSEServerConfig(url='https://notion.com/sse'), + ] + ) + self.mock_user_context.get_mcp_api_key.return_value = None + + # Act + llm, mcp_config = await self.service._configure_llm_and_mcp( + self.mock_user, None + ) + + # Assert + assert isinstance(llm, LLM) + assert 'mcpServers' in mcp_config + + # Should have default server + 2 custom SSE servers + mcp_servers = mcp_config['mcpServers'] + assert 'default' in mcp_servers + + # Find SSE servers (they have sse_ prefix) + sse_servers = {k: v for k, v in mcp_servers.items() if k.startswith('sse_')} + assert len(sse_servers) == 2 + + # Verify SSE server configurations + for server_name, server_config in sse_servers.items(): + assert server_name.startswith('sse_') + assert len(server_name) > 4 # Has UUID suffix + assert 'url' in server_config + assert 'transport' in server_config + assert server_config['transport'] == 'sse' + + # Check if this is the Linear server (has headers) + if 'headers' in server_config: + assert server_config['headers']['Authorization'] == 'Bearer linear_key' + + @pytest.mark.asyncio + async def test_configure_llm_and_mcp_with_custom_shttp_servers(self): + """Test _configure_llm_and_mcp merges custom SHTTP servers with timeout.""" + # Arrange + from openhands.core.config.mcp_config import MCPConfig, MCPSHTTPServerConfig + + self.mock_user.mcp_config = MCPConfig( + shttp_servers=[ + MCPSHTTPServerConfig( + url='https://example.com/mcp', + api_key='test_key', + timeout=120, + ) + ] + ) + self.mock_user_context.get_mcp_api_key.return_value = None + + # Act + llm, mcp_config = await self.service._configure_llm_and_mcp( + self.mock_user, None + ) + + # Assert + assert isinstance(llm, LLM) + mcp_servers = mcp_config['mcpServers'] + + # Find SHTTP servers + shttp_servers = {k: v for k, v in mcp_servers.items() if k.startswith('shttp_')} + assert len(shttp_servers) == 1 + + server_config = list(shttp_servers.values())[0] + assert server_config['url'] == 'https://example.com/mcp' + assert server_config['transport'] == 'streamable-http' + assert server_config['headers']['Authorization'] == 'Bearer test_key' + assert server_config['timeout'] == 120 + + @pytest.mark.asyncio + async def test_configure_llm_and_mcp_with_custom_stdio_servers(self): + """Test _configure_llm_and_mcp merges custom STDIO servers with explicit names.""" + # Arrange + from openhands.core.config.mcp_config import MCPConfig, MCPStdioServerConfig + + self.mock_user.mcp_config = MCPConfig( + stdio_servers=[ + MCPStdioServerConfig( + name='my-custom-server', + command='npx', + args=['-y', 'my-package'], + env={'API_KEY': 'secret'}, + ) + ] + ) + self.mock_user_context.get_mcp_api_key.return_value = None + + # Act + llm, mcp_config = await self.service._configure_llm_and_mcp( + self.mock_user, None + ) + + # Assert + assert isinstance(llm, LLM) + mcp_servers = mcp_config['mcpServers'] + + # STDIO server should use its explicit name + assert 'my-custom-server' in mcp_servers + server_config = mcp_servers['my-custom-server'] + assert server_config['command'] == 'npx' + assert server_config['args'] == ['-y', 'my-package'] + assert server_config['env'] == {'API_KEY': 'secret'} + + @pytest.mark.asyncio + async def test_configure_llm_and_mcp_merges_system_and_custom_servers(self): + """Test _configure_llm_and_mcp merges both system and custom MCP servers.""" + # Arrange + from pydantic import SecretStr + + from openhands.core.config.mcp_config import ( + MCPConfig, + MCPSSEServerConfig, + MCPStdioServerConfig, + ) + + self.mock_user.search_api_key = SecretStr('tavily_key') + self.mock_user.mcp_config = MCPConfig( + sse_servers=[MCPSSEServerConfig(url='https://custom.com/sse')], + stdio_servers=[ + MCPStdioServerConfig( + name='custom-stdio', command='node', args=['app.js'] + ) + ], + ) + self.mock_user_context.get_mcp_api_key.return_value = 'mcp_api_key' + + # Act + llm, mcp_config = await self.service._configure_llm_and_mcp( + self.mock_user, None + ) + + # Assert + mcp_servers = mcp_config['mcpServers'] + + # Should have system servers + assert 'default' in mcp_servers + assert 'tavily' in mcp_servers + + # Should have custom SSE server with UUID name + sse_servers = [k for k in mcp_servers if k.startswith('sse_')] + assert len(sse_servers) == 1 + + # Should have custom STDIO server with explicit name + assert 'custom-stdio' in mcp_servers + + # Total: default + tavily + 1 SSE + 1 STDIO = 4 servers + assert len(mcp_servers) == 4 + + @pytest.mark.asyncio + async def test_configure_llm_and_mcp_custom_config_error_handling(self): + """Test _configure_llm_and_mcp handles errors in custom MCP config gracefully.""" + # Arrange + self.mock_user.mcp_config = Mock() + # Simulate error when accessing sse_servers + self.mock_user.mcp_config.sse_servers = property( + lambda self: (_ for _ in ()).throw(Exception('Config error')) + ) + self.mock_user_context.get_mcp_api_key.return_value = None + + # Act + llm, mcp_config = await self.service._configure_llm_and_mcp( + self.mock_user, None + ) + + # Assert - should still return valid config with system servers only + assert isinstance(llm, LLM) + mcp_servers = mcp_config['mcpServers'] + assert 'default' in mcp_servers + # Custom servers should not be added due to error + + @pytest.mark.asyncio + async def test_configure_llm_and_mcp_sdk_format_with_mcpservers_wrapper(self): + """Test _configure_llm_and_mcp returns SDK-required format with mcpServers key.""" + # Arrange + self.mock_user_context.get_mcp_api_key.return_value = 'mcp_key' + + # Act + llm, mcp_config = await self.service._configure_llm_and_mcp( + self.mock_user, None + ) + + # Assert - SDK expects {'mcpServers': {...}} format + assert 'mcpServers' in mcp_config + assert isinstance(mcp_config['mcpServers'], dict) + + # Verify structure matches SDK expectations + for server_name, server_config in mcp_config['mcpServers'].items(): + assert isinstance(server_name, str) + assert isinstance(server_config, dict) + + @pytest.mark.asyncio + async def test_configure_llm_and_mcp_empty_custom_config(self): + """Test _configure_llm_and_mcp handles empty custom MCP config.""" + # Arrange + from openhands.core.config.mcp_config import MCPConfig + + self.mock_user.mcp_config = MCPConfig( + sse_servers=[], stdio_servers=[], shttp_servers=[] + ) + self.mock_user_context.get_mcp_api_key.return_value = None + + # Act + llm, mcp_config = await self.service._configure_llm_and_mcp( + self.mock_user, None + ) + + # Assert + mcp_servers = mcp_config['mcpServers'] + # Should only have system default server + assert 'default' in mcp_servers + assert len(mcp_servers) == 1 + + @pytest.mark.asyncio + async def test_configure_llm_and_mcp_sse_server_without_api_key(self): + """Test _configure_llm_and_mcp handles SSE servers without API keys.""" + # Arrange + from openhands.core.config.mcp_config import MCPConfig, MCPSSEServerConfig + + self.mock_user.mcp_config = MCPConfig( + sse_servers=[MCPSSEServerConfig(url='https://public.com/sse')] + ) + self.mock_user_context.get_mcp_api_key.return_value = None + + # Act + llm, mcp_config = await self.service._configure_llm_and_mcp( + self.mock_user, None + ) + + # Assert + mcp_servers = mcp_config['mcpServers'] + sse_servers = {k: v for k, v in mcp_servers.items() if k.startswith('sse_')} + + # Server should exist but without headers + assert len(sse_servers) == 1 + server_config = list(sse_servers.values())[0] + assert 'headers' not in server_config + assert server_config['url'] == 'https://public.com/sse' + assert server_config['transport'] == 'sse' + + @pytest.mark.asyncio + async def test_configure_llm_and_mcp_shttp_server_without_timeout(self): + """Test _configure_llm_and_mcp handles SHTTP servers without timeout.""" + # Arrange + from openhands.core.config.mcp_config import MCPConfig, MCPSHTTPServerConfig + + self.mock_user.mcp_config = MCPConfig( + shttp_servers=[MCPSHTTPServerConfig(url='https://example.com/mcp')] + ) + self.mock_user_context.get_mcp_api_key.return_value = None + + # Act + llm, mcp_config = await self.service._configure_llm_and_mcp( + self.mock_user, None + ) + + # Assert + mcp_servers = mcp_config['mcpServers'] + shttp_servers = {k: v for k, v in mcp_servers.items() if k.startswith('shttp_')} + + assert len(shttp_servers) == 1 + server_config = list(shttp_servers.values())[0] + # Timeout should be included even if None (defaults to 60) + assert 'timeout' in server_config + + @pytest.mark.asyncio + async def test_configure_llm_and_mcp_stdio_server_without_env(self): + """Test _configure_llm_and_mcp handles STDIO servers without environment variables.""" + # Arrange + from openhands.core.config.mcp_config import MCPConfig, MCPStdioServerConfig + + self.mock_user.mcp_config = MCPConfig( + stdio_servers=[ + MCPStdioServerConfig( + name='simple-server', command='node', args=['app.js'] + ) + ] + ) + self.mock_user_context.get_mcp_api_key.return_value = None + + # Act + llm, mcp_config = await self.service._configure_llm_and_mcp( + self.mock_user, None + ) + + # Assert + mcp_servers = mcp_config['mcpServers'] + assert 'simple-server' in mcp_servers + server_config = mcp_servers['simple-server'] + + # Should not have env key if not provided + assert 'env' not in server_config + assert server_config['command'] == 'node' + assert server_config['args'] == ['app.js'] + + @pytest.mark.asyncio + async def test_configure_llm_and_mcp_multiple_servers_same_type(self): + """Test _configure_llm_and_mcp handles multiple custom servers of the same type.""" + # Arrange + from openhands.core.config.mcp_config import MCPConfig, MCPSSEServerConfig + + self.mock_user.mcp_config = MCPConfig( + sse_servers=[ + MCPSSEServerConfig(url='https://server1.com/sse'), + MCPSSEServerConfig(url='https://server2.com/sse'), + MCPSSEServerConfig(url='https://server3.com/sse'), + ] + ) + self.mock_user_context.get_mcp_api_key.return_value = None + + # Act + llm, mcp_config = await self.service._configure_llm_and_mcp( + self.mock_user, None + ) + + # Assert + mcp_servers = mcp_config['mcpServers'] + sse_servers = {k: v for k, v in mcp_servers.items() if k.startswith('sse_')} + + # All 3 servers should be present with unique UUID-based names + assert len(sse_servers) == 3 + + # Verify all have unique names + server_names = list(sse_servers.keys()) + assert len(set(server_names)) == 3 # All names are unique + + # Verify all URLs are preserved + urls = [v['url'] for v in sse_servers.values()] + assert 'https://server1.com/sse' in urls + assert 'https://server2.com/sse' in urls + assert 'https://server3.com/sse' in urls + + @pytest.mark.asyncio + async def test_configure_llm_and_mcp_mixed_server_types(self): + """Test _configure_llm_and_mcp handles all three server types together.""" + # Arrange + from openhands.core.config.mcp_config import ( + MCPConfig, + MCPSHTTPServerConfig, + MCPSSEServerConfig, + MCPStdioServerConfig, + ) + + self.mock_user.mcp_config = MCPConfig( + sse_servers=[ + MCPSSEServerConfig(url='https://sse.example.com/sse', api_key='sse_key') + ], + shttp_servers=[ + MCPSHTTPServerConfig(url='https://shttp.example.com/mcp', timeout=90) + ], + stdio_servers=[ + MCPStdioServerConfig( + name='stdio-server', + command='npx', + args=['mcp-server'], + env={'TOKEN': 'value'}, + ) + ], + ) + self.mock_user_context.get_mcp_api_key.return_value = None + + # Act + llm, mcp_config = await self.service._configure_llm_and_mcp( + self.mock_user, None + ) + + # Assert + mcp_servers = mcp_config['mcpServers'] + + # Check all server types are present + sse_count = len([k for k in mcp_servers if k.startswith('sse_')]) + shttp_count = len([k for k in mcp_servers if k.startswith('shttp_')]) + stdio_count = 1 if 'stdio-server' in mcp_servers else 0 + + assert sse_count == 1 + assert shttp_count == 1 + assert stdio_count == 1 + + # Verify each type has correct configuration + sse_server = next(v for k, v in mcp_servers.items() if k.startswith('sse_')) + assert sse_server['transport'] == 'sse' + assert sse_server['headers']['Authorization'] == 'Bearer sse_key' + + shttp_server = next(v for k, v in mcp_servers.items() if k.startswith('shttp_')) + assert shttp_server['transport'] == 'streamable-http' + assert shttp_server['timeout'] == 90 + + stdio_server = mcp_servers['stdio-server'] + assert stdio_server['command'] == 'npx' + assert stdio_server['env'] == {'TOKEN': 'value'}