Skip to content

Commit a1373dc

Browse files
committed
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
1 parent c457d7e commit a1373dc

File tree

4 files changed

+87
-48
lines changed

4 files changed

+87
-48
lines changed

src/mcpm/commands/install.py

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +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 should_force_operation
23+
from mcpm.utils.non_interactive import is_explicit_non_interactive, should_force_operation
2424
from mcpm.utils.repository import RepositoryManager
2525
from mcpm.utils.rich_click_config import click
2626

@@ -65,7 +65,7 @@ def _replace_node_executable(server_config: ServerConfig) -> ServerConfig:
6565
def global_add_server(server_config: ServerConfig, force: bool = False) -> bool:
6666
"""Add a server to the global MCPM configuration."""
6767
if global_config_manager.server_exists(server_config.name) and not force:
68-
console.print(f"[bold red]Error:[/] Server '{server_config.name}' already exists in global configuration.")
68+
console.print(f"[bold red]Error:[/ ] Server '{server_config.name}' already exists in global configuration.")
6969
console.print("Use --force to override.")
7070
return False
7171

@@ -76,6 +76,10 @@ def global_add_server(server_config: ServerConfig, force: bool = False) -> bool:
7676
def prompt_with_default(prompt_text, default="", hide_input=False, required=False, force=False):
7777
"""Prompt the user with a default value that can be edited directly.
7878
79+
In non-interactive mode (via --force or MCPM_NON_INTERACTIVE), this function
80+
returns the default value immediately. If a required value has no default
81+
in non-interactive mode, it raises click.UsageError.
82+
7983
Args:
8084
prompt_text: The prompt text to display
8185
default: The default value to show in the prompt
@@ -86,11 +90,12 @@ def prompt_with_default(prompt_text, default="", hide_input=False, required=Fals
8690
Returns:
8791
The user's input or the default value if empty
8892
"""
89-
# Check for explicit non-interactive mode (Env Var)
90-
# We do NOT check is_non_interactive() here because it includes isatty(),
91-
# which returns True in tests (CliRunner), causing us to skip mocked prompts.
92-
# Users desiring non-interactive behavior must set MCPM_NON_INTERACTIVE=true.
93-
if os.getenv("MCPM_NON_INTERACTIVE", "").lower() == "true" or should_force_operation(force):
93+
# Check for explicit non-interactive mode (Env Var) or Force flag
94+
# We use is_explicit_non_interactive() instead of the broader is_non_interactive()
95+
# because the latter includes isatty() checks. In test environments using CliRunner,
96+
# isatty() returns True, which would incorrectly skip our mocked prompts if we checked it here.
97+
# We specifically want to allow interaction in tests unless the Env Var is set.
98+
if is_explicit_non_interactive() or should_force_operation(force):
9499
if default:
95100
return default
96101
if required:
@@ -124,7 +129,7 @@ def prompt_with_default(prompt_text, default="", hide_input=False, required=Fals
124129
# Empty result for required field without default is not allowed
125130
if not result.strip() and required and not default:
126131
console.print("[yellow]Warning: Required value cannot be empty.[/]")
127-
return prompt_with_default(prompt_text, default, hide_input, required)
132+
return prompt_with_default(prompt_text, default, hide_input, required, force)
128133

129134
return result
130135
except KeyboardInterrupt:
@@ -159,7 +164,7 @@ def install(server_name, force=False, alias=None):
159164
# Get server metadata from repository
160165
server_metadata = repo_manager.get_server_metadata(server_name)
161166
if not server_metadata:
162-
console.print(f"[bold red]Error:[/] Server '{server_name}' not found in registry.")
167+
console.print(f"[bold red]Error:[/ ] Server '{server_name}' not found in registry.")
163168
console.print(f"Available servers: {', '.join(repo_manager._fetch_servers().keys())}")
164169
return
165170

@@ -178,7 +183,8 @@ def install(server_name, force=False, alias=None):
178183

179184
# Confirm addition
180185
alias_text = f" as '{alias}'" if alias else ""
181-
if not should_force_operation(force) and not Confirm.ask(f"Install this server to global configuration{alias_text}?"):
186+
# Bypass confirmation if force flag is set OR explicit non-interactive mode is enabled
187+
if not (should_force_operation(force) or is_explicit_non_interactive()) and not Confirm.ask(f"Install this server to global configuration{alias_text}?"):
182188
console.print("[yellow]Operation cancelled.[/]")
183189
return
184190

@@ -222,8 +228,8 @@ def install(server_name, force=False, alias=None):
222228
method_id = next(iter(installations))
223229
selected_method = installations[method_id]
224230

225-
# If multiple methods are available and not forced, offer selection
226-
if len(installations) > 1 and not should_force_operation(force):
231+
# If multiple methods are available and not forced/non-interactive, offer selection
232+
if len(installations) > 1 and not (should_force_operation(force) or is_explicit_non_interactive()):
227233
console.print("\n[bold]Available installation methods:[/]")
228234
methods_list = []
229235

@@ -332,6 +338,7 @@ def install(server_name, force=False, alias=None):
332338
default=env_value,
333339
hide_input=_should_hide_input(arg_name),
334340
required=is_required,
341+
force=force,
335342
)
336343
if user_value != env_value:
337344
# User provided a different value
@@ -349,6 +356,7 @@ def install(server_name, force=False, alias=None):
349356
default=example if example else "",
350357
hide_input=_should_hide_input(arg_name),
351358
required=is_required,
359+
force=force,
352360
)
353361

