mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-08 22:38:05 -05:00
fix(backend): unable to use custom mcp servers (v1 conversations) (#12038)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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'}
|
||||
|
||||
Reference in New Issue
Block a user