Spaces:
Build error
Build error
| """Bash-related tests for the DockerRuntime, which connects to the ActionExecutor running in the sandbox.""" | |
| import json | |
| import os | |
| import socket | |
| import time | |
| import docker | |
| import pytest | |
| from conftest import ( | |
| _load_runtime, | |
| ) | |
| import openhands | |
| from openhands.core.config import MCPConfig | |
| from openhands.core.config.mcp_config import MCPSSEServerConfig, MCPStdioServerConfig | |
| from openhands.core.logger import openhands_logger as logger | |
| from openhands.events.action import CmdRunAction, MCPAction | |
| from openhands.events.observation import CmdOutputObservation, MCPObservation | |
| # ============================================================================================================================ | |
| # Bash-specific tests | |
| # ============================================================================================================================ | |
| pytestmark = pytest.mark.skipif( | |
| os.environ.get('TEST_RUNTIME') == 'cli', | |
| reason='CLIRuntime does not support MCP actions', | |
| ) | |
| def sse_mcp_docker_server(): | |
| """Manages the lifecycle of the SSE MCP Docker container for tests, using a random available port.""" | |
| image_name = 'supercorp/supergateway' | |
| # Find a free port | |
| with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: | |
| s.bind(('', 0)) | |
| host_port = s.getsockname()[1] | |
| container_internal_port = ( | |
| 8000 # The port the MCP server listens on *inside* the container | |
| ) | |
| container_command_args = [ | |
| '--stdio', | |
| 'npx -y @modelcontextprotocol/server-filesystem /', | |
| '--port', | |
| str(container_internal_port), # MCP server inside container listens on this | |
| '--baseUrl', | |
| f'http://localhost:{host_port}', # The URL used to access the server from the host | |
| ] | |
| client = docker.from_env() | |
| container = None | |
| log_streamer = None | |
| # Import LogStreamer here as it's specific to this fixture's needs | |
| from openhands.runtime.utils.log_streamer import LogStreamer | |
| try: | |
| logger.info( | |
| f'Starting Docker container {image_name} with command: {" ".join(container_command_args)} ' | |
| f'and mapping internal port {container_internal_port} to host port {host_port}', | |
| extra={'msg_type': 'ACTION'}, | |
| ) | |
| container = client.containers.run( | |
| image_name, | |
| command=container_command_args, | |
| ports={ | |
| f'{container_internal_port}/tcp': host_port | |
| }, # Map container's internal port to the random host port | |
| detach=True, | |
| auto_remove=True, | |
| stdin_open=True, | |
| ) | |
| logger.info( | |
| f'Container {container.short_id} started, listening on host port {host_port}.' | |
| ) | |
| log_streamer = LogStreamer( | |
| container, | |
| lambda level, msg: getattr(logger, level.lower())( | |
| f'[MCP server {container.short_id}] {msg}' | |
| ), | |
| ) | |
| # Wait for the server to initialize, as in the original tests | |
| time.sleep(10) | |
| yield {'url': f'http://localhost:{host_port}/sse'} | |
| finally: | |
| if container: | |
| logger.info(f'Stopping container {container.short_id}...') | |
| try: | |
| container.stop(timeout=5) | |
| logger.info( | |
| f'Container {container.short_id} stopped (and should be auto-removed).' | |
| ) | |
| except docker.errors.NotFound: | |
| logger.info( | |
| f'Container {container.short_id} not found, likely already stopped and removed.' | |
| ) | |
| except Exception as e: | |
| logger.error(f'Error stopping container {container.short_id}: {e}') | |
| if log_streamer: | |
| log_streamer.close() | |
| def test_default_activated_tools(): | |
| project_root = os.path.dirname(openhands.__file__) | |
| mcp_config_path = os.path.join(project_root, 'runtime', 'mcp', 'config.json') | |
| assert os.path.exists(mcp_config_path), ( | |
| f'MCP config file not found at {mcp_config_path}' | |
| ) | |
| with open(mcp_config_path, 'r') as f: | |
| mcp_config = json.load(f) | |
| assert 'mcpServers' in mcp_config | |
| assert 'default' in mcp_config['mcpServers'] | |
| assert 'tools' in mcp_config | |
| # no tools are always activated yet | |
| assert len(mcp_config['tools']) == 0 | |
| async def test_fetch_mcp_via_stdio(temp_dir, runtime_cls, run_as_openhands): | |
| mcp_stdio_server_config = MCPStdioServerConfig( | |
| name='fetch', command='uvx', args=['mcp-server-fetch'] | |
| ) | |
| override_mcp_config = MCPConfig(stdio_servers=[mcp_stdio_server_config]) | |
| runtime, config = _load_runtime( | |
| temp_dir, runtime_cls, run_as_openhands, override_mcp_config=override_mcp_config | |
| ) | |
| # Test browser server | |
| action_cmd = CmdRunAction(command='python3 -m http.server 8000 > server.log 2>&1 &') | |
| logger.info(action_cmd, extra={'msg_type': 'ACTION'}) | |
| obs = runtime.run_action(action_cmd) | |
| logger.info(obs, extra={'msg_type': 'OBSERVATION'}) | |
| assert isinstance(obs, CmdOutputObservation) | |
| assert obs.exit_code == 0 | |
| assert '[1]' in obs.content | |
| action_cmd = CmdRunAction(command='sleep 3 && cat server.log') | |
| logger.info(action_cmd, extra={'msg_type': 'ACTION'}) | |
| obs = runtime.run_action(action_cmd) | |
| logger.info(obs, extra={'msg_type': 'OBSERVATION'}) | |
| assert obs.exit_code == 0 | |
| mcp_action = MCPAction(name='fetch', arguments={'url': 'http://localhost:8000'}) | |
| obs = await runtime.call_tool_mcp(mcp_action) | |
| logger.info(obs, extra={'msg_type': 'OBSERVATION'}) | |
| assert isinstance(obs, MCPObservation), ( | |
| 'The observation should be a MCPObservation.' | |
| ) | |
| result_json = json.loads(obs.content) | |
| assert not result_json['isError'] | |
| assert len(result_json['content']) == 1 | |
| assert result_json['content'][0]['type'] == 'text' | |
| assert ( | |
| result_json['content'][0]['text'] | |
| == 'Contents of http://localhost:8000/:\n---\n\n* <server.log>\n\n---' | |
| ) | |
| runtime.close() | |
| async def test_filesystem_mcp_via_sse( | |
| temp_dir, runtime_cls, run_as_openhands, sse_mcp_docker_server | |
| ): | |
| sse_server_info = sse_mcp_docker_server | |
| sse_url = sse_server_info['url'] | |
| runtime = None | |
| try: | |
| mcp_sse_server_config = MCPSSEServerConfig(url=sse_url) | |
| override_mcp_config = MCPConfig(sse_servers=[mcp_sse_server_config]) | |
| runtime, config = _load_runtime( | |
| temp_dir, | |
| runtime_cls, | |
| run_as_openhands, | |
| override_mcp_config=override_mcp_config, | |
| ) | |
| mcp_action = MCPAction(name='list_directory', arguments={'path': '.'}) | |
| obs = await runtime.call_tool_mcp(mcp_action) | |
| logger.info(obs, extra={'msg_type': 'OBSERVATION'}) | |
| assert isinstance(obs, MCPObservation), ( | |
| 'The observation should be a MCPObservation.' | |
| ) | |
| assert '[FILE] .dockerenv' in obs.content | |
| finally: | |
| if runtime: | |
| runtime.close() | |
| # Container and log_streamer cleanup is handled by the sse_mcp_docker_server fixture | |
| async def test_both_stdio_and_sse_mcp( | |
| temp_dir, runtime_cls, run_as_openhands, sse_mcp_docker_server | |
| ): | |
| sse_server_info = sse_mcp_docker_server | |
| sse_url = sse_server_info['url'] | |
| runtime = None | |
| try: | |
| mcp_sse_server_config = MCPSSEServerConfig(url=sse_url) | |
| # Also add stdio server | |
| mcp_stdio_server_config = MCPStdioServerConfig( | |
| name='fetch', command='uvx', args=['mcp-server-fetch'] | |
| ) | |
| override_mcp_config = MCPConfig( | |
| sse_servers=[mcp_sse_server_config], stdio_servers=[mcp_stdio_server_config] | |
| ) | |
| runtime, config = _load_runtime( | |
| temp_dir, | |
| runtime_cls, | |
| run_as_openhands, | |
| override_mcp_config=override_mcp_config, | |
| ) | |
| # ======= Test SSE server ======= | |
| mcp_action_sse = MCPAction(name='list_directory', arguments={'path': '.'}) | |
| obs_sse = await runtime.call_tool_mcp(mcp_action_sse) | |
| logger.info(obs_sse, extra={'msg_type': 'OBSERVATION'}) | |
| assert isinstance(obs_sse, MCPObservation), ( | |
| 'The observation should be a MCPObservation.' | |
| ) | |
| assert '[FILE] .dockerenv' in obs_sse.content | |
| # ======= Test stdio server ======= | |
| # Test browser server | |
| action_cmd_http = CmdRunAction( | |
| command='python3 -m http.server 8000 > server.log 2>&1 &' | |
| ) | |
| logger.info(action_cmd_http, extra={'msg_type': 'ACTION'}) | |
| obs_http = runtime.run_action(action_cmd_http) | |
| logger.info(obs_http, extra={'msg_type': 'OBSERVATION'}) | |
| assert isinstance(obs_http, CmdOutputObservation) | |
| assert obs_http.exit_code == 0 | |
| assert '[1]' in obs_http.content | |
| action_cmd_cat = CmdRunAction(command='sleep 3 && cat server.log') | |
| logger.info(action_cmd_cat, extra={'msg_type': 'ACTION'}) | |
| obs_cat = runtime.run_action(action_cmd_cat) | |
| logger.info(obs_cat, extra={'msg_type': 'OBSERVATION'}) | |
| assert obs_cat.exit_code == 0 | |
| mcp_action_fetch = MCPAction( | |
| # NOTE: the tool name is `fetch_fetch` because the tool name is `fetch` | |
| # And FastMCP Proxy will pre-pend the server name (in this case, `fetch`) | |
| # to the tool name, so the full tool name becomes `fetch_fetch` | |
| name='fetch', | |
| arguments={'url': 'http://localhost:8000'}, | |
| ) | |
| obs_fetch = await runtime.call_tool_mcp(mcp_action_fetch) | |
| logger.info(obs_fetch, extra={'msg_type': 'OBSERVATION'}) | |
| assert isinstance(obs_fetch, MCPObservation), ( | |
| 'The observation should be a MCPObservation.' | |
| ) | |
| result_json = json.loads(obs_fetch.content) | |
| assert not result_json['isError'] | |
| assert len(result_json['content']) == 1 | |
| assert result_json['content'][0]['type'] == 'text' | |
| assert ( | |
| result_json['content'][0]['text'] | |
| == 'Contents of http://localhost:8000/:\n---\n\n* <server.log>\n\n---' | |
| ) | |
| finally: | |
| if runtime: | |
| runtime.close() | |
| # SSE Docker container cleanup is handled by the sse_mcp_docker_server fixture | |
| async def test_microagent_and_one_stdio_mcp_in_config( | |
| temp_dir, runtime_cls, run_as_openhands | |
| ): | |
| runtime = None | |
| try: | |
| filesystem_config = MCPStdioServerConfig( | |
| name='filesystem', | |
| command='npx', | |
| args=[ | |
| '@modelcontextprotocol/server-filesystem', | |
| '/', | |
| ], | |
| ) | |
| override_mcp_config = MCPConfig(stdio_servers=[filesystem_config]) | |
| runtime, config = _load_runtime( | |
| temp_dir, | |
| runtime_cls, | |
| run_as_openhands, | |
| override_mcp_config=override_mcp_config, | |
| ) | |
| # NOTE: this simulate the case where the microagent adds a new stdio server to the runtime | |
| # but that stdio server is not in the initial config | |
| # Actual invocation of the microagent involves `add_mcp_tools_to_agent` | |
| # which will call `get_mcp_config` with the stdio server from microagent's config | |
| fetch_config = MCPStdioServerConfig( | |
| name='fetch', command='uvx', args=['mcp-server-fetch'] | |
| ) | |
| updated_config = runtime.get_mcp_config([fetch_config]) | |
| logger.info(f'updated_config: {updated_config}') | |
| # ======= Test the stdio server in the config ======= | |
| mcp_action_sse = MCPAction( | |
| name='filesystem_list_directory', arguments={'path': '/'} | |
| ) | |
| obs_sse = await runtime.call_tool_mcp(mcp_action_sse) | |
| logger.info(obs_sse, extra={'msg_type': 'OBSERVATION'}) | |
| assert isinstance(obs_sse, MCPObservation), ( | |
| 'The observation should be a MCPObservation.' | |
| ) | |
| assert '[FILE] .dockerenv' in obs_sse.content | |
| # ======= Test the stdio server added by the microagent ======= | |
| # Test browser server | |
| action_cmd_http = CmdRunAction( | |
| command='python3 -m http.server 8000 > server.log 2>&1 &' | |
| ) | |
| logger.info(action_cmd_http, extra={'msg_type': 'ACTION'}) | |
| obs_http = runtime.run_action(action_cmd_http) | |
| logger.info(obs_http, extra={'msg_type': 'OBSERVATION'}) | |
| assert isinstance(obs_http, CmdOutputObservation) | |
| assert obs_http.exit_code == 0 | |
| assert '[1]' in obs_http.content | |
| action_cmd_cat = CmdRunAction(command='sleep 3 && cat server.log') | |
| logger.info(action_cmd_cat, extra={'msg_type': 'ACTION'}) | |
| obs_cat = runtime.run_action(action_cmd_cat) | |
| logger.info(obs_cat, extra={'msg_type': 'OBSERVATION'}) | |
| assert obs_cat.exit_code == 0 | |
| mcp_action_fetch = MCPAction( | |
| name='fetch_fetch', arguments={'url': 'http://localhost:8000'} | |
| ) | |
| obs_fetch = await runtime.call_tool_mcp(mcp_action_fetch) | |
| logger.info(obs_fetch, extra={'msg_type': 'OBSERVATION'}) | |
| assert isinstance(obs_fetch, MCPObservation), ( | |
| 'The observation should be a MCPObservation.' | |
| ) | |
| result_json = json.loads(obs_fetch.content) | |
| assert not result_json['isError'] | |
| assert len(result_json['content']) == 1 | |
| assert result_json['content'][0]['type'] == 'text' | |
| assert ( | |
| result_json['content'][0]['text'] | |
| == 'Contents of http://localhost:8000/:\n---\n\n* <server.log>\n\n---' | |
| ) | |
| finally: | |
| if runtime: | |
| runtime.close() | |
| # SSE Docker container cleanup is handled by the sse_mcp_docker_server fixture | |