354362
# Only add non-empty values to the environment
@@ -402,7 +410,7 @@ def install(server_name, force=False, alias=None):
402410
if has_non_standard_argument_define:
403411
# no matter in argument / env
404412
console.print(
405-
"[bold yellow]WARNING:[/] [bold]Non-standard argument format detected in server configuration.[/]\n"
413+
"[bold yellow]WARNING:[/ ] [bold]Non-standard argument format detected in server configuration.[/]\n"
406414
"[bold cyan]Future versions of MCPM will standardize all arguments in server configuration to use ${VARIABLE_NAME} format.[/]\n"
407415
"[bold]Please verify that your input arguments are correctly recognized.[/]\n"
408416
)
@@ -506,7 +514,7 @@ def extract_from_value(value):
506514

507515
# Check all fields in the installation method
508516
for key, value in installation_method.items():
509-
if key not in ["type", "description", "recommended"]: # Skip metadata fields
517+
if key not in ["type", "description", "recommended"]:
510518
extract_from_value(value)
511519

512520
return referenced
@@ -569,10 +577,12 @@ def _replace_argument_variables(value: str, prev_value: str, variables: dict) ->
569577
if matched:
570578
original, var_name = matched.group(0), matched.group(1)
571579
# Use empty string as default when key not found
572-
return value.replace(original, variables.get(var_name, "")), ReplacementStatus.STANDARD_REPLACE
580+
return value.replace(original, variables.get(var_name, "")),
581+
ReplacementStatus.STANDARD_REPLACE
573582

574583
# arg: --VAR=your var value
575-
key_value_match = re.match(r"^([A-Z_]+)=(.+)$", value)
584+
key_value_match = re.match(r"^([A-Z_]+)=(.+)$
585+
", value)
576586
if key_value_match:
577587
arg_key = key_value_match.group(1)
578588
if arg_key in variables:
@@ -591,4 +601,4 @@ def _replace_argument_variables(value: str, prev_value: str, variables: dict) ->
591601
return value, ReplacementStatus.NOT_REPLACED
592602

593603
# nothing to replace
594-
return value, ReplacementStatus.NOT_REPLACED
604+
return value, ReplacementStatus.NOT_REPLACED

src/mcpm/commands/uninstall.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +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 should_force_operation
10+
from mcpm.utils.non_interactive import is_explicit_non_interactive, should_force_operation
1111
from mcpm.utils.rich_click_config import click
1212

1313
console = Console()
@@ -18,14 +18,14 @@ def global_get_server(server_name: str):
1818
"""Get a server from the global MCPM configuration."""
1919
server = global_config_manager.get_server(server_name)
2020
if not server:
21-
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.")
2222
return server
2323

2424

2525
def global_remove_server(server_name: str) -> bool:
2626
"""Remove a server from the global MCPM configuration and clean up profile tags."""
2727
if not global_config_manager.server_exists(server_name):
28-
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.")
2929
return False
3030

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

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

6060
print_server_config(server_info)
6161

62-
# Get confirmation if --force is not used
63-
if not should_force_operation(force):
64-
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}")
6565
console.print("[italic]To bypass this confirmation, use --force[/]")
6666
# Use Rich's Confirm for a better user experience
6767
confirmed = Confirm.ask("Proceed with removal?")
@@ -70,13 +70,13 @@ def uninstall(server_name, force):
7070
return
7171

