Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/mcp/cli/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
235 changes: 217 additions & 18 deletions tests/client/test_config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import json
import subprocess
import re
from pathlib import Path
from unittest.mock import patch

Expand All @@ -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):
Expand All @@ -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"