Skip to content

Commit 00dce3a

Browse files
committed
Fix bug in add server argument
1 parent 6a2d6b5 commit 00dce3a

File tree

4 files changed

+183
-44
lines changed

4 files changed

+183
-44
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ dependencies = [
2929
"watchfiles>=1.0.4",
3030
"duckdb>=1.2.2",
3131
"psutil>=7.0.0",
32+
"prompt-toolkit>=3.0.0",
3233
]
3334

3435
[project.urls]

src/mcpm/commands/server_operations/add.py

Lines changed: 109 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
import re
88

99
import click
10+
from prompt_toolkit import PromptSession
11+
from prompt_toolkit.formatted_text import HTML
12+
from prompt_toolkit.key_binding import KeyBindings
13+
from prompt_toolkit.styles import Style
1014
from rich.console import Console
1115
from rich.progress import Progress, SpinnerColumn, TextColumn
1216
from rich.prompt import Confirm
@@ -23,6 +27,64 @@
2327
repo_manager = RepositoryManager()
2428
profile_config_manager = ProfileConfigManager()
2529

30+
# Create a prompt session with custom styling
31+
prompt_session = PromptSession()
32+
style = Style.from_dict(
33+
{
34+
"prompt": "ansicyan bold",
35+
"default": "ansiyellow",
36+
"description": "ansiwhite",
37+
"required": "ansired",
38+
"optional": "ansigreen",
39+
}
40+
)
41+
42+
# Create key bindings
43+
kb = KeyBindings()
44+
45+
46+
def prompt_with_default(prompt_text, default="", hide_input=False, required=False):
47+
"""Prompt the user with a default value that can be edited directly.
48+
49+
Args:
50+
prompt_text: The prompt text to display
51+
default: The default value to show in the prompt
52+
hide_input: Whether to hide the input (for passwords)
53+
required: Whether this is a required field
54+
55+
Returns:
56+
The user's input or the default value if empty
57+
"""
58+
# if default:
59+
# console.print(f"Default: [yellow]{default}[/]")
60+
61+
# Get user input
62+
try:
63+
result = prompt_session.prompt(
64+
message=HTML(f"<prompt>{prompt_text}</prompt> > "),
65+
style=style,
66+
default=default,
67+
is_password=hide_input,
68+
key_bindings=kb,
69+
)
70+
71+
# Empty result for non-required field means leave it empty
72+
if not result.strip() and not required:
73+
return ""
74+
75+
# Empty result for required field with default should use default
76+
if not result.strip() and required and default:
77+
return default
78+
79+
# Empty result for required field without default is not allowed
80+
if not result.strip() and required and not default:
81+
console.print("[yellow]Warning: Required value cannot be empty.[/]")
82+
return prompt_with_default(prompt_text, default, hide_input, required)
83+
84+
return result
85+
except KeyboardInterrupt:
86+
raise click.Abort()
87+
2688

2789
@click.command()
2890
@click.argument("server_name")
@@ -222,33 +284,36 @@ def add(server_name, force=False, alias=None, target: str | None = None):
222284
description = arg_info.get("description", "")
223285
is_required = arg_info.get("required", False)
224286
example = arg_info.get("example", "")
225-
example_text = f" (example: {example})" if example else ""
226-
227-
# Build prompt text
228-
prompt_text = f"Enter value for {arg_name}{example_text}"
229-
if description:
230-
prompt_text += f"\n{description}"
231287

232288
# Add required indicator
233289
if is_required:
234290
required_args[arg_name] = arg_info
235-
prompt_text += " (required)"
291+
required_text = "(required)"
292+
required_html = "<ansired>(required)</ansired>"
236293
else:
237-
prompt_text += " (optional, press Enter to skip)"
294+
required_text = "(optional)"
295+
required_html = "<ansigreen>(optional)</ansigreen>"
296+
297+
# Build clean prompt text for console display and prompt
298+
console_prompt_text = f"{arg_name} {required_text}"
299+
html_prompt_text = f"{arg_name} {required_html}"
238300

239301
# Check if the argument is already set in environment
240302
env_value = os.environ.get(arg_name, "")
241303

