Skip to main content
Glama
test_agent_installer.py•28.6 kB
"""Tests for AI agent detection and installation modules. Test Coverage: - Agent detection across platforms (mocked) - Config file parsing (valid/invalid JSON) - Backup and restore operations - Installation with various scenarios - Error handling and rollback - Cross-platform path resolution """ from __future__ import annotations import json from pathlib import Path from unittest.mock import Mock, patch import pytest from mcp_skills.services.agent_detector import AgentDetector, DetectedAgent from mcp_skills.services.agent_installer import AgentInstaller, InstallResult class TestAgentDetector: """Test suite for AgentDetector.""" def test_platform_detection(self): """Test platform normalization.""" detector = AgentDetector() assert detector.platform in ["darwin", "win32", "linux"] @patch("platform.system") def test_darwin_platform(self, mock_system): """Test macOS platform detection.""" mock_system.return_value = "Darwin" detector = AgentDetector() assert detector.platform == "darwin" @patch("platform.system") def test_windows_platform(self, mock_system): """Test Windows platform detection.""" mock_system.return_value = "Windows" detector = AgentDetector() assert detector.platform == "win32" @patch("platform.system") def test_linux_platform(self, mock_system): """Test Linux platform detection.""" mock_system.return_value = "Linux" detector = AgentDetector() assert detector.platform == "linux" def test_detect_all_returns_list(self): """Test that detect_all returns a list of DetectedAgent objects.""" detector = AgentDetector() agents = detector.detect_all() assert isinstance(agents, list) assert len(agents) >= 3 # At least Claude Desktop, Claude Code, Auggie for agent in agents: assert isinstance(agent, DetectedAgent) assert agent.name assert agent.id assert isinstance(agent.config_path, Path) assert isinstance(agent.exists, bool) def test_detect_specific_agent_claude_desktop(self): """Test detecting Claude Desktop specifically.""" detector = AgentDetector() agent = detector.detect_agent("claude-desktop") assert agent is not None assert agent.name == "Claude Desktop" assert agent.id == "claude-desktop" assert "Claude" in str(agent.config_path) def test_detect_specific_agent_claude_code(self): """Test detecting Claude Code specifically.""" detector = AgentDetector() agent = detector.detect_agent("claude-code") assert agent is not None assert agent.name == "Claude Code" assert agent.id == "claude-code" assert "Code" in str(agent.config_path) def test_detect_unknown_agent(self): """Test detecting unknown agent returns None.""" detector = AgentDetector() agent = detector.detect_agent("nonexistent-agent") assert agent is None def test_config_paths_are_absolute(self): """Test that all detected config paths are absolute.""" detector = AgentDetector() agents = detector.detect_all() for agent in agents: assert agent.config_path.is_absolute() class TestAgentInstaller: """Test suite for AgentInstaller.""" @pytest.fixture def installer(self): """Create an AgentInstaller instance.""" return AgentInstaller() @pytest.fixture def temp_agent(self, tmp_path): """Create a temporary detected agent for testing.""" config_dir = tmp_path / "test_agent" config_dir.mkdir() config_path = config_dir / "config.json" return DetectedAgent( name="Test Agent", id="test-agent", config_path=config_path, exists=False, ) def test_install_creates_new_config(self, installer, temp_agent, tmp_path): """Test installation creates new config when none exists.""" result = installer.install(temp_agent) assert result.success assert result.agent_name == "Test Agent" assert result.config_path.exists() # Verify config content with open(result.config_path) as f: config = json.load(f) assert "mcpServers" in config assert "mcp-skillset" in config["mcpServers"] assert config["mcpServers"]["mcp-skillset"]["command"] == "mcp-skillset" def test_install_with_existing_config(self, installer, temp_agent): """Test installation updates existing config.""" # Create existing config existing_config = { "someOtherSetting": "value", "mcpServers": { "other-server": { "command": "other", "args": [], } }, } temp_agent.config_path.write_text(json.dumps(existing_config)) temp_agent.exists = True result = installer.install(temp_agent) assert result.success # Verify config merged correctly with open(result.config_path) as f: config = json.load(f) assert config["someOtherSetting"] == "value" assert "other-server" in config["mcpServers"] assert "mcp-skillset" in config["mcpServers"] def test_install_creates_backup(self, installer, temp_agent): """Test that installation creates backup of existing config.""" # Create existing config existing_config = {"existing": "data"} temp_agent.config_path.write_text(json.dumps(existing_config)) temp_agent.exists = True result = installer.install(temp_agent) assert result.success assert result.backup_path is not None assert result.backup_path.exists() # Verify backup contains original data with open(result.backup_path) as f: backup_config = json.load(f) assert backup_config == existing_config def test_install_refuses_duplicate_without_force(self, installer, temp_agent): """Test installation fails if mcp-skillset already exists without --force.""" # Create config with existing mcp-skillset existing_config = { "mcpServers": { "mcp-skillset": { "command": "old-command", "args": ["old"], } } } temp_agent.config_path.write_text(json.dumps(existing_config)) temp_agent.exists = True result = installer.install(temp_agent, force=False) assert not result.success assert "already installed" in result.error def test_install_overwrites_with_force(self, installer, temp_agent): """Test installation overwrites existing mcp-skillset with --force.""" # Create config with existing mcp-skillset existing_config = { "mcpServers": { "mcp-skillset": { "command": "old-command", "args": ["old"], } } } temp_agent.config_path.write_text(json.dumps(existing_config)) temp_agent.exists = True result = installer.install(temp_agent, force=True) assert result.success # Verify new config with open(result.config_path) as f: config = json.load(f) assert config["mcpServers"]["mcp-skillset"]["command"] == "mcp-skillset" assert config["mcpServers"]["mcp-skillset"]["args"] == ["mcp"] def test_install_dry_run_no_changes(self, installer, temp_agent): """Test dry run mode doesn't modify files.""" result = installer.install(temp_agent, dry_run=True) assert result.success assert "DRY RUN" in result.changes_made assert not temp_agent.config_path.exists() def test_install_handles_corrupted_json(self, installer, temp_agent): """Test installation fails gracefully with corrupted JSON.""" # Write invalid JSON temp_agent.config_path.write_text("{ invalid json }") temp_agent.exists = True result = installer.install(temp_agent) assert not result.success assert "Failed to parse" in result.error def test_install_handles_missing_directory(self, installer, tmp_path): """Test installation fails when config directory doesn't exist.""" nonexistent_dir = tmp_path / "nonexistent" config_path = nonexistent_dir / "config.json" agent = DetectedAgent( name="Test Agent", id="test-agent", config_path=config_path, exists=False, ) result = installer.install(agent) assert not result.success assert "not found" in result.error def test_config_validation(self, installer): """Test configuration validation logic.""" # Valid config valid_config = { "mcpServers": { "mcp-skillset": { "command": "mcp-skillset", "args": ["mcp"], "env": {}, } } } assert installer._validate_config(valid_config) # Missing mcpServers invalid_config = {"other": "data"} assert not installer._validate_config(invalid_config) # Invalid mcpServers type invalid_config = {"mcpServers": "not a dict"} assert not installer._validate_config(invalid_config) # Missing mcp-skillset invalid_config = {"mcpServers": {"other": {}}} assert not installer._validate_config(invalid_config) # Invalid mcp-skillset structure invalid_config = { "mcpServers": { "mcp-skillset": { "command": "mcp-skillset", # Missing 'args' } } } assert not installer._validate_config(invalid_config) def test_backup_path_format(self, installer, temp_agent): """Test backup file naming includes timestamp.""" # Create existing config temp_agent.config_path.write_text("{}") temp_agent.exists = True result = installer.install(temp_agent) assert result.success assert result.backup_path is not None # Verify backup filename format backup_name = result.backup_path.name assert backup_name.startswith("config.json.backup.") assert len(backup_name.split(".")) >= 4 # name.json.backup.timestamp def test_install_preserves_json_formatting(self, installer, temp_agent): """Test that installed config is properly formatted JSON.""" result = installer.install(temp_agent) assert result.success # Read and verify formatting config_text = result.config_path.read_text() # Should be valid JSON config = json.loads(config_text) assert isinstance(config, dict) # Should be pretty-printed (has indentation) assert " " in config_text or "\t" in config_text # Should have trailing newline assert config_text.endswith("\n") def test_describe_changes_new_config(self, installer, temp_agent): """Test change description for new config.""" description = installer._describe_changes({}) assert "new config" in description.lower() def test_describe_changes_existing_config(self, installer, temp_agent): """Test change description for existing config.""" existing = {"mcpServers": {"other": {}}} description = installer._describe_changes(existing) assert "add" in description.lower() or "mcp-skillset" in description.lower() def test_describe_changes_update_existing(self, installer, temp_agent): """Test change description when updating existing mcp-skillset.""" existing = {"mcpServers": {"mcp-skillset": {"command": "old"}}} description = installer._describe_changes(existing) assert "update" in description.lower() class TestClaudeCLIIntegration: """Test suite for Claude CLI integration (1M-432).""" @pytest.fixture def installer(self): """Create an AgentInstaller instance.""" return AgentInstaller() @pytest.fixture def claude_code_agent(self, tmp_path): """Create a Claude Code agent for testing.""" config_path = tmp_path / "Code" / "settings.json" return DetectedAgent( name="Claude Code", id="claude-code", config_path=config_path, exists=False, ) @pytest.fixture def claude_desktop_agent(self, tmp_path): """Create a Claude Desktop agent for testing.""" config_path = tmp_path / "Claude" / "claude_desktop_config.json" return DetectedAgent( name="Claude Desktop", id="claude-desktop", config_path=config_path, exists=False, ) @patch("subprocess.run") @patch("shutil.which") def test_claude_cli_installation_success( self, mock_which, mock_run, installer, claude_code_agent ): """Test successful Claude CLI installation. Verifies that when the claude CLI is available and the add command succeeds, the installation completes successfully. """ # Mock CLI available mock_which.return_value = "/usr/local/bin/claude" # Mock get command failure (server doesn't exist), then add success mock_run.side_effect = [ Mock(returncode=1, stdout="", stderr="Not found"), # get fails Mock(returncode=0, stdout="", stderr=""), # add succeeds ] # Install result = installer.install(claude_code_agent) # Verify success assert result.success assert result.agent_name == "Claude Code" assert result.agent_id == "claude-code" assert "Claude CLI" in result.changes_made # Verify subprocess calls (get and add) assert mock_run.call_count == 2 # Verify get command get_call = mock_run.call_args_list[0][0][0] assert "claude" in get_call assert "mcp" in get_call assert "get" in get_call assert "mcp-skillset" in get_call # Verify add command add_call = mock_run.call_args_list[1][0][0] assert "claude" in add_call assert "mcp" in add_call assert "add" in add_call assert "mcp-skillset" in add_call @patch("shutil.which") def test_claude_cli_not_found(self, mock_which, installer, claude_code_agent): """Test error when Claude CLI is not found. Verifies that installation fails with clear error message when the claude CLI is not available on the system. """ # Mock CLI not available mock_which.return_value = None # Install result = installer.install(claude_code_agent) # Verify failure assert not result.success assert result.error is not None assert "CLI not found" in result.error assert "install Claude Code" in result.error @patch("subprocess.run") @patch("shutil.which") def test_claude_cli_already_installed( self, mock_which, mock_run, installer, claude_code_agent ): """Test detection of already installed server. Verifies that without --force flag, installation fails when mcp-skillset is already installed. """ # Mock CLI available mock_which.return_value = "/usr/local/bin/claude" # Mock 'get' command returning success (server exists) mock_run.return_value = Mock(returncode=0, stdout="", stderr="") # Install without force result = installer.install(claude_code_agent, force=False) # Verify failure assert not result.success assert result.error is not None assert "already installed" in result.error assert "--force" in result.error # Verify 'get' command was called call_args = mock_run.call_args[0][0] assert "claude" in call_args assert "mcp" in call_args assert "get" in call_args assert "mcp-skillset" in call_args @patch("subprocess.run") @patch("shutil.which") def test_claude_cli_force_reinstall( self, mock_which, mock_run, installer, claude_code_agent ): """Test force reinstall workflow. Verifies that with --force flag, installation removes existing server and adds it again. """ # Mock CLI available mock_which.return_value = "/usr/local/bin/claude" # Mock successful CLI commands mock_run.return_value = Mock(returncode=0, stdout="", stderr="") # Install with force result = installer.install(claude_code_agent, force=True) # Verify success assert result.success assert "Claude CLI" in result.changes_made # Verify both remove and add commands were called assert mock_run.call_count >= 2 # Check remove command remove_call = mock_run.call_args_list[0][0][0] assert "claude" in remove_call assert "mcp" in remove_call assert "remove" in remove_call assert "mcp-skillset" in remove_call # Check add command add_call = mock_run.call_args_list[1][0][0] assert "claude" in add_call assert "mcp" in add_call assert "add" in add_call assert "mcp-skillset" in add_call @patch("subprocess.run") @patch("shutil.which") def test_claude_cli_dry_run( self, mock_which, mock_run, installer, claude_code_agent ): """Test dry-run mode. Verifies that dry-run mode shows what would be done without actually making any changes. """ # Mock CLI available mock_which.return_value = "/usr/local/bin/claude" # Mock get command failure (server doesn't exist) mock_run.return_value = Mock(returncode=1, stdout="", stderr="Not found") # Install in dry-run mode result = installer.install(claude_code_agent, dry_run=True) # Verify success but no actual execution assert result.success assert result.changes_made is not None assert "[DRY RUN]" in result.changes_made assert "claude mcp add" in result.changes_made assert "mcp-skillset" in result.changes_made # Verify only get was called (to check if installed), not add/remove assert mock_run.call_count == 1 call_args = mock_run.call_args[0][0] assert "get" in call_args @patch("subprocess.run") @patch("shutil.which") def test_claude_cli_dry_run_with_force( self, mock_which, mock_run, installer, claude_code_agent ): """Test dry-run mode with force flag. Verifies that dry-run mode shows both remove and add commands when force flag is used. """ # Mock CLI available mock_which.return_value = "/usr/local/bin/claude" # Install in dry-run mode with force result = installer.install(claude_code_agent, dry_run=True, force=True) # Verify success assert result.success assert "[DRY RUN]" in result.changes_made assert "remove" in result.changes_made assert "add" in result.changes_made # Verify NO subprocess calls assert not mock_run.called @patch("subprocess.run") @patch("shutil.which") def test_claude_cli_add_command_fails( self, mock_which, mock_run, installer, claude_code_agent ): """Test handling of failed add command. Verifies that installation fails gracefully when the CLI add command returns an error. """ # Mock CLI available mock_which.return_value = "/usr/local/bin/claude" # Mock failed add command mock_run.return_value = Mock( returncode=1, stdout="", stderr="Error: Failed to add MCP server", ) # Install result = installer.install(claude_code_agent) # Verify failure assert not result.success assert result.error is not None assert "Failed to add" in result.error assert "Error:" in result.error @patch("subprocess.run") @patch("shutil.which") def test_claude_cli_get_command_fails_allows_install( self, mock_which, mock_run, installer, claude_code_agent ): """Test that failed 'get' command allows installation. When checking if server exists fails (e.g., server doesn't exist), installation should proceed without force flag. """ # Mock CLI available mock_which.return_value = "/usr/local/bin/claude" # Mock get command failure (server doesn't exist), then add success mock_run.side_effect = [ Mock(returncode=1, stdout="", stderr="Not found"), # get fails Mock(returncode=0, stdout="", stderr=""), # add succeeds ] # Install without force result = installer.install(claude_code_agent, force=False) # Verify success (should proceed since get failed) assert result.success assert "Claude CLI" in result.changes_made def test_backward_compatibility_claude_desktop( self, installer, claude_desktop_agent, tmp_path ): """Test backward compatibility with Claude Desktop. Verifies that Claude Desktop still uses JSON config file method and NOT the CLI method. """ # Create config directory claude_desktop_agent.config_path.parent.mkdir(parents=True, exist_ok=True) # Install for Claude Desktop (should use JSON method) with patch("subprocess.run") as mock_run: result = installer.install(claude_desktop_agent) # Verify subprocess was NOT called (JSON method doesn't use subprocess) assert not mock_run.called # Verify config file was created (JSON method) assert result.success assert claude_desktop_agent.config_path.exists() # Verify it's valid JSON config with open(claude_desktop_agent.config_path) as f: config = json.load(f) assert "mcpServers" in config assert "mcp-skillset" in config["mcpServers"] @patch("subprocess.run") @patch("shutil.which") def test_claude_cli_routing_based_on_agent_id( self, mock_which, mock_run, installer, claude_code_agent, claude_desktop_agent, tmp_path, ): """Test that installation method is routed based on agent ID. Verifies that: - claude-code uses CLI method - claude-desktop uses JSON method """ # Setup for CLI test mock_which.return_value = "/usr/local/bin/claude" # Mock get command failure (server doesn't exist), then add success mock_run.side_effect = [ Mock(returncode=1, stdout="", stderr="Not found"), # get fails Mock(returncode=0, stdout="", stderr=""), # add succeeds ] # Install Claude Code (should use CLI) result_code = installer.install(claude_code_agent) assert result_code.success assert mock_run.call_count == 2 # get + add # Reset mocks mock_run.reset_mock() # Install Claude Desktop (should NOT use CLI) claude_desktop_agent.config_path.parent.mkdir(parents=True, exist_ok=True) result_desktop = installer.install(claude_desktop_agent) assert result_desktop.success assert not mock_run.called # JSON method doesn't call subprocess class TestCrossPlatformPaths: """Test cross-platform path resolution.""" @patch("platform.system") def test_claude_desktop_paths_darwin(self, mock_system): """Test Claude Desktop paths on macOS.""" mock_system.return_value = "Darwin" detector = AgentDetector() agent = detector.detect_agent("claude-desktop") assert agent is not None assert "Library/Application Support/Claude" in str(agent.config_path) @patch("platform.system") def test_claude_desktop_paths_linux(self, mock_system): """Test Claude Desktop paths on Linux.""" mock_system.return_value = "Linux" detector = AgentDetector() agent = detector.detect_agent("claude-desktop") assert agent is not None assert ".config/Claude" in str(agent.config_path) @patch("platform.system") @patch.dict("os.environ", {"APPDATA": "C:\\Users\\Test\\AppData\\Roaming"}) def test_claude_desktop_paths_windows(self, mock_system): """Test Claude Desktop paths on Windows.""" mock_system.return_value = "Windows" detector = AgentDetector() agent = detector.detect_agent("claude-desktop") assert agent is not None # Windows path should contain Claude directory class TestInstallResult: """Test InstallResult data class.""" def test_install_result_success(self): """Test creating successful InstallResult.""" result = InstallResult( success=True, agent_name="Test Agent", agent_id="test-agent", config_path=Path("/test/path"), backup_path=Path("/test/backup"), changes_made="Added mcp-skillset", ) assert result.success assert result.agent_name == "Test Agent" assert result.error is None def test_install_result_failure(self): """Test creating failed InstallResult.""" result = InstallResult( success=False, agent_name="Test Agent", agent_id="test-agent", config_path=Path("/test/path"), error="Something went wrong", ) assert not result.success assert result.error == "Something went wrong" assert result.backup_path is None class TestAgentNameDetection: """Test suite for agent name detection bug fixes.""" def test_claude_code_name_is_correct(self): """Test that Claude Code is detected with correct name (Bug Fix #2).""" detector = AgentDetector() agent = detector.detect_agent("claude-code") assert agent is not None assert agent.name == "Claude Code" assert agent.id == "claude-code" assert "Code" in str(agent.config_path) assert "settings.json" in str(agent.config_path) def test_claude_desktop_name_is_correct(self): """Test that Claude Desktop is detected with correct name (Bug Fix #2).""" detector = AgentDetector() agent = detector.detect_agent("claude-desktop") assert agent is not None assert agent.name == "Claude Desktop" assert agent.id == "claude-desktop" assert "Claude" in str(agent.config_path) assert "claude_desktop_config.json" in str(agent.config_path) def test_all_agents_have_unique_names(self): """Test that all detected agents have unique, correct names.""" detector = AgentDetector() agents = detector.detect_all() # Collect agent names names = {agent.name for agent in agents} # Verify expected agents have correct names assert "Claude Desktop" in names assert "Claude Code" in names assert "Auggie" in names # Verify no duplicate names assert len(names) == len(agents) def test_agent_name_matches_config_path(self): """Test that agent names correctly match their config paths.""" detector = AgentDetector() agents = detector.detect_all() for agent in agents: if agent.id == "claude-desktop": assert agent.name == "Claude Desktop" assert "Claude" in str(agent.config_path) assert "claude_desktop_config.json" in str(agent.config_path) elif agent.id == "claude-code": assert agent.name == "Claude Code" assert "Code" in str(agent.config_path) assert "settings.json" in str(agent.config_path) elif agent.id == "auggie": assert agent.name == "Auggie" assert "Auggie" in str(agent.config_path)

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/bobmatnyc/mcp-skills'

If you have feedback or need assistance with the MCP directory API, please join our Discord server