7272
# Log the removal action
73-
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}")
7474

7575
# Remove from global configuration
7676
success = global_remove_server(server_name)
7777

7878
if success:
79-
console.print(f"[green]Successfully removed server:[/] {server_name}")
79+
console.print(f"[green]Successfully removed server:[/ ] {server_name}")
8080
console.print("[dim]Note: Server has been removed from global config. Profile tags are also cleared.[/]")
8181
else:
82-
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: 15 additions & 2 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
@@ -308,4 +321,4 @@ def merge_server_config_updates(
308321
else:
309322
updated_config["headers"] = new_headers
310323

311-
return updated_config
324+
return updated_config

tests/test_add.py

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,16 @@ def test_add_server(windsurf_manager, monkeypatch, tmp_path):
4141
)
4242

4343
# Mock prompt_toolkit's prompt to return our test values
44-
with patch("prompt_toolkit.PromptSession.prompt", side_effect=["json", "test-api-key"]):
44+
# Note: With --force, the CLI will look for env vars for required args instead of prompting
45+
monkeypatch.setenv("fmt", "json")
46+
monkeypatch.setenv("API_KEY", "test-api-key")
47+
48+
# We still patch PromptSession to ensure it's NOT called (or ignored if called incorrectly)
49+
with patch("prompt_toolkit.PromptSession.prompt") as mock_prompt:
4550
runner = CliRunner()
4651
result = runner.invoke(install, ["server-test", "--force", "--alias", "test"])
4752
assert result.exit_code == 0
53+
mock_prompt.assert_not_called()
4854

4955
# Check that the server was added to global configuration with alias
5056
server = global_config_manager.get_server("test")
@@ -95,8 +101,13 @@ def test_add_server_with_missing_arg(windsurf_manager, monkeypatch, tmp_path):
95101

96102
# Instead of mocking Console and Progress, we'll mock key methods directly
97103
# This is a simpler approach that avoids complex mock setup
104+
105+
# Set environment variables for required arguments
106+
monkeypatch.setenv("fmt", "json")
107+
monkeypatch.setenv("API_KEY", "test-api-key")
108+
98109
with (
99-
patch("prompt_toolkit.PromptSession.prompt", side_effect=["json", "test-api-key"]),
110+
patch("prompt_toolkit.PromptSession.prompt") as mock_prompt,
100111
patch("rich.progress.Progress.start"),
101112
patch("rich.progress.Progress.stop"),
102113
patch("rich.progress.Progress.add_task"),
@@ -111,6 +122,7 @@ def test_add_server_with_missing_arg(windsurf_manager, monkeypatch, tmp_path):
111122
print(f"Output: {result.stdout}")
112123

113124
assert result.exit_code == 0
125+
mock_prompt.assert_not_called()
114126

115127
# Check that the server was added with alias and the missing argument is replaced with empty string
116128
server = global_config_manager.get_server("test-missing-arg")
@@ -166,8 +178,12 @@ def test_add_server_with_empty_args(windsurf_manager, monkeypatch, tmp_path):
166178
)
167179

168180
# Mock prompt responses for required arguments only
181+
monkeypatch.setenv("fmt", "json")
182+
monkeypatch.setenv("API_KEY", "test-api-key")
183+
# OPTIONAL is not set, simulating empty/missing optional arg
184+
169185
with (
170-
patch("prompt_toolkit.PromptSession.prompt", side_effect=["json", "test-api-key"]),
186+
patch("prompt_toolkit.PromptSession.prompt") as mock_prompt,
171187
patch("rich.progress.Progress.start"),
172188
patch("rich.progress.Progress.stop"),
173189
patch("rich.progress.Progress.add_task"),
@@ -176,6 +192,7 @@ def test_add_server_with_empty_args(windsurf_manager, monkeypatch, tmp_path):
176192
result = runner.invoke(install, ["server-test", "--force", "--alias", "test-empty-args"])
177193

178194
assert result.exit_code == 0
195+
mock_prompt.assert_not_called()
179196

180197
# Check that the server was added and optional arguments are empty
181198
server = global_config_manager.get_server("test-empty-args")
@@ -269,14 +286,18 @@ def test_add_server_with_configured_npx(windsurf_manager, monkeypatch, tmp_path)
269286
)
270287

