mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 14:58:39 +08:00
feat: Proxy Tavily MCP connections through app server (#14278)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
co-authored by
openhands
parent
bcc175b7d4
commit
949a15a560
@@ -36,7 +36,6 @@ from server.routes.github_proxy import add_github_proxy_routes # noqa: E402
|
||||
from server.routes.integration.jira import jira_integration_router # noqa: E402
|
||||
from server.routes.integration.jira_dc import jira_dc_integration_router # noqa: E402
|
||||
from server.routes.integration.slack import slack_router # noqa: E402
|
||||
from server.routes.mcp_patch import patch_mcp_server # noqa: E402
|
||||
from server.routes.oauth_device import oauth_device_router # noqa: E402
|
||||
from server.routes.org_invitations import ( # noqa: E402
|
||||
accept_router as invitation_accept_router,
|
||||
@@ -69,8 +68,6 @@ from openhands.app_server.static import SPAStaticFiles # noqa: E402
|
||||
|
||||
directory = os.getenv('FRONTEND_DIRECTORY', './frontend/build')
|
||||
|
||||
patch_mcp_server()
|
||||
|
||||
|
||||
@base_app.get('/saas')
|
||||
def is_saas():
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import os
|
||||
|
||||
from fastmcp import Client
|
||||
from fastmcp.client.transports import NpxStdioTransport
|
||||
from fastmcp.server import create_proxy
|
||||
|
||||
from openhands.app_server.mcp.mcp_router import mcp_server
|
||||
from openhands.app_server.utils.logger import openhands_logger as logger
|
||||
|
||||
ENABLE_MCP_SEARCH_ENGINE = (
|
||||
os.getenv('ENABLE_MCP_SEARCH_ENGINE', 'false').lower() == 'true'
|
||||
)
|
||||
|
||||
|
||||
def patch_mcp_server():
|
||||
if not ENABLE_MCP_SEARCH_ENGINE:
|
||||
logger.warning('Tavily search integration is disabled')
|
||||
return
|
||||
|
||||
TAVILY_API_KEY = os.getenv('TAVILY_API_KEY')
|
||||
|
||||
if TAVILY_API_KEY:
|
||||
proxy_client = Client(
|
||||
transport=NpxStdioTransport(
|
||||
package='tavily-mcp@0.2.1', env_vars={'TAVILY_API_KEY': TAVILY_API_KEY}
|
||||
)
|
||||
)
|
||||
proxy_server = create_proxy(proxy_client)
|
||||
|
||||
mcp_server.mount(namespace='tavily', server=proxy_server)
|
||||
logger.info('Tavily search integration initialized successfully')
|
||||
else:
|
||||
logger.warning('Tavily API key not found, skipping search integration')
|
||||
@@ -16,7 +16,7 @@ from fastapi.responses import JSONResponse
|
||||
from openhands.app_server import v1_router
|
||||
from openhands.app_server.config import get_app_lifespan_service
|
||||
from openhands.app_server.integrations.service_types import AuthenticationError
|
||||
from openhands.app_server.mcp.mcp_router import mcp_server
|
||||
from openhands.app_server.mcp.mcp_router import init_tavily_proxy, mcp_server
|
||||
from openhands.app_server.middleware import (
|
||||
CacheControlMiddleware,
|
||||
InMemoryRateLimiter,
|
||||
@@ -27,6 +27,9 @@ from openhands.app_server.static import SPAStaticFiles
|
||||
from openhands.app_server.status.status_router import router as health_router
|
||||
from openhands.app_server.version import get_version
|
||||
|
||||
# Initialize the Tavily MCP proxy before creating the app
|
||||
init_tavily_proxy()
|
||||
|
||||
mcp_app = mcp_server.http_app(path='/mcp', stateless_http=True)
|
||||
|
||||
|
||||
|
||||
@@ -61,7 +61,6 @@ from openhands.app_server.config import (
|
||||
get_event_callback_service,
|
||||
resolve_provider_llm_base_url,
|
||||
)
|
||||
from openhands.app_server.config_api.config_models import AppMode
|
||||
from openhands.app_server.errors import SandboxError
|
||||
from openhands.app_server.event.event_service import EventService
|
||||
from openhands.app_server.event_callback.event_callback_models import EventCallback
|
||||
@@ -180,7 +179,6 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
openhands_provider_base_url: str | None
|
||||
access_token_hard_timeout: timedelta | None
|
||||
app_mode: str | None = None
|
||||
tavily_api_key: str | None = None
|
||||
|
||||
async def _get_sandbox_grouping_strategy(self) -> SandboxGroupingStrategy:
|
||||
"""Get the sandbox grouping strategy from user settings."""
|
||||
@@ -995,44 +993,23 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
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, conversation_id: UUID
|
||||
self, mcp_servers: dict[str, Any], conversation_id: UUID
|
||||
) -> None:
|
||||
"""Add system-generated MCP servers (default OpenHands server and Tavily).
|
||||
"""Add system-generated MCP servers (default OpenHands server).
|
||||
|
||||
The default server includes the Tavily search proxy if configured.
|
||||
Tavily search is proxied through the app server to avoid exposing
|
||||
the API key to sandboxes.
|
||||
|
||||
Args:
|
||||
mcp_servers: Dictionary to add servers to
|
||||
user: User information for API keys
|
||||
conversation_id: Conversation ID forwarded to the OpenHands MCP server
|
||||
"""
|
||||
if not self.web_url:
|
||||
return
|
||||
|
||||
# Add default OpenHands MCP server
|
||||
# Add default OpenHands MCP server (includes Tavily proxy if configured)
|
||||
mcp_url = f'{self.web_url}/mcp/mcp'
|
||||
mcp_servers['default'] = {
|
||||
'url': mcp_url,
|
||||
@@ -1044,16 +1021,6 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
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 _merge_custom_mcp_config(
|
||||
self, mcp_servers: dict[str, Any], user: UserInfo
|
||||
) -> None:
|
||||
@@ -1109,8 +1076,8 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
# Configure MCP - SDK expects format: {'mcpServers': {'server_name': {...}}}
|
||||
mcp_servers: dict[str, Any] = {}
|
||||
|
||||
# Add system-generated servers (default + tavily)
|
||||
await self._add_system_mcp_servers(mcp_servers, user, conversation_id)
|
||||
# Add system-generated servers (default MCP server with Tavily proxy)
|
||||
await self._add_system_mcp_servers(mcp_servers, conversation_id)
|
||||
|
||||
# Merge custom servers from user settings
|
||||
self._merge_custom_mcp_config(mcp_servers, user)
|
||||
@@ -2093,10 +2060,6 @@ class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector):
|
||||
'be retrieved by a sandboxed conversation.'
|
||||
),
|
||||
)
|
||||
tavily_api_key: SecretStr | None = Field(
|
||||
default=None,
|
||||
description='The Tavily Search API key to add to MCP integration',
|
||||
)
|
||||
|
||||
async def inject(
|
||||
self, state: InjectorState, request: Request | None = None
|
||||
@@ -2155,14 +2118,6 @@ class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector):
|
||||
# If server_config is not available (e.g., in tests), continue without it
|
||||
pass
|
||||
|
||||
# We supply the global tavily key only if the app mode is not SAAS, where
|
||||
# currently the search endpoints are patched into the app server instead
|
||||
# so the tavily key does not need to be shared
|
||||
if self.tavily_api_key and app_mode != AppMode.SAAS:
|
||||
tavily_api_key = self.tavily_api_key.get_secret_value()
|
||||
else:
|
||||
tavily_api_key = None
|
||||
|
||||
yield LiveStatusAppConversationService(
|
||||
init_git_in_empty_workspace=self.init_git_in_empty_workspace,
|
||||
user_context=user_context,
|
||||
@@ -2182,5 +2137,4 @@ class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector):
|
||||
openhands_provider_base_url=config.openhands_provider_base_url,
|
||||
access_token_hard_timeout=access_token_hard_timeout,
|
||||
app_mode=app_mode,
|
||||
tavily_api_key=tavily_api_key,
|
||||
)
|
||||
|
||||
@@ -118,6 +118,14 @@ def get_openhands_provider_base_url() -> str | None:
|
||||
return os.getenv('OPENHANDS_PROVIDER_BASE_URL') or os.getenv('LLM_BASE_URL') or None
|
||||
|
||||
|
||||
def get_default_tavily_api_key() -> str | None:
|
||||
"""Return the Tavily API key from environment, if configured.
|
||||
|
||||
Falls back to SEARCH_API_KEY for backward compatibility.
|
||||
"""
|
||||
return os.getenv('TAVILY_API_KEY') or os.getenv('SEARCH_API_KEY') or None
|
||||
|
||||
|
||||
# The SDK auto-fills this URL as the default for openhands/ and litellm_proxy/
|
||||
# models. Deployments (e.g. staging) may use a different LLM proxy, configured
|
||||
# via OPENHANDS_PROVIDER_BASE_URL.
|
||||
@@ -194,6 +202,10 @@ class AppServerConfig(OpenHandsModel):
|
||||
default_factory=get_openhands_provider_base_url,
|
||||
description='Base URL for the OpenHands provider',
|
||||
)
|
||||
tavily_api_key: str | None = Field(
|
||||
default_factory=get_default_tavily_api_key,
|
||||
description='Tavily API key for search integration (proxied via MCP server)',
|
||||
)
|
||||
# Dependency Injection Injectors
|
||||
llm_model: LLMModelServiceInjector | None = None
|
||||
event: EventServiceInjector | None = None
|
||||
@@ -385,13 +397,7 @@ def config_from_env() -> AppServerConfig:
|
||||
)
|
||||
|
||||
if config.app_conversation is None:
|
||||
tavily_api_key = None
|
||||
tavily_api_key_str = os.getenv('TAVILY_API_KEY') or os.getenv('SEARCH_API_KEY')
|
||||
if tavily_api_key_str:
|
||||
tavily_api_key = SecretStr(tavily_api_key_str)
|
||||
config.app_conversation = LiveStatusAppConversationServiceInjector(
|
||||
tavily_api_key=tavily_api_key
|
||||
)
|
||||
config.app_conversation = LiveStatusAppConversationServiceInjector()
|
||||
|
||||
if config.pending_message is None:
|
||||
from openhands.app_server.pending_messages.pending_message_service import (
|
||||
|
||||
@@ -3,8 +3,10 @@ import re
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp import Client, FastMCP
|
||||
from fastmcp.client.transports import StreamableHttpTransport
|
||||
from fastmcp.exceptions import ToolError
|
||||
from fastmcp.server import create_proxy
|
||||
from fastmcp.server.dependencies import get_http_request
|
||||
from pydantic import Field
|
||||
|
||||
@@ -44,6 +46,35 @@ HOST = f'https://{os.getenv("WEB_HOST", "app.all-hands.dev").strip()}'
|
||||
CONVERSATION_URL = HOST + '/conversations/{}'
|
||||
|
||||
|
||||
def init_tavily_proxy() -> None:
|
||||
"""Initialize the Tavily MCP proxy if API key is configured.
|
||||
|
||||
This mounts a proxy to Tavily's MCP server under the 'tavily' namespace,
|
||||
allowing sandboxes to use Tavily search without the API key being exposed.
|
||||
"""
|
||||
config = get_global_config()
|
||||
tavily_api_key = config.tavily_api_key
|
||||
|
||||
if not tavily_api_key:
|
||||
logger.info('Tavily API key not configured, skipping Tavily MCP proxy')
|
||||
return
|
||||
|
||||
try:
|
||||
# Create a client that connects to Tavily's HTTP MCP endpoint
|
||||
proxy_client = Client(
|
||||
transport=StreamableHttpTransport(
|
||||
url=f'https://mcp.tavily.com/mcp/?tavilyApiKey={tavily_api_key}'
|
||||
)
|
||||
)
|
||||
proxy_server = create_proxy(proxy_client)
|
||||
|
||||
# Mount under 'tavily' namespace so tools are accessible as tavily_*
|
||||
mcp_server.mount(namespace='tavily', server=proxy_server)
|
||||
logger.info('Tavily MCP proxy initialized successfully')
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to initialize Tavily MCP proxy: {e}')
|
||||
|
||||
|
||||
async def get_conversation_link(
|
||||
service: GitService, conversation_id: str | None, body: str
|
||||
) -> str:
|
||||
|
||||
@@ -26,7 +26,6 @@ from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
from openhands.app_server.app_conversation.live_status_app_conversation_service import (
|
||||
LiveStatusAppConversationService,
|
||||
)
|
||||
from openhands.app_server.config_api.config_models import AppMode
|
||||
from openhands.app_server.integrations.provider import ProviderToken, ProviderType
|
||||
from openhands.app_server.integrations.service_types import SuggestedTask, TaskType
|
||||
from openhands.app_server.sandbox.sandbox_models import (
|
||||
@@ -716,188 +715,6 @@ class TestLiveStatusAppConversationService:
|
||||
assert isinstance(llm, LLM)
|
||||
assert mcp_config == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configure_llm_and_mcp_tavily_with_user_search_api_key(self):
|
||||
"""Test _configure_llm_and_mcp adds tavily when user has search_api_key."""
|
||||
# Arrange
|
||||
self.mock_user.search_api_key = SecretStr('user_search_key')
|
||||
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, self.conversation_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(llm, LLM)
|
||||
assert 'mcpServers' in mcp_config
|
||||
assert 'default' in mcp_config['mcpServers']
|
||||
assert 'tavily' in mcp_config['mcpServers']
|
||||
assert (
|
||||
mcp_config['mcpServers']['tavily']['url']
|
||||
== 'https://mcp.tavily.com/mcp/?tavilyApiKey=user_search_key'
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configure_llm_and_mcp_tavily_with_env_tavily_key(self):
|
||||
"""Test _configure_llm_and_mcp adds tavily when service has tavily_api_key."""
|
||||
# Arrange
|
||||
self.service.tavily_api_key = 'env_tavily_key'
|
||||
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, self.conversation_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(llm, LLM)
|
||||
assert 'mcpServers' in mcp_config
|
||||
assert 'default' in mcp_config['mcpServers']
|
||||
assert 'tavily' in mcp_config['mcpServers']
|
||||
assert (
|
||||
mcp_config['mcpServers']['tavily']['url']
|
||||
== 'https://mcp.tavily.com/mcp/?tavilyApiKey=env_tavily_key'
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configure_llm_and_mcp_tavily_user_key_takes_precedence(self):
|
||||
"""Test _configure_llm_and_mcp user search_api_key takes precedence over env key."""
|
||||
# Arrange
|
||||
self.mock_user.search_api_key = SecretStr('user_search_key')
|
||||
self.service.tavily_api_key = 'env_tavily_key'
|
||||
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, self.conversation_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(llm, LLM)
|
||||
assert 'mcpServers' in mcp_config
|
||||
assert 'tavily' in mcp_config['mcpServers']
|
||||
assert (
|
||||
mcp_config['mcpServers']['tavily']['url']
|
||||
== 'https://mcp.tavily.com/mcp/?tavilyApiKey=user_search_key'
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configure_llm_and_mcp_no_tavily_without_keys(self):
|
||||
"""Test _configure_llm_and_mcp does not add tavily when no keys are available."""
|
||||
# Arrange
|
||||
self.mock_user.search_api_key = None
|
||||
self.service.tavily_api_key = None
|
||||
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, self.conversation_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(llm, LLM)
|
||||
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):
|
||||
"""Test _configure_llm_and_mcp does not add tavily in SAAS mode without user search_api_key.
|
||||
|
||||
In SAAS mode, the global tavily_api_key should not be passed to the service instance,
|
||||
so tavily should only be added if the user has their own search_api_key.
|
||||
"""
|
||||
# Arrange - simulate SAAS mode where no global tavily key is available
|
||||
self.service.app_mode = AppMode.SAAS.value
|
||||
self.service.tavily_api_key = None # In SAAS mode, this should be None
|
||||
self.mock_user.search_api_key = None
|
||||
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, self.conversation_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(llm, LLM)
|
||||
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):
|
||||
"""Test _configure_llm_and_mcp adds tavily in SAAS mode when user has search_api_key.
|
||||
|
||||
Even in SAAS mode, if the user has their own search_api_key, tavily should be added.
|
||||
"""
|
||||
# Arrange - simulate SAAS mode with user having their own search key
|
||||
self.service.app_mode = AppMode.SAAS.value
|
||||
self.service.tavily_api_key = None # In SAAS mode, this should be None
|
||||
self.mock_user.search_api_key = SecretStr('user_search_key')
|
||||
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, self.conversation_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(llm, LLM)
|
||||
assert 'mcpServers' in mcp_config
|
||||
assert 'default' in mcp_config['mcpServers']
|
||||
assert 'tavily' in mcp_config['mcpServers']
|
||||
assert (
|
||||
mcp_config['mcpServers']['tavily']['url']
|
||||
== 'https://mcp.tavily.com/mcp/?tavilyApiKey=user_search_key'
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configure_llm_and_mcp_tavily_with_empty_user_search_key(self):
|
||||
"""Test _configure_llm_and_mcp handles empty user search_api_key correctly."""
|
||||
# Arrange
|
||||
self.mock_user.search_api_key = SecretStr('') # Empty string
|
||||
self.service.tavily_api_key = 'env_tavily_key'
|
||||
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, self.conversation_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(llm, LLM)
|
||||
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['mcpServers']['tavily']['url']
|
||||
== 'https://mcp.tavily.com/mcp/?tavilyApiKey=env_tavily_key'
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configure_llm_and_mcp_tavily_with_whitespace_user_search_key(self):
|
||||
"""Test _configure_llm_and_mcp handles whitespace-only user search_api_key correctly."""
|
||||
# Arrange
|
||||
self.mock_user.search_api_key = SecretStr(' ') # Whitespace only
|
||||
self.service.tavily_api_key = 'env_tavily_key'
|
||||
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, self.conversation_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(llm, LLM)
|
||||
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['mcpServers']['tavily']['url']
|
||||
== 'https://mcp.tavily.com/mcp/?tavilyApiKey=env_tavily_key'
|
||||
)
|
||||
|
||||
def test_compute_plan_path_default_uses_agents_tmp(self):
|
||||
"""Test _compute_plan_path returns .agents_tmp/PLAN.md for default/GitHub."""
|
||||
# Arrange
|
||||
@@ -1835,7 +1652,6 @@ class TestLiveStatusAppConversationService:
|
||||
StdioMCPServer,
|
||||
)
|
||||
|
||||
self.mock_user.search_api_key = SecretStr('tavily_key')
|
||||
self.mock_user.mcp_config = MCPConfig(
|
||||
mcpServers={
|
||||
'custom-sse': RemoteMCPServer(
|
||||
@@ -1852,12 +1668,13 @@ class TestLiveStatusAppConversationService:
|
||||
|
||||
mcp_servers = mcp_config['mcpServers']
|
||||
|
||||
# System provides default MCP server (Tavily is proxied through it if configured)
|
||||
assert 'default' in mcp_servers
|
||||
assert 'tavily' in mcp_servers
|
||||
# Custom servers are merged
|
||||
assert 'custom-sse' in mcp_servers
|
||||
assert 'custom-stdio' in mcp_servers
|
||||
|
||||
assert len(mcp_servers) == 4
|
||||
assert len(mcp_servers) == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configure_llm_and_mcp_custom_config_error_handling(self):
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import warnings
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openhands.app_server.integrations.service_types import GitService
|
||||
from openhands.app_server.mcp.mcp_router import get_conversation_link
|
||||
from openhands.app_server.mcp.mcp_router import get_conversation_link, init_tavily_proxy
|
||||
from openhands.app_server.types import AppMode
|
||||
|
||||
|
||||
@@ -149,3 +149,140 @@ async def test_get_conversation_link_none_conversation_id():
|
||||
|
||||
# Verify get_user was never called (early return)
|
||||
mock_service.get_user.assert_not_called()
|
||||
|
||||
|
||||
class TestInitTavilyProxy:
|
||||
"""Tests for init_tavily_proxy function."""
|
||||
|
||||
def test_init_tavily_proxy_no_api_key(self):
|
||||
"""Test init_tavily_proxy does nothing when no API key is configured."""
|
||||
with (
|
||||
patch(
|
||||
'openhands.app_server.mcp.mcp_router.get_global_config'
|
||||
) as mock_config,
|
||||
patch('openhands.app_server.mcp.mcp_router.logger') as mock_logger,
|
||||
patch('openhands.app_server.mcp.mcp_router.Client') as mock_client,
|
||||
patch('openhands.app_server.mcp.mcp_router.create_proxy') as mock_proxy,
|
||||
patch('openhands.app_server.mcp.mcp_router.mcp_server') as mock_mcp_server,
|
||||
):
|
||||
# Configure no API key
|
||||
mock_config.return_value.tavily_api_key = None
|
||||
|
||||
# Call the function
|
||||
init_tavily_proxy()
|
||||
|
||||
# Verify it logged the skip message
|
||||
mock_logger.info.assert_called_once_with(
|
||||
'Tavily API key not configured, skipping Tavily MCP proxy'
|
||||
)
|
||||
|
||||
# Verify no proxy was created
|
||||
mock_client.assert_not_called()
|
||||
mock_proxy.assert_not_called()
|
||||
mock_mcp_server.mount.assert_not_called()
|
||||
|
||||
def test_init_tavily_proxy_empty_api_key(self):
|
||||
"""Test init_tavily_proxy does nothing when API key is empty string."""
|
||||
with (
|
||||
patch(
|
||||
'openhands.app_server.mcp.mcp_router.get_global_config'
|
||||
) as mock_config,
|
||||
patch('openhands.app_server.mcp.mcp_router.logger') as mock_logger,
|
||||
patch('openhands.app_server.mcp.mcp_router.Client') as mock_client,
|
||||
patch('openhands.app_server.mcp.mcp_router.create_proxy') as mock_proxy,
|
||||
patch('openhands.app_server.mcp.mcp_router.mcp_server') as mock_mcp_server,
|
||||
):
|
||||
# Configure empty API key
|
||||
mock_config.return_value.tavily_api_key = ''
|
||||
|
||||
# Call the function
|
||||
init_tavily_proxy()
|
||||
|
||||
# Verify it logged the skip message
|
||||
mock_logger.info.assert_called_once_with(
|
||||
'Tavily API key not configured, skipping Tavily MCP proxy'
|
||||
)
|
||||
|
||||
# Verify no proxy was created
|
||||
mock_client.assert_not_called()
|
||||
mock_proxy.assert_not_called()
|
||||
mock_mcp_server.mount.assert_not_called()
|
||||
|
||||
def test_init_tavily_proxy_with_api_key(self):
|
||||
"""Test init_tavily_proxy creates and mounts proxy when API key is configured."""
|
||||
with (
|
||||
patch(
|
||||
'openhands.app_server.mcp.mcp_router.get_global_config'
|
||||
) as mock_config,
|
||||
patch('openhands.app_server.mcp.mcp_router.logger') as mock_logger,
|
||||
patch('openhands.app_server.mcp.mcp_router.Client') as mock_client,
|
||||
patch(
|
||||
'openhands.app_server.mcp.mcp_router.StreamableHttpTransport'
|
||||
) as mock_transport,
|
||||
patch(
|
||||
'openhands.app_server.mcp.mcp_router.create_proxy'
|
||||
) as mock_create_proxy,
|
||||
patch('openhands.app_server.mcp.mcp_router.mcp_server') as mock_mcp_server,
|
||||
):
|
||||
# Configure API key
|
||||
mock_config.return_value.tavily_api_key = 'test-tavily-key'
|
||||
|
||||
# Setup mocks
|
||||
mock_transport_instance = MagicMock()
|
||||
mock_transport.return_value = mock_transport_instance
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client.return_value = mock_client_instance
|
||||
mock_proxy_server = MagicMock()
|
||||
mock_create_proxy.return_value = mock_proxy_server
|
||||
|
||||
# Call the function
|
||||
init_tavily_proxy()
|
||||
|
||||
# Verify transport was created with correct URL
|
||||
mock_transport.assert_called_once_with(
|
||||
url='https://mcp.tavily.com/mcp/?tavilyApiKey=test-tavily-key'
|
||||
)
|
||||
|
||||
# Verify client was created with the transport
|
||||
mock_client.assert_called_once_with(transport=mock_transport_instance)
|
||||
|
||||
# Verify proxy was created from the client
|
||||
mock_create_proxy.assert_called_once_with(mock_client_instance)
|
||||
|
||||
# Verify proxy was mounted with correct namespace
|
||||
mock_mcp_server.mount.assert_called_once_with(
|
||||
namespace='tavily', server=mock_proxy_server
|
||||
)
|
||||
|
||||
# Verify success was logged
|
||||
mock_logger.info.assert_called_once_with(
|
||||
'Tavily MCP proxy initialized successfully'
|
||||
)
|
||||
|
||||
def test_init_tavily_proxy_handles_exception(self):
|
||||
"""Test init_tavily_proxy handles exceptions gracefully."""
|
||||
with (
|
||||
patch(
|
||||
'openhands.app_server.mcp.mcp_router.get_global_config'
|
||||
) as mock_config,
|
||||
patch('openhands.app_server.mcp.mcp_router.logger') as mock_logger,
|
||||
patch('openhands.app_server.mcp.mcp_router.Client') as mock_client,
|
||||
patch('openhands.app_server.mcp.mcp_router.StreamableHttpTransport'),
|
||||
patch('openhands.app_server.mcp.mcp_router.mcp_server') as mock_mcp_server,
|
||||
):
|
||||
# Configure API key
|
||||
mock_config.return_value.tavily_api_key = 'test-tavily-key'
|
||||
|
||||
# Make Client raise an exception
|
||||
mock_client.side_effect = Exception('Connection failed')
|
||||
|
||||
# Call the function - should not raise
|
||||
init_tavily_proxy()
|
||||
|
||||
# Verify error was logged
|
||||
mock_logger.error.assert_called_once_with(
|
||||
'Failed to initialize Tavily MCP proxy: Connection failed'
|
||||
)
|
||||
|
||||
# Verify mount was not called
|
||||
mock_mcp_server.mount.assert_not_called()
|
||||
|
||||
Reference in New Issue
Block a user