SuperClaude/src/superclaude/cli/install_mcp.py

400 lines
14 KiB
Python
Raw Normal View History

2025-11-13 16:29:10 +01:00
"""
MCP Server Installation Module for SuperClaude
Installs and manages MCP servers using the latest Claude Code API.
Based on the installer logic from commit d4a17fc but adapted for modern Claude Code.
"""
import os
import platform
import shlex
import subprocess
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import click
# MCP Server Registry
# Adapted from commit d4a17fc with modern transport configuration
MCP_SERVERS = {
"sequential-thinking": {
"name": "sequential-thinking",
"description": "Multi-step problem solving and systematic analysis",
"transport": "stdio",
"command": "npx -y @modelcontextprotocol/server-sequential-thinking",
"required": False,
},
"context7": {
"name": "context7",
"description": "Official library documentation and code examples",
"transport": "stdio",
"command": "npx -y @upstash/context7-mcp",
"required": False,
},
"magic": {
"name": "magic",
"description": "Modern UI component generation and design systems",
"transport": "stdio",
"command": "npx -y @21st-dev/magic",
"required": False,
"api_key_env": "TWENTYFIRST_API_KEY",
"api_key_description": "21st.dev API key for UI component generation",
},
"playwright": {
"name": "playwright",
"description": "Cross-browser E2E testing and automation",
"transport": "stdio",
"command": "npx -y @playwright/mcp@latest",
"required": False,
},
"serena": {
"name": "serena",
"description": "Semantic code analysis and intelligent editing",
"transport": "stdio",
"command": "uvx --from git+https://github.com/oraios/serena serena start-mcp-server --context ide-assistant --enable-web-dashboard false --enable-gui-log-window false",
"required": False,
},
"morphllm-fast-apply": {
"name": "morphllm-fast-apply",
"description": "Fast Apply capability for context-aware code modifications",
"transport": "stdio",
"command": "npx -y @morph-llm/morph-fast-apply",
"required": False,
"api_key_env": "MORPH_API_KEY",
"api_key_description": "Morph API key for Fast Apply",
},
"tavily": {
"name": "tavily",
"description": "Web search and real-time information retrieval for deep research",
"transport": "stdio",
"command": "npx -y tavily-mcp@0.1.2",
"required": False,
"api_key_env": "TAVILY_API_KEY",
"api_key_description": "Tavily API key for web search (get from https://app.tavily.com)",
},
"chrome-devtools": {
"name": "chrome-devtools",
"description": "Chrome DevTools debugging and performance analysis",
"transport": "stdio",
"command": "npx -y chrome-devtools-mcp@latest",
"required": False,
},
}
def _run_command(cmd: List[str], **kwargs) -> subprocess.CompletedProcess:
"""
Run a command with proper cross-platform shell handling.
Args:
cmd: Command as list of strings
**kwargs: Additional subprocess.run arguments
Returns:
CompletedProcess result
"""
if platform.system() == "Windows":
# On Windows, wrap command in 'cmd /c' to properly handle commands like npx
cmd = ["cmd", "/c"] + cmd
return subprocess.run(cmd, **kwargs)
else:
# macOS/Linux: Use string format with proper shell to support aliases
cmd_str = " ".join(shlex.quote(str(arg)) for arg in cmd)
# Use the user's shell to execute the command, supporting aliases
user_shell = os.environ.get("SHELL", "/bin/bash")
return subprocess.run(
cmd_str, shell=True, env=os.environ, executable=user_shell, **kwargs
)
def check_prerequisites() -> Tuple[bool, List[str]]:
"""Check if required tools are available."""
errors = []
# Check Claude CLI
try:
result = _run_command(
["claude", "--version"], capture_output=True, text=True, timeout=10
)
if result.returncode != 0:
errors.append("Claude CLI not found - required for MCP server management")
except (subprocess.TimeoutExpired, FileNotFoundError):
errors.append("Claude CLI not found - required for MCP server management")
# Check Node.js for npm-based servers
try:
result = _run_command(
["node", "--version"], capture_output=True, text=True, timeout=10
)
if result.returncode != 0:
errors.append("Node.js not found - required for npm-based MCP servers")
else:
version = result.stdout.strip()
try:
version_num = int(version.lstrip("v").split(".")[0])
if version_num < 18:
errors.append(
f"Node.js version {version} found, but version 18+ required"
)
except:
pass
except (subprocess.TimeoutExpired, FileNotFoundError):
errors.append("Node.js not found - required for npm-based MCP servers")
# Check uv for Python-based servers (optional)
try:
result = _run_command(
["uv", "--version"], capture_output=True, text=True, timeout=10
)
if result.returncode != 0:
click.echo("⚠️ uv not found - required for Serena MCP server", err=True)
except (subprocess.TimeoutExpired, FileNotFoundError):
click.echo("⚠️ uv not found - required for Serena MCP server", err=True)
return len(errors) == 0, errors
def check_mcp_server_installed(server_name: str) -> bool:
"""Check if an MCP server is already installed."""
try:
result = _run_command(
["claude", "mcp", "list"], capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
return False
# Parse output to check if server is installed
output = result.stdout.lower()
return server_name.lower() in output
except (subprocess.TimeoutExpired, subprocess.SubprocessError):
return False
def prompt_for_api_key(server_name: str, env_var: str, description: str) -> Optional[str]:
"""Prompt user for API key if needed."""
click.echo(f"\n🔑 MCP server '{server_name}' requires an API key")
click.echo(f" Environment variable: {env_var}")
click.echo(f" Description: {description}")
# Check if already set in environment
if os.getenv(env_var):
click.echo(f"{env_var} already set in environment")
return os.getenv(env_var)
# Prompt user
if click.confirm(f" Would you like to set {env_var} now?", default=True):
api_key = click.prompt(f" Enter {env_var}", hide_input=True)
return api_key
else:
click.echo(f" ⚠️ Proceeding without {env_var} - server may not function properly")
return None
def install_mcp_server(
server_info: Dict,
scope: str = "user",
dry_run: bool = False
) -> bool:
"""
Install a single MCP server using modern Claude Code API.
Args:
server_info: Server configuration dictionary
scope: Installation scope (local, project, user)
dry_run: If True, only show what would be done
Returns:
True if successful, False otherwise
"""
server_name = server_info["name"]
transport = server_info["transport"]
command = server_info["command"]
click.echo(f"📦 Installing MCP server: {server_name}")
# Check if already installed
if check_mcp_server_installed(server_name):
click.echo(f" ✅ Already installed: {server_name}")
return True
# Handle API key requirements
env_args = []
if "api_key_env" in server_info:
api_key_env = server_info["api_key_env"]
api_key = prompt_for_api_key(
server_name,
api_key_env,
server_info.get("api_key_description", f"API key for {server_name}")
)
if api_key:
env_args = ["--env", f"{api_key_env}={api_key}"]
# Build installation command using modern Claude Code API
# Format: claude mcp add --transport <transport> [--scope <scope>] [--env KEY=VALUE] <name> -- <command>
cmd = ["claude", "mcp", "add", "--transport", transport]
# Add scope if not default
if scope != "local":
cmd.extend(["--scope", scope])
# Add environment variables if any
if env_args:
cmd.extend(env_args)
# Add server name
cmd.append(server_name)
# Add separator
cmd.append("--")
# Add server command (split into parts)
cmd.extend(shlex.split(command))
if dry_run:
click.echo(f" [DRY RUN] Would run: {' '.join(cmd)}")
return True
try:
click.echo(f" Running: claude mcp add --transport {transport} {server_name} -- {command}")
result = _run_command(
cmd,
capture_output=True,
text=True,
timeout=120
)
if result.returncode == 0:
click.echo(f" ✅ Successfully installed: {server_name}")
return True
else:
error_msg = result.stderr.strip() if result.stderr else "Unknown error"
click.echo(f" ❌ Failed to install {server_name}: {error_msg}", err=True)
return False
except subprocess.TimeoutExpired:
click.echo(f" ❌ Timeout installing {server_name}", err=True)
return False
except Exception as e:
click.echo(f" ❌ Error installing {server_name}: {e}", err=True)
return False
def list_available_servers():
"""List all available MCP servers."""
click.echo("📋 Available MCP Servers:\n")
for server_key, server_info in MCP_SERVERS.items():
name = server_info["name"]
description = server_info["description"]
api_key_note = ""
if "api_key_env" in server_info:
api_key_note = f" (requires {server_info['api_key_env']})"
# Check if installed
is_installed = check_mcp_server_installed(name)
status = "✅ installed" if is_installed else "⬜ not installed"
click.echo(f" {name:25} {status}")
click.echo(f" {description}{api_key_note}")
click.echo()
click.echo(f"Total: {len(MCP_SERVERS)} servers available")
def install_mcp_servers(
selected_servers: Optional[List[str]] = None,
scope: str = "user",
dry_run: bool = False
) -> Tuple[bool, str]:
"""
Install MCP servers for Claude Code.
Args:
selected_servers: List of server names to install, or None for interactive selection
scope: Installation scope (local, project, user)
dry_run: If True, only show what would be done
Returns:
Tuple of (success, message)
"""
# Check prerequisites
success, errors = check_prerequisites()
if not success:
error_msg = "Prerequisites not met:\n" + "\n".join(f"{e}" for e in errors)
return False, error_msg
# Determine which servers to install
if selected_servers:
# Use explicitly selected servers
servers_to_install = []
for server_name in selected_servers:
if server_name in MCP_SERVERS:
servers_to_install.append(server_name)
else:
click.echo(f"⚠️ Unknown server: {server_name}", err=True)
if not servers_to_install:
return False, "No valid servers selected"
else:
# Interactive selection
click.echo("📋 Available MCP servers:\n")
server_options = []
for key, info in MCP_SERVERS.items():
api_note = f" (requires {info['api_key_env']})" if "api_key_env" in info else ""
server_options.append(f"{info['name']:25} - {info['description']}{api_note}")
for i, option in enumerate(server_options, 1):
click.echo(f" {i}. {option}")
click.echo("\n 0. Install all servers")
click.echo()
selection = click.prompt(
"Select servers to install (comma-separated numbers, or 0 for all)",
default="0"
)
if selection.strip() == "0":
servers_to_install = list(MCP_SERVERS.keys())
else:
try:
indices = [int(x.strip()) for x in selection.split(",")]
server_list = list(MCP_SERVERS.keys())
servers_to_install = [server_list[i-1] for i in indices if 0 < i <= len(server_list)]
except (ValueError, IndexError):
return False, "Invalid selection"
if not servers_to_install:
return False, "No servers selected"
# Install each server
click.echo(f"\n🔌 Installing {len(servers_to_install)} MCP server(s)...\n")
installed_count = 0
failed_servers = []
for server_name in servers_to_install:
server_info = MCP_SERVERS[server_name]
if install_mcp_server(server_info, scope, dry_run):
installed_count += 1
else:
failed_servers.append(server_name)
# Generate result message
if failed_servers:
message = f"\n⚠️ Partially completed: {installed_count}/{len(servers_to_install)} servers installed\n"
message += f"Failed servers: {', '.join(failed_servers)}"
return False, message
else:
message = f"\n✅ Successfully installed {installed_count} MCP server(s)!\n"
message += f"\n Use 'claude mcp list' to see all installed servers"
message += f"\n Use '/mcp' in Claude Code to check server status"
return True, message