From 9853ce73ce2477f982f251bb6af18d070ad4d365 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 6 Nov 2025 15:56:53 +0000 Subject: [PATCH] Replace integration test with proper unit tests for config generation The test_command_execution test was an integration test disguised as a unit test. It executed external commands (uv run) which caused several problems: - Modified uv.lock file as a side effect - Required external dependencies (uv binary, network access) - Was slow and potentially flaky - Tested the wrong layer (uv CLI instead of config generation) Replaced with 15 comprehensive unit tests that properly test the update_claude_config function's actual responsibilities: - JSON structure generation - Argument array construction - Path resolution to absolute paths - Environment variable merging - Package deduplication - Server preservation and updates - Error handling Benefits: - Fast: runs in ~1.5s vs several seconds - No external dependencies or side effects - Tests the correct abstraction layer - Better coverage of edge cases - More maintainable and reliable --- src/mcp/cli/claude.py | 7 +- tests/client/test_config.py | 235 +++++++++++++++++++++++++++++++++--- 2 files changed, 222 insertions(+), 20 deletions(-) diff --git a/src/mcp/cli/claude.py b/src/mcp/cli/claude.py index 6a2effa3be..ae91db766d 100644 --- a/src/mcp/cli/claude.py +++ b/src/mcp/cli/claude.py @@ -115,8 +115,11 @@ def update_claude_config( # Convert file path to absolute before adding to command # Split off any :object suffix first - if ":" in file_spec: - file_path, server_object = file_spec.rsplit(":", 1) + # Use rfind to get the last colon, and check it's not a Windows drive letter (position 1) + colon_index = file_spec.rfind(":") + if colon_index > 1: # Position 0 invalid, position 1 would be drive letter like C: + file_path = file_spec[:colon_index] + server_object = file_spec[colon_index + 1 :] file_spec = f"{Path(file_path).resolve()}:{server_object}" else: file_spec = str(Path(file_spec).resolve()) diff --git a/tests/client/test_config.py b/tests/client/test_config.py index f144dcffb9..e70bdf8179 100644 --- a/tests/client/test_config.py +++ b/tests/client/test_config.py @@ -1,5 +1,5 @@ import json -import subprocess +import re from pathlib import Path from unittest.mock import patch @@ -23,36 +23,221 @@ def mock_config_path(temp_config_dir: Path): yield temp_config_dir -def test_command_execution(mock_config_path: Path): - """Test that the generated command can actually be executed.""" - # Setup - server_name = "test_server" - file_spec = "test_server.py:app" +def test_basic_config_creation(mock_config_path: Path): + """Test that config file is created with correct structure.""" + success = update_claude_config(file_spec="server.py:app", server_name="test") - # Update config - success = update_claude_config(file_spec=file_spec, server_name=server_name) assert success + config_file = mock_config_path / "claude_desktop_config.json" + assert config_file.exists() + + config = json.loads(config_file.read_text()) + assert "mcpServers" in config + assert "test" in config["mcpServers"] + + server = config["mcpServers"]["test"] + assert "command" in server + assert "args" in server + # Command should be the path to uv executable + assert server["command"].lower().endswith("uv") or server["command"].lower().endswith("uv.exe") + + +def test_args_structure(mock_config_path: Path): + """Test that args are built correctly.""" + success = update_claude_config(file_spec="server.py:app", server_name="test") + assert success + + config = json.loads((mock_config_path / "claude_desktop_config.json").read_text()) + args = config["mcpServers"]["test"]["args"] + + # Should be: ["run", "--with", "mcp[cli]", "mcp", "run", "/abs/path/server.py:app"] + assert args[0] == "run" + assert "--with" in args + assert "mcp[cli]" in args + assert "mcp" in args + assert args[args.index("mcp") + 1] == "run" + assert args[-1].endswith("server.py:app") + + +def test_absolute_file_path_resolution(mock_config_path: Path, tmp_path: Path): + """Test that file paths are resolved to absolute paths.""" + # Create a test file + server_file = tmp_path / "my_server.py" + server_file.write_text("# test") + + success = update_claude_config(file_spec=str(server_file) + ":app", server_name="test") + assert success + + config = json.loads((mock_config_path / "claude_desktop_config.json").read_text()) + args = config["mcpServers"]["test"]["args"] + + # Last arg should be absolute path with :app suffix + assert args[-1] == f"{server_file.resolve()}:app" + # Split on last colon to extract path (handles Windows drive letters like C:) + file_path = args[-1].rsplit(":", 1)[0] + assert Path(file_path).is_absolute() + + +def test_env_vars_initial(mock_config_path: Path): + """Test that environment variables are set correctly on initial config.""" + success = update_claude_config( + file_spec="server.py:app", server_name="test", env_vars={"KEY1": "value1", "KEY2": "value2"} + ) + assert success + + config = json.loads((mock_config_path / "claude_desktop_config.json").read_text()) + env = config["mcpServers"]["test"]["env"] + + assert env["KEY1"] == "value1" + assert env["KEY2"] == "value2" + + +def test_env_vars_merged(mock_config_path: Path): + """Test that environment variables are merged correctly on update.""" + # First call with env vars + update_claude_config(file_spec="server.py:app", server_name="test", env_vars={"KEY1": "value1", "KEY2": "value2"}) + + # Second call with overlapping env vars + update_claude_config( + file_spec="server.py:app", server_name="test", env_vars={"KEY2": "new_value", "KEY3": "value3"} + ) + + config = json.loads((mock_config_path / "claude_desktop_config.json").read_text()) + env = config["mcpServers"]["test"]["env"] + + assert env["KEY1"] == "value1" # Preserved + assert env["KEY2"] == "new_value" # Updated + assert env["KEY3"] == "value3" # Added + + +def test_env_vars_preserved_when_none(mock_config_path: Path): + """Test that existing env vars are preserved when update doesn't specify any.""" + # First call with env vars + update_claude_config(file_spec="server.py:app", server_name="test", env_vars={"KEY1": "value1"}) + + # Second call without env vars + update_claude_config(file_spec="server.py:app", server_name="test") + + config = json.loads((mock_config_path / "claude_desktop_config.json").read_text()) + env = config["mcpServers"]["test"]["env"] + + assert env["KEY1"] == "value1" # Should still be there + + +def test_multiple_packages(mock_config_path: Path): + """Test that multiple packages are included with --with.""" + success = update_claude_config(file_spec="server.py:app", server_name="test", with_packages=["requests", "httpx"]) + assert success + + config = json.loads((mock_config_path / "claude_desktop_config.json").read_text()) + args = config["mcpServers"]["test"]["args"] - # Read the generated config + # Should have: --with mcp[cli] --with httpx --with requests (sorted) + with_indices = [i for i, arg in enumerate(args) if arg == "--with"] + assert len(with_indices) == 3 + + packages = [args[i + 1] for i in with_indices] + assert "mcp[cli]" in packages + assert "httpx" in packages + assert "requests" in packages + + +def test_package_deduplication(mock_config_path: Path): + """Test that duplicate packages are deduplicated.""" + success = update_claude_config( + file_spec="server.py:app", server_name="test", with_packages=["mcp[cli]", "requests", "requests"] + ) + assert success + + config = json.loads((mock_config_path / "claude_desktop_config.json").read_text()) + args = config["mcpServers"]["test"]["args"] + + # Count --with flags + with_count = sum(1 for arg in args if arg == "--with") + # Should have mcp[cli] and requests only once each + assert with_count == 2 + + +def test_editable_package(mock_config_path: Path, tmp_path: Path): + """Test that editable package is added correctly.""" + editable_dir = tmp_path / "my_package" + editable_dir.mkdir() + + success = update_claude_config(file_spec="server.py:app", server_name="test", with_editable=editable_dir) + assert success + + config = json.loads((mock_config_path / "claude_desktop_config.json").read_text()) + args = config["mcpServers"]["test"]["args"] + + assert "--with-editable" in args + idx = args.index("--with-editable") + assert args[idx + 1] == str(editable_dir) + + +def test_preserves_other_servers(mock_config_path: Path): + """Test that existing servers are preserved when adding a new one.""" + # Create config with existing server config_file = mock_config_path / "claude_desktop_config.json" + config_file.write_text( + json.dumps({"mcpServers": {"existing_server": {"command": "some_command", "args": ["arg1", "arg2"]}}}) + ) + + # Add new server + success = update_claude_config(file_spec="server.py:app", server_name="new_server") + assert success + config = json.loads(config_file.read_text()) + assert "existing_server" in config["mcpServers"] + assert "new_server" in config["mcpServers"] + assert config["mcpServers"]["existing_server"]["command"] == "some_command" + assert config["mcpServers"]["existing_server"]["args"] == ["arg1", "arg2"] - # Get the command and args - server_config = config["mcpServers"][server_name] - command = server_config["command"] - args = server_config["args"] - test_args = [command] + args + ["--help"] +def test_updates_existing_server(mock_config_path: Path): + """Test that updating an existing server replaces command/args but merges env vars.""" + # Create initial server + update_claude_config(file_spec="old_server.py:app", server_name="test", env_vars={"OLD": "value"}) - result = subprocess.run(test_args, capture_output=True, text=True, timeout=5, check=False) + # Update the same server + update_claude_config(file_spec="new_server.py:app", server_name="test", env_vars={"NEW": "value"}) - assert result.returncode == 0 - assert "usage" in result.stdout.lower() + config = json.loads((mock_config_path / "claude_desktop_config.json").read_text()) + args = config["mcpServers"]["test"]["args"] + + # Should have new file spec + assert args[-1].endswith("new_server.py:app") + # Env vars should be merged (NEW takes precedence but OLD is preserved) + assert "NEW" in config["mcpServers"]["test"]["env"] + assert "OLD" in config["mcpServers"]["test"]["env"] + + +def test_error_handling_missing_config_dir(tmp_path: Path): + """Test that missing config directory raises appropriate error.""" + with patch("mcp.cli.claude.get_claude_config_path", return_value=None): + with pytest.raises(RuntimeError, match="Claude Desktop config directory not found"): + update_claude_config(file_spec="server.py:app", server_name="test") + + +def test_file_spec_without_colon(mock_config_path: Path, tmp_path: Path): + """Test file spec without :object suffix.""" + server_file = tmp_path / "server.py" + server_file.write_text("# test") + + success = update_claude_config(file_spec=str(server_file), server_name="test") + assert success + + config = json.loads((mock_config_path / "claude_desktop_config.json").read_text()) + args = config["mcpServers"]["test"]["args"] + + # Last arg should be absolute path without object suffix + assert args[-1] == str(server_file.resolve()) + # Verify no object suffix was added (like :app) - check it doesn't end with :identifier + assert not re.search(r":\w+$", args[-1]), "Should not have object suffix like :app" def test_absolute_uv_path(mock_config_path: Path): """Test that the absolute path to uv is used when available.""" - # Mock the shutil.which function to return a fake path + # Mock the get_uv_path function to return a fake path mock_uv_path = "/usr/local/bin/uv" with patch("mcp.cli.claude.get_uv_path", return_value=mock_uv_path): @@ -73,3 +258,17 @@ def test_absolute_uv_path(mock_config_path: Path): command = server_config["command"] assert command == mock_uv_path + + +def test_creates_mcpservers_key_if_missing(mock_config_path: Path): + """Test that mcpServers key is created if config exists but key is missing.""" + config_file = mock_config_path / "claude_desktop_config.json" + config_file.write_text(json.dumps({"someOtherKey": "value"})) + + success = update_claude_config(file_spec="server.py:app", server_name="test") + assert success + + config = json.loads(config_file.read_text()) + assert "mcpServers" in config + assert "someOtherKey" in config # Original content preserved + assert config["someOtherKey"] == "value"