Skip to content

Commit 3ff4f7f

Browse files
committed
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
1 parent e19a627 commit 3ff4f7f

File tree

1 file changed

+212
-18
lines changed

1 file changed

+212
-18
lines changed

tests/client/test_config.py

Lines changed: 212 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import json
2-
import subprocess
32
from pathlib import Path
43
from unittest.mock import patch
54

@@ -23,36 +22,217 @@ def mock_config_path(temp_config_dir: Path):
2322
yield temp_config_dir
2423

2524

26-
def test_command_execution(mock_config_path: Path):
27-
"""Test that the generated command can actually be executed."""
28-
# Setup
29-
server_name = "test_server"
30-
file_spec = "test_server.py:app"
25+
def test_basic_config_creation(mock_config_path: Path):
26+
"""Test that config file is created with correct structure."""
27+
success = update_claude_config(file_spec="server.py:app", server_name="test")
3128

32-
# Update config
33-
success = update_claude_config(file_spec=file_spec, server_name=server_name)
3429
assert success
30+
config_file = mock_config_path / "claude_desktop_config.json"
31+
assert config_file.exists()
32+
33+
config = json.loads(config_file.read_text())
34+
assert "mcpServers" in config
35+
assert "test" in config["mcpServers"]
36+
37+
server = config["mcpServers"]["test"]
38+
assert "command" in server
39+
assert "args" in server
40+
assert server["command"].endswith("uv") or server["command"].endswith("uv.exe")
41+
42+
43+
def test_args_structure(mock_config_path: Path):
44+
"""Test that args are built correctly."""
45+
success = update_claude_config(file_spec="server.py:app", server_name="test")
46+
assert success
47+
48+
config = json.loads((mock_config_path / "claude_desktop_config.json").read_text())
49+
args = config["mcpServers"]["test"]["args"]
50+
51+
# Should be: ["run", "--with", "mcp[cli]", "mcp", "run", "/abs/path/server.py:app"]
52+
assert args[0] == "run"
53+
assert "--with" in args
54+
assert "mcp[cli]" in args
55+
assert "mcp" in args
56+
assert args[args.index("mcp") + 1] == "run"
57+
assert args[-1].endswith("server.py:app")
58+
59+
60+
def test_absolute_file_path_resolution(mock_config_path: Path, tmp_path: Path):
61+
"""Test that file paths are resolved to absolute paths."""
62+
# Create a test file
63+
server_file = tmp_path / "my_server.py"
64+
server_file.write_text("# test")
65+
66+
success = update_claude_config(file_spec=str(server_file) + ":app", server_name="test")
67+
assert success
68+
69+
config = json.loads((mock_config_path / "claude_desktop_config.json").read_text())
70+
args = config["mcpServers"]["test"]["args"]
71+
72+
# Last arg should be absolute path
73+
assert args[-1] == f"{server_file.resolve()}:app"
74+
assert Path(args[-1].split(":")[0]).is_absolute()
75+
76+
77+
def test_env_vars_initial(mock_config_path: Path):
78+
"""Test that environment variables are set correctly on initial config."""
79+
success = update_claude_config(
80+
file_spec="server.py:app", server_name="test", env_vars={"KEY1": "value1", "KEY2": "value2"}
81+
)
82+
assert success
83+
84+
config = json.loads((mock_config_path / "claude_desktop_config.json").read_text())
85+
env = config["mcpServers"]["test"]["env"]
86+
87+
assert env["KEY1"] == "value1"
88+
assert env["KEY2"] == "value2"
89+
90+
91+
def test_env_vars_merged(mock_config_path: Path):
92+
"""Test that environment variables are merged correctly on update."""
93+
# First call with env vars
94+
update_claude_config(file_spec="server.py:app", server_name="test", env_vars={"KEY1": "value1", "KEY2": "value2"})
95+
96+
# Second call with overlapping env vars
97+
update_claude_config(
98+
file_spec="server.py:app", server_name="test", env_vars={"KEY2": "new_value", "KEY3": "value3"}
99+
)
100+
101+
config = json.loads((mock_config_path / "claude_desktop_config.json").read_text())
102+
env = config["mcpServers"]["test"]["env"]
103+
104+
assert env["KEY1"] == "value1" # Preserved
105+
assert env["KEY2"] == "new_value" # Updated
106+
assert env["KEY3"] == "value3" # Added
107+
108+
109+
def test_env_vars_preserved_when_none(mock_config_path: Path):
110+
"""Test that existing env vars are preserved when update doesn't specify any."""
111+
# First call with env vars
112+
update_claude_config(file_spec="server.py:app", server_name="test", env_vars={"KEY1": "value1"})
113+
114+
# Second call without env vars
115+
update_claude_config(file_spec="server.py:app", server_name="test")
116+
117+
config = json.loads((mock_config_path / "claude_desktop_config.json").read_text())
118+
env = config["mcpServers"]["test"]["env"]
119+
120+
assert env["KEY1"] == "value1" # Should still be there
121+
122+
123+
def test_multiple_packages(mock_config_path: Path):
124+
"""Test that multiple packages are included with --with."""
125+
success = update_claude_config(file_spec="server.py:app", server_name="test", with_packages=["requests", "httpx"])
126+
assert success
127+
128+
config = json.loads((mock_config_path / "claude_desktop_config.json").read_text())
129+
args = config["mcpServers"]["test"]["args"]
35130