304+
# Print description if available
305+
if description:
306+
console.print(f"[dim]{description}[/]")
307+
242308
if env_value:
243309
# Show the existing value as default
244-
console.print(f"[green]Found {arg_name} in environment: {env_value}[/]")
310+
console.print(f"[green]Found in environment: {env_value}[/]")
245311
try:
246-
user_value = click.prompt(
247-
prompt_text,
312+
user_value = prompt_with_default(
313+
html_prompt_text,
248314
default=env_value,
249-
hide_input="token" in arg_name.lower()
250-
or "key" in arg_name.lower()
251-
or "secret" in arg_name.lower(),
315+
hide_input=_should_hide_input(arg_name),
316+
required=is_required,
252317
)
253318
if user_value != env_value:
254319
# User provided a different value
@@ -261,29 +326,17 @@ def add(server_name, force=False, alias=None, target: str | None = None):
261326
else:
262327
# No environment value
263328
try:
264-
if is_required:
265-
# Required argument must have a value
266-
user_value = click.prompt(
267-
prompt_text,
268-
hide_input="token" in arg_name.lower()
269-
or "key" in arg_name.lower()
270-
or "secret" in arg_name.lower(),
271-
)
329+
user_value = prompt_with_default(
330+
html_prompt_text,
331+
default=example if example else "",
332+
hide_input=_should_hide_input(arg_name),
333+
required=is_required,
334+
)
335+
336+
# Only add non-empty values to the environment
337+
if user_value and user_value.strip():
272338
processed_variables[arg_name] = user_value
273-
else:
274-
# Optional argument can be skipped
275-
user_value = click.prompt(
276-
prompt_text,
277-
default="",
278-
show_default=False,
279-
hide_input="token" in arg_name.lower()
280-
or "key" in arg_name.lower()
281-
or "secret" in arg_name.lower(),
282-
)
283-
# Only add non-empty values to the environment
284-
if user_value and user_value.strip():
285-
processed_variables[arg_name] = user_value
286-
# Explicitly don't add anything if the user leaves it blank
339+
# Explicitly don't add anything if the user leaves it blank
287340
except click.Abort:
288341
if is_required:
289342
console.print(f"[yellow]Warning: Required argument {arg_name} not provided.[/]")
@@ -309,9 +362,11 @@ def add(server_name, force=False, alias=None, target: str | None = None):
309362
arg_info = required_args.get(key, {})
310363
description = arg_info.get("description", "")
311364
try:
312-
user_value = click.prompt(
365+
user_value = prompt_with_default(
313366
f"Enter value for {key} ({description})",
314-
hide_input="token" in key.lower() or "key" in key.lower(),
367+
default="",
368+
hide_input=_should_hide_input(key),
369+
required=True,
315370
)
316371
processed_env[key] = user_value
317372
except click.Abort:
@@ -333,11 +388,13 @@ def add(server_name, force=False, alias=None, target: str | None = None):
333388
# replace arguments with values
334389
processed_args = []
335390
for arg in install_args:
336-
matched = re.match(r"\$\{(.*?)\}", arg)
391+
# check if the argument contains a variable
392+
matched = re.search(r"\$\{([^}]+)\}", arg)
337393
if matched:
338394
original, key = matched.group(0), matched.group(1)
339395
if key not in processed_variables:
340-
# not required, remove the argument from args
396+
# Keep the original argument if variable not found
397+
processed_args.append(arg)
341398
continue
342399
replaced_arg = arg.replace(original, processed_variables.get(key, arg))
343400
processed_args.append(replaced_arg)
@@ -391,3 +448,15 @@ def add(server_name, force=False, alias=None, target: str | None = None):
391448
console.print(f' Try: [italic]"{prompt}"[/]\n')
392449
else:
393450
console.print(f"[bold red]Failed to add {server_name} to {target_name}.[/]")
451+
452+
453+
def _should_hide_input(arg_name: str) -> bool:
454+
"""Determine if input should be hidden for a given argument name.
455+
456+
Args:
457+
arg_name: The name of the argument to check
458+
459+
Returns:
460+
bool: True if input should be hidden, False otherwise
461+
"""
462+
return "token" in arg_name.lower() or "key" in arg_name.lower() or "secret" in arg_name.lower()

tests/test_add.py

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from unittest.mock import Mock
1+
from unittest.mock import Mock, patch
22

33
from click.testing import CliRunner
44

@@ -34,13 +34,80 @@ def test_add_server(windsurf_manager, monkeypatch):
3434
}
3535
),
3636
)
37-
runner = CliRunner()
38-
result = runner.invoke(add, ["server-test", "--force", "--alias", "test"], input="\njson\n\ntest-api-key\n")
39-
assert result.exit_code == 0
37+
38+
# Mock prompt_toolkit's prompt to return our test values
39+
with patch("prompt_toolkit.PromptSession.prompt", side_effect=["json", "test-api-key"]):
40+
runner = CliRunner()
41+
result = runner.invoke(add, ["server-test", "--force", "--alias", "test"])
42+
assert result.exit_code == 0
4043

