diff --git a/README.md b/README.md index 5dca4359..4b951fe0 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,11 @@ mcpm search [QUERY] # Search the MCP Registry for available servers mcpm add SERVER_URL # Add an MCP server configuration (from URL or registry name) mcpm add SERVER_URL --alias ALIAS # Add with a custom alias +# 🛠️ Add custom server +mcpm import stdio SERVER_NAME --command COMMAND --args ARGS --env ENV # Add a stdio MCP server to a client +mcpm import sse SERVER_NAME --url URL # Add a SSE MCP server to a client +mcpm import interact # Add a server by configuring it interactively + # 📋 List and Remove mcpm ls # List server configurations for the active client/profile mcpm rm SERVER_NAME # Remove a server configuration diff --git a/README.zh-CN.md b/README.zh-CN.md index fff3239e..62d84ceb 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -126,6 +126,11 @@ mcpm search [QUERY] # 在 MCP 注册表中搜索可用服务器 mcpm add SERVER_URL # 添加 MCP 服务器配置(从 URL 或注册表名称) mcpm add SERVER_URL --alias ALIAS # 添加并使用自定义别名 +# 🛠️ 自定义添加 +mcpm import stdio SERVER_NAME --command COMMAND --args ARGS --env ENV # 手动添加一个 stdio MCP 服务器 +mcpm import sse SERVER_NAME --url URL # 手动添加一个 SSE MCP 服务器 +mcpm import interact # 通过交互式添加一个服务器 + # 📋 列出和删除 mcpm ls # 列出活动客户端/配置文件的服务器配置 mcpm rm SERVER_NAME # 删除服务器配置 diff --git a/src/mcpm/cli.py b/src/mcpm/cli.py index 358969a9..97ec1868 100644 --- a/src/mcpm/cli.py +++ b/src/mcpm/cli.py @@ -12,6 +12,7 @@ add, client, config, + custom, info, inspector, list, @@ -149,7 +150,8 @@ def main(ctx, help_flag, version): commands_table.add_row("[yellow]server[/]") commands_table.add_row(" [cyan]search[/]", "Search available MCP servers.") commands_table.add_row(" [cyan]info[/]", "Show detailed information about a specific MCP server.") - commands_table.add_row(" [cyan]add[/]", "Add an MCP server directly to a client.") + commands_table.add_row(" [cyan]add[/]", "Add an MCP server directly to a client/profile.") + commands_table.add_row(" [cyan]import[/]", "Import a custom MCP server to a client/profile.") commands_table.add_row(" [cyan]cp[/]", "Copy a server from one client/profile to another.") commands_table.add_row(" [cyan]mv[/]", "Move a server from one client/profile to another.") commands_table.add_row(" [cyan]rm[/]", "Remove an installed MCP server.") @@ -194,6 +196,7 @@ def main(ctx, help_flag, version): main.add_command(profile.activate) main.add_command(profile.deactivate) main.add_command(router.router, name="router") +main.add_command(custom.import_server, name="import") if __name__ == "__main__": main() diff --git a/src/mcpm/commands/__init__.py b/src/mcpm/commands/__init__.py index f87acb5c..02819301 100644 --- a/src/mcpm/commands/__init__.py +++ b/src/mcpm/commands/__init__.py @@ -2,8 +2,23 @@ MCPM commands package """ -__all__ = ["add", "client", "inspector", "list", "pop", "profile", "remove", "search", "stash", "transfer", "router"] +__all__ = [ + "add", + "client", + "inspector", + "list", + "pop", + "profile", + "remove", + "search", + "stash", + "transfer", + "router", + "custom", +] # All command modules + + from . import client, inspector, list, profile, router, search -from .server_operations import add, pop, remove, stash, transfer +from .server_operations import add, custom, pop, remove, stash, transfer diff --git a/src/mcpm/commands/list.py b/src/mcpm/commands/list.py index feada44d..80f9400d 100644 --- a/src/mcpm/commands/list.py +++ b/src/mcpm/commands/list.py @@ -8,10 +8,11 @@ from mcpm.clients.client_config import ClientConfigManager from mcpm.clients.client_registry import ClientRegistry +from mcpm.commands.server_operations.common import determine_scope from mcpm.profile.profile_config import ProfileConfigManager from mcpm.schemas.server_config import ServerConfig -from mcpm.utils.display import print_active_scope, print_client_error, print_no_active_scope, print_server_config -from mcpm.utils.scope import ScopeType, extract_from_scope, format_scope +from mcpm.utils.display import print_client_error, print_server_config +from mcpm.utils.scope import ScopeType, format_scope console = Console() client_config_manager = ClientConfigManager() @@ -29,14 +30,9 @@ def list(target: str | None = None): mcpm ls mcpm ls -t @cursor """ - if target is None: - target = ClientRegistry.determine_active_scope() - if not target: - print_no_active_scope() - return - print_active_scope(target) - - scope_type, scope = extract_from_scope(target) + scope_type, scope = determine_scope(target) + if not scope: + return if scope_type == ScopeType.CLIENT: # Get the active client manager and information diff --git a/src/mcpm/commands/server_operations/add.py b/src/mcpm/commands/server_operations/add.py index 6dbae323..0fce3f4c 100644 --- a/src/mcpm/commands/server_operations/add.py +++ b/src/mcpm/commands/server_operations/add.py @@ -17,12 +17,11 @@ from rich.prompt import Confirm from mcpm.clients.client_registry import ClientRegistry -from mcpm.commands.server_operations.common import client_add_server, profile_add_server +from mcpm.commands.server_operations.common import client_add_server, determine_scope, profile_add_server from mcpm.profile.profile_config import ProfileConfigManager from mcpm.schemas.full_server_config import FullServerConfig -from mcpm.utils.display import print_active_scope, print_no_active_scope from mcpm.utils.repository import RepositoryManager -from mcpm.utils.scope import ScopeType, extract_from_scope +from mcpm.utils.scope import ScopeType console = Console() repo_manager = RepositoryManager() @@ -106,15 +105,10 @@ def add(server_name, force=False, alias=None, target: str | None = None): """ config_name = alias or server_name - if target is None: - # Get the active scope - target = ClientRegistry.determine_active_scope() - if not target: - print_no_active_scope() - return - print_active_scope(target) + scope_type, scope = determine_scope(target) + if not scope: + return - scope_type, scope = extract_from_scope(target) if scope_type == ScopeType.PROFILE: # Get profile profile = scope diff --git a/src/mcpm/commands/server_operations/common.py b/src/mcpm/commands/server_operations/common.py index 5e50ec21..71798e22 100644 --- a/src/mcpm/commands/server_operations/common.py +++ b/src/mcpm/commands/server_operations/common.py @@ -9,15 +9,25 @@ console = Console() +def determine_scope(scope: str | None) -> tuple[ScopeType | None, str | None]: + if not scope: + # Get the active scope + scope = ClientRegistry.determine_active_scope() + if not scope: + print_no_active_scope() + return None, None + print_active_scope(scope) + + scope_type, scope = extract_from_scope(scope) + return scope_type, scope + + def determine_target(target: str) -> tuple[ScopeType | None, str | None, str | None]: scope_type, scope, server_name = parse_server(target) if not scope: - active_scope = ClientRegistry.determine_active_scope() - if not active_scope: - print_no_active_scope() + scope_type, scope = determine_scope(scope) + if not scope: return None, None, None - scope_type, scope = extract_from_scope(active_scope) - print_active_scope(active_scope) return scope_type, scope, server_name diff --git a/src/mcpm/commands/server_operations/custom.py b/src/mcpm/commands/server_operations/custom.py new file mode 100644 index 00000000..925de4b1 --- /dev/null +++ b/src/mcpm/commands/server_operations/custom.py @@ -0,0 +1,206 @@ +import shlex + +import click +from rich.console import Console +from rich.prompt import Confirm, Prompt + +from mcpm.commands.server_operations.common import client_add_server, determine_scope, profile_add_server +from mcpm.schemas.server_config import SSEServerConfig, STDIOServerConfig +from mcpm.utils.display import print_server_config +from mcpm.utils.scope import ScopeType + +console = Console() + + +@click.group() +@click.help_option("-h", "--help") +def import_server(): + """Add server definitions manually.""" + pass + + +@import_server.command() +@click.argument("server_name", required=True) +@click.option("--command", "-c", help="Executable command", required=True) +@click.option("--args", "-a", multiple=True, help="Arguments for the command (can be used multiple times)") +@click.option("--env", "-e", multiple=True, help="Environment variables, format: ENV=val (can be used multiple times)") +@click.option("--target", "-t", help="Target client or profile") +@click.option("--force", is_flag=True, help="Force reinstall if server is already installed") +@click.help_option("-h", "--help") +def stdio(server_name, command, args, env, target, force): + """Add a server by specifying command, args, and env variables. + Examples: + + \b + mcpm import stdio --command --args --args --env = --env = + """ + scope_type, scope = determine_scope(target) + if not scope: + return + + # Extract env variables + env_vars = {} + for item in env: + if "=" in item: + key, value = item.split("=", 1) + env_vars[key] = value + else: + console.print(f"[yellow]Ignoring invalid env: {item}[/]") + + try: + # support spaces and quotes in args + parsed_args = shlex.split(" ".join(args)) if args else [] + server_config = STDIOServerConfig( + name=server_name, + command=command, + args=parsed_args, + env=env_vars, + ) + print_server_config(server_config) + except ValueError as e: + console.print(f"[bold red]Error:[/] {e}") + return + + if not Confirm.ask(f"Add this server to {scope_type} {scope}?"): + return + console.print(f"[green]Importing server to {scope_type} {scope}[/]") + + if scope_type == ScopeType.CLIENT: + success = client_add_server(scope, server_config, force) + else: + success = profile_add_server(scope, server_config, force) + + if success: + console.print(f"[bold green]Stdio server '{server_name}' added successfully to {scope_type} {scope}.") + else: + console.print(f"[bold red]Failed to add stdio server '{server_name}' to {scope_type} {scope}.") + + +@import_server.command() +@click.argument("server_name", required=True) +@click.option("--url", "-u", required=True, help="Server URL") +@click.option("--header", "-H", multiple=True, help="HTTP headers, format: KEY=val (can be used multiple times)") +@click.option("--target", "-t", help="Target to import server to") +@click.option("--force", is_flag=True, help="Force reinstall if server is already installed") +@click.help_option("-h", "--help") +def sse(server_name, url, header, target, force): + """Add a server by specifying a URL and headers. + Examples: + + \b + mcpm import sse --url --header = --header = + """ + scope_type, scope = determine_scope(target) + if not scope: + return + + headers = {} + for item in header: + if "=" in item: + key, value = item.split("=", 1) + headers[key] = value + else: + console.print(f"[yellow]Ignoring invalid header: {item}[/]") + + try: + server_config = SSEServerConfig( + name=server_name, + url=url, + headers=headers, + ) + print_server_config(server_config) + except ValueError as e: + console.print(f"[bold red]Error:[/] {e}") + return + + if not Confirm.ask(f"Add this server to {scope_type} {scope}?"): + return + console.print(f"[green]Importing server to {scope_type} {scope}[/]") + + if scope_type == ScopeType.CLIENT: + success = client_add_server(scope, server_config, force) + else: + success = profile_add_server(scope, server_config, force) + + if success: + console.print(f"[bold green]SSE server '{server_name}' added successfully to {scope_type} {scope}.") + else: + console.print(f"[bold red]Failed to add SSE server '{server_name}' to {scope_type} {scope}.") + + +@import_server.command() +@click.option("--target", "-t", help="Target to import server to") +@click.help_option("-h", "--help") +def interact(target: str | None = None): + """Add a server by manually configuring it interactively.""" + scope_type, scope = determine_scope(target) + if not scope: + return + + server_name = Prompt.ask("Enter server name") + if not server_name: + console.print("[red]Server name cannot be empty.[/]") + return + + config_type = Prompt.ask("Select server type", choices=["stdio", "sse"], default="stdio") + + if config_type == "stdio": + command = Prompt.ask("Enter command (executable)") + args = Prompt.ask("Enter arguments (space-separated, optional)", default="") + env_input = Prompt.ask("Enter env variables (format: KEY=VAL, comma-separated, optional)", default="") + env = {} + if env_input.strip(): + for pair in env_input.split(","): + if "=" in pair: + k, v = pair.split("=", 1) + env[k.strip()] = v.strip() + try: + # support spaces and quotes in args + parsed_args = shlex.split(args) if args.strip() else [] + server_config = STDIOServerConfig( + name=server_name, + command=command, + args=parsed_args, + env=env, + ) + except ValueError as e: + console.print(f"[bold red]Error:[/] {e}") + return + elif config_type == "sse": + url = Prompt.ask("Enter SSE server URL") + headers_input = Prompt.ask("Enter HTTP headers (format: KEY=VAL, comma-separated, optional)", default="") + headers = {} + if headers_input.strip(): + for pair in headers_input.split(","): + if "=" in pair: + k, v = pair.split("=", 1) + headers[k.strip()] = v.strip() + try: + server_config = SSEServerConfig( + name=server_name, + url=url, + headers=headers, + ) + except ValueError as e: + console.print(f"[bold red]Error:[/] {e}") + return + else: + console.print(f"[red]Unknown server type: {config_type}[/]") + return + + print_server_config(server_config) + if not Confirm.ask(f"Add this server to {scope_type} {scope}?"): + return + console.print(f"[green]Importing server to {scope_type} {scope}[/]") + + if scope_type == ScopeType.CLIENT: + success = client_add_server(scope, server_config, False) + else: + success = profile_add_server(scope, server_config, False) + + if success: + console.print( + f"[bold green]{config_type.upper()} server '{server_name}' added successfully to {scope_type} {scope}." + ) + else: + console.print(f"[bold red]Failed to add {config_type.upper()} server '{server_name}' to {scope_type} {scope}.") diff --git a/src/mcpm/commands/server_operations/remove.py b/src/mcpm/commands/server_operations/remove.py index 011ca334..832b1f34 100644 --- a/src/mcpm/commands/server_operations/remove.py +++ b/src/mcpm/commands/server_operations/remove.py @@ -4,7 +4,6 @@ import click from rich.console import Console -from rich.markup import escape from rich.prompt import Confirm from mcpm.commands.server_operations.common import ( @@ -14,6 +13,7 @@ profile_get_server, profile_remove_server, ) +from mcpm.utils.display import print_server_config from mcpm.utils.scope import ScopeType console = Console() @@ -54,31 +54,7 @@ def remove(server_name, force): # Display server information before removal console.print(f"\n[bold cyan]Server information for:[/] {server_name}") - # Server command - command = getattr(server_info, "command", "N/A") - console.print(f" Command: [green]{command}[/]") - - # Display arguments - args = getattr(server_info, "args", []) - if args: - console.print(" Arguments:") - for i, arg in enumerate(args): - console.print(f" {i}: [yellow]{escape(arg)}[/]") - - # Get package name (usually the second argument) - if len(args) > 1: - console.print(f" Package: [magenta]{args[1]}[/]") - - # Display environment variables - env_vars = getattr(server_info, "env", {}) - if env_vars and len(env_vars) > 0: - console.print(" Environment Variables:") - for key, value in env_vars.items(): - console.print(f' [bold blue]{key}[/] = [green]"{value}"[/]') - else: - console.print(" Environment Variables: [italic]None[/]") - - console.print(" " + "-" * 50) + print_server_config(server_info) # Get confirmation if --force is not used if not force: diff --git a/src/mcpm/utils/display.py b/src/mcpm/utils/display.py index 7c555d14..342a29ca 100644 --- a/src/mcpm/utils/display.py +++ b/src/mcpm/utils/display.py @@ -27,6 +27,11 @@ def print_server_config(server_config: ServerConfig, is_stashed=False): if isinstance(server_config, SSEServerConfig): console.print(f" Url: [green]{server_config.url}[/]") + headers = server_config.headers + if headers: + console.print(" Headers:") + for key, value in headers.items(): + console.print(f' [bold blue]{key}[/] = [green]"{value}"[/]') console.print(" " + "-" * 50) return command = server_config.command