diff --git a/src/mcpm/commands/install.py b/src/mcpm/commands/install.py index f526a41..c1cc182 100644 --- a/src/mcpm/commands/install.py +++ b/src/mcpm/commands/install.py @@ -5,6 +5,7 @@ import json import os import re +import shutil from enum import Enum from prompt_toolkit import PromptSession @@ -20,6 +21,7 @@ from mcpm.profile.profile_config import ProfileConfigManager from mcpm.schemas.full_server_config import FullServerConfig from mcpm.utils.config import NODE_EXECUTABLES, ConfigManager +from mcpm.utils.non_interactive import should_force_operation from mcpm.utils.repository import RepositoryManager from mcpm.utils.rich_click_config import click @@ -72,7 +74,7 @@ def global_add_server(server_config: ServerConfig, force: bool = False) -> bool: return global_config_manager.add_server(server_config, force) -def prompt_with_default(prompt_text, default="", hide_input=False, required=False): +def prompt_with_default(prompt_text, default="", hide_input=False, required=False, force=False): """Prompt the user with a default value that can be edited directly. Args: @@ -80,10 +82,23 @@ def prompt_with_default(prompt_text, default="", hide_input=False, required=Fals default: The default value to show in the prompt hide_input: Whether to hide the input (for passwords) required: Whether this is a required field + force: Whether to force non-interactive mode Returns: The user's input or the default value if empty """ + # Check for explicit non-interactive mode (Env Var) + # We do NOT check is_non_interactive() here because it includes isatty(), + # which returns True in tests (CliRunner), causing us to skip mocked prompts. + # Users desiring non-interactive behavior must set MCPM_NON_INTERACTIVE=true. + if os.getenv("MCPM_NON_INTERACTIVE", "").lower() == "true" or should_force_operation(force): + if default: + return default + if required: + # Cannot fulfill required argument without default in non-interactive mode + raise click.UsageError("A required value has no default and cannot be prompted in non-interactive mode.") + return "" + # if default: # console.print(f"Default: [yellow]{default}[/]") @@ -137,7 +152,8 @@ def install(server_name, force=False, alias=None): config_name = alias or server_name # All servers are installed to global configuration - console.print("[yellow]Installing server to global configuration...[/]") + console_stderr = Console(stderr=True) + console_stderr.print("[yellow]Installing server to global configuration...[/]") # Get server metadata from repository server_metadata = repo_manager.get_server_metadata(server_name) @@ -161,7 +177,9 @@ def install(server_name, force=False, alias=None): # Confirm addition alias_text = f" as '{alias}'" if alias else "" - if not force and not Confirm.ask(f"Install this server to global configuration{alias_text}?"): + if not should_force_operation(force) and not Confirm.ask( + f"Install this server to global configuration{alias_text}?" + ): console.print("[yellow]Operation cancelled.[/]") return @@ -206,7 +224,7 @@ def install(server_name, force=False, alias=None): selected_method = installations[method_id] # If multiple methods are available and not forced, offer selection - if len(installations) > 1 and not force: + if len(installations) > 1 and not should_force_operation(force): console.print("\n[bold]Available installation methods:[/]") methods_list = [] @@ -411,6 +429,40 @@ def install(server_name, force=False, alias=None): mcp_command = install_command mcp_args = processed_args + # --- Auto-UVX Injection Logic --- + # If 'uv' is available and we are using python/pip, try to upgrade to 'uv run' for isolation. + # This solves the "Pydantic Versioning" dependency hell by isolating servers. + if mcp_command in ["python", "python3", "pip"] and shutil.which("uv"): + # We need to determine the package name to run 'uv run --with ' + # If package_name was defined in the installation method, use it. + # If not, check if we are running 'python -m ' and guess package name from module? + # Or default to the server name if reasonable? + target_package = package_name + + # If args start with '-m', the next arg is the module. + # Often module == package (e.g. mcp_server_time -> mcp-server-time? No, dashes vs underscores). + # But 'uv run --with python -m ' usually works if PyPI name matches. + + if not target_package and mcp_args and mcp_args[0] == "-m" and len(mcp_args) > 1: + # Heuristic: Assume package name matches module name (with _ -> - maybe?) + # Ideally, the registry should provide 'package'. + # For now, we only auto-upgrade if we have a package name OR if we are brave. + # Let's rely on package_name variable extracted earlier from selected_method.get("package") + pass + + if target_package: + console.print(f"[bold blue]🚀 Auto-upgrading to 'uv run' for isolation (package: {target_package})[/]") + # Old: python -m module ... + # New: uv run --with package python -m module ... + + # We prepend 'run --with package' to the command execution + # mcp_command becomes 'uv' + # mcp_args becomes ['run', '--with', target_package, original_command] + mcp_args + + new_args = ["run", "--with", target_package, mcp_command] + mcp_args + mcp_command = "uv" + mcp_args = new_args + # Create server configuration using FullServerConfig full_server_config = FullServerConfig( name=config_name, @@ -426,7 +478,7 @@ def install(server_name, force=False, alias=None): ) # Add server to global configuration - success = global_add_server(full_server_config.to_server_config(), force) + success = global_add_server(full_server_config.to_server_config(), should_force_operation(force)) if success: # Server has been successfully added to the global configuration diff --git a/src/mcpm/commands/uninstall.py b/src/mcpm/commands/uninstall.py index 4e058f4..85525df 100644 --- a/src/mcpm/commands/uninstall.py +++ b/src/mcpm/commands/uninstall.py @@ -7,6 +7,7 @@ from mcpm.global_config import GlobalConfigManager from mcpm.utils.display import print_server_config +from mcpm.utils.non_interactive import should_force_operation from mcpm.utils.rich_click_config import click console = Console() @@ -59,7 +60,7 @@ def uninstall(server_name, force): print_server_config(server_info) # Get confirmation if --force is not used - if not force: + if not should_force_operation(force): console.print(f"\n[bold yellow]Are you sure you want to remove:[/] {server_name}") console.print("[italic]To bypass this confirmation, use --force[/]") # Use Rich's Confirm for a better user experience diff --git a/src/mcpm/utils/non_interactive.py b/src/mcpm/utils/non_interactive.py index c2d4efd..4670330 100644 --- a/src/mcpm/utils/non_interactive.py +++ b/src/mcpm/utils/non_interactive.py @@ -32,13 +32,17 @@ def is_non_interactive() -> bool: return False -def should_force_operation() -> bool: +def should_force_operation(cli_force_flag: bool = False) -> bool: """ Check if operations should be forced (skip confirmations). - Returns True if MCPM_FORCE environment variable is set to 'true'. + Args: + cli_force_flag: Boolean flag from CLI args (e.g. --force) + + Returns: + True if cli_force_flag is True OR MCPM_FORCE environment variable is set to 'true'. """ - return os.getenv("MCPM_FORCE", "").lower() == "true" + return cli_force_flag or os.getenv("MCPM_FORCE", "").lower() == "true" def should_output_json() -> bool: