mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
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:
parent
5b13cfc2a0
commit
7a45ebf0f4
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
118
tests/unit/test_mcp_integration.py
Normal file
118
tests/unit/test_mcp_integration.py
Normal 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
|
||||
144
tests/unit/test_mcp_settings_merge.py
Normal file
144
tests/unit/test_mcp_settings_merge.py
Normal 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'
|
||||
Loading…
x
Reference in New Issue
Block a user