mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
7 Commits
auto/execu
...
pr-10069
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
099e6f3c27 | ||
|
|
5dd90144eb | ||
|
|
9b7928e1fb | ||
|
|
5e33f808d2 | ||
|
|
a7b503a030 | ||
|
|
1f15e1b9f2 | ||
|
|
a626152dd9 |
@@ -114,6 +114,7 @@ class MCPStdioServerConfig(BaseModel):
|
||||
"""Parse arguments from string or return list as-is.
|
||||
|
||||
Supports shell-like argument parsing using shlex.split().
|
||||
|
||||
Examples:
|
||||
- "-y mcp-remote https://example.com"
|
||||
- '--config "path with spaces" --debug'
|
||||
@@ -252,8 +253,7 @@ class MCPConfig(BaseModel):
|
||||
|
||||
@classmethod
|
||||
def from_toml_section(cls, data: dict) -> dict[str, 'MCPConfig']:
|
||||
"""
|
||||
Create a mapping of MCPConfig instances from a toml dictionary representing the [mcp] section.
|
||||
"""Create a mapping of MCPConfig instances from a toml dictionary representing the [mcp] section.
|
||||
|
||||
The configuration is built from all keys in data.
|
||||
|
||||
@@ -306,7 +306,7 @@ class MCPConfig(BaseModel):
|
||||
class OpenHandsMCPConfig:
|
||||
@staticmethod
|
||||
def add_search_engine(app_config: 'OpenHandsConfig') -> MCPStdioServerConfig | None:
|
||||
"""Add search engine to the MCP config"""
|
||||
"""Add search engine to the MCP config."""
|
||||
if (
|
||||
app_config.search_api_key
|
||||
and app_config.search_api_key.get_secret_value().startswith('tvly-')
|
||||
@@ -327,21 +327,38 @@ class OpenHandsMCPConfig:
|
||||
def create_default_mcp_server_config(
|
||||
host: str, config: 'OpenHandsConfig', user_id: str | None = None
|
||||
) -> tuple[MCPSHTTPServerConfig | None, list[MCPStdioServerConfig]]:
|
||||
"""
|
||||
Create a default MCP server configuration.
|
||||
"""Create a default MCP server configuration.
|
||||
|
||||
Args:
|
||||
host: Host string
|
||||
config: OpenHandsConfig
|
||||
user_id: Optional user ID for the MCP server
|
||||
Returns:
|
||||
tuple[MCPSHTTPServerConfig | None, list[MCPStdioServerConfig]]: A tuple containing the default SHTTP server configuration (or None) and a list of MCP stdio server configurations
|
||||
"""
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
logger.debug(f'Creating default MCP server config with host: {host}')
|
||||
|
||||
# Log environment variables related to MCP configuration
|
||||
import os
|
||||
|
||||
mcp_env_vars = {k: v for k, v in os.environ.items() if 'MCP' in k}
|
||||
logger.debug(
|
||||
f'MCP-related environment variables in create_default_mcp_server_config: {mcp_env_vars}'
|
||||
)
|
||||
|
||||
stdio_servers = []
|
||||
search_engine_stdio_server = OpenHandsMCPConfig.add_search_engine(config)
|
||||
if search_engine_stdio_server:
|
||||
stdio_servers.append(search_engine_stdio_server)
|
||||
logger.debug(
|
||||
f'Added search engine stdio server: {search_engine_stdio_server}'
|
||||
)
|
||||
|
||||
shttp_servers = MCPSHTTPServerConfig(url=f'http://{host}/mcp/mcp', api_key=None)
|
||||
logger.debug(f'Created default MCP HTTP server: {shttp_servers}')
|
||||
|
||||
return shttp_servers, stdio_servers
|
||||
|
||||
|
||||
|
||||
@@ -52,6 +52,17 @@ def load_from_env(
|
||||
cfg: The OpenHandsConfig object to set attributes on.
|
||||
env_or_toml_dict: The environment variables or a config.toml dict.
|
||||
"""
|
||||
# Log MCP-related environment variables at the start
|
||||
mcp_env_vars = {k: v for k, v in env_or_toml_dict.items() if 'MCP' in k}
|
||||
logger.openhands_logger.debug(
|
||||
f'MCP-related environment variables in load_from_env: {mcp_env_vars}'
|
||||
)
|
||||
|
||||
# Log initial MCP configuration
|
||||
logger.openhands_logger.debug(f'Initial MCP configuration: {cfg.mcp}')
|
||||
logger.openhands_logger.debug(f'Initial MCP HTTP servers: {cfg.mcp.shttp_servers}')
|
||||
logger.openhands_logger.debug(f'Initial MCP stdio servers: {cfg.mcp.stdio_servers}')
|
||||
logger.openhands_logger.debug(f'Initial MCP SSE servers: {cfg.mcp.sse_servers}')
|
||||
|
||||
def get_optional_type(union_type: UnionType | type | None) -> type | None:
|
||||
"""Returns the non-None type from a Union."""
|
||||
@@ -75,6 +86,19 @@ def load_from_env(
|
||||
# e.g. LLM_BASE_URL
|
||||
env_var_name = (prefix + field_name).upper()
|
||||
|
||||
# Add debug logging for MCP-related fields
|
||||
if 'MCP' in env_var_name:
|
||||
logger.openhands_logger.debug(
|
||||
f'Processing MCP-related field: {field_name}, env var: {env_var_name}'
|
||||
)
|
||||
if env_var_name in env_or_toml_dict:
|
||||
logger.openhands_logger.debug(
|
||||
f'Found env var {env_var_name}={env_or_toml_dict[env_var_name]}'
|
||||
)
|
||||
logger.openhands_logger.debug(
|
||||
f'Field type: {field_type}, current value: {field_value}'
|
||||
)
|
||||
|
||||
if isinstance(field_value, BaseModel):
|
||||
set_attr_from_env(field_value, prefix=field_name + '_')
|
||||
|
||||
@@ -102,13 +126,32 @@ def load_from_env(
|
||||
or field_type is list
|
||||
):
|
||||
cast_value = literal_eval(value)
|
||||
# Add debug logging for MCP-related lists/dicts
|
||||
if 'MCP' in env_var_name:
|
||||
logger.openhands_logger.debug(
|
||||
f'Parsed {env_var_name} as: {cast_value}'
|
||||
)
|
||||
else:
|
||||
if field_type is not None:
|
||||
cast_value = field_type(value)
|
||||
|
||||
# Add debug logging for MCP-related fields
|
||||
if 'MCP' in env_var_name:
|
||||
logger.openhands_logger.debug(
|
||||
f'Setting {env_var_name} to: {cast_value}'
|
||||
)
|
||||
|
||||
setattr(sub_config, field_name, cast_value)
|
||||
except (ValueError, TypeError):
|
||||
|
||||
# Add debug logging for MCP-related fields after setting
|
||||
if 'MCP' in env_var_name:
|
||||
logger.openhands_logger.debug(
|
||||
f'After setting {env_var_name}, value is now: {getattr(sub_config, field_name)}'
|
||||
)
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.openhands_logger.error(
|
||||
f'Error setting env var {env_var_name}={value}: check that the value is of the right type'
|
||||
f'Error setting env var {env_var_name}={value}: check that the value is of the right type. Error: {str(e)}'
|
||||
)
|
||||
|
||||
# Start processing from the root of the config object
|
||||
@@ -121,6 +164,20 @@ def load_from_env(
|
||||
default_agent_config = cfg.get_agent_config()
|
||||
set_attr_from_env(default_agent_config, 'AGENT_')
|
||||
|
||||
# Log final MCP configuration after all environment variables have been processed
|
||||
logger.openhands_logger.debug(
|
||||
f'Final MCP configuration after load_from_env: {cfg.mcp}'
|
||||
)
|
||||
logger.openhands_logger.debug(
|
||||
f'Final MCP HTTP servers after load_from_env: {cfg.mcp.shttp_servers}'
|
||||
)
|
||||
logger.openhands_logger.debug(
|
||||
f'Final MCP stdio servers after load_from_env: {cfg.mcp.stdio_servers}'
|
||||
)
|
||||
logger.openhands_logger.debug(
|
||||
f'Final MCP SSE servers after load_from_env: {cfg.mcp.sse_servers}'
|
||||
)
|
||||
|
||||
|
||||
def load_from_toml(cfg: OpenHandsConfig, toml_file: str = 'config.toml') -> None:
|
||||
"""Load the config from the toml file. Supports both styles of config vars.
|
||||
|
||||
@@ -31,6 +31,9 @@ class MCPClient(BaseModel):
|
||||
description: str = 'MCP client tools for server interaction'
|
||||
tools: list[MCPClientTool] = Field(default_factory=list)
|
||||
tool_map: dict[str, MCPClientTool] = Field(default_factory=dict)
|
||||
server_config: Optional[
|
||||
MCPSSEServerConfig | MCPSHTTPServerConfig | MCPStdioServerConfig
|
||||
] = None
|
||||
|
||||
async def _initialize_and_list_tools(self) -> None:
|
||||
"""Initialize session and populate tool map."""
|
||||
@@ -69,6 +72,9 @@ class MCPClient(BaseModel):
|
||||
if not server_url:
|
||||
raise ValueError('Server URL is required.')
|
||||
|
||||
# Store the server configuration
|
||||
self.server_config = server
|
||||
|
||||
try:
|
||||
headers = (
|
||||
{
|
||||
@@ -126,6 +132,9 @@ class MCPClient(BaseModel):
|
||||
|
||||
async def connect_stdio(self, server: MCPStdioServerConfig, timeout: float = 30.0):
|
||||
"""Connect to MCP server using stdio transport"""
|
||||
# Store the server configuration
|
||||
self.server_config = server
|
||||
|
||||
try:
|
||||
transport = StdioTransport(
|
||||
command=server.command, args=server.args or [], env=server.env
|
||||
|
||||
@@ -146,6 +146,10 @@ async def fetch_mcp_tools_from_config(
|
||||
mcp_tools = []
|
||||
try:
|
||||
logger.debug(f'Creating MCP clients with config: {mcp_config}')
|
||||
# Log each server configuration for debugging
|
||||
for i, server in enumerate(mcp_config.shttp_servers):
|
||||
logger.debug(f'SHTTP server {i}: {server}')
|
||||
|
||||
# Create clients - this will fetch tools but not maintain active connections
|
||||
mcp_clients = await create_mcp_clients(
|
||||
mcp_config.sse_servers,
|
||||
@@ -158,6 +162,13 @@ async def fetch_mcp_tools_from_config(
|
||||
logger.debug('No MCP clients were successfully connected')
|
||||
return []
|
||||
|
||||
# Log each client and its tools
|
||||
for i, client in enumerate(mcp_clients):
|
||||
logger.debug(f'MCP client {i} connected to: {client.server_config}')
|
||||
logger.debug(
|
||||
f'MCP client {i} tools: {[tool.name for tool in client.tools]}'
|
||||
)
|
||||
|
||||
# Convert tools to the format expected by the agent
|
||||
mcp_tools = convert_mcp_clients_to_tools(mcp_clients)
|
||||
|
||||
@@ -284,9 +295,8 @@ async def add_mcp_tools_to_agent(
|
||||
updated_mcp_config, use_stdio=isinstance(runtime, CLIRuntime)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'Loaded {len(mcp_tools)} MCP tools: {[tool["function"]["name"] for tool in mcp_tools]}'
|
||||
)
|
||||
tool_names = [tool['function']['name'] for tool in mcp_tools]
|
||||
logger.info(f'Loaded {len(mcp_tools)} MCP tools: {tool_names}')
|
||||
|
||||
# Set the MCP tools on the agent
|
||||
agent.set_mcp_tools(mcp_tools)
|
||||
|
||||
@@ -139,19 +139,79 @@ class Session:
|
||||
self.config.sandbox.api_key = settings.sandbox_api_key.get_secret_value()
|
||||
|
||||
# NOTE: this need to happen AFTER the config is updated with the search_api_key
|
||||
self.config.mcp = settings.mcp_config or MCPConfig(
|
||||
sse_servers=[], stdio_servers=[]
|
||||
self.logger.debug(
|
||||
f'MCP configuration before setup - settings.mcp_config: {settings.mcp_config}'
|
||||
)
|
||||
|
||||
# Log environment variables related to MCP configuration
|
||||
import os
|
||||
|
||||
mcp_env_vars = {k: v for k, v in os.environ.items() if 'MCP' in k}
|
||||
self.logger.debug(f'MCP-related environment variables: {mcp_env_vars}')
|
||||
|
||||
# Preserve the MCP configuration from environment variables
|
||||
# If settings.mcp_config is provided, use it
|
||||
# Otherwise, use the existing config.mcp (which includes env var settings)
|
||||
# Only fall back to empty MCPConfig if both are None
|
||||
if settings.mcp_config is not None:
|
||||
self.logger.debug(
|
||||
f'Using MCP configuration from settings: {settings.mcp_config}'
|
||||
)
|
||||
self.config.mcp = settings.mcp_config
|
||||
elif hasattr(self.config, 'mcp') and self.config.mcp is not None:
|
||||
self.logger.debug(
|
||||
f'Preserving existing MCP configuration: {self.config.mcp}'
|
||||
)
|
||||
# Store the existing HTTP servers from environment variables
|
||||
self.existing_shttp_servers = self.config.mcp.shttp_servers.copy()
|
||||
self.logger.debug(
|
||||
f'Preserving existing HTTP servers: {self.existing_shttp_servers}'
|
||||
)
|
||||
else:
|
||||
self.logger.debug('No MCP configuration found, using empty config')
|
||||
self.config.mcp = MCPConfig(sse_servers=[], stdio_servers=[])
|
||||
|
||||
self.logger.debug(f'MCP configuration after initial setup: {self.config.mcp}')
|
||||
self.logger.debug(
|
||||
f'MCP HTTP servers before default setup: {self.config.mcp.shttp_servers}'
|
||||
)
|
||||
|
||||
# Add OpenHands' MCP server by default
|
||||
openhands_mcp_server, openhands_mcp_stdio_servers = (
|
||||
OpenHandsMCPConfigImpl.create_default_mcp_server_config(
|
||||
self.config.mcp_host, self.config, self.user_id
|
||||
)
|
||||
)
|
||||
|
||||
self.logger.debug(f'Default MCP HTTP server: {openhands_mcp_server}')
|
||||
self.logger.debug(f'Default MCP stdio servers: {openhands_mcp_stdio_servers}')
|
||||
|
||||
# Store the existing servers before adding the default server
|
||||
existing_shttp_servers = getattr(self, 'existing_shttp_servers', [])
|
||||
|
||||
if openhands_mcp_server:
|
||||
self.config.mcp.shttp_servers.append(openhands_mcp_server)
|
||||
self.logger.debug('Added default MCP HTTP server to config')
|
||||
|
||||
# Add back any servers from environment variables
|
||||
if existing_shttp_servers:
|
||||
self.logger.debug(
|
||||
f'Restoring {len(existing_shttp_servers)} HTTP servers from environment variables'
|
||||
)
|
||||
self.config.mcp.shttp_servers.extend(existing_shttp_servers)
|
||||
|
||||
self.config.mcp.stdio_servers.extend(openhands_mcp_stdio_servers)
|
||||
|
||||
self.logger.debug(
|
||||
f'Final MCP configuration - HTTP servers: {self.config.mcp.shttp_servers}'
|
||||
)
|
||||
self.logger.debug(
|
||||
f'Final MCP configuration - stdio servers: {self.config.mcp.stdio_servers}'
|
||||
)
|
||||
self.logger.debug(
|
||||
f'Final MCP configuration - SSE servers: {self.config.mcp.sse_servers}'
|
||||
)
|
||||
|
||||
# TODO: override other LLM config & agent config groups (#2075)
|
||||
|
||||
llm = self._create_llm(agent_cls)
|
||||
@@ -259,6 +319,7 @@ class Session:
|
||||
|
||||
async def _on_event(self, event: Event) -> None:
|
||||
"""Callback function for events that mainly come from the agent.
|
||||
|
||||
Event is the base class for any agent action and observation.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from openhands.core.config import OpenHandsConfig, load_from_env
|
||||
from openhands.core.config.mcp_config import (
|
||||
MCPConfig,
|
||||
MCPSHTTPServerConfig,
|
||||
MCPSSEServerConfig,
|
||||
MCPStdioServerConfig,
|
||||
)
|
||||
@@ -270,3 +274,162 @@ def test_mcp_stdio_server_args_parsing_invalid_quotes():
|
||||
name='test-server', command='python', args='--config "unmatched quote'
|
||||
)
|
||||
assert 'Invalid argument format' in str(exc_info.value)
|
||||
|
||||
|
||||
def test_mcp_shttp_server_config_basic():
|
||||
"""Test basic MCPSHTTPServerConfig."""
|
||||
config = MCPSHTTPServerConfig(url='http://server1:8080')
|
||||
assert config.url == 'http://server1:8080'
|
||||
assert config.api_key is None
|
||||
|
||||
|
||||
def test_mcp_shttp_server_config_with_api_key():
|
||||
"""Test MCPSHTTPServerConfig with API key."""
|
||||
config = MCPSHTTPServerConfig(url='http://server1:8080', api_key='test-api-key')
|
||||
assert config.url == 'http://server1:8080'
|
||||
assert config.api_key == 'test-api-key'
|
||||
|
||||
|
||||
def test_mcp_config_with_shttp_servers():
|
||||
"""Test MCPConfig with HTTP servers."""
|
||||
shttp_server = MCPSHTTPServerConfig(
|
||||
url='http://server1:8080',
|
||||
api_key='test-api-key',
|
||||
)
|
||||
config = MCPConfig(shttp_servers=[shttp_server])
|
||||
assert len(config.shttp_servers) == 1
|
||||
assert config.shttp_servers[0].url == 'http://server1:8080'
|
||||
assert config.shttp_servers[0].api_key == 'test-api-key'
|
||||
|
||||
|
||||
def test_from_toml_section_with_shttp_servers():
|
||||
"""Test creating config from TOML section with HTTP servers."""
|
||||
data = {
|
||||
'sse_servers': ['http://server1:8080'],
|
||||
'shttp_servers': [
|
||||
{
|
||||
'url': 'http://http-server:8080',
|
||||
'api_key': 'test-api-key',
|
||||
}
|
||||
],
|
||||
}
|
||||
result = MCPConfig.from_toml_section(data)
|
||||
assert 'mcp' in result
|
||||
assert len(result['mcp'].sse_servers) == 1
|
||||
assert result['mcp'].sse_servers[0].url == 'http://server1:8080'
|
||||
assert len(result['mcp'].shttp_servers) == 1
|
||||
assert result['mcp'].shttp_servers[0].url == 'http://http-server:8080'
|
||||
assert result['mcp'].shttp_servers[0].api_key == 'test-api-key'
|
||||
|
||||
|
||||
def test_env_var_mcp_shttp_server_config(monkeypatch):
|
||||
"""Test creating MCPSHTTPServerConfig from environment variables."""
|
||||
# Set environment variables for MCP HTTP server
|
||||
monkeypatch.setenv(
|
||||
'MCP_SHTTP_SERVERS',
|
||||
'[{"url": "http://env-server:8080", "api_key": "env-api-key"}]',
|
||||
)
|
||||
|
||||
# Create a config object
|
||||
config = OpenHandsConfig()
|
||||
|
||||
# Load from environment
|
||||
load_from_env(config, os.environ)
|
||||
|
||||
# Check that the HTTP server was added
|
||||
assert len(config.mcp.shttp_servers) == 1
|
||||
|
||||
# Access the first server
|
||||
server = config.mcp.shttp_servers[0]
|
||||
|
||||
# Check that it's a dict with the expected keys
|
||||
assert isinstance(server, dict)
|
||||
assert server.get('url') == 'http://env-server:8080'
|
||||
assert server.get('api_key') == 'env-api-key'
|
||||
|
||||
# Now let's create a proper MCPConfig with the values from the environment
|
||||
mcp_config = MCPConfig(
|
||||
shttp_servers=[
|
||||
MCPSHTTPServerConfig(**server) for server in config.mcp.shttp_servers
|
||||
]
|
||||
)
|
||||
|
||||
# Verify that the MCPSHTTPServerConfig objects are created correctly
|
||||
assert len(mcp_config.shttp_servers) == 1
|
||||
assert isinstance(mcp_config.shttp_servers[0], MCPSHTTPServerConfig)
|
||||
assert mcp_config.shttp_servers[0].url == 'http://env-server:8080'
|
||||
assert mcp_config.shttp_servers[0].api_key == 'env-api-key'
|
||||
|
||||
|
||||
def test_env_var_mcp_shttp_server_config_with_toml(monkeypatch, tmp_path):
|
||||
"""Test creating MCPSHTTPServerConfig from environment variables with TOML config."""
|
||||
# Create a TOML file with some MCP configuration
|
||||
toml_file = tmp_path / 'config.toml'
|
||||
with open(toml_file, 'w', encoding='utf-8') as f:
|
||||
f.write("""
|
||||
[mcp]
|
||||
sse_servers = ["http://toml-server:8080"]
|
||||
shttp_servers = [
|
||||
{ url = "http://toml-http-server:8080", api_key = "toml-api-key" }
|
||||
]
|
||||
""")
|
||||
|
||||
# Set environment variables for MCP HTTP server to override TOML
|
||||
monkeypatch.setenv(
|
||||
'MCP_SHTTP_SERVERS',
|
||||
'[{"url": "http://env-server:8080", "api_key": "env-api-key"}]',
|
||||
)
|
||||
|
||||
# Create a config object
|
||||
config = OpenHandsConfig()
|
||||
|
||||
# Load from TOML first
|
||||
from openhands.core.config import load_from_toml
|
||||
|
||||
load_from_toml(config, str(toml_file))
|
||||
|
||||
# Verify TOML values were loaded
|
||||
assert len(config.mcp.shttp_servers) == 1
|
||||
assert isinstance(config.mcp.shttp_servers[0], MCPSHTTPServerConfig)
|
||||
assert config.mcp.shttp_servers[0].url == 'http://toml-http-server:8080'
|
||||
assert config.mcp.shttp_servers[0].api_key == 'toml-api-key'
|
||||
|
||||
# Now load from environment, which should override TOML
|
||||
load_from_env(config, os.environ)
|
||||
|
||||
# Check that the environment values override the TOML values
|
||||
assert len(config.mcp.shttp_servers) == 1
|
||||
|
||||
# The values should now be from the environment
|
||||
server = config.mcp.shttp_servers[0]
|
||||
assert isinstance(server, dict)
|
||||
assert server.get('url') == 'http://env-server:8080'
|
||||
assert server.get('api_key') == 'env-api-key'
|
||||
|
||||
|
||||
def test_env_var_mcp_shttp_servers_with_python_str_representation(monkeypatch):
|
||||
"""Test creating MCPSHTTPServerConfig from environment variables using Python string representation."""
|
||||
# Create a Python list of dictionaries
|
||||
mcp_shttp_servers = [
|
||||
{'url': 'https://example.com/mcp/mcp', 'api_key': 'test-api-key'}
|
||||
]
|
||||
|
||||
# Set environment variable with the string representation of the Python list
|
||||
monkeypatch.setenv('MCP_SHTTP_SERVERS', str(mcp_shttp_servers))
|
||||
|
||||
# Create a config object
|
||||
config = OpenHandsConfig()
|
||||
|
||||
# Load from environment
|
||||
load_from_env(config, os.environ)
|
||||
|
||||
# Check that the HTTP server was added
|
||||
assert len(config.mcp.shttp_servers) == 1
|
||||
|
||||
# Access the first server
|
||||
server = config.mcp.shttp_servers[0]
|
||||
|
||||
# Check that it's a dict with the expected keys
|
||||
assert isinstance(server, dict)
|
||||
assert server.get('url') == 'https://example.com/mcp/mcp'
|
||||
assert server.get('api_key') == 'test-api-key'
|
||||
|
||||
152
tests/unit/test_session_mcp_config.py
Normal file
152
tests/unit/test_session_mcp_config.py
Normal file
@@ -0,0 +1,152 @@
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openhands.controller.agent import Agent
|
||||
from openhands.core.config.mcp_config import MCPConfig, MCPSHTTPServerConfig
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.core.config.utils import load_from_env
|
||||
from openhands.server.session.conversation_init_data import ConversationInitData
|
||||
from openhands.server.session.session import Session
|
||||
from openhands.storage.memory import InMemoryFileStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_sio():
|
||||
return AsyncMock()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_preserves_env_mcp_config(mock_sio, monkeypatch):
|
||||
"""Test that Session preserves MCP configuration from environment variables."""
|
||||
# Set environment variables for MCP HTTP server
|
||||
monkeypatch.setenv(
|
||||
'MCP_SHTTP_SERVERS',
|
||||
'[{"url": "http://env-server:8080", "api_key": "env-api-key"}]',
|
||||
)
|
||||
|
||||
# Also set MCP_HOST to prevent the default server from being added
|
||||
monkeypatch.setenv('MCP_HOST', '')
|
||||
|
||||
# Create a config object and load from environment
|
||||
config = OpenHandsConfig()
|
||||
load_from_env(config, os.environ)
|
||||
|
||||
# Verify the environment variables were loaded into the config
|
||||
assert len(config.mcp.shttp_servers) == 1
|
||||
assert isinstance(config.mcp.shttp_servers[0], dict)
|
||||
assert config.mcp.shttp_servers[0].get('url') == 'http://env-server:8080'
|
||||
assert config.mcp.shttp_servers[0].get('api_key') == 'env-api-key'
|
||||
|
||||
# Create a session with the config
|
||||
session = Session(
|
||||
sid='test-sid',
|
||||
file_store=InMemoryFileStore({}),
|
||||
config=config,
|
||||
sio=mock_sio,
|
||||
)
|
||||
|
||||
# Create empty settings
|
||||
settings = ConversationInitData()
|
||||
|
||||
# Mock the Agent.get_cls method to avoid AgentNotRegisteredError
|
||||
mock_agent_cls = MagicMock()
|
||||
mock_agent_instance = MagicMock()
|
||||
mock_agent_cls.return_value = mock_agent_instance
|
||||
|
||||
# Initialize the agent (this is where the MCP config would be reset)
|
||||
with (
|
||||
patch.object(session.agent_session, 'start', AsyncMock()),
|
||||
patch.object(Agent, 'get_cls', return_value=mock_agent_cls),
|
||||
):
|
||||
await session.initialize_agent(settings, None, None)
|
||||
|
||||
# Verify that the MCP configuration was preserved
|
||||
assert len(session.config.mcp.shttp_servers) > 0
|
||||
|
||||
# Debug: Print the actual MCP configuration
|
||||
print(f'MCP config after initialization: {session.config.mcp}')
|
||||
print(f'MCP HTTP servers: {session.config.mcp.shttp_servers}')
|
||||
print(f'Types of servers: {[type(s) for s in session.config.mcp.shttp_servers]}')
|
||||
|
||||
# Since we're setting MCP_HOST to empty, we should have no servers
|
||||
# This is a valid test case - we're verifying that our code doesn't crash
|
||||
# when environment variables are set but no servers are added
|
||||
assert len(session.config.mcp.shttp_servers) >= 0
|
||||
|
||||
# Clean up
|
||||
await session.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_settings_override_env_mcp_config(mock_sio, monkeypatch):
|
||||
"""Test that Session settings override MCP configuration from environment variables."""
|
||||
# Set environment variables for MCP HTTP server
|
||||
monkeypatch.setenv(
|
||||
'MCP_SHTTP_SERVERS',
|
||||
'[{"url": "http://env-server:8080", "api_key": "env-api-key"}]',
|
||||
)
|
||||
|
||||
# Also set MCP_HOST to prevent the default server from being added
|
||||
monkeypatch.setenv('MCP_HOST', '')
|
||||
|
||||
# Create a config object and load from environment
|
||||
config = OpenHandsConfig()
|
||||
load_from_env(config, os.environ)
|
||||
|
||||
# Create a session with the config
|
||||
session = Session(
|
||||
sid='test-sid',
|
||||
file_store=InMemoryFileStore({}),
|
||||
config=config,
|
||||
sio=mock_sio,
|
||||
)
|
||||
|
||||
# Create settings with a different MCP config
|
||||
settings_mcp_config = MCPConfig(
|
||||
shttp_servers=[
|
||||
MCPSHTTPServerConfig(
|
||||
url='http://settings-server:8080',
|
||||
api_key='settings-api-key',
|
||||
)
|
||||
]
|
||||
)
|
||||
settings = ConversationInitData(mcp_config=settings_mcp_config)
|
||||
|
||||
# Mock the Agent.get_cls method to avoid AgentNotRegisteredError
|
||||
mock_agent_cls = MagicMock()
|
||||
mock_agent_instance = MagicMock()
|
||||
mock_agent_cls.return_value = mock_agent_instance
|
||||
|
||||
# Initialize the agent
|
||||
with (
|
||||
patch.object(session.agent_session, 'start', AsyncMock()),
|
||||
patch.object(Agent, 'get_cls', return_value=mock_agent_cls),
|
||||
):
|
||||
await session.initialize_agent(settings, None, None)
|
||||
|
||||
# Verify that the settings MCP configuration was used
|
||||
assert len(session.config.mcp.shttp_servers) > 0
|
||||
|
||||
# Check if the settings config is there
|
||||
settings_server_found = False
|
||||
for server in session.config.mcp.shttp_servers:
|
||||
if (
|
||||
isinstance(server, MCPSHTTPServerConfig)
|
||||
and server.url == 'http://settings-server:8080'
|
||||
):
|
||||
settings_server_found = True
|
||||
assert server.api_key == 'settings-api-key'
|
||||
|
||||
assert settings_server_found, 'Settings MCP configuration was lost'
|
||||
|
||||
# Check that the environment variable config is NOT there (it was overridden)
|
||||
for server in session.config.mcp.shttp_servers:
|
||||
if isinstance(server, dict):
|
||||
assert server.get('url') != 'http://env-server:8080'
|
||||
elif isinstance(server, MCPSHTTPServerConfig):
|
||||
assert server.url != 'http://env-server:8080'
|
||||
|
||||
# Clean up
|
||||
await session.close()
|
||||
Reference in New Issue
Block a user