From f4fa7921c23301dbc9ab5f29ce6a419b6df4879b Mon Sep 17 00:00:00 2001 From: Thang Le <42534763+toreleon@users.noreply.github.com> Date: Sun, 13 Apr 2025 02:23:45 +0700 Subject: [PATCH] =?UTF-8?q?Add=20CI/CD=20workflows=20for=20testing,=20vers?= =?UTF-8?q?ion=20bumping,=20and=20PyPI=20publishing=E2=80=A6=20(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add CI/CD workflows for testing, version bumping, and PyPI publishing; enhance README with development and CI/CD details; implement comprehensive tests for MCPHub and MCPServers functionality. * Enhance CI workflow by installing all extras with Poetry * Update tests/test_mcphub.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 65 ++++++ .github/workflows/version-bump.yml | 64 ++++++ README.md | 28 +++ pyproject.toml | 20 +- tests/conftest.py | 120 +++++++++++ tests/test_mcphub.py | 142 ++++++++++++ tests/test_params.py | 111 ++++++++++ tests/test_servers.py | 336 +++++++++++++++++++++++++++++ 8 files changed, 885 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/version-bump.yml create mode 100644 tests/conftest.py create mode 100644 tests/test_mcphub.py create mode 100644 tests/test_params.py create mode 100644 tests/test_servers.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f2fd630 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: Python CI + +on: + push: + branches: [ master, release ] + pull_request: + branches: [ master, release ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install poetry + poetry install --all-extras + + - name: Run tests + run: | + poetry run pytest tests/ -v + + publish: + needs: test + runs-on: ubuntu-latest + # Only run on release branch when push (merge) + if: github.event_name == 'push' && github.ref == 'refs/heads/release' + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.12" + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install poetry + python -m pip install build twine + + - name: Build and publish + env: + PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + run: | + # Build the package + python -m build + + # Publish to PyPI + python -m twine upload --repository-url https://upload.pypi.org/legacy/ dist/* --username __token__ --password $PYPI_API_TOKEN \ No newline at end of file diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml new file mode 100644 index 0000000..676aac5 --- /dev/null +++ b/.github/workflows/version-bump.yml @@ -0,0 +1,64 @@ +name: Version Bump + +on: + push: + branches: [ release ] + +jobs: + bump-version: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install tomlkit + + - name: Configure Git + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + + - name: Bump version + run: | + # Python script to bump version + python - << 'EOF' + import tomlkit + import re + + # Read current version from pyproject.toml + with open('pyproject.toml', 'r') as f: + pyproject = tomlkit.parse(f.read()) + + current_version = pyproject['project']['version'] + print(f"Current version: {current_version}") + + # Parse version components + match = re.match(r"(\d+)\.(\d+)\.(\d+)(.*)$", current_version) + major, minor, patch, suffix = match.groups() + + # Bump patch version + new_version = f"{major}.{minor}.{int(patch) + 1}{suffix}" + print(f"New version: {new_version}") + + # Update pyproject.toml + pyproject['project']['version'] = new_version + with open('pyproject.toml', 'w') as f: + f.write(tomlkit.dumps(pyproject)) + EOF + + # Commit and push changes + git add pyproject.toml + git commit -m "Bump version for new release" + git push \ No newline at end of file diff --git a/README.md b/README.md index 9081d59..fff1abd 100644 --- a/README.md +++ b/README.md @@ -293,6 +293,34 @@ MCPHub simplifies the integration of Model Context Protocol (MCP) servers into A This architecture provides a seamless way to integrate MCP capabilities into any AI application while maintaining clean separation of concerns and framework flexibility. +## Development + +### Testing + +Run the unit tests with pytest: + +```bash +pytest tests/ -v +``` + +### CI/CD Pipeline + +This project uses GitHub Actions for continuous integration and deployment: + +1. **Automated Testing**: Tests are run on Python 3.10, 3.11, and 3.12 for every push to main and release branches and for pull requests. + +2. **Automatic Version Bumping**: When code is pushed to the `release` branch, the patch version is automatically incremented. + +3. **PyPI Publishing**: When code is merged to the `release` branch and tests pass, the package is automatically built and published to PyPI. + +#### Setting Up PyPI Deployment + +To enable automatic PyPI deployment, you need to add a PyPI API token as a GitHub Secret: + +1. Generate a PyPI API token at https://pypi.org/manage/account/token/ +2. Go to your GitHub repository settings → Secrets and variables → Actions +3. Add a new repository secret named `PYPI_API_TOKEN` with the token value from PyPI + ## Contributing We welcome contributions! Please check out our [Contributing Guide](CONTRIBUTING.md) for guidelines on how to proceed. diff --git a/pyproject.toml b/pyproject.toml index 33a6f42..575f6c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,11 +25,24 @@ dependencies = [ "python-dotenv>=1.0.0,<2.0.0", "build (>=1.2.2.post1,<2.0.0)", "twine (>=6.1.0,<7.0.0)", +] +requires-python = "<4.0,>=3.10" + +[project.optional-dependencies] +openai = [ + "openai-agents (>=0.0.9,<0.0.10)", +] +langchain = [ + "langchain-mcp-adapters (>=0.0.7,<0.0.8)", +] +autogen = [ + "autogen-ext[mcp] (>=0.5.1,<0.6.0)", +] +all = [ "openai-agents (>=0.0.9,<0.0.10)", "langchain-mcp-adapters (>=0.0.7,<0.0.8)", "autogen-ext[mcp] (>=0.5.1,<0.6.0)", ] -requires-python = "<4.0,>=3.10" [project.urls] Documentation = "https://raw.githubusercontent.com/Cognitive-Stack/mcphub/refs/heads/master/README.md" @@ -38,3 +51,8 @@ Source = "https://github.com/Cognitive-Stack/mcphub" [project.scripts] mcphub = "mcphub.cli.commands:main" + +[tool.poetry.group.dev.dependencies] +pytest = "^8.3.5" +pytest-asyncio = "^0.26.0" + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..48af591 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,120 @@ +import json +import os +import pytest +from pathlib import Path +from unittest import mock + + +@pytest.fixture +def temp_config_file(tmp_path): + """Create a temporary .mcphub.json file for testing.""" + config_content = { + "mcpServers": { + "test-server": { + "package_name": "test-mcp-server", + "command": "python", + "args": ["-m", "test_server"], + "env": {"TEST_ENV": "test_value"}, + "description": "Test MCP Server", + "tags": ["test", "demo"] + } + } + } + + config_file = tmp_path / ".mcphub.json" + with open(config_file, "w") as f: + json.dump(config_content, f) + + return config_file + + +@pytest.fixture +def mock_mcp_preconfigured_servers(tmp_path): + """Mock the mcphub_preconfigured_servers.json file.""" + content = { + "predefined-server": { + "command": "python", + "args": ["-m", "predefined_server"], + "description": "Predefined MCP Server", + "tags": ["predefined", "demo"] + } + } + + preconfigured_path = tmp_path / "mcphub_preconfigured_servers.json" + with open(preconfigured_path, "w") as f: + json.dump(content, f) + + return preconfigured_path + + +@pytest.fixture +def mock_cache_dir(tmp_path): + """Create and return a mock cache directory.""" + cache_dir = tmp_path / ".mcphub_cache" + cache_dir.mkdir(exist_ok=True) + return cache_dir + + +@pytest.fixture +def mock_path_exists(): + """Mock Path.exists to always return True for .mcphub.json files.""" + original_exists = Path.exists + + def patched_exists(self): + if self.name == ".mcphub.json": + return True + return original_exists(self) + + with mock.patch("pathlib.Path.exists", patched_exists): + yield + + +@pytest.fixture +def mock_current_dir(monkeypatch, tmp_path, temp_config_file, mock_cache_dir): + """Mock the current directory to use the temporary config file.""" + # Create a function that returns the parent directory of temp_config_file + def mock_cwd(): + return Path(temp_config_file).parent + + # Patch Path.cwd() to return our mock directory + monkeypatch.setattr(Path, "cwd", mock_cwd) + + # Return the mock current directory + return Path(temp_config_file).parent + + +@pytest.fixture +def mock_find_config_path(monkeypatch, temp_config_file): + """Mock the _find_config_path method to return our test config file.""" + def mock_find_config(self): # Add self parameter here to fix TypeError + return str(temp_config_file) + + # Apply the monkeypatch + monkeypatch.setattr("mcphub.mcphub.MCPHub._find_config_path", mock_find_config) + + return str(temp_config_file) + + +@pytest.fixture +def mock_mcphub_init(monkeypatch, temp_config_file): + """Mock MCPHub initialization to avoid filesystem operations.""" + # Create patch for _find_config_path + def mock_find_config(self): + return str(temp_config_file) + + # Create patch for _get_cache_dir in MCPServers + def mock_get_cache_dir(self): + cache_dir = Path(temp_config_file).parent / ".mcphub_cache" + cache_dir.mkdir(exist_ok=True) + return cache_dir + + # Create patch for _setup_all_servers in MCPServers + def mock_setup_all_servers(self): + pass # No-op to avoid setup operations + + # Apply the monkeypatches + monkeypatch.setattr("mcphub.mcphub.MCPHub._find_config_path", mock_find_config) + monkeypatch.setattr("mcphub.mcp_servers.servers.MCPServers._get_cache_dir", mock_get_cache_dir) + monkeypatch.setattr("mcphub.mcp_servers.servers.MCPServers._setup_all_servers", mock_setup_all_servers) + + return str(temp_config_file) \ No newline at end of file diff --git a/tests/test_mcphub.py b/tests/test_mcphub.py new file mode 100644 index 0000000..4ef9dc4 --- /dev/null +++ b/tests/test_mcphub.py @@ -0,0 +1,142 @@ +import pytest +from unittest import mock +from pathlib import Path + +from mcphub.mcphub import MCPHub +from mcphub.mcp_servers import MCPServerConfig +from mcphub.mcp_servers.exceptions import ServerConfigNotFoundError + + +class TestMCPHub: + @mock.patch('pathlib.Path.cwd') + @mock.patch('pathlib.Path.exists') + def test_find_config_path_success(self, mock_exists, mock_cwd, temp_config_file): + """Test successfully finding config path.""" + # Mock cwd and exists to find the config file + mock_cwd.return_value = Path(temp_config_file).parent + mock_exists.return_value = True + + # Initialize MCPHub which will call _find_config_path + hub = MCPHub() + + # Test that server_params was initialized correctly + assert hub.servers_params is not None + + @mock.patch('pathlib.Path.cwd') + @mock.patch('pathlib.Path.exists') + def test_find_config_path_failure(self, mock_exists, mock_cwd): + """Test failure to find config path.""" + # Mock cwd and exists to not find the config file + mock_cwd.return_value = Path("/some/path") + mock_exists.return_value = False + + # Initializing MCPHub should raise FileNotFoundError + with pytest.raises(FileNotFoundError): + hub = MCPHub() + + def test_fetch_server_params(self, mock_mcphub_init, temp_config_file): + """Test fetching server parameters.""" + hub = MCPHub() + + # Create a mock for retrieve_server_params to return expected values + server_config = MCPServerConfig( + package_name="test-mcp-server", + command="python", + args=["-m", "test_server"], + env={"TEST_ENV": "test_value"}, + description="Test MCP Server", + tags=["test", "demo"] + ) + + # Mock the retrieve_server_params method + hub.servers_params.retrieve_server_params = mock.MagicMock( + side_effect=lambda name: server_config if name == "test-server" else None + ) + + # Test retrieving server parameters + result = hub.fetch_server_params("test-server") + assert result is not None + assert result.package_name == "test-mcp-server" + + # Test with non-existent server + assert hub.fetch_server_params("non-existent") is None + + def test_fetch_stdio_server_config(self, mock_mcphub_init, temp_config_file): + """Test fetching StdioServerParameters.""" + hub = MCPHub() + + # Create a mock StdioServerParameters + from mcp import StdioServerParameters + stdio_params = StdioServerParameters( + command="python", + args=["-m", "test_server"], + env={"TEST_ENV": "test_value"} + ) + + # Mock the convert_to_stdio_params method + hub.servers_params.convert_to_stdio_params = mock.MagicMock( + side_effect=lambda name: stdio_params if name == "test-server" else None + ) + + # Test retrieving stdio server parameters + result = hub.fetch_stdio_server_config("test-server") + assert result is not None + assert result.command == "python" + assert result.args == ["-m", "test_server"] + + # Set up the method to raise an exception for non-existent server + hub.servers_params.convert_to_stdio_params.side_effect = lambda name: ( + stdio_params if name == "test-server" else (_ for _ in ()).throw(ServerConfigNotFoundError(f"Server '{name}' not found")) + ) + + # Test with non-existent server + with pytest.raises(ServerConfigNotFoundError): + hub.fetch_stdio_server_config("non-existent") + + @mock.patch('mcphub.mcp_servers.MCPServers.make_openai_mcp_server') + def test_fetch_openai_mcp_server(self, mock_make_server, mock_mcphub_init, temp_config_file): + """Test fetching an OpenAI MCP server.""" + mock_server = mock.MagicMock() + mock_make_server.return_value = mock_server + + hub = MCPHub() + server = hub.fetch_openai_mcp_server("test-server") + + assert server == mock_server + mock_make_server.assert_called_once_with("test-server", True) + + @mock.patch('mcphub.mcp_servers.MCPServers.get_langchain_mcp_tools') + async def test_fetch_langchain_mcp_tools(self, mock_get_tools, mock_mcphub_init, temp_config_file): + """Test fetching Langchain MCP tools.""" + mock_tools = ["tool1", "tool2"] + mock_get_tools.return_value = mock_tools + + hub = MCPHub() + tools = await hub.fetch_langchain_mcp_tools("test-server") + + assert tools == mock_tools + mock_get_tools.assert_called_once_with("test-server", True) + + @mock.patch('mcphub.mcp_servers.MCPServers.make_autogen_mcp_adapters') + async def test_fetch_autogen_mcp_adapters(self, mock_make_adapters, mock_mcphub_init, temp_config_file): + """Test fetching Autogen MCP adapters.""" + mock_adapters = ["adapter1", "adapter2"] + mock_make_adapters.return_value = mock_adapters + + hub = MCPHub() + adapters = await hub.fetch_autogen_mcp_adapters("test-server") + + assert adapters == mock_adapters + mock_make_adapters.assert_called_once_with("test-server") + + @mock.patch('mcphub.mcp_servers.MCPServers.list_tools') + async def test_list_tools(self, mock_list_tools, mock_mcphub_init, temp_config_file): + """Test listing tools from an MCP server.""" + mock_tools = ["tool1", "tool2"] + mock_list_tools.return_value = mock_tools + + hub = MCPHub() + tools = await hub.list_tools("test-server") + + assert tools == mock_tools + mock_list_tools.assert_called_once_with("test-server") \ No newline at end of file diff --git a/tests/test_params.py b/tests/test_params.py new file mode 100644 index 0000000..3793587 --- /dev/null +++ b/tests/test_params.py @@ -0,0 +1,111 @@ +import json +import pytest +from pathlib import Path +from unittest.mock import patch, mock_open + +from mcphub.mcp_servers.params import MCPServersParams, MCPServerConfig +from mcphub.mcp_servers.exceptions import ServerConfigNotFoundError + + +class TestMCPServersParams: + def test_load_user_config(self, temp_config_file): + """Test loading user configuration from a file.""" + params = MCPServersParams(str(temp_config_file)) + server_params = params.servers_params + + assert len(server_params) == 1 + assert server_params[0].package_name == "test-mcp-server" + assert server_params[0].command == "python" + assert server_params[0].args == ["-m", "test_server"] + assert server_params[0].env == {"TEST_ENV": "test_value"} + assert server_params[0].description == "Test MCP Server" + assert server_params[0].tags == ["test", "demo"] + + def test_retrieve_server_params(self, temp_config_file): + """Test retrieving server parameters by name.""" + params = MCPServersParams(str(temp_config_file)) + server_config = params.retrieve_server_params("test-server") + + assert server_config is not None + assert server_config.package_name == "test-mcp-server" + assert server_config.command == "python" + + # Test with non-existent server + assert params.retrieve_server_params("non-existent") is None + + def test_convert_to_stdio_params(self, temp_config_file): + """Test converting server parameters to StdioServerParameters.""" + params = MCPServersParams(str(temp_config_file)) + stdio_params = params.convert_to_stdio_params("test-server") + + assert stdio_params.command == "python" + assert stdio_params.args == ["-m", "test_server"] + assert stdio_params.env == {"TEST_ENV": "test_value"} + + # Test with non-existent server + with pytest.raises(ServerConfigNotFoundError): + params.convert_to_stdio_params("non-existent") + + def test_update_server_path(self, temp_config_file): + """Test updating server path.""" + params = MCPServersParams(str(temp_config_file)) + params.update_server_path("test-server", "/new/path") + + server_config = params.retrieve_server_params("test-server") + assert server_config.cwd == "/new/path" + + # Test with non-existent server + with pytest.raises(ServerConfigNotFoundError): + params.update_server_path("non-existent", "/new/path") + + def test_file_not_found(self): + """Test handling of non-existent config file.""" + with pytest.raises(FileNotFoundError): + MCPServersParams("/nonexistent/path/.mcphub.json") + + def test_invalid_json(self, tmp_path): + """Test handling of invalid JSON in config file.""" + invalid_json_file = tmp_path / ".mcphub.json" + with open(invalid_json_file, "w") as f: + f.write("{ invalid json") + + with pytest.raises(ValueError): + MCPServersParams(str(invalid_json_file)) + + @patch('pathlib.Path.exists') + def test_load_predefined_servers(self, mock_exists, mock_mcp_preconfigured_servers): + """Test loading predefined server parameters.""" + # Create a config that references a predefined server + config_content = { + "mcpServers": { + "my-server": { + "package_name": "predefined-server" + } + } + } + + config_file = Path(mock_mcp_preconfigured_servers).parent / ".mcphub.json" + with open(config_file, "w") as f: + json.dump(config_content, f) + + # Setup mock for Path.exists() + mock_exists.return_value = True + + # Mock _load_predefined_servers_params to return our test data + mock_predefined_data = { + "predefined-server": { + "command": "python", + "args": ["-m", "predefined_server"], + "description": "Predefined MCP Server", + "tags": ["predefined", "demo"] + } + } + + with patch.object(MCPServersParams, '_load_predefined_servers_params', return_value=mock_predefined_data): + params = MCPServersParams(str(config_file)) + server_params = params.servers_params + + assert len(server_params) == 1 + assert server_params[0].package_name == "predefined-server" + assert server_params[0].command == "python" + assert server_params[0].args == ["-m", "predefined_server"] \ No newline at end of file diff --git a/tests/test_servers.py b/tests/test_servers.py new file mode 100644 index 0000000..b1460b0 --- /dev/null +++ b/tests/test_servers.py @@ -0,0 +1,336 @@ +import json +import pytest +import subprocess +from pathlib import Path +from unittest import mock + +from mcphub.mcp_servers.servers import MCPServers +from mcphub.mcp_servers.params import MCPServersParams, MCPServerConfig +from mcphub.mcp_servers.exceptions import ServerConfigNotFoundError, SetupError + + +class TestMCPServers: + + @mock.patch('subprocess.run') + @mock.patch('pathlib.Path.exists') + @mock.patch('pathlib.Path.chmod') + @mock.patch('builtins.open', new_callable=mock.mock_open) + def test_run_setup_script_success(self, mock_open, mock_chmod, mock_exists, mock_run, temp_config_file, mock_current_dir): + """Test successful setup script execution.""" + # Mock Path.exists to return True for all paths + mock_exists.return_value = True + + # Mock subprocess.run to simulate successful script execution + mock_process = mock.Mock() + mock_process.returncode = 0 + mock_run.return_value = mock_process + + # Create a valid JSON config for testing + config_content = { + "mcpServers": { + "test-server": { + "package_name": "test-mcp-server", + "command": "python", + "args": ["-m", "test_server"], + "env": {"TEST_ENV": "test_value"} + } + } + } + + # Create a temp directory path to use as script_path + script_path = mock_current_dir / "temp_scripts" + + # Create a patched MCPServersParams class that doesn't attempt to read from disk + with mock.patch('mcphub.mcp_servers.params.MCPServersParams._load_user_config', + return_value=config_content.get('mcpServers', {})), \ + mock.patch('mcphub.mcp_servers.params.MCPServersParams._load_predefined_servers_params', + return_value={}), \ + mock.patch('pathlib.Path.unlink'): # Mock unlink to prevent file deletion errors + + params = MCPServersParams(str(temp_config_file)) + + # Setup MCPServers with mocked _setup_all_servers + with mock.patch.object(MCPServers, '_setup_all_servers'): + servers = MCPServers(params) + + setup_script = "npm install" + + servers._run_setup_script(script_path, setup_script) + + # Check that the temporary script was created with correct content + mock_open.assert_called_with(script_path / "setup_temp.sh", "w") + mock_open().write.assert_any_call("#!/bin/bash\n") + mock_open().write.assert_any_call(setup_script + "\n") + + # Check that chmod was called to make script executable + mock_chmod.assert_called_once() + + # Check that the script was executed with correct parameters + mock_run.assert_called_once() + args, kwargs = mock_run.call_args + assert str(script_path / "setup_temp.sh") in str(args[0]) + assert kwargs["cwd"] == script_path + + @mock.patch('subprocess.run') + @mock.patch('pathlib.Path.exists') + def test_run_setup_script_failure(self, mock_exists, mock_run, temp_config_file, mock_current_dir): + """Test failed setup script execution.""" + mock_exists.return_value = True + mock_run.side_effect = subprocess.CalledProcessError(1, "setup_script", stderr="Script failed") + + # Initialize MCPServersParams and MCPServers with mock _setup_all_servers + params = MCPServersParams(str(temp_config_file)) + with mock.patch.object(MCPServers, '_setup_all_servers'): + servers = MCPServers(params) + + with pytest.raises(SetupError): + servers._run_setup_script(Path("/test/path"), "npm install") + + @mock.patch.object(MCPServers, '_clone_repository') + @mock.patch.object(MCPServers, '_run_setup_script') + @mock.patch.object(MCPServers, '_update_server_path') + @mock.patch('pathlib.Path.exists') + def test_setup_server(self, mock_exists, mock_update_path, mock_run_setup, mock_clone, temp_config_file, mock_current_dir): + """Test setting up a server.""" + mock_exists.return_value = True + mock_clone.return_value = Path("/test/repo") + + # Create a server config with repo_url and setup_script + server_config = MCPServerConfig( + package_name="test-mcp-server", + command="python", + args=["-m", "test_server"], + env={"TEST_ENV": "test_value"}, + description="Test MCP Server", + tags=["test", "demo"], + repo_url="https://github.com/test/repo.git", + setup_script="npm install" + ) + + # Mock the servers_params directly to avoid file access + with mock.patch('mcphub.mcp_servers.params.MCPServersParams._load_user_config', + return_value={"test-server": { + "package_name": "test-mcp-server", + "command": "python", + "args": ["-m", "test_server"], + "env": {"TEST_ENV": "test_value"}, + "repo_url": "https://github.com/test/repo.git", + "setup_script": "npm install" + }}), \ + mock.patch('mcphub.mcp_servers.params.MCPServersParams._load_predefined_servers_params', + return_value={}): + + params = MCPServersParams(str(temp_config_file)) + + # Override the _servers_params attribute directly + params._servers_params = {"test-mcp-server": server_config} + + # Mock the retrieve_server_params method to return our custom server_config + params.retrieve_server_params = mock.MagicMock(return_value=server_config) + + with mock.patch.object(MCPServers, '_setup_all_servers'): + servers = MCPServers(params) + servers.setup_server(server_config) + + # Check that clone_repository and run_setup_script were called with correct args + mock_clone.assert_called_once_with("https://github.com/test/repo.git", "test-mcp-server") + mock_run_setup.assert_called_once_with(Path("/test/repo"), "npm install") + + @mock.patch('mcphub.mcp_servers.servers.MCPServerStdio') + @mock.patch('mcphub.mcp_servers.servers.MCPServerStdioParams') # Fix: Updated mock path to match import in servers.py + @mock.patch('pathlib.Path.exists') + def test_make_openai_mcp_server(self, mock_exists, mock_mcp_params, mock_mcp_server, temp_config_file, mock_current_dir): + """Test creating an OpenAI MCP server.""" + mock_exists.return_value = True + + # Mock the return value for MCPServerStdioParams + mock_params_instance = mock.MagicMock() + mock_mcp_params.return_value = mock_params_instance + + # Initialize MCPServersParams and MCPServers with custom setup + with mock.patch('mcphub.mcp_servers.params.MCPServersParams._load_user_config', + return_value={"test-server": { + "package_name": "test-mcp-server", + "command": "python", + "args": ["-m", "test_server"], + "env": {"TEST_ENV": "test_value"} + }}), \ + mock.patch('mcphub.mcp_servers.params.MCPServersParams._load_predefined_servers_params', + return_value={}): + + # Create MCPServersParams + params = MCPServersParams(str(temp_config_file)) + + # Create a server config and manually add it to _servers_params + server_config = MCPServerConfig( + package_name="test-mcp-server", + command="python", + args=["-m", "test_server"], + env={"TEST_ENV": "test_value"}, + description="Test MCP Server", + tags=["test", "demo"] + ) + params._servers_params = {"test-server": server_config} + + # Mock the retrieve_server_params method + params.retrieve_server_params = mock.MagicMock( + side_effect=lambda name: server_config if name == "test-server" else None + ) + + with mock.patch.object(MCPServers, '_setup_all_servers'): + servers = MCPServers(params) + server = servers.make_openai_mcp_server("test-server") + + # Check that MCPServerStdioParams was called correctly + mock_mcp_params.assert_called_once_with( + command="python", + args=["-m", "test_server"], + env={"TEST_ENV": "test_value"}, + cwd=None + ) + + # Check that MCPServerStdio was created with correct params + mock_mcp_server.assert_called_once_with( + params=mock_params_instance, + cache_tools_list=True + ) + + # Test with non-existent server + with pytest.raises(ServerConfigNotFoundError): + servers.make_openai_mcp_server("non-existent") + + @mock.patch('mcphub.mcp_servers.servers.MCPServerStdio') + @mock.patch('langchain_mcp_adapters.tools.load_mcp_tools') + @mock.patch('pathlib.Path.exists') + async def test_get_langchain_mcp_tools(self, mock_exists, mock_load_tools, mock_server, temp_config_file, mock_current_dir): + """Test getting Langchain MCP tools.""" + mock_exists.return_value = True + + # Mock the context manager and async operations + mock_server_instance = mock.AsyncMock() + mock_server.return_value.__aenter__.return_value = mock_server_instance + mock_load_tools.return_value = ["tool1", "tool2"] + + # Initialize MCPServersParams and MCPServers with mock _setup_all_servers + params = MCPServersParams(str(temp_config_file)) + with mock.patch.object(MCPServers, '_setup_all_servers'): + servers = MCPServers(params) + tools = await servers.get_langchain_mcp_tools("test-server") + + assert tools == ["tool1", "tool2"] + mock_load_tools.assert_called_once() + + @mock.patch('mcphub.mcp_servers.servers.MCPServerStdio') + @mock.patch('autogen_ext.tools.mcp.StdioMcpToolAdapter.from_server_params') + @mock.patch('pathlib.Path.exists') + async def test_make_autogen_mcp_adapters(self, mock_exists, mock_adapter, mock_server, temp_config_file, mock_current_dir): + """Test creating Autogen MCP adapters.""" + mock_exists.return_value = True + + # Mock the server instance and tool listing + mock_server_instance = mock.AsyncMock() + mock_server_instance.list_tools.return_value = [ + mock.MagicMock(name="tool1"), + mock.MagicMock(name="tool2") + ] + mock_server.return_value.__aenter__.return_value = mock_server_instance + + # Mock the adapter creation + mock_adapter.side_effect = ["adapter1", "adapter2"] + + # Initialize MCPServersParams and MCPServers with mock _setup_all_servers + params = MCPServersParams(str(temp_config_file)) + with mock.patch.object(MCPServers, '_setup_all_servers'): + servers = MCPServers(params) + adapters = await servers.make_autogen_mcp_adapters("test-server") + + assert adapters == ["adapter1", "adapter2"] + assert mock_adapter.call_count == 2 + + @mock.patch('mcphub.mcp_servers.servers.MCPServerStdio') + @mock.patch('pathlib.Path.exists') + async def test_list_tools(self, mock_exists, mock_server, temp_config_file, mock_current_dir): + """Test listing tools from an MCP server.""" + mock_exists.return_value = True + + # Mock the server instance + mock_server_instance = mock.AsyncMock() + mock_server_instance.list_tools.return_value = ["tool1", "tool2"] + mock_server.return_value.__aenter__.return_value = mock_server_instance + + # Initialize MCPServersParams and MCPServers with mock _setup_all_servers + params = MCPServersParams(str(temp_config_file)) + with mock.patch.object(MCPServers, '_setup_all_servers'): + servers = MCPServers(params) + tools = await servers.list_tools("test-server") + + assert tools == ["tool1", "tool2"] + mock_server_instance.list_tools.assert_called_once() + + @mock.patch('subprocess.run') + @mock.patch('pathlib.Path.exists') + @mock.patch.object(MCPServers, '_get_cache_dir') + def test_clone_repository_success(self, mock_get_cache_dir, mock_exists, mock_run, temp_config_file, mock_current_dir): + """Test successful repository cloning.""" + # Mock the cache directory + mock_cache_dir = mock_current_dir / ".mcphub_cache" + mock_get_cache_dir.return_value = mock_cache_dir + + # Set up mocks for subprocess and exists + mock_exists.return_value = False # Repository doesn't exist yet + mock_process = mock.Mock() + mock_process.returncode = 0 + mock_run.return_value = mock_process + + # Initialize MCPServersParams and MCPServers with mock _setup_all_servers + params = MCPServersParams(str(temp_config_file)) + with mock.patch.object(MCPServers, '_setup_all_servers'): + servers = MCPServers(params) + + # Test cloning repository + repo_url = "https://github.com/test/repo.git" + repo_name = "test/repo" + result = servers._clone_repository(repo_url, repo_name) + + # Expected result should be the cache_dir / repo + expected_path = mock_cache_dir / "repo" + assert result == expected_path + + # Check that git clone was called with correct parameters + mock_run.assert_called_once() + args, kwargs = mock_run.call_args + assert args[0] == ["git", "clone", repo_url, str(expected_path)] + assert kwargs["check"] == True + assert kwargs["capture_output"] == True + assert kwargs["text"] == True + + @mock.patch('subprocess.run') + @mock.patch('pathlib.Path.exists') + @mock.patch.object(MCPServers, '_get_cache_dir') + def test_clone_repository_failure(self, mock_get_cache_dir, mock_exists, mock_run, temp_config_file, mock_current_dir): + """Test failed repository cloning.""" + # Mock the cache directory + mock_cache_dir = mock_current_dir / ".mcphub_cache" + mock_get_cache_dir.return_value = mock_cache_dir + + # Set up mocks + mock_exists.return_value = False # Repository doesn't exist yet + mock_run.side_effect = subprocess.CalledProcessError( + 128, "git clone", stderr="Error: Repository not found" + ) + + # Initialize MCPServersParams and MCPServers with mock _setup_all_servers + params = MCPServersParams(str(temp_config_file)) + with mock.patch.object(MCPServers, '_setup_all_servers'): + servers = MCPServers(params) + + # Test with invalid repo URL + repo_url = "https://github.com/invalid/repo.git" + repo_name = "invalid/repo" + + # Should raise SetupError + with pytest.raises(SetupError) as exc_info: + servers._clone_repository(repo_url, repo_name) + + # Check error message + assert f"Failed to clone repository {repo_url}" in str(exc_info.value) \ No newline at end of file