Skip to content

Commit a4a7d45

Browse files
JRedekercalmini
andauthored
feat: Enhanced Non-Interactive Mode for Automation (#283)
* feat: add improved non-interactive mode support for install/uninstall commands * fix: use explicit env var check to avoid breaking tests in non-tty environments * refactor: consolidate force logic and improve error handling based on review * refactor: consolidate force logic and improve error handling based on review * refactor: consolidate force logic, improve error handling, and ensure tests pass * fix: address code review comments - Remove unused 'is_non_interactive' import in install.py - Use stderr for installation status message in install.py * fix(mcpm): Address PR #283 feedback - explicit non-interactive checks and test fixes - Add is_explicit_non_interactive() helper to avoid TTY detection issues in tests - Update install/uninstall logic to strictly respect force/non-interactive flags - Pass force flag correctly to prompt_with_default - Update prompt_with_default docstring - Update tests to use env vars for input when --force is used * fix: Resolve syntax error in install.py regex pattern * fix: Correct tuple unpacking in variable replacement logic --------- Co-authored-by: calmini <76392644+calmini@users.noreply.github.com>
1 parent f9940d6 commit a4a7d45

File tree

4 files changed

+103
-44
lines changed

4 files changed

+103
-44
lines changed

src/mcpm/commands/install.py

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from mcpm.profile.profile_config import ProfileConfigManager
2121
from mcpm.schemas.full_server_config import FullServerConfig
2222
from mcpm.utils.config import NODE_EXECUTABLES, ConfigManager
23+
from mcpm.utils.non_interactive import is_explicit_non_interactive, should_force_operation
2324
from mcpm.utils.repository import RepositoryManager
2425
from mcpm.utils.rich_click_config import click
2526

@@ -88,26 +89,44 @@ def _replace_node_executable(server_config: ServerConfig) -> ServerConfig:
8889
def global_add_server(server_config: ServerConfig, force: bool = False) -> bool:
8990
"""Add a server to the global MCPM configuration."""
9091
if global_config_manager.server_exists(server_config.name) and not force:
91-
console.print(f"[bold red]Error:[/] Server '{server_config.name}' already exists in global configuration.")
92+
console.print(f"[bold red]Error:[/ ] Server '{server_config.name}' already exists in global configuration.")
9293
console.print("Use --force to override.")
9394
return False
9495

9596
server_config = _replace_node_executable(server_config)
9697
return global_config_manager.add_server(server_config, force)
9798

9899

99-
def prompt_with_default(prompt_text, default="", hide_input=False, required=False):
100+
def prompt_with_default(prompt_text, default="", hide_input=False, required=False, force=False):
100101
"""Prompt the user with a default value that can be edited directly.
101102
103+
In non-interactive mode (via --force or MCPM_NON_INTERACTIVE), this function
104+
returns the default value immediately. If a required value has no default
105+
in non-interactive mode, it raises click.UsageError.
106+
102107
Args:
103108
prompt_text: The prompt text to display
104109
default: The default value to show in the prompt
105110
hide_input: Whether to hide the input (for passwords)
106111
required: Whether this is a required field
112+
force: Whether to force non-interactive mode
107113
108114
Returns:
109115
The user's input or the default value if empty
110116
"""
117+
# Check for explicit non-interactive mode (Env Var) or Force flag
118+
# We use is_explicit_non_interactive() instead of the broader is_non_interactive()
119+
# because the latter includes isatty() checks. In test environments using CliRunner,
120+
# isatty() returns True, which would incorrectly skip our mocked prompts if we checked it here.
121+
# We specifically want to allow interaction in tests unless the Env Var is set.
122+
if is_explicit_non_interactive() or should_force_operation(force):
123+
if default:
124+
return default
125+
if required:
126+
# Cannot fulfill required argument without default in non-interactive mode
127+
raise click.UsageError("A required value has no default and cannot be prompted in non-interactive mode.")
128+
return ""
129+
111130
# if default:
112131
# console.print(f"Default: [yellow]{default}[/]")
113132

@@ -142,7 +161,7 @@ def prompt_with_default(prompt_text, default="", hide_input=False, required=Fals
142161
# Empty result for required field without default is not allowed
143162
if not result.strip() and required and not default:
144163
console.print("[yellow]Warning: Required value cannot be empty.[/]")
145-
return prompt_with_default(prompt_text, default, hide_input, required)
164+
return prompt_with_default(prompt_text, default, hide_input, required, force)
146165

147166
return result
148167
except (KeyboardInterrupt, EOFError):
@@ -171,12 +190,13 @@ def install(server_name, force=False, alias=None):
171190
config_name = alias or server_name
172191

173192
# All servers are installed to global configuration
174-
console.print("[yellow]Installing server to global configuration...[/]")
193+
console_stderr = Console(stderr=True)
194+
console_stderr.print("[yellow]Installing server to global configuration...[/]")
175195

176196
# Get server metadata from repository
177197
server_metadata = repo_manager.get_server_metadata(server_name)
178198
if not server_metadata:
179-
console.print(f"[bold red]Error:[/] Server '{server_name}' not found in registry.")
199+
console.print(f"[bold red]Error:[/ ] Server '{server_name}' not found in registry.")
180200
console.print(f"Available servers: {', '.join(repo_manager._fetch_servers().keys())}")
181201
return
182202

@@ -195,7 +215,10 @@ def install(server_name, force=False, alias=None):
195215

196216
# Confirm addition
197217
alias_text = f" as '{alias}'" if alias else ""
198-
if not force and not Confirm.ask(f"Install this server to global configuration{alias_text}?"):
218+
# Bypass confirmation if force flag is set OR explicit non-interactive mode is enabled
219+
if not (should_force_operation(force) or is_explicit_non_interactive()) and not Confirm.ask(
220+
f"Install this server to global configuration{alias_text}?"
221+
):
199222
console.print("[yellow]Operation cancelled.[/]")
200223
return
201224

@@ -239,8 +262,8 @@ def install(server_name, force=False, alias=None):
239262
method_id = next(iter(installations))
240263
selected_method = installations[method_id]
241264

242-
# If multiple methods are available and not forced, offer selection
243-
if len(installations) > 1 and not force:
265+
# If multiple methods are available and not forced/non-interactive, offer selection
266+
if len(installations) > 1 and not (should_force_operation(force) or is_explicit_non_interactive()):
244267
console.print("\n[bold]Available installation methods:[/]")
245268
methods_list = []
246269

@@ -349,6 +372,7 @@ def install(server_name, force=False, alias=None):
349372
default=env_value,
350373
hide_input=_should_hide_input(arg_name),
351374
required=is_required,
375+
force=force,
352376
)
353377
if user_value != env_value:
354378
# User provided a different value
@@ -366,6 +390,7 @@ def install(server_name, force=False, alias=None):
366390
default=example if example else "",
367391
hide_input=_should_hide_input(arg_name),
368392
required=is_required,
393+
force=force,
369394
)
370395