271288
# Mock Rich's progress display to prevent 'Only one live display may be active at once' error
289+
monkeypatch.setenv("fmt", "json")
290+
monkeypatch.setenv("API_KEY", "test-api-key")
291+
272292
with (
273293
patch("rich.progress.Progress.__enter__", return_value=Mock()),
274294
patch("rich.progress.Progress.__exit__"),
275-
patch("prompt_toolkit.PromptSession.prompt", side_effect=["json", "test-api-key"]),
295+
patch("prompt_toolkit.PromptSession.prompt") as mock_prompt,
276296
):
277297
runner = CliRunner()
278298
result = runner.invoke(install, ["server-test", "--force", "--alias", "test"])
279299
assert result.exit_code == 0
300+
mock_prompt.assert_not_called()
280301

281302
# Check that the server was added with alias
282303
server = global_config_manager.get_server("test")
@@ -384,27 +405,19 @@ def test_add_server_with_filtered_arguments(windsurf_manager, monkeypatch, tmp_p
384405

385406
# Mock prompt_toolkit's prompt to return our test values
386407
# Should only be called once for API_KEY since that's the only referenced variable
387-
prompt_calls = []
388-
389-
def mock_prompt_func(*args, **kwargs):
390-
prompt_calls.append(kwargs.get("message", ""))
391-
return "test-api-key"
392-
408+
# With force=True, we use env vars
409+
monkeypatch.setenv("API_KEY", "test-api-key")
410+
393411
with (
394-
patch("prompt_toolkit.PromptSession.prompt", side_effect=mock_prompt_func),
412+
patch("prompt_toolkit.PromptSession.prompt") as mock_prompt,
395413
patch("rich.progress.Progress.start"),
396414
patch("rich.progress.Progress.stop"),
397415
patch("rich.progress.Progress.add_task"),
398416
):
399417
runner = CliRunner()
400418
result = runner.invoke(install, ["test-server", "--force"])
401419
assert result.exit_code == 0
402-
403-
# Check that only API_KEY was prompted for
404-
assert len(prompt_calls) == 1
405-
assert "API_KEY" in str(prompt_calls[0])
406-
assert "DATABASE_URL" not in str(prompt_calls[0])
407-
assert "UNUSED_VAR" not in str(prompt_calls[0])
420+
mock_prompt.assert_not_called()
408421

409422
# Check that the server was added correctly
410423
server = global_config_manager.get_server("test-server")
@@ -449,20 +462,23 @@ def test_add_http_server_with_headers(windsurf_manager, monkeypatch, tmp_path):
449462
)
450463

451464
# Mock prompt_toolkit's prompt to return our test values
465+
monkeypatch.setenv("API_TOKEN", "test-token-123")
466+
452467
with (
453-
patch("prompt_toolkit.PromptSession.prompt", side_effect=["test-token-123"]),
468+
patch("prompt_toolkit.PromptSession.prompt") as mock_prompt,
454469
patch("rich.progress.Progress.start"),
455470
patch("rich.progress.Progress.stop"),
456471
patch("rich.progress.Progress.add_task"),
457472
):
458473
runner = CliRunner()
459474
result = runner.invoke(install, ["api-server", "--force"])
460475
assert result.exit_code == 0
476+
mock_prompt.assert_not_called()
461477

462478
# Check that the server was added to global configuration as RemoteServerConfig
463479
server = global_config_manager.get_server("api-server")
464480
assert server is not None
465481
assert isinstance(server, RemoteServerConfig)
466482
assert server.url == "https://api.example.com/mcp"
467483
# Check headers were properly set with variable replacement
468-
assert server.headers == {"Authorization": "Bearer test-token-123", "X-API-Version": "1.0"}
484+
assert server.headers == {"Authorization": "Bearer test-token-123", "X-API-Version": "1.0"}

0 commit comments

Comments
 (0)