Skip to content
62 changes: 57 additions & 5 deletions src/mcpm/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import json
import os
import re
import shutil
from enum import Enum

from prompt_toolkit import PromptSession
Expand All @@ -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

Expand Down Expand Up @@ -72,18 +74,31 @@ 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:
prompt_text: The prompt text to display
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}[/]")

Expand Down Expand Up @@ -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)
Expand All @@ -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

Expand Down Expand Up @@ -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 = []

Expand Down Expand Up @@ -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 <package>'
# If package_name was defined in the installation method, use it.
# If not, check if we are running 'python -m <module>' 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 <module> python -m <module>' 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,
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/mcpm/commands/uninstall.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions src/mcpm/utils/non_interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down