4144
# Check that the server was added with alias
4245
server = windsurf_manager.get_server("test")
4346
assert server is not None
4447
assert server.command == "npx"
4548
assert server.args == ["-y", "@modelcontextprotocol/server-test", "--fmt", "json"]
4649
assert server.env["API_KEY"] == "test-api-key"
50+
51+
52+
def test_add_server_with_missing_arg(windsurf_manager, monkeypatch):
53+
"""Test add server with a missing argument that should remain in the args"""
54+
monkeypatch.setattr(ClientRegistry, "get_active_client", Mock(return_value="windsurf"))
55+
monkeypatch.setattr(ClientRegistry, "get_active_client_manager", Mock(return_value=windsurf_manager))
56+
monkeypatch.setattr(ClientRegistry, "get_client_manager", Mock(return_value=windsurf_manager))
57+
monkeypatch.setattr(
58+
RepositoryManager,
59+
"_fetch_servers",
60+
Mock(
61+
return_value={
62+
"server-test": {
63+
"installations": {
64+
"npm": {
65+
"type": "npm",
66+
"command": "npx",
67+
"args": [
68+
"-y",
69+
"@modelcontextprotocol/server-test",
70+
"--fmt",
71+
"${fmt}",
72+
"--timezone",
73+
"${TZ}", # TZ is not in the arguments list
74+
],
75+
"env": {"API_KEY": "${API_KEY}"},
76+
}
77+
},
78+
"arguments": {
79+
"fmt": {"type": "string", "description": "Output format", "required": True},
80+
"API_KEY": {"type": "string", "description": "API key", "required": True},
81+
# Deliberately not including TZ to test the bug fix
82+
},
83+
}
84+
}
85+
),
86+
)
87+
88+
# Instead of mocking Console and Progress, we'll mock key methods directly
89+
# This is a simpler approach that avoids complex mock setup
90+
with (
91+
patch("prompt_toolkit.PromptSession.prompt", side_effect=["json", "test-api-key"]),
92+
patch("rich.progress.Progress.start"),
93+
patch("rich.progress.Progress.stop"),
94+
patch("rich.progress.Progress.add_task"),
95+
):
96+
# Use CliRunner which provides its own isolated environment
97+
runner = CliRunner(mix_stderr=False)
98+
result = runner.invoke(add, ["server-test", "--force", "--alias", "test-missing-arg"])
99+
100+
if result.exit_code != 0:
101+
print(f"Exit code: {result.exit_code}")
102+
print(f"Exception: {result.exception}")
103+
print(f"Output: {result.stdout}")
104+
105+
assert result.exit_code == 0
106+
107+
# Check that the server was added with alias and the missing argument is preserved
108+
server = windsurf_manager.get_server("test-missing-arg")
109+
assert server is not None
110+
assert server.command == "npx"
111+
# The ${TZ} argument should remain intact since it's not in the processed variables
112+
assert server.args == ["-y", "@modelcontextprotocol/server-test", "--fmt", "json", "--timezone", "${TZ}"]
113+
assert server.env["API_KEY"] == "test-api-key"

uv.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)