371396
# Only add non-empty values to the environment
@@ -419,7 +444,7 @@ def install(server_name, force=False, alias=None):
419444
if has_non_standard_argument_define:
420445
# no matter in argument / env
421446
console.print(
422-
"[bold yellow]WARNING:[/] [bold]Non-standard argument format detected in server configuration.[/]\n"
447+
"[bold yellow]WARNING:[/ ] [bold]Non-standard argument format detected in server configuration.[/]\n"
423448
"[bold cyan]Future versions of MCPM will standardize all arguments in server configuration to use ${VARIABLE_NAME} format.[/]\n"
424449
"[bold]Please verify that your input arguments are correctly recognized.[/]\n"
425450
)
@@ -460,7 +485,7 @@ def install(server_name, force=False, alias=None):
460485
)
461486

462487
# Add server to global configuration
463-
success = global_add_server(full_server_config.to_server_config(), force)
488+
success = global_add_server(full_server_config.to_server_config(), should_force_operation(force))
464489

465490
if success:
466491
# Server has been successfully added to the global configuration
@@ -523,7 +548,7 @@ def extract_from_value(value):
523548

524549
# Check all fields in the installation method
525550
for key, value in installation_method.items():
526-
if key not in ["type", "description", "recommended"]: # Skip metadata fields
551+
if key not in ["type", "description", "recommended"]:
527552
extract_from_value(value)
528553

529554
return referenced

src/mcpm/commands/uninstall.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from mcpm.global_config import GlobalConfigManager
99
from mcpm.utils.display import print_server_config
10+
from mcpm.utils.non_interactive import is_explicit_non_interactive, should_force_operation
1011
from mcpm.utils.rich_click_config import click
1112

1213
console = Console()
@@ -17,14 +18,14 @@ def global_get_server(server_name: str):
1718
"""Get a server from the global MCPM configuration."""
1819
server = global_config_manager.get_server(server_name)
1920
if not server:
20-
console.print(f"[bold red]Error:[/] Server '{server_name}' not found in global configuration.")
21+
console.print(f"[bold red]Error:[/ ] Server '{server_name}' not found in global configuration.")
2122
return server
2223

