Fix MCP config priority logic in sessions.py (#9237)

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
This commit is contained in:
Xingyao Wang 2025-07-29 14:47:19 -04:00 committed by GitHub
parent 5b13cfc2a0
commit 7a45ebf0f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 366 additions and 17 deletions

View File

@ -10,6 +10,7 @@ Model Context Protocol (MCP) is a mechanism that allows OpenHands to communicate
servers can provide additional functionality to the agent, such as specialized data processing, external API access,
or custom tools. MCP is based on the open standard defined at [modelcontextprotocol.io](https://modelcontextprotocol.io).
<Note>
MCP is currently not available on OpenHands Cloud. This feature is only available when running OpenHands locally.
</Note>
@ -35,41 +36,57 @@ MCP configuration can be defined in:
* The OpenHands UI through the Settings under the `MCP` tab.
* The `config.toml` file under the `[mcp]` section if not using the UI.
### Configuration Example via config.toml
### Configuration Examples
#### Recommended: Using Proxy Servers (SSE/HTTP)
For stdio-based MCP servers, we recommend using MCP proxy tools like [`supergateway`](https://github.com/supercorp-ai/supergateway) instead of direct stdio connections.
[SuperGateway](https://github.com/supercorp-ai/supergateway) is a popular MCP proxy that converts stdio MCP servers to HTTP/SSE endpoints:
Start the proxy servers separately:
```bash
# Terminal 1: Filesystem server proxy
supergateway --stdio "npx @modelcontextprotocol/server-filesystem /" --port 8080
# Terminal 2: Fetch server proxy
supergateway --stdio "uvx mcp-server-fetch" --port 8081
```
Then configure OpenHands to use the HTTP endpoint:
```toml
[mcp]
# SSE Servers - External servers that communicate via Server-Sent Events
# SSE Servers - Recommended approach using proxy tools
sse_servers = [
# Basic SSE server with just a URL
"http://example.com:8080/mcp",
# SSE server with API key authentication
{url="https://secure-example.com/mcp", api_key="your-api-key"}
# SuperGateway proxy for fetch server
"http://localhost:8081/sse",
# External MCP service with authentication
{url="https://api.example.com/mcp/sse", api_key="your-api-key"}
]
```
# SHTTP Servers - External servers that communicate via Streamable HTTP
shttp_servers = [
# Basic SHTTP server with just a URL
"http://example.com:8080/mcp",
# SHTTP server with API key authentication
{url="https://secure-example.com/mcp", api_key="your-api-key"}
]
# Stdio Servers - Local processes that communicate via standard input/output
#### Alternative: Direct Stdio Servers (Not Recommended for Production)
```toml
[mcp]
# Direct stdio servers - use only for development/testing
stdio_servers = [
# Basic stdio server
{name="fetch", command="uvx", args=["mcp-server-fetch"]},
# Stdio server with environment variables
{
name="data-processor",
command="python",
args=["-m", "my_mcp_server"],
name="filesystem",
command="npx",
args=["@modelcontextprotocol/server-filesystem", "/"],
env={
"DEBUG": "true",
"PORT": "8080"
"DEBUG": "true"
}
}
]
@ -103,6 +120,8 @@ SHTTP (Streamable HTTP) servers are configured using either a string URL or an o
### Stdio Servers
**Note**: While stdio servers are supported, we recommend using MCP proxies (see above) for better reliability and performance.
Stdio servers are configured using an object with the following properties:
- `name` (required)
@ -123,6 +142,39 @@ Stdio servers are configured using an object with the following properties:
- Default: `{}`
- Description: Environment variables to set for the server process
#### When to Use Direct Stdio
Direct stdio connections may still be appropriate in these scenarios:
- **Development and testing**: Quick prototyping of MCP servers
- **Simple, single-use tools**: Tools that don't require high reliability or concurrent access
- **Local-only environments**: When you don't want to manage additional proxy processes
For production use, we recommend using proxy tools like SuperGateway.
### Other Proxy Tools
Other options include:
- **Custom FastAPI/Express servers**: Build your own HTTP wrapper around stdio MCP servers
- **Docker-based proxies**: Containerized solutions for better isolation
- **Cloud-hosted MCP services**: Third-party services that provide MCP endpoints
### Troubleshooting MCP Connections
#### Common Issues with Stdio Servers
- **Process crashes**: Stdio processes may crash without proper error handling
- **Deadlocks**: Stdio communication can deadlock under high load
- **Resource leaks**: Zombie processes if not properly managed
- **Debugging difficulty**: Hard to inspect stdio communication
#### Benefits of Using Proxies
- **HTTP status codes**: Clear error reporting via standard HTTP responses
- **Request logging**: Easy to log and monitor HTTP requests
- **Load balancing**: Can distribute requests across multiple server instances
- **Health checks**: HTTP endpoints can provide health status
- **CORS support**: Better integration with web-based tools
## Transport Protocols
OpenHands supports three different MCP transport protocols:

View File

@ -52,6 +52,11 @@ class DefaultUserAuth(UserAuth):
return settings
settings_store = await self.get_user_settings_store()
settings = await settings_store.load()
# Merge config.toml settings with stored settings
if settings:
settings = settings.merge_with_config_settings()
self._settings = settings
return settings

View File

@ -137,3 +137,33 @@ class Settings(BaseModel):
max_budget_per_task=app_config.max_budget_per_task,
)
return settings
def merge_with_config_settings(self) -> 'Settings':
"""Merge config.toml settings with stored settings.
Config.toml takes priority for MCP settings, but they are merged rather than replaced.
This method can be used by both server mode and CLI mode.
"""
# Get config.toml settings
config_settings = Settings.from_config()
if not config_settings or not config_settings.mcp_config:
return self
# If stored settings don't have MCP config, use config.toml MCP config
if not self.mcp_config:
self.mcp_config = config_settings.mcp_config
return self
# Both have MCP config - merge them with config.toml taking priority
merged_mcp = MCPConfig(
sse_servers=list(config_settings.mcp_config.sse_servers)
+ list(self.mcp_config.sse_servers),
stdio_servers=list(config_settings.mcp_config.stdio_servers)
+ list(self.mcp_config.stdio_servers),
shttp_servers=list(config_settings.mcp_config.shttp_servers)
+ list(self.mcp_config.shttp_servers),
)
# Create new settings with merged MCP config
self.mcp_config = merged_mcp
return self

View File

@ -0,0 +1,118 @@
"""Integration test for MCP settings merging in the full flow."""
from unittest.mock import AsyncMock, patch
import pytest
from openhands.core.config.mcp_config import MCPConfig, MCPSSEServerConfig
from openhands.server.user_auth.default_user_auth import DefaultUserAuth
from openhands.storage.data_models.settings import Settings
from openhands.storage.settings.file_settings_store import FileSettingsStore
@pytest.mark.asyncio
async def test_user_auth_mcp_merging_integration():
"""Test that MCP merging works in the user auth flow."""
# Mock config.toml settings
config_settings = Settings(
mcp_config=MCPConfig(
sse_servers=[MCPSSEServerConfig(url='http://config-server.com')]
)
)
# Mock stored frontend settings
stored_settings = Settings(
llm_model='gpt-4',
mcp_config=MCPConfig(
sse_servers=[MCPSSEServerConfig(url='http://frontend-server.com')]
),
)
# Create user auth instance
user_auth = DefaultUserAuth()
# Mock the settings store to return stored settings
mock_settings_store = AsyncMock(spec=FileSettingsStore)
mock_settings_store.load.return_value = stored_settings
with patch.object(
user_auth, 'get_user_settings_store', return_value=mock_settings_store
):
with patch.object(Settings, 'from_config', return_value=config_settings):
# Get user settings - this should trigger the merging
merged_settings = await user_auth.get_user_settings()
# Verify merging worked correctly
assert merged_settings is not None
assert merged_settings.llm_model == 'gpt-4'
assert merged_settings.mcp_config is not None
assert len(merged_settings.mcp_config.sse_servers) == 2
# Config.toml server should come first (priority)
assert merged_settings.mcp_config.sse_servers[0].url == 'http://config-server.com'
assert merged_settings.mcp_config.sse_servers[1].url == 'http://frontend-server.com'
@pytest.mark.asyncio
async def test_user_auth_caching_behavior():
"""Test that user auth caches the merged settings correctly."""
config_settings = Settings(
mcp_config=MCPConfig(
sse_servers=[MCPSSEServerConfig(url='http://config-server.com')]
)
)
stored_settings = Settings(
llm_model='gpt-4',
mcp_config=MCPConfig(
sse_servers=[MCPSSEServerConfig(url='http://frontend-server.com')]
),
)
user_auth = DefaultUserAuth()
mock_settings_store = AsyncMock(spec=FileSettingsStore)
mock_settings_store.load.return_value = stored_settings
with patch.object(
user_auth, 'get_user_settings_store', return_value=mock_settings_store
):
with patch.object(
Settings, 'from_config', return_value=config_settings
) as mock_from_config:
# First call should load and merge
settings1 = await user_auth.get_user_settings()
# Second call should use cached version
settings2 = await user_auth.get_user_settings()
# Verify both calls return the same merged settings
assert settings1 is settings2
assert len(settings1.mcp_config.sse_servers) == 2
# Settings store should only be called once (first time)
mock_settings_store.load.assert_called_once()
# from_config should only be called once (during merging)
mock_from_config.assert_called_once()
@pytest.mark.asyncio
async def test_user_auth_no_stored_settings():
"""Test behavior when no settings are stored (first time user)."""
user_auth = DefaultUserAuth()
# Mock settings store to return None (no stored settings)
mock_settings_store = AsyncMock(spec=FileSettingsStore)
mock_settings_store.load.return_value = None
with patch.object(
user_auth, 'get_user_settings_store', return_value=mock_settings_store
):
settings = await user_auth.get_user_settings()
# Should return None when no settings are stored
assert settings is None

View File

@ -0,0 +1,144 @@
"""Test MCP settings merging functionality."""
from unittest.mock import patch
import pytest
from openhands.core.config.mcp_config import (
MCPConfig,
MCPSSEServerConfig,
MCPStdioServerConfig,
)
from openhands.storage.data_models.settings import Settings
@pytest.mark.asyncio
async def test_mcp_settings_merge_config_only():
"""Test merging when only config.toml has MCP settings."""
# Mock config.toml with MCP settings
mock_config_settings = Settings(
mcp_config=MCPConfig(
sse_servers=[MCPSSEServerConfig(url='http://config-server.com')]
)
)
# Frontend settings without MCP config
frontend_settings = Settings(llm_model='gpt-4')
with patch.object(Settings, 'from_config', return_value=mock_config_settings):
merged_settings = frontend_settings.merge_with_config_settings()
# Should use config.toml MCP settings
assert merged_settings.mcp_config is not None
assert len(merged_settings.mcp_config.sse_servers) == 1
assert merged_settings.mcp_config.sse_servers[0].url == 'http://config-server.com'
assert merged_settings.llm_model == 'gpt-4'
@pytest.mark.asyncio
async def test_mcp_settings_merge_frontend_only():
"""Test merging when only frontend has MCP settings."""
# Mock config.toml without MCP settings
mock_config_settings = Settings(llm_model='claude-3')
# Frontend settings with MCP config
frontend_settings = Settings(
llm_model='gpt-4',
mcp_config=MCPConfig(
sse_servers=[MCPSSEServerConfig(url='http://frontend-server.com')]
),
)
with patch.object(Settings, 'from_config', return_value=mock_config_settings):
merged_settings = frontend_settings.merge_with_config_settings()
# Should keep frontend MCP settings
assert merged_settings.mcp_config is not None
assert len(merged_settings.mcp_config.sse_servers) == 1
assert merged_settings.mcp_config.sse_servers[0].url == 'http://frontend-server.com'
assert merged_settings.llm_model == 'gpt-4'
@pytest.mark.asyncio
async def test_mcp_settings_merge_both_present():
"""Test merging when both config.toml and frontend have MCP settings."""
# Mock config.toml with MCP settings
mock_config_settings = Settings(
mcp_config=MCPConfig(
sse_servers=[MCPSSEServerConfig(url='http://config-server.com')],
stdio_servers=[
MCPStdioServerConfig(
name='config-stdio', command='config-cmd', args=['arg1']
)
],
)
)
# Frontend settings with different MCP config
frontend_settings = Settings(
llm_model='gpt-4',
mcp_config=MCPConfig(
sse_servers=[MCPSSEServerConfig(url='http://frontend-server.com')],
stdio_servers=[
MCPStdioServerConfig(
name='frontend-stdio', command='frontend-cmd', args=['arg2']
)
],
),
)
with patch.object(Settings, 'from_config', return_value=mock_config_settings):
merged_settings = frontend_settings.merge_with_config_settings()
# Should merge both with config.toml taking priority (appearing first)
assert merged_settings.mcp_config is not None
assert len(merged_settings.mcp_config.sse_servers) == 2
assert merged_settings.mcp_config.sse_servers[0].url == 'http://config-server.com'
assert merged_settings.mcp_config.sse_servers[1].url == 'http://frontend-server.com'
assert len(merged_settings.mcp_config.stdio_servers) == 2
assert merged_settings.mcp_config.stdio_servers[0].name == 'config-stdio'
assert merged_settings.mcp_config.stdio_servers[1].name == 'frontend-stdio'
assert merged_settings.llm_model == 'gpt-4'
@pytest.mark.asyncio
async def test_mcp_settings_merge_no_config():
"""Test merging when config.toml has no MCP settings."""
# Mock config.toml without MCP settings
mock_config_settings = None
# Frontend settings with MCP config
frontend_settings = Settings(
llm_model='gpt-4',
mcp_config=MCPConfig(
sse_servers=[MCPSSEServerConfig(url='http://frontend-server.com')]
),
)
with patch.object(Settings, 'from_config', return_value=mock_config_settings):
merged_settings = frontend_settings.merge_with_config_settings()
# Should keep frontend settings unchanged
assert merged_settings.mcp_config is not None
assert len(merged_settings.mcp_config.sse_servers) == 1
assert merged_settings.mcp_config.sse_servers[0].url == 'http://frontend-server.com'
assert merged_settings.llm_model == 'gpt-4'
@pytest.mark.asyncio
async def test_mcp_settings_merge_neither_present():
"""Test merging when neither config.toml nor frontend have MCP settings."""
# Mock config.toml without MCP settings
mock_config_settings = Settings(llm_model='claude-3')
# Frontend settings without MCP config
frontend_settings = Settings(llm_model='gpt-4')
with patch.object(Settings, 'from_config', return_value=mock_config_settings):
merged_settings = frontend_settings.merge_with_config_settings()
# Should keep frontend settings unchanged
assert merged_settings.mcp_config is None
assert merged_settings.llm_model == 'gpt-4'