feat: Proxy Tavily MCP connections through app server (#14278)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Tim O'Farrell
2026-05-04 06:59:42 -06:00
committed by GitHub
co-authored by openhands
parent bcc175b7d4
commit 949a15a560
8 changed files with 200 additions and 288 deletions
-3
View File
@@ -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():
-33
View File
@@ -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')
+4 -1
View File
@@ -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,
)
+13 -7
View File
@@ -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 (
+32 -1
View File
@@ -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):
+139 -2
View File
@@ -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()