Skip to content

Commit 9853ce7

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 3390e49 commit 9853ce7

File tree

2 files changed

+222
-20
lines changed

2 files changed

+222
-20
lines changed

src/mcp/cli/claude.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,11 @@ def update_claude_config(
115115

116116
# Convert file path to absolute before adding to command
117117
# Split off any :object suffix first
118-
if ":" in file_spec:
119-
file_path, server_object = file_spec.rsplit(":", 1)
118+
# Use rfind to get the last colon, and check it's not a Windows drive letter (position 1)
119+
colon_index = file_spec.rfind(":")
120+
if colon_index > 1: # Position 0 invalid, position 1 would be drive letter like C:
121+
file_path = file_spec[:colon_index]
122+
server_object = file_spec[colon_index + 1 :]
120123
file_spec = f"{Path(file_path).resolve()}:{server_object}"
121124
else:
122125
file_spec = str(Path(file_spec).resolve())

tests/client/test_config.py

Lines changed: 217 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import json
2-
import subprocess
2+
import re
33
from pathlib import Path
44
from unittest.mock import patch
55

@@ -23,36 +23,221 @@ def mock_config_path(temp_config_dir: Path):
2323
yield temp_config_dir
2424

2525

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"
26+
def test_basic_config_creation(mock_config_path: Path):
27+
"""Test that config file is created with correct structure."""
28+
success = update_claude_config(file_spec="server.py:app", server_name="test")
3129

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

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

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

45-
test_args = [command] + args + ["--help"]
196+
def test_updates_existing_server(mock_config_path: Path):
197+
"""Test that updating an existing server replaces command/args but merges env vars."""
198+
# Create initial server
199+
update_claude_config(file_spec="old_server.py:app", server_name="test", env_vars={"OLD": "value"})
46200

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

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

52237

53238
def test_absolute_uv_path(mock_config_path: Path):
54239
"""Test that the absolute path to uv is used when available."""
55-
# Mock the shutil.which function to return a fake path
240+
# Mock the get_uv_path function to return a fake path
56241
mock_uv_path = "/usr/local/bin/uv"
57242

58243
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):
73258
command = server_config["command"]
74259

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

0 commit comments

Comments
 (0)