36-
# Read the generated config
131+
# Should have: --with mcp[cli] --with httpx --with requests (sorted)
132+
with_indices = [i for i, arg in enumerate(args) if arg == "--with"]
133+
assert len(with_indices) == 3
134+
135+
packages = [args[i + 1] for i in with_indices]
136+
assert "mcp[cli]" in packages
137+
assert "httpx" in packages
138+
assert "requests" in packages
139+
140+
141+
def test_package_deduplication(mock_config_path: Path):
142+
"""Test that duplicate packages are deduplicated."""
143+
success = update_claude_config(
144+
file_spec="server.py:app", server_name="test", with_packages=["mcp[cli]", "requests", "requests"]
145+
)
146+
assert success
147+
148+
config = json.loads((mock_config_path / "claude_desktop_config.json").read_text())
149+
args = config["mcpServers"]["test"]["args"]
150+
151+
# Count --with flags
152+
with_count = sum(1 for arg in args if arg == "--with")
153+
# Should have mcp[cli] and requests only once each
154+
assert with_count == 2
155+
156+
157+
def test_editable_package(mock_config_path: Path, tmp_path: Path):
158+
"""Test that editable package is added correctly."""
159+
editable_dir = tmp_path / "my_package"
160+
editable_dir.mkdir()
161+
162+
success = update_claude_config(file_spec="server.py:app", server_name="test", with_editable=editable_dir)
163+
assert success
164+
165+
config = json.loads((mock_config_path / "claude_desktop_config.json").read_text())
166+
args = config["mcpServers"]["test"]["args"]
167+
168+
assert "--with-editable" in args
169+
idx = args.index("--with-editable")
170+
assert args[idx + 1] == str(editable_dir)
171+
172+
173+
def test_preserves_other_servers(mock_config_path: Path):
174+
"""Test that existing servers are preserved when adding a new one."""
175+
# Create config with existing server
37176
config_file = mock_config_path / "claude_desktop_config.json"
177+
config_file.write_text(
178+
json.dumps({"mcpServers": {"existing_server": {"command": "some_command", "args": ["arg1", "arg2"]}}})
179+
)
180+
181+
# Add new server
182+
success = update_claude_config(file_spec="server.py:app", server_name="new_server")
183+
assert success
184+
38185
config = json.loads(config_file.read_text())
186+
assert "existing_server" in config["mcpServers"]
187+
assert "new_server" in config["mcpServers"]
188+
assert config["mcpServers"]["existing_server"]["command"] == "some_command"
189+
assert config["mcpServers"]["existing_server"]["args"] == ["arg1", "arg2"]
39190

40-
# Get the command and args
41-
server_config = config["mcpServers"][server_name]
42-
command = server_config["command"]
43-
args = server_config["args"]
44191

45-
test_args = [command] + args + ["--help"]
192+
def test_updates_existing_server(mock_config_path: Path):
193+
"""Test that updating an existing server replaces its config."""
194+
# Create initial server
195+
update_claude_config(file_spec="old_server.py:app", server_name="test", env_vars={"OLD": "value"})
46196

47-
result = subprocess.run(test_args, capture_output=True, text=True, timeout=5, check=False)
197+
# Update the same server
198+
update_claude_config(file_spec="new_server.py:app", server_name="test", env_vars={"NEW": "value"})
48199

49-
assert result.returncode == 0
50-
assert "usage" in result.stdout.lower()
200+
config = json.loads((mock_config_path / "claude_desktop_config.json").read_text())
201+
args = config["mcpServers"]["test"]["args"]
202+
203+
# Should have new file spec
204+
assert args[-1].endswith("new_server.py:app")
205+
# Env vars should be merged (NEW takes precedence but OLD is preserved)
206+
assert "NEW" in config["mcpServers"]["test"]["env"]
207+
assert "OLD" in config["mcpServers"]["test"]["env"]
208+
209+
210+
def test_error_handling_missing_config_dir(tmp_path: Path):
211+
"""Test that missing config directory raises appropriate error."""
212+
with patch("mcp.cli.claude.get_claude_config_path", return_value=None):
213+
with pytest.raises(RuntimeError, match="Claude Desktop config directory not found"):
214+
update_claude_config(file_spec="server.py:app", server_name="test")
215+
216+
217+
def test_file_spec_without_colon(mock_config_path: Path, tmp_path: Path):
218+
"""Test file spec without :object suffix."""
219+
server_file = tmp_path / "server.py"
220+
server_file.write_text("# test")
221+
222+
success = update_claude_config(file_spec=str(server_file), server_name="test")
223+
assert success
224+
225+
config = json.loads((mock_config_path / "claude_desktop_config.json").read_text())
226+
args = config["mcpServers"]["test"]["args"]
227+
228+
# Last arg should be absolute path without colon
229+
assert args[-1] == str(server_file.resolve())
230+
assert ":" not in args[-1]
51231

52232

53233
def test_absolute_uv_path(mock_config_path: Path):
54234
"""Test that the absolute path to uv is used when available."""
55-
# Mock the shutil.which function to return a fake path
235+
# Mock the get_uv_path function to return a fake path
56236
mock_uv_path = "/usr/local/bin/uv"
57237

58238
with patch("mcp.cli.claude.get_uv_path", return_value=mock_uv_path):
@@ -73,3 +253,17 @@ def test_absolute_uv_path(mock_config_path: Path):
73253
command = server_config["command"]
74254

75255
assert command == mock_uv_path
256+
257+
258+
def test_creates_mcpservers_key_if_missing(mock_config_path: Path):
259+
"""Test that mcpServers key is created if config exists but key is missing."""
260+
config_file = mock_config_path / "claude_desktop_config.json"
261+
config_file.write_text(json.dumps({"someOtherKey": "value"}))
262+
263+
success = update_claude_config(file_spec="server.py:app", server_name="test")
264+
assert success
265+
266+
config = json.loads(config_file.read_text())
267+
assert "mcpServers" in config
268+
assert "someOtherKey" in config # Original content preserved
269+
assert config["someOtherKey"] == "value"

0 commit comments

Comments
 (0)