2324

2425
def global_remove_server(server_name: str) -> bool:
2526
"""Remove a server from the global MCPM configuration and clean up profile tags."""
2627
if not global_config_manager.server_exists(server_name):
27-
console.print(f"[bold red]Error:[/] Server '{server_name}' not found in global configuration.")
28+
console.print(f"[bold red]Error:[/ ] Server '{server_name}' not found in global configuration.")
2829
return False
2930

3031
# Remove from global config (this automatically removes all profile tags)
@@ -54,13 +55,13 @@ def uninstall(server_name, force):
5455
return # Error message already printed by global_get_server
5556

5657
# Display server information before removal
57-
console.print(f"\n[bold cyan]Server information for:[/] {server_name}")
58+
console.print(f"\n[bold cyan]Server information for:[/ ] {server_name}")
5859

5960
print_server_config(server_info)
6061

61-
# Get confirmation if --force is not used
62-
if not force:
63-
console.print(f"\n[bold yellow]Are you sure you want to remove:[/] {server_name}")
62+
# Get confirmation if --force is not used and not in non-interactive mode
63+
if not (should_force_operation(force) or is_explicit_non_interactive()):
64+
console.print(f"\n[bold yellow]Are you sure you want to remove:[/ ] {server_name}")
6465
console.print("[italic]To bypass this confirmation, use --force[/]")
6566
# Use Rich's Confirm for a better user experience
6667
confirmed = Confirm.ask("Proceed with removal?")
@@ -69,13 +70,13 @@ def uninstall(server_name, force):
6970
return
7071

7172
# Log the removal action
72-
console.print(f"[bold red]Removing MCP server from global configuration:[/] {server_name}")
73+
console.print(f"[bold red]Removing MCP server from global configuration:[/ ] {server_name}")
7374

7475
# Remove from global configuration
7576
success = global_remove_server(server_name)
7677

7778
if success:
78-
console.print(f"[green]Successfully removed server:[/] {server_name}")
79+
console.print(f"[green]Successfully removed server:[/ ] {server_name}")
7980
console.print("[dim]Note: Server has been removed from global config. Profile tags are also cleared.[/]")
8081
else:
81-
console.print(f"[bold red]Error:[/] Failed to remove server '{server_name}'.")
82+
console.print(f"[bold red]Error:[/ ] Failed to remove server '{server_name}'.")

src/mcpm/utils/non_interactive.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@
77
from typing import Dict, List, Optional
88

99

10+
def is_explicit_non_interactive() -> bool:
11+
"""
12+
Check if non-interactive mode is explicitly enabled via environment variable.
13+
14+
This excludes implicit detection (like isatty) to avoid issues in tests or
15+
environments where TTY detection behaves unexpectedly but automation is not desired.
16+
17+
Returns:
18+
True if MCPM_NON_INTERACTIVE environment variable is set to 'true'
19+
"""
20+
return os.getenv("MCPM_NON_INTERACTIVE", "").lower() == "true"
21+
22+
1023
def is_non_interactive() -> bool:
1124
"""
1225
Check if running in non-interactive mode.
@@ -17,7 +30,7 @@ def is_non_interactive() -> bool:
1730
- Running in a CI environment
1831
"""
1932
# Check explicit non-interactive flag
20-
if os.getenv("MCPM_NON_INTERACTIVE", "").lower() == "true":
33+
if is_explicit_non_interactive():
2134
return True
2235

2336
# Check if not connected to a TTY
@@ -32,13 +45,17 @@ def is_non_interactive() -> bool:
3245
return False
3346

3447

35-
def should_force_operation() -> bool:
48+
def should_force_operation(cli_force_flag: bool = False) -> bool:
3649
"""
3750
Check if operations should be forced (skip confirmations).
3851
39-
Returns True if MCPM_FORCE environment variable is set to 'true'.
52+
Args:
53+
cli_force_flag: Boolean flag from CLI args (e.g. --force)
54+
55+
Returns:
56+
True if cli_force_flag is True OR MCPM_FORCE environment variable is set to 'true'.
4057
"""
41-
return os.getenv("MCPM_FORCE", "").lower() == "true"
58+
return cli_force_flag or os.getenv("MCPM_FORCE", "").lower() == "true"
4259

4360

4461
def should_output_json() -> bool:
@@ -304,4 +321,4 @@ def merge_server_config_updates(
304321
else:
305322
updated_config["headers"] = new_headers
306323

307-
return updated_config
324+
return updated_config

0 commit comments

Comments
 (0)