OpenHands/openhands-cli/tests/test_mcp_config_validation.py
2025-10-30 09:20:27 -04:00

207 lines
6.0 KiB
Python

"""Parametrized tests for MCP configuration screen functionality."""
import json
from pathlib import Path
from unittest.mock import patch
import pytest
from openhands_cli.locations import MCP_CONFIG_FILE
from openhands_cli.tui.settings.mcp_screen import MCPScreen
from openhands.sdk import LLM, Agent
@pytest.fixture
def persistence_dir(tmp_path, monkeypatch):
"""Patch PERSISTENCE_DIR to tmp and return the directory Path."""
monkeypatch.setattr(
'openhands_cli.tui.settings.mcp_screen.PERSISTENCE_DIR',
str(tmp_path),
raising=True,
)
return tmp_path
def _create_agent(mcp_config=None) -> Agent:
if mcp_config is None:
mcp_config = {}
return Agent(
llm=LLM(model='test-model', api_key='test-key', usage_id='test-service'),
tools=[],
mcp_config=mcp_config,
)
def _maybe_write_mcp_file(dirpath: Path, file_content):
"""Write mcp.json if file_content is provided.
file_content:
- None -> do not create file (missing)
- "INVALID"-> write invalid JSON
- dict -> dump as JSON
"""
if file_content is None:
return
cfg_path = dirpath / MCP_CONFIG_FILE
if file_content == 'INVALID':
cfg_path.write_text('{"invalid": json content}')
else:
cfg_path.write_text(json.dumps(file_content))
# Shared "always expected" help text snippets
ALWAYS_EXPECTED = [
'MCP (Model Context Protocol) Configuration',
'To get started:',
'~/.openhands/mcp.json',
'https://gofastmcp.com/clients/client#configuration-format',
'Restart your OpenHands session',
]
CASES = [
# Agent has an existing server; should list "Current Agent MCP Servers"
dict(
id='agent_has_existing',
agent_mcp={
'mcpServers': {
'existing_server': {
'command': 'python',
'args': ['-m', 'existing_server'],
}
}
},
file_content=None, # no incoming file
expected=[
'Current Agent MCP Servers:',
'existing_server',
],
unexpected=[],
),
# Agent has none; should show "None configured on the current agent"
dict(
id='agent_has_none',
agent_mcp={},
file_content=None,
expected=[
'Current Agent MCP Servers:',
'None configured on the current agent',
],
unexpected=[],
),
# New servers present only in mcp.json
dict(
id='new_servers_on_restart',
agent_mcp={},
file_content={
'mcpServers': {
'fetch': {'command': 'uvx', 'args': ['mcp-server-fetch']},
'notion': {'url': 'https://mcp.notion.com/mcp', 'auth': 'oauth'},
}
},
expected=[
'Incoming Servers on Restart',
'New servers (will be added):',
'fetch',
'notion',
],
unexpected=[],
),
# Overriding/updating servers present in both agent and mcp.json (but different config)
dict(
id='overriding_servers_on_restart',
agent_mcp={
'mcpServers': {
'fetch': {'command': 'python', 'args': ['-m', 'old_fetch_server']}
}
},
file_content={
'mcpServers': {'fetch': {'command': 'uvx', 'args': ['mcp-server-fetch']}}
},
expected=[
'Incoming Servers on Restart',
'Updated servers (configuration will change):',
'fetch',
'Current:',
'Incoming:',
],
unexpected=[],
),
# All servers already synced (matching config)
dict(
id='already_synced',
agent_mcp={
'mcpServers': {
'fetch': {
'command': 'uvx',
'args': ['mcp-server-fetch'],
'env': {},
'transport': 'stdio',
}
}
},
file_content={
'mcpServers': {'fetch': {'command': 'uvx', 'args': ['mcp-server-fetch']}}
},
expected=[
'Incoming Servers on Restart',
'All configured servers match the current agent configuration',
],
unexpected=[],
),
# Invalid JSON file handling
dict(
id='invalid_json_file',
agent_mcp={},
file_content='INVALID',
expected=[
'Invalid MCP configuration file',
'Please check your configuration file format',
],
unexpected=[],
),
# Missing JSON file handling
dict(
id='missing_json_file',
agent_mcp={},
file_content=None, # explicitly missing
expected=[
'Configuration file not found',
'No incoming servers detected for next restart',
],
unexpected=[],
),
]
@pytest.mark.parametrize('case', CASES, ids=[c['id'] for c in CASES])
@patch('openhands_cli.tui.settings.mcp_screen.print_formatted_text')
def test_display_mcp_info_parametrized(mock_print, case, persistence_dir):
"""Table-driven test for MCPScreen.display_mcp_info covering all scenarios."""
# Arrange
agent = _create_agent(case['agent_mcp'])
_maybe_write_mcp_file(persistence_dir, case['file_content'])
screen = MCPScreen()
# Act
screen.display_mcp_info(agent)
# Gather output
all_calls = [str(call_args) for call_args in mock_print.call_args_list]
content = ' '.join(all_calls)
# Invariants: help instructions should always be present
for snippet in ALWAYS_EXPECTED:
assert snippet in content, f'Missing help snippet: {snippet}'
# Scenario-specific expectations
for snippet in case['expected']:
assert snippet in content, (
f'Expected snippet not found for case {case["id"]}: {snippet}'
)
for snippet in case.get('unexpected', []):
assert snippet not in content, (
f'Unexpected snippet found for case {case["id"]}: {snippet}'
)