"""Connect command for configuring MCP clients.""" import json import logging import os import platform import re import shutil import subprocess from pathlib import Path import typer from arcade_mcp_server.settings import find_env_file from dotenv import dotenv_values from arcade_cli.console import console logger = logging.getLogger(__name__) def is_wsl() -> bool: """Check if running in Windows Subsystem for Linux.""" # Check for WSL environment variable if os.environ.get("WSL_DISTRO_NAME"): return True # Check /proc/version for WSL indicators try: with open("/proc/version", encoding="utf-8") as f: version_info = f.read().lower() return "microsoft" in version_info or "wsl" in version_info except (FileNotFoundError, PermissionError): return False def get_windows_username() -> str | None: """Get the Windows username when running in WSL.""" try: # Try to get username from Windows environment via cmd.exe # Note: cmd.exe is safe to use here as it's a Windows system binary available in WSL result = subprocess.run( ["cmd.exe", "/c", "echo", "%USERNAME%"], # noqa: S607 capture_output=True, text=True, timeout=5, ) if result.returncode == 0: username = result.stdout.strip() # Remove any carriage returns username = username.replace("\r", "") if username and username != "%USERNAME%": return username except (subprocess.SubprocessError, FileNotFoundError, subprocess.TimeoutExpired): pass return None def _resolve_windows_appdata() -> Path: """Resolve the Windows roaming AppData directory via ``platformdirs``. ``platformdirs`` is the de-facto standard Python library for resolving OS-specific user directories. On Windows it reads the ``APPDATA`` environment variable (and the Windows registry as a fallback), so a single call covers every real-world scenario. """ from platformdirs import user_data_dir try: result = user_data_dir(appname=None, appauthor=False, roaming=True) except TypeError: # Older platformdirs versions require positional args only. # Signature: user_data_dir(appname, appauthor, version, roaming) logger.debug("platformdirs raised TypeError; retrying with positional args") result = user_data_dir(None, False, None, True) return Path(result) def _dedupe_paths(paths: list[Path]) -> list[Path]: """Return paths in order, removing duplicates (case-insensitive on Windows).""" deduped: list[Path] = [] seen: set[str] = set() for path in paths: key = os.path.normcase(str(path)) if key in seen: continue seen.add(key) deduped.append(path) return deduped def _get_windows_cursor_config_paths() -> list[Path]: """Return known Windows Cursor config locations (primary first).""" return _dedupe_paths([ _resolve_windows_appdata() / "Cursor" / "mcp.json", Path.home() / ".cursor" / "mcp.json", ]) def _format_path_for_display(path: Path, platform_system: str | None = None) -> str: path_str = str(path) if " " in path_str: system = platform_system or platform.system() if system == "Windows": return f'"{path_str}"' return path_str.replace(" ", "\\ ") return path_str def _warn_overwrite(config: dict, section: str, server_name: str, config_path: Path) -> None: if section in config and server_name in config[section]: config_display = _format_path_for_display(config_path) console.print( f"[yellow]Warning: MCP server '{server_name}' already exists in {config_display}. " "This will overwrite the existing entry. Use --name to keep both.[/yellow]" ) def get_claude_config_path() -> Path: """Get the Claude Desktop configuration file path.""" system = platform.system() if system == "Darwin": # macOS return ( Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json" ) elif system == "Windows": return _resolve_windows_appdata() / "Claude" / "claude_desktop_config.json" else: # Linux # Check if we're in WSL - if so, use Windows path if is_wsl(): username = get_windows_username() if username: # Use the Windows AppData path accessible via WSL mount return Path( f"/mnt/c/Users/{username}/AppData/Roaming/Claude/claude_desktop_config.json" ) else: console.print( "[yellow]Warning: Running in WSL but couldn't determine Windows username. " "Using Linux path instead. Claude Desktop may not detect this configuration.[/yellow]" ) return Path.home() / ".config" / "Claude" / "claude_desktop_config.json" def get_cursor_config_path() -> Path: """Get the Cursor configuration file path.""" system = platform.system() if system == "Darwin": # macOS return Path.home() / ".cursor" / "mcp.json" elif system == "Windows": candidates = _get_windows_cursor_config_paths() for path in candidates: if path.exists(): return path return candidates[0] else: # Linux # Check if we're in WSL - if so, use Windows path if is_wsl(): username = get_windows_username() if username: # Use the Windows AppData path accessible via WSL mount return Path(f"/mnt/c/Users/{username}/AppData/Roaming/Cursor/mcp.json") else: console.print( "[yellow]Warning: Running in WSL but couldn't determine Windows username. " "Using Linux path instead. Cursor may not detect this configuration.[/yellow]" ) return Path.home() / ".config" / "Cursor" / "mcp.json" def get_vscode_config_path() -> Path: """Get the VS Code configuration file path.""" # Paths to global 'Default User' MCP configuration file system = platform.system() if system == "Darwin": # macOS return Path.home() / "Library" / "Application Support" / "Code" / "User" / "mcp.json" elif system == "Windows": return _resolve_windows_appdata() / "Code" / "User" / "mcp.json" else: # Linux # Check if we're in WSL - if so, use Windows path if is_wsl(): username = get_windows_username() if username: # Use the Windows AppData path accessible via WSL mount return Path(f"/mnt/c/Users/{username}/AppData/Roaming/Code/User/mcp.json") else: console.print( "[yellow]Warning: Running in WSL but couldn't determine Windows username. " "Using Linux path instead. VS Code may not detect this configuration.[/yellow]" ) return Path.home() / ".config" / "Code" / "User" / "mcp.json" def is_uv_installed() -> bool: """Check if uv is installed and available in PATH.""" return shutil.which("uv") is not None def get_tool_secrets() -> dict: """Get tool secrets from .env file for stdio servers. Discovers .env file by traversing upward from the current directory through parent directories until a .env file is found. Returns: Dictionary of environment variables from the .env file, or empty dict if not found. """ env_path = find_env_file() if env_path is not None: return dotenv_values(env_path) return {} def find_python_interpreter() -> Path: """ Find the Python interpreter in the virtual environment. NOTE: This function assumes it is called from the project root directory (where .venv lives). Currently, callers like `arcade deploy` enforce this by requiring pyproject.toml in cwd. If this requirement is relaxed in the future, this function should be updated to: 1. Accept a project_root parameter, OR 2. Honor VIRTUAL_ENV / UV_PROJECT_ENVIRONMENT env vars, OR 3. Search upward from cwd to find pyproject.toml and resolve .venv relative to that """ venv_python = None # Check for .venv first (uv default) if (Path.cwd() / ".venv").exists(): system = platform.system() if system == "Windows": venv_python = Path.cwd() / ".venv" / "Scripts" / "python.exe" else: venv_python = Path.cwd() / ".venv" / "bin" / "python" # Fall back to system python if no venv found if not venv_python or not venv_python.exists(): console.print("[yellow]Warning: No .venv found, using system python[/yellow]") import sys venv_python = Path(sys.executable) return venv_python def get_stdio_config(entrypoint_file: str, server_name: str) -> dict: """Get the appropriate stdio configuration based on whether uv is installed.""" server_file = Path.cwd() / entrypoint_file uv_executable = shutil.which("uv") if uv_executable: return { # Use the absolute uv path so GUI clients can launch reliably even # when they were started with a different PATH than the shell. "command": uv_executable, "args": [ "run", "--directory", str(Path.cwd()), "python", entrypoint_file, ], "env": get_tool_secrets(), } else: console.print( "[yellow]Warning: uv is not installed. Install uv for the best experience with arcade configure CLI command.[/yellow]" ) venv_python = find_python_interpreter() return { "command": str(venv_python), "args": [str(server_file)], "env": get_tool_secrets(), } def configure_claude_local( entrypoint_file: str, server_name: str, port: int = 8000, config_path: Path | None = None ) -> None: """Configure Claude Desktop to add a local MCP server to the configuration.""" config_path = config_path or get_claude_config_path() # Handle both absolute and relative config paths if config_path and not config_path.is_absolute(): config_path = Path.cwd() / config_path config_path.parent.mkdir(parents=True, exist_ok=True) # Load existing config or create new one config = {} if config_path.exists(): with open(config_path, encoding="utf-8") as f: config = json.load(f) # Add or update MCP servers configuration if "mcpServers" not in config: config["mcpServers"] = {} _warn_overwrite(config, "mcpServers", server_name, config_path) # Claude Desktop uses stdio transport config["mcpServers"][server_name] = get_stdio_config(entrypoint_file, server_name) # Write updated config with open(config_path, "w", encoding="utf-8") as f: json.dump(config, f, indent=2) console.print( f"✅ Configured Claude Desktop by adding local MCP server '{server_name}' to the configuration", style="green", ) config_file_path = _format_path_for_display(config_path) console.print(f" MCP client config file: {config_file_path}", style="dim") console.print( f" Server file: {_format_path_for_display(Path.cwd() / entrypoint_file)}", style="dim", ) if is_uv_installed(): console.print(" Using uv to run server", style="dim") else: console.print(f" Python interpreter: {find_python_interpreter()}", style="dim") console.print(" Restart Claude Desktop for changes to take effect.", style="yellow") def configure_claude_arcade( server_name: str, transport: str, config_path: Path | None = None ) -> None: """Configure Claude Desktop to add an Arcade Cloud MCP server to the configuration.""" # This would connect to the Arcade Cloud to get the server URL # For now, this is a placeholder console.print("[red]Connecting to Arcade Cloud servers not yet implemented[/red]") def configure_cursor_local( entrypoint_file: str, server_name: str, transport: str, port: int = 8000, config_path: Path | None = None, ) -> None: """Configure Cursor to add a local MCP server to the configuration.""" def http_config(server_name: str, port: int = 8000) -> dict: return { "name": server_name, "type": "stream", # Cursor prefers stream "url": f"http://localhost:{port}/mcp", } if config_path is not None: target_paths = [config_path] elif platform.system() == "Windows": primary_path = get_cursor_config_path() target_paths = _dedupe_paths([primary_path, *_get_windows_cursor_config_paths()]) else: target_paths = [get_cursor_config_path()] # Handle both absolute and relative config paths. resolved_target_paths: list[Path] = [] for path in target_paths: resolved_target_paths.append(path if path.is_absolute() else Path.cwd() / path) server_config = ( get_stdio_config(entrypoint_file, server_name) if transport == "stdio" else http_config(server_name, port) ) for idx, config_path in enumerate(resolved_target_paths): config_path.parent.mkdir(parents=True, exist_ok=True) # Load existing config or create new one config = {} if config_path.exists(): with open(config_path, encoding="utf-8") as f: config = json.load(f) # Add or update MCP servers configuration if "mcpServers" not in config: config["mcpServers"] = {} if idx == 0: _warn_overwrite(config, "mcpServers", server_name, config_path) config["mcpServers"][server_name] = server_config # Write updated config with open(config_path, "w", encoding="utf-8") as f: json.dump(config, f, indent=2) primary_config_path = resolved_target_paths[0] console.print( f"✅ Configured Cursor by adding local MCP server '{server_name}' to the configuration", style="green", ) config_file_path = _format_path_for_display(primary_config_path) console.print(f" MCP client config file: {config_file_path}", style="dim") compatibility_paths = resolved_target_paths[1:] if compatibility_paths: compatibility_display = ", ".join( _format_path_for_display(path) for path in compatibility_paths ) console.print( f" Also updated compatibility config file(s): {compatibility_display}", style="dim", ) if transport == "http": console.print(f" MCP Server URL: http://localhost:{port}/mcp", style="dim") elif transport == "stdio": if is_uv_installed(): console.print(" Using uv to run server", style="dim") else: console.print(f" Python interpreter: {find_python_interpreter()}", style="dim") console.print(" Restart Cursor for changes to take effect.", style="yellow") def configure_cursor_arcade( server_name: str, transport: str, config_path: Path | None = None ) -> None: """Configure Cursor to add an Arcade Cloud MCP server to the configuration.""" console.print("[red]Connecting to Arcade Cloud servers not yet implemented[/red]") def configure_vscode_local( entrypoint_file: str, server_name: str, transport: str, port: int = 8000, config_path: Path | None = None, ) -> None: """Configure VS Code to add a local MCP server to the configuration.""" def http_config(port: int = 8000) -> dict: return { "type": "http", "url": f"http://localhost:{port}/mcp", } config_path = config_path or get_vscode_config_path() # Handle both absolute and relative config paths if config_path and not config_path.is_absolute(): config_path = Path.cwd() / config_path config_path.parent.mkdir(parents=True, exist_ok=True) # Load existing config or create new one config = {} if config_path.exists(): with open(config_path, encoding="utf-8") as f: try: config = json.load(f) except json.JSONDecodeError as e: raise ValueError( f"\n\tFailed to load MCP configuration file at {_format_path_for_display(config_path)} " f"\n\tThe file contains invalid JSON: {e}. " "\n\tPlease check the file format or delete it to create a new configuration." ) # Add or update MCP servers configuration if "servers" not in config: config["servers"] = {} _warn_overwrite(config, "servers", server_name, config_path) config["servers"][server_name] = ( get_stdio_config(entrypoint_file, server_name) if transport == "stdio" else http_config(port) ) # Write updated config with open(config_path, "w", encoding="utf-8") as f: json.dump(config, f, indent=2) console.print( f"✅ Configured VS Code by adding local MCP server '{server_name}' to the configuration", style="green", ) config_file_path = _format_path_for_display(config_path) console.print(f" MCP client config file: {config_file_path}", style="dim") if transport == "http": console.print(f" MCP Server URL: http://localhost:{port}/mcp", style="dim") elif transport == "stdio": if is_uv_installed(): console.print(" Using uv to run server", style="dim") else: console.print(f" Python interpreter: {find_python_interpreter()}", style="dim") console.print(" Restart VS Code for changes to take effect.", style="yellow") def configure_vscode_arcade(server_name: str, transport: str, path: Path | None = None) -> None: """Configure VS Code to add an Arcade Cloud MCP server to the configuration.""" console.print("[red]Connecting to Arcade Cloud servers not yet implemented[/red]") def configure_client( client: str, entrypoint_file: str, server_name: str | None = None, transport: str = "stdio", host: str = "local", port: int = 8000, config_path: Path | None = None, ) -> None: """ Configure an MCP client to connect to a server. Args: client: The MCP client to configure (claude, cursor, vscode) entrypoint_file: The name of the Python file in the current directory that runs the server. This file must run the server when invoked directly. Only used for stdio servers. server_name: Name of the server to add to the configuration transport: The transport to use for the MCP server configuration host: The host of the server to configure (local or arcade) port: Port for local HTTP servers (default: 8000) config_path: Custom path to the MCP client configuration file """ if not server_name: # Use the name of the current directory as the server name server_name = Path.cwd().name if transport == "stdio": if not bool(re.match(r"^[a-zA-Z0-9_-]+\.py$", entrypoint_file)): raise ValueError(f"Entrypoint file '{entrypoint_file}' is not a valid Python file name") if not (Path.cwd() / entrypoint_file).exists(): raise ValueError(f"Entrypoint file '{entrypoint_file}' is not in the current directory") client_lower = client.lower() if client_lower == "claude": if transport != "stdio": raise ValueError("Claude Desktop only supports stdio transport via configuration file") if host == "local": configure_claude_local(entrypoint_file, server_name, port, config_path) else: configure_claude_arcade(server_name, transport, config_path) elif client_lower == "cursor": if host == "local": configure_cursor_local(entrypoint_file, server_name, transport, port, config_path) else: configure_cursor_arcade(server_name, transport, config_path) elif client_lower == "vscode": if host == "local": configure_vscode_local(entrypoint_file, server_name, transport, port, config_path) else: configure_vscode_arcade(server_name, transport, config_path) else: raise typer.BadParameter( f"Unknown client: {client}. Supported clients: claude, cursor, vscode." )