From 9c07d629c8411b9e3caff1b6414f4e41ad0ab500 Mon Sep 17 00:00:00 2001 From: Chen Nie Date: Thu, 27 Mar 2025 13:50:38 +0800 Subject: [PATCH 1/2] Client manager refactor --- src/mcpm/cli.py | 5 +- src/mcpm/clients/base.py | 203 +++++++++++++++++++++++ src/mcpm/clients/claude_desktop.py | 108 ++++++------ src/mcpm/clients/cursor.py | 150 +++++++++-------- src/mcpm/clients/windsurf.py | 176 ++++++++++++++------ src/mcpm/commands/add.py | 255 +++++++++++++++++++++++++++++ src/mcpm/commands/install.py | 202 +++++++++++++++-------- src/mcpm/utils/config.py | 119 ++++++-------- src/mcpm/utils/server_config.py | 176 ++++++++++++++++++++ tests/test_windsurf_integration.py | 182 +++++++++++++++++++- 10 files changed, 1276 insertions(+), 300 deletions(-) create mode 100644 src/mcpm/clients/base.py create mode 100644 src/mcpm/commands/add.py create mode 100644 src/mcpm/utils/server_config.py diff --git a/src/mcpm/cli.py b/src/mcpm/cli.py index c23433ca..33e66dea 100644 --- a/src/mcpm/cli.py +++ b/src/mcpm/cli.py @@ -19,6 +19,7 @@ server, client, inspector, + add, ) console = Console() @@ -103,10 +104,11 @@ def main(ctx, help_flag): # Display available commands in a table console.print("[bold]Commands:[/]") commands_table = Table(show_header=False, box=None, padding=(0, 2, 0, 0)) + commands_table.add_row(" [cyan]add[/]", "Add an MCP server directly to a client.") commands_table.add_row(" [cyan]client[/]", "Manage the active MCPM client.") commands_table.add_row(" [cyan]edit[/]", "View or edit the active MCPM client's configuration file.") commands_table.add_row(" [cyan]inspector[/]", "Launch the MCPM Inspector UI to examine servers.") - commands_table.add_row(" [cyan]install[/]", "Install an MCP server.") + commands_table.add_row(" [cyan]install[/]", "[yellow][DEPRECATED][/] Install an MCP server (use add instead).") commands_table.add_row(" [cyan]list[/]", "List all installed MCP servers.") commands_table.add_row(" [cyan]remove[/]", "Remove an installed MCP server.") commands_table.add_row(" [cyan]search[/]", "Search available MCP servers.") @@ -124,6 +126,7 @@ def main(ctx, help_flag): main.add_command(search.search) main.add_command(install.install) main.add_command(remove.remove) +main.add_command(add.add) main.add_command(list_servers.list) main.add_command(edit.edit) diff --git a/src/mcpm/clients/base.py b/src/mcpm/clients/base.py new file mode 100644 index 00000000..d06c9e61 --- /dev/null +++ b/src/mcpm/clients/base.py @@ -0,0 +1,203 @@ +""" +Base client manager module that defines the interface for all client managers. +""" + +import os +import json +import logging +from typing import Dict, Optional, Any, List + +from mcpm.utils.server_config import ServerConfig + +logger = logging.getLogger(__name__) + +class BaseClientManager: + """Base class for all client managers providing a common interface""" + + def __init__(self, config_path: str): + """Initialize with a configuration path""" + self.config_path = config_path + self._config = None + + def _load_config(self) -> Dict[str, Any]: + """Load client configuration file + + Returns: + Dict containing the client configuration + """ + if not os.path.exists(self.config_path): + logger.warning(f"Client config file not found at: {self.config_path}") + return self._get_empty_config() + + try: + with open(self.config_path, 'r') as f: + return json.load(f) + except json.JSONDecodeError: + logger.error(f"Error parsing client config file: {self.config_path}") + + # Backup the corrupt file + if os.path.exists(self.config_path): + backup_path = f"{self.config_path}.bak" + try: + os.rename(self.config_path, backup_path) + logger.info(f"Backed up corrupt config file to: {backup_path}") + except Exception as e: + logger.error(f"Failed to backup corrupt file: {str(e)}") + + # Return empty config + return self._get_empty_config() + + def _save_config(self, config: Dict[str, Any]) -> bool: + """Save configuration to client config file + + Args: + config: Configuration to save + + Returns: + bool: Success or failure + """ + try: + # Create directory if it doesn't exist + os.makedirs(os.path.dirname(self.config_path), exist_ok=True) + + with open(self.config_path, 'w') as f: + json.dump(config, f, indent=2) + return True + except Exception as e: + logger.error(f"Error saving client config: {str(e)}") + return False + + def _get_empty_config(self) -> Dict[str, Any]: + """Get an empty config structure for this client + + Returns: + Dict containing empty configuration structure + """ + # To be overridden by subclasses + return {"mcpServers": {}} + + def get_servers(self) -> Dict[str, Any]: + """Get all MCP servers configured for this client + + Returns: + Dict of server configurations by name + """ + # To be overridden by subclasses + config = self._load_config() + return config.get("mcpServers", {}) + + def get_server(self, server_name: str) -> Optional[Dict[str, Any]]: + """Get a specific MCP server configuration + + Args: + server_name: Name of the server to retrieve + + Returns: + Server configuration or None if not found + """ + servers = self.get_servers() + return servers.get(server_name) + + def _add_server_config(self, server_name: str, server_config: Dict[str, Any]) -> bool: + """Add or update an MCP server in client config using raw config dictionary + + Note: This is an internal method that should generally not be called directly. + Use add_server with a ServerConfig object instead for better type safety and validation. + + Args: + server_name: Name of the server + server_config: Server configuration dictionary + + Returns: + bool: Success or failure + """ + # To be implemented by subclasses + raise NotImplementedError("Subclasses must implement _add_server_config") + + def add_server(self, server_config: ServerConfig) -> bool: + """Add or update a server using a ServerConfig object + + This is the preferred method for adding servers as it ensures proper type safety + and validation through the ServerConfig object. + + Args: + server_config: StandardServer configuration object + + Returns: + bool: Success or failure + """ + # Default implementation - can be overridden by subclasses + return self._add_server_config(server_config.name, self._convert_to_client_format(server_config)) + + def _convert_to_client_format(self, server_config: ServerConfig) -> Dict[str, Any]: + """Convert ServerConfig to client-specific format + + Args: + server_config: StandardServer configuration + + Returns: + Dict containing client-specific configuration + """ + # To be implemented by subclasses + raise NotImplementedError("Subclasses must implement _convert_to_client_format") + + def _convert_from_client_format(self, server_name: str, client_config: Dict[str, Any]) -> ServerConfig: + """Convert client-specific format to ServerConfig + + Args: + server_name: Name of the server + client_config: Client-specific configuration + + Returns: + ServerConfig object + """ + # To be implemented by subclasses + raise NotImplementedError("Subclasses must implement _convert_from_client_format") + + def get_server_configs(self) -> List[ServerConfig]: + """Get all MCP servers as ServerConfig objects + + Returns: + List of ServerConfig objects + """ + servers = self.get_servers() + return [ + self._convert_from_client_format(name, config) + for name, config in servers.items() + ] + + def get_server_config(self, server_name: str) -> Optional[ServerConfig]: + """Get a specific MCP server as a ServerConfig object + + Args: + server_name: Name of the server + + Returns: + ServerConfig or None if not found + """ + server = self.get_server(server_name) + if server: + return self._convert_from_client_format(server_name, server) + return None + + def remove_server(self, server_name: str) -> bool: + """Remove an MCP server from client config + + Args: + server_name: Name of the server to remove + + Returns: + bool: Success or failure + """ + # To be implemented by subclasses + raise NotImplementedError("Subclasses must implement remove_server") + + def is_client_installed(self) -> bool: + """Check if this client is installed + + Returns: + bool: True if client is installed, False otherwise + """ + # Default implementation - can be overridden by subclasses + client_dir = os.path.dirname(self.config_path) + return os.path.isdir(client_dir) diff --git a/src/mcpm/clients/claude_desktop.py b/src/mcpm/clients/claude_desktop.py index a54e0e1b..7fb65de0 100644 --- a/src/mcpm/clients/claude_desktop.py +++ b/src/mcpm/clients/claude_desktop.py @@ -3,10 +3,12 @@ """ import os -import json import logging -from typing import Dict, Optional, Any import platform +from typing import Dict, Any + +from mcpm.clients.base import BaseClientManager +from mcpm.utils.server_config import ServerConfig logger = logging.getLogger(__name__) @@ -19,51 +21,29 @@ # Linux (unsupported by Claude Desktop currently, but future-proofing) CLAUDE_CONFIG_PATH = os.path.expanduser("~/.config/Claude/claude_desktop_config.json") -class ClaudeDesktopManager: +class ClaudeDesktopManager(BaseClientManager): """Manages Claude Desktop MCP server configurations""" def __init__(self, config_path: str = CLAUDE_CONFIG_PATH): - self.config_path = config_path - self._config = None - - def _load_config(self) -> Dict[str, Any]: - """Load Claude Desktop configuration file""" - if not os.path.exists(self.config_path): - logger.warning(f"Claude Desktop config file not found at: {self.config_path}") - return {"mcpServers": {}} - - try: - with open(self.config_path, 'r') as f: - return json.load(f) - except json.JSONDecodeError: - logger.error(f"Error parsing Claude Desktop config file: {self.config_path}") - return {"mcpServers": {}} - - def _save_config(self, config: Dict[str, Any]) -> bool: - """Save configuration to Claude Desktop config file""" - try: - # Create directory if it doesn't exist - os.makedirs(os.path.dirname(self.config_path), exist_ok=True) - - with open(self.config_path, 'w') as f: - json.dump(config, f, indent=2) - return True - except Exception as e: - logger.error(f"Error saving Claude Desktop config: {str(e)}") - return False + super().__init__(config_path) - def get_servers(self) -> Dict[str, Any]: - """Get all MCP servers configured in Claude Desktop""" - config = self._load_config() - return config.get("mcpServers", {}) - - def get_server(self, server_name: str) -> Optional[Dict[str, Any]]: - """Get a specific MCP server configuration""" - servers = self.get_servers() - return servers.get(server_name) + def _get_empty_config(self) -> Dict[str, Any]: + """Get empty config structure for Claude Desktop""" + return {"mcpServers": {}} - def add_server(self, server_name: str, server_config: Dict[str, Any]) -> bool: - """Add or update an MCP server in Claude Desktop config""" + def _add_server_config(self, server_name: str, server_config: Dict[str, Any]) -> bool: + """Add or update an MCP server in Claude Desktop config using raw config dictionary + + Note: This is an internal method that should generally not be called directly. + Use add_server with a ServerConfig object instead for better type safety and validation. + + Args: + server_name: Name of the server + server_config: Server configuration dictionary + + Returns: + bool: Success or failure + """ config = self._load_config() # Initialize mcpServers if it doesn't exist @@ -74,6 +54,44 @@ def add_server(self, server_name: str, server_config: Dict[str, Any]) -> bool: config["mcpServers"][server_name] = server_config return self._save_config(config) + + def add_server(self, server_config: ServerConfig) -> bool: + """Add or update a server using a ServerConfig object + + This is the preferred method for adding servers as it ensures proper type safety + and validation through the ServerConfig object. + + Args: + server_config: ServerConfig object + + Returns: + bool: Success or failure + """ + client_config = self._convert_to_client_format(server_config) + return self._add_server_config(server_config.name, client_config) + + def _convert_to_client_format(self, server_config: ServerConfig) -> Dict[str, Any]: + """Convert ServerConfig to Claude Desktop format + + Args: + server_config: StandardServer configuration + + Returns: + Dict containing Claude Desktop-specific configuration + """ + return server_config.to_claude_desktop_format() + + def _convert_from_client_format(self, server_name: str, client_config: Dict[str, Any]) -> ServerConfig: + """Convert Claude Desktop format to ServerConfig + + Args: + server_name: Name of the server + client_config: Claude Desktop-specific configuration + + Returns: + ServerConfig object + """ + return ServerConfig.from_claude_desktop_format(server_name, client_config) def remove_server(self, server_name: str) -> bool: """Remove an MCP server from Claude Desktop config""" @@ -87,9 +105,7 @@ def remove_server(self, server_name: str) -> bool: del config["mcpServers"][server_name] return self._save_config(config) - + def is_claude_desktop_installed(self) -> bool: """Check if Claude Desktop is installed""" - # Check for the presence of the Claude Desktop directory - claude_dir = os.path.dirname(self.config_path) - return os.path.isdir(claude_dir) + return self.is_client_installed() diff --git a/src/mcpm/clients/cursor.py b/src/mcpm/clients/cursor.py index a39a6f72..1eeb2778 100644 --- a/src/mcpm/clients/cursor.py +++ b/src/mcpm/clients/cursor.py @@ -3,9 +3,11 @@ """ import os -import json import logging -from typing import Dict, Any, List, Optional +from typing import Dict, Any + +from mcpm.clients.base import BaseClientManager +from mcpm.utils.server_config import ServerConfig # Cursor stores MCP configuration in: # - Project config: .cursor/mcp.json in the project directory @@ -31,78 +33,98 @@ def get_project_config_path(project_dir: str) -> str: return os.path.join(project_dir, ".cursor", "mcp.json") -class CursorManager: +class CursorManager(BaseClientManager): """Manages Cursor client configuration for MCP""" - def __init__(self): - self.config_path = CURSOR_CONFIG_PATH + def __init__(self, config_path: str = CURSOR_CONFIG_PATH): + super().__init__(config_path) - def is_cursor_installed(self) -> bool: - """Check if Cursor is installed""" - return os.path.isdir(os.path.dirname(self.config_path)) + def _get_empty_config(self) -> Dict[str, Any]: + """Get empty config structure for Cursor""" + return {"mcpServers": {}} - def read_config(self) -> Optional[Dict[str, Any]]: - """Read the Cursor MCP configuration""" - if not os.path.exists(self.config_path): - return None + def _add_server_config(self, server_name: str, server_config: Dict[str, Any]) -> bool: + """Add or update an MCP server in Cursor config using raw config dictionary + + Note: This is an internal method that should generally not be called directly. + Use add_server with a ServerConfig object instead for better type safety and validation. + + Args: + server_name: Name of the server + server_config: Server configuration dictionary - try: - with open(self.config_path, 'r') as f: - return json.load(f) - except json.JSONDecodeError: - logger.error(f"Error parsing Cursor config file: {self.config_path}") - return None - except Exception as e: - logger.error(f"Error reading Cursor config file: {str(e)}") - return None - - def write_config(self, config: Dict[str, Any]) -> bool: - """Write the Cursor MCP configuration""" - try: - # Create directory if it doesn't exist - os.makedirs(os.path.dirname(self.config_path), exist_ok=True) + Returns: + bool: Success or failure + """ + config = self._load_config() + + # Initialize mcpServers if it doesn't exist + if "mcpServers" not in config: + config["mcpServers"] = {} - with open(self.config_path, 'w') as f: - json.dump(config, f, indent=2) - return True - except Exception as e: - logger.error(f"Error writing Cursor config file: {str(e)}") - return False - - def create_default_config(self) -> Dict[str, Any]: - """Create a default Cursor MCP configuration""" - return { - "mcpServers": {} - } - - def sync_mcp_servers(self, servers: List[Dict[str, Any]]) -> bool: - """Sync MCP servers to Cursor configuration""" - config = self.read_config() or self.create_default_config() + # Add or update the server + config["mcpServers"][server_name] = server_config + + return self._save_config(config) - # Update mcpServers section - for server in servers: - name = server.get("name") - if name: - config.setdefault("mcpServers", {})[name] = { - "command": server.get("command", ""), - "args": server.get("args", []), - } - - # Add environment variables if present - if "env" in server and server["env"]: - config["mcpServers"][name]["env"] = server["env"] + def add_server(self, server_config: ServerConfig) -> bool: + """Add or update a server using a ServerConfig object + + This is the preferred method for adding servers as it ensures proper type safety + and validation through the ServerConfig object. + + Args: + server_config: ServerConfig object + + Returns: + bool: Success or failure + """ + client_config = self._convert_to_client_format(server_config) + return self._add_server_config(server_config.name, client_config) + + def _convert_to_client_format(self, server_config: ServerConfig) -> Dict[str, Any]: + """Convert ServerConfig to Cursor format - # Write updated config - return self.write_config(config) + Args: + server_config: StandardServer configuration + + Returns: + Dict containing Cursor-specific configuration + """ + return server_config.to_cursor_format() + + def _convert_from_client_format(self, server_name: str, client_config: Dict[str, Any]) -> ServerConfig: + """Convert Cursor format to ServerConfig - def get_servers(self) -> Dict[str, Any]: - """Get MCP servers from Cursor configuration + Args: + server_name: Name of the server + client_config: Cursor-specific configuration + + Returns: + ServerConfig object + """ + return ServerConfig.from_cursor_format(server_name, client_config) + + def remove_server(self, server_name: str) -> bool: + """Remove an MCP server from Cursor config + Args: + server_name: Name of the server to remove + Returns: - Dict[str, Any]: Dictionary mapping server names to their configurations + bool: Success or failure """ - config = self.read_config() - if not config: - return {} + config = self._load_config() + + if "mcpServers" not in config or server_name not in config["mcpServers"]: + logger.warning(f"Server not found in Cursor config: {server_name}") + return False - return config.get("mcpServers", {}) + # Remove the server + del config["mcpServers"][server_name] + + return self._save_config(config) + + def is_cursor_installed(self) -> bool: + """Check if Cursor is installed""" + return self.is_client_installed() diff --git a/src/mcpm/clients/windsurf.py b/src/mcpm/clients/windsurf.py index 62437e59..73116e74 100644 --- a/src/mcpm/clients/windsurf.py +++ b/src/mcpm/clients/windsurf.py @@ -3,11 +3,13 @@ """ import os -import json import logging -from typing import Dict, Optional, Any +from typing import Dict, Any, Optional, List import platform +from mcpm.clients.base import BaseClientManager +from mcpm.utils.server_config import ServerConfig + logger = logging.getLogger(__name__) # Windsurf config paths based on platform @@ -19,51 +21,33 @@ # Linux WINDSURF_CONFIG_PATH = os.path.expanduser("~/.codeium/windsurf/mcp_config.json") -class WindsurfManager: +class WindsurfManager(BaseClientManager): """Manages Windsurf MCP server configurations""" def __init__(self, config_path: str = WINDSURF_CONFIG_PATH): - self.config_path = config_path - self._config = None - - def _load_config(self) -> Dict[str, Any]: - """Load Windsurf configuration file""" - if not os.path.exists(self.config_path): - logger.warning(f"Windsurf config file not found at: {self.config_path}") - return {"mcpServers": {}} - - try: - with open(self.config_path, 'r') as f: - return json.load(f) - except json.JSONDecodeError: - logger.error(f"Error parsing Windsurf config file: {self.config_path}") - return {"mcpServers": {}} + super().__init__(config_path) + + def _get_empty_config(self) -> Dict[str, Any]: + """Get empty config structure for Windsurf""" + return {"mcpServers": {}} - def _save_config(self, config: Dict[str, Any]) -> bool: - """Save configuration to Windsurf config file""" - try: - # Create directory if it doesn't exist - os.makedirs(os.path.dirname(self.config_path), exist_ok=True) + def _add_server_config(self, server_name: str, server_config: Dict[str, Any]) -> bool: + """Add or update an MCP server in Windsurf config using raw config dictionary + + Note: This is an internal method that should generally not be called directly. + Use add_server with a ServerConfig object instead for better type safety and validation. + + Args: + server_name: Name of the server + server_config: Server configuration dictionary - with open(self.config_path, 'w') as f: - json.dump(config, f, indent=2) - return True - except Exception as e: - logger.error(f"Error saving Windsurf config: {str(e)}") - return False - - def get_servers(self) -> Dict[str, Any]: - """Get all MCP servers configured in Windsurf""" - config = self._load_config() - return config.get("mcpServers", {}) - - def get_server(self, server_name: str) -> Optional[Dict[str, Any]]: - """Get a specific MCP server configuration""" - servers = self.get_servers() - return servers.get(server_name) - - def add_server(self, server_name: str, server_config: Dict[str, Any]) -> bool: - """Add or update an MCP server in Windsurf config""" + Returns: + bool: Success or failure + """ + # Validate required fields - just log a warning but don't block + if "command" not in server_config: + logger.warning(f"Server config for {server_name} is missing 'command' field") + config = self._load_config() # Initialize mcpServers if it doesn't exist @@ -74,9 +58,58 @@ def add_server(self, server_name: str, server_config: Dict[str, Any]) -> bool: config["mcpServers"][server_name] = server_config return self._save_config(config) + + def add_server(self, server_config: ServerConfig) -> bool: + """Add or update a server using a ServerConfig object + + This is the preferred method for adding servers as it ensures proper type safety + and validation through the ServerConfig object. + + Args: + server_config: ServerConfig object + + Returns: + bool: Success or failure + """ + client_config = self._convert_to_client_format(server_config) + return self._add_server_config(server_config.name, client_config) + + def _convert_to_client_format(self, server_config: ServerConfig) -> Dict[str, Any]: + """Convert ServerConfig to Windsurf format + + Args: + server_config: StandardServer configuration + + Returns: + Dict containing Windsurf-specific configuration + """ + # Use the to_windsurf_format method which now handles all required fields + # This includes command, args, env, path and other metadata fields + return server_config.to_windsurf_format() + + def _convert_from_client_format(self, server_name: str, client_config: Dict[str, Any]) -> ServerConfig: + """Convert Windsurf format to ServerConfig + + Args: + server_name: Name of the server + client_config: Windsurf-specific configuration + + Returns: + ServerConfig object + """ + # Simply use the ServerConfig.from_windsurf_format method + # This internally calls from_dict which handles conversion of env to env_vars + return ServerConfig.from_windsurf_format(server_name, client_config) def remove_server(self, server_name: str) -> bool: - """Remove an MCP server from Windsurf config""" + """Remove an MCP server from Windsurf config + + Args: + server_name: Name of the server to remove + + Returns: + bool: Success or failure + """ config = self._load_config() if "mcpServers" not in config or server_name not in config["mcpServers"]: @@ -87,9 +120,56 @@ def remove_server(self, server_name: str) -> bool: del config["mcpServers"][server_name] return self._save_config(config) - + def is_windsurf_installed(self) -> bool: - """Check if Windsurf is installed""" - # Check for the presence of the Windsurf directory - windsurf_dir = os.path.dirname(self.config_path) - return os.path.isdir(windsurf_dir) + """Check if Windsurf is installed + + Returns: + bool: True if Windsurf is installed, False otherwise + """ + return self.is_client_installed() + + def get_servers(self) -> Dict[str, Any]: + """Get all MCP servers from the Windsurf config + + Returns: + Dict of server configurations by name + """ + config = self._load_config() + return config.get("mcpServers", {}) + + def get_server(self, server_name: str) -> Optional[Dict[str, Any]]: + """Get a specific MCP server from the Windsurf config + + Args: + server_name: Name of the server to retrieve + + Returns: + Server configuration dictionary or None if not found + """ + servers = self.get_servers() + return servers.get(server_name) + + def get_server_config(self, server_name: str) -> Optional[ServerConfig]: + """Get a specific MCP server config as a ServerConfig object + + Args: + server_name: Name of the server to retrieve + + Returns: + ServerConfig object or None if server not found + """ + client_config = self.get_server(server_name) + if client_config is None: + return None + return self._convert_from_client_format(server_name, client_config) + + def get_server_configs(self) -> List[ServerConfig]: + """Get all MCP server configs as ServerConfig objects + + Returns: + List of ServerConfig objects + """ + servers = self.get_servers() + return [self._convert_from_client_format(name, config) + for name, config in servers.items()] diff --git a/src/mcpm/commands/add.py b/src/mcpm/commands/add.py new file mode 100644 index 00000000..89a056bc --- /dev/null +++ b/src/mcpm/commands/add.py @@ -0,0 +1,255 @@ +""" +Add command for adding MCP servers directly to client configurations +""" + +import os +import json +from datetime import datetime + +import click +from rich.console import Console +from rich.progress import Progress, SpinnerColumn, TextColumn +from rich.prompt import Confirm + +from mcpm.utils.repository import RepositoryManager +from mcpm.clients.windsurf import WindsurfManager +from mcpm.clients.claude_desktop import ClaudeDesktopManager +from mcpm.clients.cursor import CursorManager +from mcpm.utils.server_config import ServerConfig +from mcpm.utils.config import ConfigManager + +console = Console() +repo_manager = RepositoryManager() +config_manager = ConfigManager() + +# Map of client names to their manager classes +CLIENT_MANAGERS = { + "windsurf": WindsurfManager, + "claude-desktop": ClaudeDesktopManager, + "cursor": CursorManager +} + +@click.command() +@click.argument("server_name") +@click.option("--client", "-c", help="Client to add the server to (windsurf, claude-desktop, cursor)") +@click.option("--force", is_flag=True, help="Force reinstall if server is already installed") +def add(server_name, client=None, force=False): + """Add an MCP server to a client configuration. + + Examples: + mcpm add time + mcpm add github --client windsurf + mcpm add everything --force + """ + # If no client is specified, use the active client + if not client: + client = config_manager.get_active_client() + if not client: + console.print("[bold red]Error:[/] No active client found.") + console.print("Please specify a client with --client option or set an active client with 'mcpm client set '.") + return + console.print(f"[yellow]Using active client: {client}[/]") + + # Verify client is valid + if client not in CLIENT_MANAGERS: + console.print(f"[bold red]Error:[/] Unsupported client '{client}'.") + console.print(f"Supported clients: {', '.join(CLIENT_MANAGERS.keys())}") + return + + # Initialize client manager + client_manager = CLIENT_MANAGERS[client]() + + # Check if server already exists in client config + existing_server = client_manager.get_server(server_name) + if existing_server and not force: + console.print(f"[yellow]Server '{server_name}' is already added to {client}.[/]") + console.print("Use '--force' to overwrite the existing configuration.") + return + + # Get server metadata from repository + server_metadata = repo_manager.get_server_metadata(server_name) + if not server_metadata: + console.print(f"[bold red]Error:[/] Server '{server_name}' not found in registry.") + console.print(f"Available servers: {', '.join(repo_manager._fetch_servers().keys())}") + return + + # Display server information + display_name = server_metadata.get("display_name", server_name) + description = server_metadata.get("description", "No description available") + available_version = server_metadata.get("version") + author_info = server_metadata.get("author", {}) + + console.print(f"\n[bold]{display_name}[/] ({server_name}) v{available_version}") + console.print(f"[dim]{description}[/]") + + if author_info: + author_name = author_info.get("name", "Unknown") + author_url = author_info.get("url", "") + console.print(f"[dim]Author: {author_name} {author_url}[/]") + + # Confirm addition + if not force and not Confirm.ask(f"Add this server to {client}?"): + console.print("[yellow]Operation cancelled.[/]") + return + + # Create server directory in the MCP directory + base_dir = os.path.expanduser("~/.mcpm") + os.makedirs(base_dir, exist_ok=True) + + servers_dir = os.path.join(base_dir, "servers") + os.makedirs(servers_dir, exist_ok=True) + + server_dir = os.path.join(servers_dir, server_name) + os.makedirs(server_dir, exist_ok=True) + + # Extract installation information + installations = server_metadata.get("install", {}) + version = available_version + + # If no installation information is available, create minimal default values + # This allows us to add the server config without full installation details + method_key = "default" + install_type = "manual" + install_command = "echo" + install_args = [f"Server {server_name} added to configuration"] + package_name = None + env_vars = {} + required_args = {} + + # Process installation information if available + if installations: + # Find recommended installation method or default to the first one + selected_method = None + method_key = "default" + + # First check for a recommended method + for key, method in installations.items(): + if method.get("recommended", False): + selected_method = method + method_key = key + break + + # If no recommended method found, use the first one + if not selected_method and installations: + method_key = next(iter(installations)) + selected_method = installations[method_key] + + # If multiple methods are available and not forced, offer selection + if len(installations) > 1 and not force: + console.print("\n[bold]Available installation methods:[/]") + methods_list = [] + + for i, (key, method) in enumerate(installations.items(), 1): + install_type = method.get("type", "unknown") + description = method.get("description", f"{install_type} installation") + recommended = " [green](recommended)[/]" if method.get("recommended", False) else "" + + console.print(f" {i}. [cyan]{key}[/]: {description}{recommended}") + methods_list.append(key) + + # Ask user to select a method + try: + selection = click.prompt("\nSelect installation method", type=int, default=methods_list.index(method_key) + 1) + if 1 <= selection <= len(methods_list): + method_key = methods_list[selection - 1] + selected_method = installations[method_key] + except (ValueError, click.Abort): + console.print("[yellow]Using default installation method.[/]") + + # Extract installation details + if selected_method: + install_type = selected_method.get("type", install_type) + install_command = selected_method.get("command", install_command) + install_args = selected_method.get("args", install_args) + package_name = selected_method.get("package", package_name) + env_vars = selected_method.get("env", env_vars) + required_args = server_metadata.get("required_args", required_args) + + console.print(f"\n[green]Using {install_type} installation method: [bold]{method_key}[/][/]") + + # Configure the server + with Progress( + SpinnerColumn(), + TextColumn("[bold green]{task.description}[/]"), + console=console + ) as progress: + # Save metadata to server directory + progress.add_task("Saving server metadata...", total=None) + metadata_path = os.path.join(server_dir, "metadata.json") + with open(metadata_path, "w") as f: + json.dump(server_metadata, f, indent=2) + + # Configure the server + progress.add_task(f"Configuring {server_name} v{version}...", total=None) + + # Process environment variables to store in config + processed_env = {} + + for key, value in env_vars.items(): + if isinstance(value, str) and value.startswith("${") and value.endswith("}"): + env_var_name = value[2:-1] # Extract variable name from ${NAME} + is_required = env_var_name in required_args + + # For required arguments, prompt the user if not in environment + env_value = os.environ.get(env_var_name, "") + + if not env_value and is_required: + console.print(f"[yellow]Warning:[/] Required argument {env_var_name} is not set in environment") + + # Prompt for the value + arg_info = required_args[env_var_name] + description = arg_info.get("description", "") + try: + user_value = click.prompt( + f"Enter value for {env_var_name} ({description})", + hide_input="token" in env_var_name.lower() or "key" in env_var_name.lower() + ) + processed_env[key] = user_value + except click.Abort: + console.print("[yellow]Will store the reference to environment variable instead.[/]") + processed_env[key] = value # Store the reference as-is + else: + # Store reference to environment variable + processed_env[key] = value + else: + processed_env[key] = value + + # Create server configuration using ServerConfig + server_config = ServerConfig( + name=server_name, + path=server_dir, + display_name=display_name, + description=description, + version=version, + status="stopped", + command=install_command, + args=install_args, + env_vars=processed_env, + install_date=datetime.now().strftime("%Y-%m-%d"), + package=package_name, + installation_method=method_key, + installation_type=install_type + ) + + # Add the server to the client configuration + success = client_manager.add_server(server_config) + + if success: + # Update the central tracking of enabled servers for this client + config_manager.enable_server_for_client(server_name, client) + console.print(f"[bold green]Successfully added {display_name} v{version} to {client}![/]") + + # Display usage examples if available + examples = server_metadata.get("examples", []) + if examples: + console.print("\n[bold]Usage Examples:[/]") + for i, example in enumerate(examples, 1): + title = example.get("title", f"Example {i}") + description = example.get("description", "") + prompt = example.get("prompt", "") + + console.print(f" [cyan]{title}[/]: {description}") + if prompt: + console.print(f" Try: [italic]\"{prompt}\"[/]\n") + else: + console.print(f"[bold red]Failed to add {server_name} to {client}.[/]") diff --git a/src/mcpm/commands/install.py b/src/mcpm/commands/install.py index 9d3d7582..7e4c7811 100644 --- a/src/mcpm/commands/install.py +++ b/src/mcpm/commands/install.py @@ -4,14 +4,12 @@ import os import json -import subprocess from datetime import datetime import click from rich.console import Console from rich.progress import Progress, SpinnerColumn, TextColumn from rich.prompt import Confirm -from rich.panel import Panel from mcpm.utils.repository import RepositoryManager from mcpm.utils.config import ConfigManager @@ -25,13 +23,22 @@ @click.argument("server_name") @click.option("--force", is_flag=True, help="Force reinstall if server is already installed") def install(server_name, force=False): - """Install an MCP server. + """[DEPRECATED] Install an MCP server. + + This command is deprecated. Please use 'mcpm add' instead, which directly + adds servers to client configurations without using a global config. Examples: - mcpm install time - mcpm install github - mcpm install everything --force + mcpm add time + mcpm add github + mcpm add everything --force """ + # Show deprecation warning + console.print("[bold yellow]WARNING: The 'install' command is deprecated![/]") + console.print("[yellow]Please use 'mcpm add' instead, which adds servers directly to client configurations.[/]") + console.print("[yellow]Run 'mcpm add --help' for more information.[/]") + console.print("") + # Check if already installed existing_server = config_manager.get_server_info(server_name) if existing_server and not force: @@ -58,24 +65,26 @@ def install(server_name, force=False): version = available_version # Display server information - console.print(Panel( - f"[bold]{display_name}[/] [dim]v{version}[/]\n" + - f"[italic]{description}[/]\n\n" + - f"Author: {author_name}\n" + - f"License: {license_info}", - title="Server Information", - border_style="green", - )) - - # Check for API key requirements - requirements = server_metadata.get("requirements", {}) - needs_api_key = requirements.get("api_key", False) - auth_type = requirements.get("authentication") + console.print("\n[bold cyan]Server Information[/]") + console.print(f"[bold]{display_name}[/] [dim]v{version}[/]") + console.print(f"[italic]{description}[/]") + console.print() + console.print(f"Author: {author_name}") + console.print(f"License: {license_info}") + console.print() + + # Check for required arguments in the new schema + arguments = server_metadata.get("arguments", {}) + required_args = {k: v for k, v in arguments.items() if v.get("required", False)} + needs_api_key = len(required_args) > 0 if needs_api_key: - console.print("[yellow]Note:[/] This server requires an API key or authentication.") - if auth_type: - console.print(f"Authentication type: [bold]{auth_type}[/]") + console.print("\n[yellow]Note:[/] This server requires the following arguments:") + for arg_name, arg_info in required_args.items(): + description = arg_info.get("description", "") + example = arg_info.get("example", "") + example_text = f" (e.g. '{example}')" if example else "" + console.print(f" [bold]{arg_name}[/]: {description}{example_text}") # Installation preparation if not force and existing_server: @@ -86,17 +95,70 @@ def install(server_name, force=False): server_dir = os.path.expanduser(f"~/.config/mcp/servers/{server_name}") os.makedirs(server_dir, exist_ok=True) - # Get installation instructions - installation = server_metadata.get("installation", {}) - install_command = installation.get("command") - install_args = installation.get("args", []) - package_name = installation.get("package") - env_vars = installation.get("env", {}) + # Get installation instructions from the new 'installations' field + installations = server_metadata.get("installations", {}) + + # Fall back to legacy 'installation' field if needed + if not installations: + installation = server_metadata.get("installation", {}) + if installation and installation.get("command") and installation.get("args"): + installations = {"default": installation} + + if not installations: + console.print(f"[bold red]Error:[/] No installation methods found for server '{server_name}'.") + return + + # Find recommended installation method or default to the first one + selected_method = None + method_key = None + + # First check for a recommended method + for key, method in installations.items(): + if method.get("recommended", False): + selected_method = method + method_key = key + break + + # If no recommended method found, use the first one + if not selected_method: + method_key = next(iter(installations)) + selected_method = installations[method_key] + + # If multiple methods are available and not forced, offer selection + if len(installations) > 1 and not force: + console.print("\n[bold]Available installation methods:[/]") + methods_list = [] + + for i, (key, method) in enumerate(installations.items(), 1): + install_type = method.get("type", "unknown") + description = method.get("description", f"{install_type} installation") + recommended = " [green](recommended)[/]" if method.get("recommended", False) else "" + + console.print(f" {i}. [cyan]{key}[/]: {description}{recommended}") + methods_list.append(key) + + # Ask user to select a method + try: + selection = click.prompt("\nSelect installation method", type=int, default=methods_list.index(method_key) + 1) + if 1 <= selection <= len(methods_list): + method_key = methods_list[selection - 1] + selected_method = installations[method_key] + except (ValueError, click.Abort): + console.print("[yellow]Using default installation method.[/]") + + # Extract installation details + install_type = selected_method.get("type") + install_command = selected_method.get("command") + install_args = selected_method.get("args", []) + package_name = selected_method.get("package") + env_vars = selected_method.get("env", {}) if not install_command or not install_args: - console.print(f"[bold red]Error:[/] Invalid installation information for server '{server_name}'.") + console.print(f"[bold red]Error:[/] Invalid installation information for method '{method_key}'.") return + console.print(f"\n[green]Using {install_type} installation method: [bold]{method_key}[/][/]") + # Download and install server with Progress( SpinnerColumn(), @@ -110,48 +172,46 @@ def install(server_name, force=False): with open(metadata_path, "w") as f: json.dump(server_metadata, f, indent=2) - # Install using the specified command and args - progress.add_task(f"Installing {server_name} v{version}...", total=None) + # Configure the server (do not execute installation command) + progress.add_task(f"Configuring {server_name} v{version}...", total=None) - try: - # Prepare environment variables - env = os.environ.copy() - - # Replace variable placeholders with values from environment - for key, value in env_vars.items(): - if isinstance(value, str) and value.startswith("${"): - env_var_name = value[2:-1] # Extract variable name from ${NAME} - env_value = os.environ.get(env_var_name, "") - env[key] = env_value - - # Warn if variable is not set - if not env_value and needs_api_key: - console.print(f"[yellow]Warning:[/] Environment variable {env_var_name} is not set") - else: - env[key] = value - - # Run installation command - if install_command: - full_command = [install_command] + install_args - console.print(f"Running: [dim]{' '.join(full_command)}[/]") + # Process environment variables to store in config + processed_env = {} + + for key, value in env_vars.items(): + if isinstance(value, str) and value.startswith("${") and value.endswith("}"): + env_var_name = value[2:-1] # Extract variable name from ${NAME} + is_required = env_var_name in required_args - # Capture installation process output - result = subprocess.run( - full_command, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - check=False - ) + # For required arguments, prompt the user if not in environment + env_value = os.environ.get(env_var_name, "") - if result.returncode != 0: - console.print(f"[bold red]Installation failed with error code {result.returncode}[/]") - console.print(f"[red]{result.stderr}[/]") - return - except Exception as e: - console.print(f"[bold red]Error during installation:[/] {str(e)}") - return + if not env_value and is_required: + console.print(f"[yellow]Warning:[/] Required argument {env_var_name} is not set in environment") + + # Prompt for the value + arg_info = required_args[env_var_name] + description = arg_info.get("description", "") + try: + user_value = click.prompt( + f"Enter value for {env_var_name} ({description})", + hide_input="token" in env_var_name.lower() or "key" in env_var_name.lower() + ) + processed_env[key] = user_value + except click.Abort: + console.print("[yellow]Will store the reference to environment variable instead.[/]") + processed_env[key] = value # Store the reference as-is + else: + # Store reference to environment variable + processed_env[key] = value + else: + processed_env[key] = value + + # Display the installation command (for information only) + if install_command: + full_command = [install_command] + install_args + console.print(f"[dim]Installation command: {' '.join(full_command)}[/]") + console.print("[green]Server has been configured and added to client.[/]") # Create server configuration server_info = { @@ -165,6 +225,12 @@ def install(server_name, force=False): "package": package_name } + # Add installation method information if available + if method_key: + server_info["installation_method"] = method_key + if install_type: + server_info["installation_type"] = install_type + # Register the server in our config config_manager.register_server(server_name, server_info) diff --git a/src/mcpm/utils/config.py b/src/mcpm/utils/config.py index 8083889b..86fbe978 100644 --- a/src/mcpm/utils/config.py +++ b/src/mcpm/utils/config.py @@ -96,8 +96,33 @@ def get_client_servers(self, client_name: str) -> List[str]: """Get servers enabled for a specific client""" return self._config.get("clients", {}).get(client_name, {}).get("enabled_servers", []) + def _get_client_manager(self, client_name: str): + """Get the appropriate client manager for a client + + Args: + client_name: Name of the client + + Returns: + BaseClientManager or None if client not supported + """ + if client_name == "claude-desktop": + from mcpm.clients.claude_desktop import ClaudeDesktopManager + return ClaudeDesktopManager() + elif client_name == "windsurf": + from mcpm.clients.windsurf import WindsurfManager + return WindsurfManager() + elif client_name == "cursor": + from mcpm.clients.cursor import CursorManager + return CursorManager() + return None + def register_server(self, server_name: str, server_info: Dict[str, Any]) -> None: - """Register a new server""" + """[DEPRECATED] Register a new server + + This method is maintained for backward compatibility. New code should use + the client manager's add_server method directly. + """ + logger.warning("register_server is deprecated. Use client_manager.add_server() instead.") self._config.setdefault("servers", {})[server_name] = server_info self._save_config() @@ -116,46 +141,24 @@ def unregister_server(self, server_name: str) -> None: def enable_server_for_client(self, server_name: str, client_name: str) -> bool: """Enable a server for a specific client - If the server was previously disabled for this client, its configuration - will be restored from the disabled_servers section. - """ - if server_name not in self._config.get("servers", {}): - logger.error(f"Server not installed: {server_name}") - return False + Simply adds the server name to the client's enabled_servers list in the central config. + The actual server configuration is stored in each client's own config files. + Args: + server_name: Name of the server to enable + client_name: Name of the client to enable the server for + + Returns: + bool: True if successfully enabled, False otherwise + """ if client_name not in self._config.get("clients", {}): logger.error(f"Unknown client: {client_name}") return False client_config = self._config["clients"][client_name] enabled_servers = client_config.setdefault("enabled_servers", []) - disabled_servers = client_config.setdefault("disabled_servers", {}) - - # Check if we have the server in disabled servers and can restore its config - if server_name in disabled_servers: - # Find the appropriate client manager to update config - if client_name == "claude-desktop": - from mcpm.clients.claude_desktop import ClaudeDesktopManager - client_manager = ClaudeDesktopManager() - elif client_name == "windsurf": - from mcpm.clients.windsurf import WindsurfManager - client_manager = WindsurfManager() - elif client_name == "cursor": - from mcpm.clients.cursor import CursorManager - client_manager = CursorManager() - else: - # Unknown client type, but we'll still track in our config - pass - - # If we have a client manager, update its config - if 'client_manager' in locals(): - server_config = disabled_servers[server_name] - server_list = [server_config] - client_manager.sync_mcp_servers(server_list) - - # Remove from disabled list - del disabled_servers[server_name] + # Add to enabled list if not already there if server_name not in enabled_servers: enabled_servers.append(server_name) self._save_config() @@ -165,8 +168,15 @@ def enable_server_for_client(self, server_name: str, client_name: str) -> bool: def disable_server_for_client(self, server_name: str, client_name: str) -> bool: """Disable a server for a specific client - This saves the server's configuration to the disabled_servers section - so it can be restored later if re-enabled. + Removes the server from the enabled_servers list and calls the client manager + to remove it from the client's config file. + + Args: + server_name: Name of the server to disable + client_name: Name of the client to disable the server for + + Returns: + bool: True if successfully disabled, False otherwise """ if client_name not in self._config.get("clients", {}): logger.error(f"Unknown client: {client_name}") @@ -174,46 +184,21 @@ def disable_server_for_client(self, server_name: str, client_name: str) -> bool: client_config = self._config["clients"][client_name] enabled_servers = client_config.get("enabled_servers", []) - disabled_servers = client_config.setdefault("disabled_servers", {}) # Only proceed if the server is currently enabled if server_name in enabled_servers: - # Save the server configuration to disabled_servers - server_info = self._config.get("servers", {}).get(server_name, {}) - if server_info: - disabled_servers[server_name] = server_info.copy() - # Remove from enabled list enabled_servers.remove(server_name) + self._save_config() - # Find the appropriate client manager to update config - if client_name == "claude-desktop": - from mcpm.clients.claude_desktop import ClaudeDesktopManager - client_manager = ClaudeDesktopManager() - elif client_name == "windsurf": - from mcpm.clients.windsurf import WindsurfManager - client_manager = WindsurfManager() - elif client_name == "cursor": - from mcpm.clients.cursor import CursorManager - client_manager = CursorManager() - else: - # Unknown client type, but we'll still track in our config - pass + # Use the client manager to update the client's config + client_manager = self._get_client_manager(client_name) + if client_manager: + client_manager.remove_server(server_name) - # If we have a client manager, update its config to remove the server - if 'client_manager' in locals(): - # Get current client config - client_config = client_manager.read_config() or {} - - # For cursor, the structure is slightly different - if client_name == "cursor" and "mcpServers" in client_config: - if server_name in client_config["mcpServers"]: - del client_config["mcpServers"][server_name] - client_manager.write_config(client_config) - - self._save_config() + return True - return True + return False def get_active_client(self) -> str: """Get the name of the currently active client""" diff --git a/src/mcpm/utils/server_config.py b/src/mcpm/utils/server_config.py new file mode 100644 index 00000000..3ca1f02e --- /dev/null +++ b/src/mcpm/utils/server_config.py @@ -0,0 +1,176 @@ +""" +Standard server configuration model for MCP. +Provides a consistent interface for server configurations across all clients. +""" + +import datetime +from dataclasses import dataclass, field, asdict +from typing import Dict, List, Optional, Any + + +@dataclass +class ServerConfig: + """Standard model for MCP server configuration""" + + # Required fields + name: str + path: str + + # Optional fields with defaults + display_name: str = "" + description: str = "" + version: str = "1.0.0" + status: str = "stopped" # stopped, running + command: str = "" + args: List[str] = field(default_factory=list) + env_vars: Dict[str, str] = field(default_factory=dict) + install_date: str = field(default_factory=lambda: datetime.date.today().isoformat()) + installation_method: str = "" + installation_type: str = "" + package: Optional[str] = None + metadata: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary representation""" + return asdict(self) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'ServerConfig': + """Create ServerConfig from dictionary + + Args: + data: Dictionary with server configuration data + + Returns: + ServerConfig object + """ + # Handle potential key differences in source data + server_data = data.copy() + + # Handle environment variables from different formats + if "env" in server_data and "env_vars" not in server_data: + server_data["env_vars"] = server_data.pop("env") + + # Set name from the dictionary key if not in the data + if "name" not in server_data and server_data.get("display_name"): + server_data["name"] = server_data["display_name"].lower().replace(" ", "-") + + # Remove any keys that aren't in the dataclass to avoid unexpected keyword arguments + valid_fields = {field.name for field in cls.__dataclass_fields__.values()} + filtered_data = {k: v for k, v in server_data.items() if k in valid_fields} + + return cls(**filtered_data) + + def to_windsurf_format(self) -> Dict[str, Any]: + """Convert to Windsurf client format + + Following the official Windsurf MCP format as documented at + https://docs.codeium.com/windsurf/mcp + + Returns: + Dictionary in Windsurf format with only essential fields + """ + # Include only the essential MCP execution fields that Windsurf requires + # according to the documentation example: command, args, and env + result = { + "command": self.command, + "args": self.args, + } + + # Add environment variables if present + if self.env_vars: + result["env"] = self.env_vars + + return result + + def to_claude_desktop_format(self) -> Dict[str, Any]: + """Convert to Claude Desktop client format + + Returns: + Dictionary in Claude Desktop format + """ + return { + "name": self.name, + "display_name": self.display_name or f"{self.name.title()} MCP Server", + "version": self.version, + "description": self.description, + "status": self.status, + "install_date": self.install_date, + "path": self.path, + "command": self.command, + "args": self.args, + "env": self.env_vars + } + + def to_cursor_format(self) -> Dict[str, Any]: + """Convert to Cursor client format + + Returns: + Dictionary in Cursor format + """ + return { + "name": self.name, + "display_name": self.display_name or f"{self.name.title()} MCP Server", + "version": self.version, + "description": self.description, + "status": self.status, + "path": self.path, + "command": self.command, + "args": self.args, + "env": self.env_vars + } + + @classmethod + def from_windsurf_format(cls, name: str, data: Dict[str, Any]) -> 'ServerConfig': + """Create ServerConfig from Windsurf format + + Args: + name: Server name + data: Windsurf format server data + + Returns: + ServerConfig object + """ + server_data = data.copy() + server_data["name"] = name + + # Handle required fields that might be missing in the Windsurf format + # Path is required by ServerConfig but not part of the Windsurf MCP format + if "path" not in server_data: + server_data["path"] = f"/path/to/{name}" + + # Convert environment variables if present + if "env" in server_data and "env_vars" not in server_data: + server_data["env_vars"] = server_data.pop("env") + + return cls.from_dict(server_data) + + @classmethod + def from_claude_desktop_format(cls, name: str, data: Dict[str, Any]) -> 'ServerConfig': + """Create ServerConfig from Claude Desktop format + + Args: + name: Server name + data: Claude Desktop format server data + + Returns: + ServerConfig object + """ + server_data = data.copy() + server_data["name"] = name + return cls.from_dict(server_data) + + @classmethod + def from_cursor_format(cls, name: str, data: Dict[str, Any]) -> 'ServerConfig': + """Create ServerConfig from Cursor format + + Args: + name: Server name + data: Cursor format server data + + Returns: + ServerConfig object + """ + server_data = data.copy() + server_data["name"] = name + return cls.from_dict(server_data) diff --git a/tests/test_windsurf_integration.py b/tests/test_windsurf_integration.py index d26a680a..8b922e63 100644 --- a/tests/test_windsurf_integration.py +++ b/tests/test_windsurf_integration.py @@ -10,6 +10,7 @@ from mcpm.clients.windsurf import WindsurfManager from mcpm.utils.config import ConfigManager +from mcpm.utils.server_config import ServerConfig class TestWindsurfIntegration: @@ -27,7 +28,10 @@ def temp_config_file(self): "args": [ "-y", "@modelcontextprotocol/server-test" - ] + ], + "version": "1.0.0", + "path": "/path/to/server", + "display_name": "Test Server" } } } @@ -38,11 +42,43 @@ def temp_config_file(self): # Clean up os.unlink(temp_path) + @pytest.fixture + def empty_config_file(self): + """Create an empty temporary Windsurf config file for testing""" + with tempfile.NamedTemporaryFile(delete=False, suffix='.json') as f: + # Create an empty config + config = {} + f.write(json.dumps(config).encode('utf-8')) + temp_path = f.name + + yield temp_path + # Clean up + os.unlink(temp_path) + @pytest.fixture def windsurf_manager(self, temp_config_file): """Create a WindsurfManager instance using the temp config file""" return WindsurfManager(config_path=temp_config_file) + @pytest.fixture + def empty_windsurf_manager(self, empty_config_file): + """Create a WindsurfManager instance with an empty config""" + return WindsurfManager(config_path=empty_config_file) + + @pytest.fixture + def sample_server_config(self): + """Create a sample ServerConfig for testing""" + return ServerConfig( + name="sample-server", + path="/path/to/sample/server", + display_name="Sample Server", + description="A sample server for testing", + version="1.2.0", + command="npx", + args=["-y", "@modelcontextprotocol/sample-server"], + env_vars={"API_KEY": "sample-key"} + ) + @pytest.fixture def config_manager(self): """Create a ConfigManager with a temp config for testing""" @@ -66,8 +102,8 @@ def test_get_server(self, windsurf_manager): # Test non-existent server assert windsurf_manager.get_server("non-existent") is None - def test_add_server(self, windsurf_manager): - """Test adding a server to Windsurf config""" + def test_add_server_config_raw(self, windsurf_manager): + """Test adding a server to Windsurf config using the internal method""" new_server = { "command": "npx", "args": [ @@ -76,10 +112,11 @@ def test_add_server(self, windsurf_manager): ], "env": { "GOOGLE_MAPS_API_KEY": "test-key" - } + }, + "path": "/path/to/google-maps" } - success = windsurf_manager.add_server("google-maps", new_server) + success = windsurf_manager._add_server_config("google-maps", new_server) assert success # Verify server was added @@ -88,6 +125,59 @@ def test_add_server(self, windsurf_manager): assert server["command"] == "npx" assert "GOOGLE_MAPS_API_KEY" in server["env"] + def test_add_server_config_to_empty_config(self, empty_windsurf_manager): + """Test adding a server to an empty config file using the internal method""" + new_server = { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-test"], + "path": "/path/to/server" + } + + success = empty_windsurf_manager._add_server_config("test-server", new_server) + assert success + + # Verify server was added + server = empty_windsurf_manager.get_server("test-server") + assert server is not None + assert server["command"] == "npx" + + def test_add_server(self, windsurf_manager, sample_server_config): + """Test adding a ServerConfig object to Windsurf config""" + success = windsurf_manager.add_server(sample_server_config) + assert success + + # Verify server was added + server = windsurf_manager.get_server("sample-server") + assert server is not None + assert "sample-server" in windsurf_manager.get_servers() + + # Get it back and verify it's the same + retrieved_config = windsurf_manager.get_server_config("sample-server") + assert retrieved_config is not None + assert retrieved_config.name == "sample-server" + # Note: With the official Windsurf format, metadata fields aren't preserved + # Only essential execution fields (command, args, env) are preserved + assert retrieved_config.command == sample_server_config.command + assert retrieved_config.args == sample_server_config.args + + def test_convert_to_client_format(self, windsurf_manager, sample_server_config): + """Test conversion from ServerConfig to Windsurf format""" + windsurf_format = windsurf_manager._convert_to_client_format(sample_server_config) + + # Check the format follows official Windsurf MCP format (command, args, env only) + assert "command" in windsurf_format + assert "args" in windsurf_format + assert "env" in windsurf_format + assert windsurf_format["command"] == sample_server_config.command + assert windsurf_format["args"] == sample_server_config.args + assert windsurf_format["env"]["API_KEY"] == "sample-key" + + # Verify we don't include metadata fields in the official format + assert "name" not in windsurf_format + assert "display_name" not in windsurf_format + assert "version" not in windsurf_format + assert "path" not in windsurf_format + def test_remove_server(self, windsurf_manager): """Test removing a server from Windsurf config""" # First make sure server exists @@ -100,6 +190,61 @@ def test_remove_server(self, windsurf_manager): # Verify it was removed assert windsurf_manager.get_server("test-server") is None + def test_convert_from_client_format(self, windsurf_manager): + """Test conversion from Windsurf format to ServerConfig""" + windsurf_config = { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-test"], + "env": {"TEST_KEY": "test-value"}, + "path": "/path/to/server", + "display_name": "Test Server", + "version": "1.0.0" + } + + server_config = windsurf_manager._convert_from_client_format("test-convert", windsurf_config) + + # Check conversion is correct + assert isinstance(server_config, ServerConfig) + assert server_config.name == "test-convert" + assert server_config.display_name == "Test Server" + assert server_config.command == "npx" + assert server_config.args == ["-y", "@modelcontextprotocol/server-test"] + assert server_config.env_vars["TEST_KEY"] == "test-value" + assert server_config.path == "/path/to/server" + + def test_get_server_configs(self, windsurf_manager, sample_server_config): + """Test retrieving all servers as ServerConfig objects""" + # First add our sample server + windsurf_manager.add_server(sample_server_config) + + configs = windsurf_manager.get_server_configs() + + # Should have at least 2 servers (test-server from fixture and sample-server we added) + assert len(configs) >= 2 + + # Find our sample server in the list + sample_server = next((s for s in configs if s.name == "sample-server"), None) + assert sample_server is not None + # Verify essential execution fields are preserved, even if metadata isn't + assert sample_server.command == sample_server_config.command + assert sample_server.args == sample_server_config.args + + # Find the test server in the list + test_server = next((s for s in configs if s.name == "test-server"), None) + assert test_server is not None + + def test_get_server_config(self, windsurf_manager): + """Test retrieving a specific server as a ServerConfig object""" + config = windsurf_manager.get_server_config("test-server") + + assert config is not None + assert isinstance(config, ServerConfig) + assert config.name == "test-server" + assert config.display_name == "Test Server" + + # Non-existent server should return None + assert windsurf_manager.get_server_config("non-existent") is None + def test_is_windsurf_installed(self, windsurf_manager): """Test checking if Windsurf is installed""" with patch('os.path.isdir', return_value=True): @@ -107,6 +252,31 @@ def test_is_windsurf_installed(self, windsurf_manager): with patch('os.path.isdir', return_value=False): assert not windsurf_manager.is_windsurf_installed() + + def test_load_invalid_config(self): + """Test loading an invalid config file""" + with tempfile.NamedTemporaryFile(delete=False, suffix='.json') as f: + # Write invalid JSON + f.write(b"{invalid json") + temp_path = f.name + + try: + manager = WindsurfManager(config_path=temp_path) + # Should get an empty config, not error + config = manager._load_config() + assert config == {"mcpServers": {}} + finally: + # Only try to delete if file exists + if os.path.exists(temp_path): + os.unlink(temp_path) + + def test_empty_config(self, empty_windsurf_manager): + """Test handling empty config""" + servers = empty_windsurf_manager.get_servers() + assert servers == {} + + # Verify we get an empty dict, not None + assert isinstance(servers, dict) def test_config_manager_integration(self, config_manager): """Test ConfigManager integration with Windsurf client""" @@ -120,7 +290,7 @@ def test_config_manager_integration(self, config_manager): assert config_manager.get_active_client() == "windsurf" # Test server enabling/disabling for Windsurf - config_manager.register_server("test-server", {"command": "npx"}) + config_manager.register_server("test-server", {"command": "npx", "path": "/path/to/server"}) success = config_manager.enable_server_for_client("test-server", "windsurf") assert success From e7906cde6d70a00b88bdfd1732f936f3a3227886 Mon Sep 17 00:00:00 2001 From: Chen Nie Date: Thu, 27 Mar 2025 15:09:40 +0800 Subject: [PATCH 2/2] Add working --- src/mcpm/cli.py | 3 - src/mcpm/clients/claude_desktop.py | 116 +++++++++++-- src/mcpm/clients/cursor.py | 141 +++++++++++---- src/mcpm/clients/windsurf.py | 140 +++++++++------ src/mcpm/commands/add.py | 120 ++++++++++++- src/mcpm/commands/install.py | 266 ----------------------------- src/mcpm/utils/server_config.py | 239 ++++++++++---------------- tests/test_windsurf_integration.py | 44 ++--- 8 files changed, 536 insertions(+), 533 deletions(-) delete mode 100644 src/mcpm/commands/install.py diff --git a/src/mcpm/cli.py b/src/mcpm/cli.py index 33e66dea..ccf0e737 100644 --- a/src/mcpm/cli.py +++ b/src/mcpm/cli.py @@ -11,7 +11,6 @@ from mcpm import __version__ from mcpm.commands import ( search, - install, remove, list_servers, edit, @@ -108,7 +107,6 @@ def main(ctx, help_flag): commands_table.add_row(" [cyan]client[/]", "Manage the active MCPM client.") commands_table.add_row(" [cyan]edit[/]", "View or edit the active MCPM client's configuration file.") commands_table.add_row(" [cyan]inspector[/]", "Launch the MCPM Inspector UI to examine servers.") - commands_table.add_row(" [cyan]install[/]", "[yellow][DEPRECATED][/] Install an MCP server (use add instead).") commands_table.add_row(" [cyan]list[/]", "List all installed MCP servers.") commands_table.add_row(" [cyan]remove[/]", "Remove an installed MCP server.") commands_table.add_row(" [cyan]search[/]", "Search available MCP servers.") @@ -124,7 +122,6 @@ def main(ctx, help_flag): # Register commands main.add_command(search.search) -main.add_command(install.install) main.add_command(remove.remove) main.add_command(add.add) main.add_command(list_servers.list) diff --git a/src/mcpm/clients/claude_desktop.py b/src/mcpm/clients/claude_desktop.py index 7fb65de0..365d4447 100644 --- a/src/mcpm/clients/claude_desktop.py +++ b/src/mcpm/clients/claude_desktop.py @@ -5,7 +5,7 @@ import os import logging import platform -from typing import Dict, Any +from typing import Dict, Any, Optional, List from mcpm.clients.base import BaseClientManager from mcpm.utils.server_config import ServerConfig @@ -74,12 +74,60 @@ def _convert_to_client_format(self, server_config: ServerConfig) -> Dict[str, An """Convert ServerConfig to Claude Desktop format Args: - server_config: StandardServer configuration + server_config: ServerConfig object Returns: Dict containing Claude Desktop-specific configuration """ - return server_config.to_claude_desktop_format() + # Base result containing essential execution information + result = { + "command": server_config.command, + "args": server_config.args, + } + + # Add filtered environment variables if present + non_empty_env = server_config.get_filtered_env_vars() + if non_empty_env: + result["env"] = non_empty_env + + # Add additional metadata fields for display in Claude Desktop + # Fields that are None will be automatically excluded by JSON serialization + for field in ["name", "display_name", "description", "version", "status", "path", "install_date"]: + value = getattr(server_config, field, None) + if value is not None: + result[field] = value + + return result + + @classmethod + def from_claude_desktop_format(cls, server_name: str, client_config: Dict[str, Any]) -> ServerConfig: + """Convert Claude Desktop format to ServerConfig + + Args: + server_name: Name of the server + client_config: Claude Desktop-specific configuration + + Returns: + ServerConfig object + """ + # Create a dictionary that ServerConfig.from_dict can work with + server_data = { + "name": server_name, + "command": client_config.get("command", ""), + "args": client_config.get("args", []), + } + + # Add environment variables if present + if "env" in client_config: + server_data["env_vars"] = client_config["env"] + + # Add additional metadata fields if present + for field in ["display_name", "description", "version", "status", "path", "install_date", + "package", "installation_method", "installation_type"]: + if field in client_config: + server_data[field] = client_config[field] + + return ServerConfig.from_dict(server_data) def _convert_from_client_format(self, server_name: str, client_config: Dict[str, Any]) -> ServerConfig: """Convert Claude Desktop format to ServerConfig @@ -91,14 +139,27 @@ def _convert_from_client_format(self, server_name: str, client_config: Dict[str, Returns: ServerConfig object """ - return ServerConfig.from_claude_desktop_format(server_name, client_config) + return self.from_claude_desktop_format(server_name, client_config) def remove_server(self, server_name: str) -> bool: - """Remove an MCP server from Claude Desktop config""" + """Remove an MCP server from Claude Desktop config + + Args: + server_name: Name of the server to remove + + Returns: + bool: Success or failure + """ config = self._load_config() - if "mcpServers" not in config or server_name not in config["mcpServers"]: - logger.warning(f"Server not found in Claude Desktop config: {server_name}") + # Check if mcpServers exists + if "mcpServers" not in config: + logger.warning(f"Cannot remove server {server_name}: mcpServers section doesn't exist") + return False + + # Check if the server exists + if server_name not in config["mcpServers"]: + logger.warning(f"Server {server_name} not found in Claude Desktop config") return False # Remove the server @@ -106,6 +167,41 @@ def remove_server(self, server_name: str) -> bool: return self._save_config(config) - def is_claude_desktop_installed(self) -> bool: - """Check if Claude Desktop is installed""" - return self.is_client_installed() + def get_server(self, server_name: str) -> Optional[ServerConfig]: + """Get a server configuration from Claude Desktop + + Args: + server_name: Name of the server + + Returns: + ServerConfig object if found, None otherwise + """ + config = self._load_config() + + # Check if mcpServers exists + if "mcpServers" not in config: + logger.warning(f"Cannot get server {server_name}: mcpServers section doesn't exist") + return None + + # Check if the server exists + if server_name not in config["mcpServers"]: + logger.debug(f"Server {server_name} not found in Claude Desktop config") + return None + + # Get the server config and convert to StandardServer + client_config = config["mcpServers"][server_name] + return self._convert_from_client_format(server_name, client_config) + + def list_servers(self) -> List[str]: + """List all MCP servers in Claude Desktop config + + Returns: + List of server names + """ + config = self._load_config() + + # Check if mcpServers exists + if "mcpServers" not in config: + return [] + + return list(config["mcpServers"].keys()) diff --git a/src/mcpm/clients/cursor.py b/src/mcpm/clients/cursor.py index 1eeb2778..47afd872 100644 --- a/src/mcpm/clients/cursor.py +++ b/src/mcpm/clients/cursor.py @@ -1,40 +1,28 @@ """ -Cursor client configuration for MCP +Cursor integration utilities for MCP """ import os import logging -from typing import Dict, Any +import platform +from typing import Dict, Any, Optional, List from mcpm.clients.base import BaseClientManager from mcpm.utils.server_config import ServerConfig -# Cursor stores MCP configuration in: -# - Project config: .cursor/mcp.json in the project directory -# - Global config: ~/.cursor/mcp.json in the home directory - -# Global config path for Cursor -HOME_DIR = os.path.expanduser("~") -CURSOR_CONFIG_PATH = os.path.join(HOME_DIR, ".cursor", "mcp.json") - logger = logging.getLogger(__name__) -# Get the project config path for Cursor -def get_project_config_path(project_dir: str) -> str: - """ - Get the project-specific MCP configuration path for Cursor - - Args: - project_dir (str): Project directory path - - Returns: - str: Path to the project-specific MCP configuration file - """ - return os.path.join(project_dir, ".cursor", "mcp.json") - +# Cursor config paths based on platform +if platform.system() == "Darwin": # macOS + CURSOR_CONFIG_PATH = os.path.expanduser("~/Library/Application Support/Cursor/User/mcp_config.json") +elif platform.system() == "Windows": + CURSOR_CONFIG_PATH = os.path.join(os.environ.get("APPDATA", ""), "Cursor", "User", "mcp_config.json") +else: + # Linux + CURSOR_CONFIG_PATH = os.path.expanduser("~/.config/Cursor/User/mcp_config.json") class CursorManager(BaseClientManager): - """Manages Cursor client configuration for MCP""" + """Manages Cursor MCP server configurations""" def __init__(self, config_path: str = CURSOR_CONFIG_PATH): super().__init__(config_path) @@ -86,12 +74,60 @@ def _convert_to_client_format(self, server_config: ServerConfig) -> Dict[str, An """Convert ServerConfig to Cursor format Args: - server_config: StandardServer configuration + server_config: ServerConfig object Returns: Dict containing Cursor-specific configuration """ - return server_config.to_cursor_format() + # Base result containing essential execution information + result = { + "command": server_config.command, + "args": server_config.args, + } + + # Add filtered environment variables if present + non_empty_env = server_config.get_filtered_env_vars() + if non_empty_env: + result["env"] = non_empty_env + + # Add additional metadata fields for display in Cursor + # Fields that are None will be automatically excluded by JSON serialization + for field in ["name", "display_name", "description", "version", "status", "path", "install_date"]: + value = getattr(server_config, field, None) + if value is not None: + result[field] = value + + return result + + @classmethod + def from_cursor_format(cls, server_name: str, client_config: Dict[str, Any]) -> ServerConfig: + """Convert Cursor format to ServerConfig + + Args: + server_name: Name of the server + client_config: Cursor-specific configuration + + Returns: + ServerConfig object + """ + # Create a dictionary that ServerConfig.from_dict can work with + server_data = { + "name": server_name, + "command": client_config.get("command", ""), + "args": client_config.get("args", []), + } + + # Add environment variables if present + if "env" in client_config: + server_data["env_vars"] = client_config["env"] + + # Add additional metadata fields if present + for field in ["display_name", "description", "version", "status", "path", "install_date", + "package", "installation_method", "installation_type"]: + if field in client_config: + server_data[field] = client_config[field] + + return ServerConfig.from_dict(server_data) def _convert_from_client_format(self, server_name: str, client_config: Dict[str, Any]) -> ServerConfig: """Convert Cursor format to ServerConfig @@ -103,7 +139,7 @@ def _convert_from_client_format(self, server_name: str, client_config: Dict[str, Returns: ServerConfig object """ - return ServerConfig.from_cursor_format(server_name, client_config) + return self.from_cursor_format(server_name, client_config) def remove_server(self, server_name: str) -> bool: """Remove an MCP server from Cursor config @@ -116,8 +152,14 @@ def remove_server(self, server_name: str) -> bool: """ config = self._load_config() - if "mcpServers" not in config or server_name not in config["mcpServers"]: - logger.warning(f"Server not found in Cursor config: {server_name}") + # Check if mcpServers exists + if "mcpServers" not in config: + logger.warning(f"Cannot remove server {server_name}: mcpServers section doesn't exist") + return False + + # Check if the server exists + if server_name not in config["mcpServers"]: + logger.warning(f"Server {server_name} not found in Cursor config") return False # Remove the server @@ -125,6 +167,41 @@ def remove_server(self, server_name: str) -> bool: return self._save_config(config) - def is_cursor_installed(self) -> bool: - """Check if Cursor is installed""" - return self.is_client_installed() + def get_server(self, server_name: str) -> Optional[ServerConfig]: + """Get a server configuration from Cursor + + Args: + server_name: Name of the server + + Returns: + ServerConfig object if found, None otherwise + """ + config = self._load_config() + + # Check if mcpServers exists + if "mcpServers" not in config: + logger.warning(f"Cannot get server {server_name}: mcpServers section doesn't exist") + return None + + # Check if the server exists + if server_name not in config["mcpServers"]: + logger.debug(f"Server {server_name} not found in Cursor config") + return None + + # Get the server config and convert to StandardServer + client_config = config["mcpServers"][server_name] + return self._convert_from_client_format(server_name, client_config) + + def list_servers(self) -> List[str]: + """List all MCP servers in Cursor config + + Returns: + List of server names + """ + config = self._load_config() + + # Check if mcpServers exists + if "mcpServers" not in config: + return [] + + return list(config["mcpServers"].keys()) diff --git a/src/mcpm/clients/windsurf.py b/src/mcpm/clients/windsurf.py index 73116e74..196eea79 100644 --- a/src/mcpm/clients/windsurf.py +++ b/src/mcpm/clients/windsurf.py @@ -4,7 +4,7 @@ import os import logging -from typing import Dict, Any, Optional, List +from typing import Dict, Any, Optional, List, Union import platform from mcpm.clients.base import BaseClientManager @@ -77,17 +77,31 @@ def add_server(self, server_config: ServerConfig) -> bool: def _convert_to_client_format(self, server_config: ServerConfig) -> Dict[str, Any]: """Convert ServerConfig to Windsurf format + Following the official Windsurf MCP format as documented at + https://docs.codeium.com/windsurf/mcp + Args: - server_config: StandardServer configuration + server_config: ServerConfig object Returns: Dict containing Windsurf-specific configuration """ - # Use the to_windsurf_format method which now handles all required fields - # This includes command, args, env, path and other metadata fields - return server_config.to_windsurf_format() + # Include only the essential MCP execution fields that Windsurf requires + # according to the documentation example: command, args, and env + result = { + "command": server_config.command, + "args": server_config.args, + } + + # Add filtered environment variables if present + non_empty_env = server_config.get_filtered_env_vars() + if non_empty_env: + result["env"] = non_empty_env + + return result - def _convert_from_client_format(self, server_name: str, client_config: Dict[str, Any]) -> ServerConfig: + @classmethod + def from_windsurf_format(cls, server_name: str, client_config: Dict[str, Any]) -> ServerConfig: """Convert Windsurf format to ServerConfig Args: @@ -97,9 +111,43 @@ def _convert_from_client_format(self, server_name: str, client_config: Dict[str, Returns: ServerConfig object """ - # Simply use the ServerConfig.from_windsurf_format method - # This internally calls from_dict which handles conversion of env to env_vars - return ServerConfig.from_windsurf_format(server_name, client_config) + # Create a dictionary that ServerConfig.from_dict can work with + server_data = { + "name": server_name, + "command": client_config.get("command", ""), + "args": client_config.get("args", []), + } + + # Add environment variables if present + if "env" in client_config: + server_data["env_vars"] = client_config["env"] + + # Add additional metadata fields if present + for field in ["display_name", "description", "version", "status", "path", "install_date", + "package", "installation_method", "installation_type"]: + if field in client_config: + server_data[field] = client_config[field] + + return ServerConfig.from_dict(server_data) + + def _convert_from_client_format(self, server_name: str, client_config: Union[Dict[str, Any], ServerConfig]) -> ServerConfig: + """Convert Windsurf format to ServerConfig + + Args: + server_name: Name of the server + client_config: Windsurf-specific configuration or ServerConfig object + + Returns: + ServerConfig object + """ + # If client_config is already a ServerConfig, just return it + if isinstance(client_config, ServerConfig): + # Ensure the name is set correctly + if client_config.name != server_name: + client_config.name = server_name + return client_config + # Otherwise, convert from dict format + return self.from_windsurf_format(server_name, client_config) def remove_server(self, server_name: str) -> bool: """Remove an MCP server from Windsurf config @@ -112,8 +160,14 @@ def remove_server(self, server_name: str) -> bool: """ config = self._load_config() - if "mcpServers" not in config or server_name not in config["mcpServers"]: - logger.warning(f"Server not found in Windsurf config: {server_name}") + # Check if mcpServers exists + if "mcpServers" not in config: + logger.warning(f"Cannot remove server {server_name}: mcpServers section doesn't exist") + return False + + # Check if the server exists + if server_name not in config["mcpServers"]: + logger.warning(f"Server {server_name} not found in Windsurf config") return False # Remove the server @@ -121,55 +175,41 @@ def remove_server(self, server_name: str) -> bool: return self._save_config(config) - def is_windsurf_installed(self) -> bool: - """Check if Windsurf is installed - - Returns: - bool: True if Windsurf is installed, False otherwise - """ - return self.is_client_installed() - - def get_servers(self) -> Dict[str, Any]: - """Get all MCP servers from the Windsurf config - - Returns: - Dict of server configurations by name - """ - config = self._load_config() - return config.get("mcpServers", {}) - - def get_server(self, server_name: str) -> Optional[Dict[str, Any]]: - """Get a specific MCP server from the Windsurf config + def get_server(self, server_name: str) -> Optional[ServerConfig]: + """Get a server configuration from Windsurf Args: - server_name: Name of the server to retrieve + server_name: Name of the server Returns: - Server configuration dictionary or None if not found + ServerConfig object if found, None otherwise """ - servers = self.get_servers() - return servers.get(server_name) - - def get_server_config(self, server_name: str) -> Optional[ServerConfig]: - """Get a specific MCP server config as a ServerConfig object + config = self._load_config() - Args: - server_name: Name of the server to retrieve + # Check if mcpServers exists + if "mcpServers" not in config: + logger.warning(f"Cannot get server {server_name}: mcpServers section doesn't exist") + return None - Returns: - ServerConfig object or None if server not found - """ - client_config = self.get_server(server_name) - if client_config is None: + # Check if the server exists + if server_name not in config["mcpServers"]: + logger.debug(f"Server {server_name} not found in Windsurf config") return None + + # Get the server config and convert to StandardServer + client_config = config["mcpServers"][server_name] return self._convert_from_client_format(server_name, client_config) - def get_server_configs(self) -> List[ServerConfig]: - """Get all MCP server configs as ServerConfig objects + def list_servers(self) -> List[str]: + """List all MCP servers in Windsurf config Returns: - List of ServerConfig objects + List of server names """ - servers = self.get_servers() - return [self._convert_from_client_format(name, config) - for name, config in servers.items()] + config = self._load_config() + + # Check if mcpServers exists + if "mcpServers" not in config: + return [] + + return list(config["mcpServers"].keys()) diff --git a/src/mcpm/commands/add.py b/src/mcpm/commands/add.py index 89a056bc..f65887da 100644 --- a/src/mcpm/commands/add.py +++ b/src/mcpm/commands/add.py @@ -103,7 +103,7 @@ def add(server_name, client=None, force=False): os.makedirs(server_dir, exist_ok=True) # Extract installation information - installations = server_metadata.get("install", {}) + installations = server_metadata.get("installations", {}) version = available_version # If no installation information is available, create minimal default values @@ -117,9 +117,9 @@ def add(server_name, client=None, force=False): required_args = {} # Process installation information if available + selected_method = None # Initialize selected_method to None to avoid UnboundLocalError if installations: # Find recommended installation method or default to the first one - selected_method = None method_key = "default" # First check for a recommended method @@ -182,11 +182,99 @@ def add(server_name, client=None, force=False): # Configure the server progress.add_task(f"Configuring {server_name} v{version}...", total=None) + # Get all available arguments from the server metadata + all_arguments = server_metadata.get("arguments", {}) + # Process environment variables to store in config processed_env = {} + # First, prompt for all defined arguments even if they're not in env_vars + progress.stop() + if all_arguments: + console.print("\n[bold]Configure server arguments:[/]") + + for arg_name, arg_info in all_arguments.items(): + description = arg_info.get("description", "") + is_required = arg_info.get("required", False) + example = arg_info.get("example", "") + example_text = f" (example: {example})" if example else "" + + # Build prompt text + prompt_text = f"Enter value for {arg_name}{example_text}" + if description: + prompt_text += f"\n{description}" + + # Add required indicator + if is_required: + prompt_text += " (required)" + else: + prompt_text += " (optional, press Enter to skip)" + + # Check if the argument is already set in environment + env_value = os.environ.get(arg_name, "") + + if env_value: + # Show the existing value as default + console.print(f"[green]Found {arg_name} in environment: {env_value}[/]") + try: + user_value = click.prompt( + prompt_text, + default=env_value, + hide_input="token" in arg_name.lower() or "key" in arg_name.lower() or "secret" in arg_name.lower() + ) + if user_value != env_value: + # User provided a different value + processed_env[arg_name] = user_value + else: + # User kept the environment value + processed_env[arg_name] = f"${{{arg_name}}}" + except click.Abort: + # Keep environment reference on abort + processed_env[arg_name] = f"${{{arg_name}}}" + else: + # No environment value + try: + if is_required: + # Required argument must have a value + user_value = click.prompt( + prompt_text, + hide_input="token" in arg_name.lower() or "key" in arg_name.lower() or "secret" in arg_name.lower() + ) + processed_env[arg_name] = user_value + else: + # Optional argument can be skipped + user_value = click.prompt( + prompt_text, + default="", + show_default=False, + hide_input="token" in arg_name.lower() or "key" in arg_name.lower() or "secret" in arg_name.lower() + ) + # Only add non-empty values to the environment + if user_value and user_value.strip(): + processed_env[arg_name] = user_value + # Explicitly don't add anything if the user leaves it blank + except click.Abort: + if is_required: + console.print(f"[yellow]Warning: Required argument {arg_name} not provided.[/]") + # Store as environment reference even if missing + processed_env[arg_name] = f"${{{arg_name}}}" + + # Resume progress display + progress = Progress( + SpinnerColumn(), + TextColumn("[bold green]{task.description}[/]"), + console=console + ) + progress.start() + progress.add_task(f"Configuring {server_name} v{version}...", total=None) + + # Now process any remaining environment variables from the installation method for key, value in env_vars.items(): - if isinstance(value, str) and value.startswith("${") and value.endswith("}"): + # Skip if we already processed this key + if key in processed_env: + continue + + if isinstance(value, str) and value.startswith("${") and value.endswith("}"): env_var_name = value[2:-1] # Extract variable name from ${NAME} is_required = env_var_name in required_args @@ -194,10 +282,11 @@ def add(server_name, client=None, force=False): env_value = os.environ.get(env_var_name, "") if not env_value and is_required: + progress.stop() console.print(f"[yellow]Warning:[/] Required argument {env_var_name} is not set in environment") # Prompt for the value - arg_info = required_args[env_var_name] + arg_info = required_args.get(env_var_name, {}) description = arg_info.get("description", "") try: user_value = click.prompt( @@ -208,12 +297,31 @@ def add(server_name, client=None, force=False): except click.Abort: console.print("[yellow]Will store the reference to environment variable instead.[/]") processed_env[key] = value # Store the reference as-is + + # Resume progress + progress = Progress( + SpinnerColumn(), + TextColumn("[bold green]{task.description}[/]"), + console=console + ) + progress.start() + progress.add_task(f"Configuring {server_name} v{version}...", total=None) else: # Store reference to environment variable processed_env[key] = value else: processed_env[key] = value + # Get actual MCP execution command, args, and env from the selected installation method + # This ensures we use the actual server command information instead of placeholders + if selected_method: + mcp_command = selected_method.get("command", install_command) + mcp_args = selected_method.get("args", install_args) + # Env vars are already processed above + else: + mcp_command = install_command + mcp_args = install_args + # Create server configuration using ServerConfig server_config = ServerConfig( name=server_name, @@ -222,8 +330,8 @@ def add(server_name, client=None, force=False): description=description, version=version, status="stopped", - command=install_command, - args=install_args, + command=mcp_command, # Use the actual MCP server command + args=mcp_args, # Use the actual MCP server arguments env_vars=processed_env, install_date=datetime.now().strftime("%Y-%m-%d"), package=package_name, diff --git a/src/mcpm/commands/install.py b/src/mcpm/commands/install.py deleted file mode 100644 index 7e4c7811..00000000 --- a/src/mcpm/commands/install.py +++ /dev/null @@ -1,266 +0,0 @@ -""" -Install command for MCPM -""" - -import os -import json -from datetime import datetime - -import click -from rich.console import Console -from rich.progress import Progress, SpinnerColumn, TextColumn -from rich.prompt import Confirm - -from mcpm.utils.repository import RepositoryManager -from mcpm.utils.config import ConfigManager -from mcpm.utils.client_detector import detect_installed_clients - -console = Console() -repo_manager = RepositoryManager() -config_manager = ConfigManager() - -@click.command() -@click.argument("server_name") -@click.option("--force", is_flag=True, help="Force reinstall if server is already installed") -def install(server_name, force=False): - """[DEPRECATED] Install an MCP server. - - This command is deprecated. Please use 'mcpm add' instead, which directly - adds servers to client configurations without using a global config. - - Examples: - mcpm add time - mcpm add github - mcpm add everything --force - """ - # Show deprecation warning - console.print("[bold yellow]WARNING: The 'install' command is deprecated![/]") - console.print("[yellow]Please use 'mcpm add' instead, which adds servers directly to client configurations.[/]") - console.print("[yellow]Run 'mcpm add --help' for more information.[/]") - console.print("") - - # Check if already installed - existing_server = config_manager.get_server_info(server_name) - if existing_server and not force: - console.print(f"[yellow]Server '{server_name}' is already installed (v{existing_server.get('version', 'unknown')}).[/]") - console.print("Use 'mcpm install --force' to reinstall or 'mcpm update' to update it to a newer version.") - return - - # Get server metadata from repository - server_metadata = repo_manager.get_server_metadata(server_name) - if not server_metadata: - console.print(f"[bold red]Error:[/] Server '{server_name}' not found in registry.") - console.print(f"Available servers: {', '.join(repo_manager._fetch_servers().keys())}") - return - - # Display server information - display_name = server_metadata.get("display_name", server_name) - description = server_metadata.get("description", "No description available") - available_version = server_metadata.get("version") - author_info = server_metadata.get("author", {}) - author_name = author_info.get("name", "Unknown") - license_info = server_metadata.get("license", "Unknown") - - # Use the available version - version = available_version - - # Display server information - console.print("\n[bold cyan]Server Information[/]") - console.print(f"[bold]{display_name}[/] [dim]v{version}[/]") - console.print(f"[italic]{description}[/]") - console.print() - console.print(f"Author: {author_name}") - console.print(f"License: {license_info}") - console.print() - - # Check for required arguments in the new schema - arguments = server_metadata.get("arguments", {}) - required_args = {k: v for k, v in arguments.items() if v.get("required", False)} - needs_api_key = len(required_args) > 0 - - if needs_api_key: - console.print("\n[yellow]Note:[/] This server requires the following arguments:") - for arg_name, arg_info in required_args.items(): - description = arg_info.get("description", "") - example = arg_info.get("example", "") - example_text = f" (e.g. '{example}')" if example else "" - console.print(f" [bold]{arg_name}[/]: {description}{example_text}") - - # Installation preparation - if not force and existing_server: - if not Confirm.ask(f"Server '{server_name}' is already installed. Do you want to reinstall?"): - return - - # Create server directory - server_dir = os.path.expanduser(f"~/.config/mcp/servers/{server_name}") - os.makedirs(server_dir, exist_ok=True) - - # Get installation instructions from the new 'installations' field - installations = server_metadata.get("installations", {}) - - # Fall back to legacy 'installation' field if needed - if not installations: - installation = server_metadata.get("installation", {}) - if installation and installation.get("command") and installation.get("args"): - installations = {"default": installation} - - if not installations: - console.print(f"[bold red]Error:[/] No installation methods found for server '{server_name}'.") - return - - # Find recommended installation method or default to the first one - selected_method = None - method_key = None - - # First check for a recommended method - for key, method in installations.items(): - if method.get("recommended", False): - selected_method = method - method_key = key - break - - # If no recommended method found, use the first one - if not selected_method: - method_key = next(iter(installations)) - selected_method = installations[method_key] - - # If multiple methods are available and not forced, offer selection - if len(installations) > 1 and not force: - console.print("\n[bold]Available installation methods:[/]") - methods_list = [] - - for i, (key, method) in enumerate(installations.items(), 1): - install_type = method.get("type", "unknown") - description = method.get("description", f"{install_type} installation") - recommended = " [green](recommended)[/]" if method.get("recommended", False) else "" - - console.print(f" {i}. [cyan]{key}[/]: {description}{recommended}") - methods_list.append(key) - - # Ask user to select a method - try: - selection = click.prompt("\nSelect installation method", type=int, default=methods_list.index(method_key) + 1) - if 1 <= selection <= len(methods_list): - method_key = methods_list[selection - 1] - selected_method = installations[method_key] - except (ValueError, click.Abort): - console.print("[yellow]Using default installation method.[/]") - - # Extract installation details - install_type = selected_method.get("type") - install_command = selected_method.get("command") - install_args = selected_method.get("args", []) - package_name = selected_method.get("package") - env_vars = selected_method.get("env", {}) - - if not install_command or not install_args: - console.print(f"[bold red]Error:[/] Invalid installation information for method '{method_key}'.") - return - - console.print(f"\n[green]Using {install_type} installation method: [bold]{method_key}[/][/]") - - # Download and install server - with Progress( - SpinnerColumn(), - TextColumn("[bold green]{task.description}[/]"), - console=console - ) as progress: - # Download metadata - progress.add_task("Downloading server metadata...", total=None) - # Save metadata to server directory - metadata_path = os.path.join(server_dir, "metadata.json") - with open(metadata_path, "w") as f: - json.dump(server_metadata, f, indent=2) - - # Configure the server (do not execute installation command) - progress.add_task(f"Configuring {server_name} v{version}...", total=None) - - # Process environment variables to store in config - processed_env = {} - - for key, value in env_vars.items(): - if isinstance(value, str) and value.startswith("${") and value.endswith("}"): - env_var_name = value[2:-1] # Extract variable name from ${NAME} - is_required = env_var_name in required_args - - # For required arguments, prompt the user if not in environment - env_value = os.environ.get(env_var_name, "") - - if not env_value and is_required: - console.print(f"[yellow]Warning:[/] Required argument {env_var_name} is not set in environment") - - # Prompt for the value - arg_info = required_args[env_var_name] - description = arg_info.get("description", "") - try: - user_value = click.prompt( - f"Enter value for {env_var_name} ({description})", - hide_input="token" in env_var_name.lower() or "key" in env_var_name.lower() - ) - processed_env[key] = user_value - except click.Abort: - console.print("[yellow]Will store the reference to environment variable instead.[/]") - processed_env[key] = value # Store the reference as-is - else: - # Store reference to environment variable - processed_env[key] = value - else: - processed_env[key] = value - - # Display the installation command (for information only) - if install_command: - full_command = [install_command] + install_args - console.print(f"[dim]Installation command: {' '.join(full_command)}[/]") - console.print("[green]Server has been configured and added to client.[/]") - - # Create server configuration - server_info = { - "name": server_name, - "display_name": display_name, - "version": version, - "description": description, - "status": "stopped", - "install_date": datetime.now().strftime("%Y-%m-%d"), - "path": server_dir, - "package": package_name - } - - # Add installation method information if available - if method_key: - server_info["installation_method"] = method_key - if install_type: - server_info["installation_type"] = install_type - - # Register the server in our config - config_manager.register_server(server_name, server_info) - - console.print(f"[bold green]Successfully installed {display_name} v{version}![/]") - - # Handle client enablement - automatically enable for active client - active_client = config_manager.get_active_client() - - # Always enable for active client, regardless of installation status - if active_client: - success = config_manager.enable_server_for_client(server_name, active_client) - if success: - console.print(f"[green]Enabled {server_name} for active client: {active_client}[/]") - else: - console.print(f"[yellow]Failed to enable {server_name} for {active_client}[/]") - - # Show additional info about client installation if client isn't installed - installed_clients = detect_installed_clients() - if not installed_clients.get(active_client, False): - console.print(f"[dim]Note: {active_client} is configured but not detected as installed.[/]") - - # Display usage examples if available - examples = server_metadata.get("examples", []) - if examples: - console.print("\n[bold]Usage Examples:[/]") - for i, example in enumerate(examples, 1): - title = example.get("title", f"Example {i}") - description = example.get("description", "") - prompt = example.get("prompt", "") - - console.print(f" [cyan]{title}[/]: {description}") - if prompt: - console.print(f" Try: [italic]\"{prompt}\"[/]\n") diff --git a/src/mcpm/utils/server_config.py b/src/mcpm/utils/server_config.py index 3ca1f02e..722c3750 100644 --- a/src/mcpm/utils/server_config.py +++ b/src/mcpm/utils/server_config.py @@ -1,176 +1,125 @@ """ -Standard server configuration model for MCP. -Provides a consistent interface for server configurations across all clients. +Server configuration utilities for MCPM """ -import datetime -from dataclasses import dataclass, field, asdict -from typing import Dict, List, Optional, Any +import os +from typing import Dict, Any, List, Optional, ClassVar, Type, TypeVar +T = TypeVar('T', bound='ServerConfig') -@dataclass class ServerConfig: - """Standard model for MCP server configuration""" + """Standard server configuration object that is client-agnostic - # Required fields - name: str - path: str + This class provides a common representation of server configurations + that can be used across different clients. Client-specific formatting + should be implemented in each client manager class. + """ - # Optional fields with defaults - display_name: str = "" - description: str = "" - version: str = "1.0.0" - status: str = "stopped" # stopped, running - command: str = "" - args: List[str] = field(default_factory=list) - env_vars: Dict[str, str] = field(default_factory=dict) - install_date: str = field(default_factory=lambda: datetime.date.today().isoformat()) - installation_method: str = "" - installation_type: str = "" - package: Optional[str] = None - metadata: Dict[str, Any] = field(default_factory=dict) + # Fields that should be included in all serializations + REQUIRED_FIELDS: ClassVar[List[str]] = [ + "name", "command", "args", "env_vars" + ] - def to_dict(self) -> Dict[str, Any]: - """Convert to dictionary representation""" - return asdict(self) + # Fields that should be optional in serializations + OPTIONAL_FIELDS: ClassVar[List[str]] = [ + "display_name", "description", "version", "status", "path", + "install_date", "package", "installation_method", "installation_type" + ] + def __init__( + self, + name: str, + command: str, + args: List[str], + env_vars: Optional[Dict[str, str]] = None, + display_name: Optional[str] = None, + description: Optional[str] = None, + version: Optional[str] = None, + status: Optional[str] = None, + path: Optional[str] = None, + install_date: Optional[str] = None, + package: Optional[str] = None, + installation_method: Optional[str] = None, + installation_type: Optional[str] = None + ): + """Initialize a standard server configuration""" + self.name = name + self.command = command + self.args = args + self.env_vars = env_vars or {} + self.display_name = display_name or name + self.description = description or "" + self.version = version or "unknown" + self.status = status or "stopped" + self.path = path + self.install_date = install_date + self.package = package + self.installation_method = installation_method + self.installation_type = installation_type + @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'ServerConfig': - """Create ServerConfig from dictionary + def from_dict(cls: Type[T], data: Dict[str, Any]) -> T: + """Create a ServerConfig from a dictionary Args: - data: Dictionary with server configuration data + data: Dictionary containing server configuration Returns: ServerConfig object """ - # Handle potential key differences in source data - server_data = data.copy() + # Filter the dictionary to include only the fields we care about + filtered_data = {} - # Handle environment variables from different formats - if "env" in server_data and "env_vars" not in server_data: - server_data["env_vars"] = server_data.pop("env") - - # Set name from the dictionary key if not in the data - if "name" not in server_data and server_data.get("display_name"): - server_data["name"] = server_data["display_name"].lower().replace(" ", "-") - - # Remove any keys that aren't in the dataclass to avoid unexpected keyword arguments - valid_fields = {field.name for field in cls.__dataclass_fields__.values()} - filtered_data = {k: v for k, v in server_data.items() if k in valid_fields} + # Add all required and optional fields that are present + for field in cls.REQUIRED_FIELDS + cls.OPTIONAL_FIELDS: + if field in data: + filtered_data[field] = data[field] return cls(**filtered_data) - def to_windsurf_format(self) -> Dict[str, Any]: - """Convert to Windsurf client format - - Following the official Windsurf MCP format as documented at - https://docs.codeium.com/windsurf/mcp + def get_filtered_env_vars(self) -> Dict[str, str]: + """Get filtered environment variables with empty values removed - Returns: - Dictionary in Windsurf format with only essential fields - """ - # Include only the essential MCP execution fields that Windsurf requires - # according to the documentation example: command, args, and env - result = { - "command": self.command, - "args": self.args, - } - - # Add environment variables if present - if self.env_vars: - result["env"] = self.env_vars - - return result - - def to_claude_desktop_format(self) -> Dict[str, Any]: - """Convert to Claude Desktop client format - - Returns: - Dictionary in Claude Desktop format - """ - return { - "name": self.name, - "display_name": self.display_name or f"{self.name.title()} MCP Server", - "version": self.version, - "description": self.description, - "status": self.status, - "install_date": self.install_date, - "path": self.path, - "command": self.command, - "args": self.args, - "env": self.env_vars - } - - def to_cursor_format(self) -> Dict[str, Any]: - """Convert to Cursor client format - - Returns: - Dictionary in Cursor format - """ - return { - "name": self.name, - "display_name": self.display_name or f"{self.name.title()} MCP Server", - "version": self.version, - "description": self.description, - "status": self.status, - "path": self.path, - "command": self.command, - "args": self.args, - "env": self.env_vars - } - - @classmethod - def from_windsurf_format(cls, name: str, data: Dict[str, Any]) -> 'ServerConfig': - """Create ServerConfig from Windsurf format + This is a common utility for clients to filter out empty environment + variables, regardless of client-specific formatting. - Args: - name: Server name - data: Windsurf format server data - Returns: - ServerConfig object + Dictionary of non-empty environment variables """ - server_data = data.copy() - server_data["name"] = name - - # Handle required fields that might be missing in the Windsurf format - # Path is required by ServerConfig but not part of the Windsurf MCP format - if "path" not in server_data: - server_data["path"] = f"/path/to/{name}" + if not self.env_vars: + return {} - # Convert environment variables if present - if "env" in server_data and "env_vars" not in server_data: - server_data["env_vars"] = server_data.pop("env") - - return cls.from_dict(server_data) + # Filter out empty environment variables + non_empty_env = {} + for key, value in self.env_vars.items(): + # For environment variable references like ${VAR_NAME}, check if the variable exists + # and has a non-empty value. If it doesn't exist or is empty, exclude it. + if value is not None and isinstance(value, str): + if value.startswith("${") and value.endswith("}"): + # Extract the variable name from ${VAR_NAME} + env_var_name = value[2:-1] + env_value = os.environ.get(env_var_name, "") + # Only include if the variable has a value in the environment + if env_value.strip() != "": + non_empty_env[key] = value + # For regular values, only include if they're not empty + elif value.strip() != "": + non_empty_env[key] = value + + return non_empty_env - @classmethod - def from_claude_desktop_format(cls, name: str, data: Dict[str, Any]) -> 'ServerConfig': - """Create ServerConfig from Claude Desktop format + def to_dict(self) -> Dict[str, Any]: + """Convert to a dictionary with all fields - Args: - name: Server name - data: Claude Desktop format server data - Returns: - ServerConfig object + Dictionary representation of this ServerConfig """ - server_data = data.copy() - server_data["name"] = name - return cls.from_dict(server_data) - - @classmethod - def from_cursor_format(cls, name: str, data: Dict[str, Any]) -> 'ServerConfig': - """Create ServerConfig from Cursor format + result = {} - Args: - name: Server name - data: Cursor format server data - - Returns: - ServerConfig object - """ - server_data = data.copy() - server_data["name"] = name - return cls.from_dict(server_data) + # Include all fields, filtering out None values + for field in self.REQUIRED_FIELDS + self.OPTIONAL_FIELDS: + value = getattr(self, field, None) + if value is not None: + result[field] = value + + return result diff --git a/tests/test_windsurf_integration.py b/tests/test_windsurf_integration.py index 8b922e63..8c3207be 100644 --- a/tests/test_windsurf_integration.py +++ b/tests/test_windsurf_integration.py @@ -89,15 +89,16 @@ def config_manager(self): def test_get_servers(self, windsurf_manager): """Test retrieving servers from Windsurf config""" - servers = windsurf_manager.get_servers() + # Changed to list_servers which returns a list of server names + servers = windsurf_manager.list_servers() assert "test-server" in servers - assert servers["test-server"]["command"] == "npx" def test_get_server(self, windsurf_manager): """Test retrieving a specific server from Windsurf config""" server = windsurf_manager.get_server("test-server") assert server is not None - assert server["command"] == "npx" + # Now a ServerConfig object, not a dict + assert server.command == "npx" # Test non-existent server assert windsurf_manager.get_server("non-existent") is None @@ -122,8 +123,8 @@ def test_add_server_config_raw(self, windsurf_manager): # Verify server was added server = windsurf_manager.get_server("google-maps") assert server is not None - assert server["command"] == "npx" - assert "GOOGLE_MAPS_API_KEY" in server["env"] + assert server.command == "npx" + assert "GOOGLE_MAPS_API_KEY" in server.env_vars def test_add_server_config_to_empty_config(self, empty_windsurf_manager): """Test adding a server to an empty config file using the internal method""" @@ -139,7 +140,7 @@ def test_add_server_config_to_empty_config(self, empty_windsurf_manager): # Verify server was added server = empty_windsurf_manager.get_server("test-server") assert server is not None - assert server["command"] == "npx" + assert server.command == "npx" def test_add_server(self, windsurf_manager, sample_server_config): """Test adding a ServerConfig object to Windsurf config""" @@ -149,16 +150,15 @@ def test_add_server(self, windsurf_manager, sample_server_config): # Verify server was added server = windsurf_manager.get_server("sample-server") assert server is not None - assert "sample-server" in windsurf_manager.get_servers() + assert "sample-server" in windsurf_manager.list_servers() - # Get it back and verify it's the same - retrieved_config = windsurf_manager.get_server_config("sample-server") - assert retrieved_config is not None - assert retrieved_config.name == "sample-server" + # Since get_server now returns a ServerConfig, we can directly compare + assert server is not None + assert server.name == "sample-server" # Note: With the official Windsurf format, metadata fields aren't preserved # Only essential execution fields (command, args, env) are preserved - assert retrieved_config.command == sample_server_config.command - assert retrieved_config.args == sample_server_config.args + assert server.command == sample_server_config.command + assert server.args == sample_server_config.args def test_convert_to_client_format(self, windsurf_manager, sample_server_config): """Test conversion from ServerConfig to Windsurf format""" @@ -197,7 +197,7 @@ def test_convert_from_client_format(self, windsurf_manager): "args": ["-y", "@modelcontextprotocol/server-test"], "env": {"TEST_KEY": "test-value"}, "path": "/path/to/server", - "display_name": "Test Server", + "display_name": "test-convert", # Updated to match the server name "version": "1.0.0" } @@ -206,7 +206,7 @@ def test_convert_from_client_format(self, windsurf_manager): # Check conversion is correct assert isinstance(server_config, ServerConfig) assert server_config.name == "test-convert" - assert server_config.display_name == "Test Server" + assert server_config.display_name == "test-convert" assert server_config.command == "npx" assert server_config.args == ["-y", "@modelcontextprotocol/server-test"] assert server_config.env_vars["TEST_KEY"] == "test-value" @@ -235,23 +235,25 @@ def test_get_server_configs(self, windsurf_manager, sample_server_config): def test_get_server_config(self, windsurf_manager): """Test retrieving a specific server as a ServerConfig object""" - config = windsurf_manager.get_server_config("test-server") + # get_server now returns a ServerConfig, so get_server_config is redundant + config = windsurf_manager.get_server("test-server") assert config is not None assert isinstance(config, ServerConfig) assert config.name == "test-server" + # The display_name is coming from our test fixture where it's set to "Test Server" assert config.display_name == "Test Server" # Non-existent server should return None - assert windsurf_manager.get_server_config("non-existent") is None + assert windsurf_manager.get_server("non-existent") is None - def test_is_windsurf_installed(self, windsurf_manager): - """Test checking if Windsurf is installed""" + def test_is_client_installed(self, windsurf_manager): + """Test checking if Windsurf is installed (now using is_client_installed)""" with patch('os.path.isdir', return_value=True): - assert windsurf_manager.is_windsurf_installed() + assert windsurf_manager.is_client_installed() with patch('os.path.isdir', return_value=False): - assert not windsurf_manager.is_windsurf_installed() + assert not windsurf_manager.is_client_installed() def test_load_invalid_config(self): """Test loading an invalid config file"""