11import json
2- import subprocess
2+ import re
33from pathlib import Path
44from 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
53238def 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