Skip to content

Commit 2752fca

Browse files
fix: args placeholder is not replaced (#17)
1 parent b28778f commit 2752fca

File tree

8 files changed

+452
-70
lines changed

8 files changed

+452
-70
lines changed

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,9 @@ fix = true
4949
[tool.ruff.lint]
5050
select = ["F", "E", "W", "I"]
5151
fixable = ["I", "F401"]
52+
53+
[dependency-groups]
54+
dev = [
55+
"ipython>=8.34.0",
56+
"pytest>=8.3.5",
57+
]

src/mcpm/clients/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,7 @@ def from_client_format(cls, server_name: str, client_config: Dict[str, Any]) ->
306306

307307
# Add environment variables if present
308308
if "env" in client_config:
309-
server_data["env_vars"] = client_config["env"]
309+
server_data["env"] = client_config["env"]
310310

311311
return ServerConfig.from_dict(server_data)
312312

src/mcpm/commands/add.py

Lines changed: 37 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import json
66
import os
7+
import re
78

89
import click
910
from rich.console import Console
@@ -157,7 +158,6 @@ def add(server_name, force=False, alias=None):
157158
install_args = selected_method.get("args", install_args)
158159
package_name = selected_method.get("package", package_name)
159160
env_vars = selected_method.get("env", env_vars)
160-
required_args = server_metadata.get("required_args", required_args)
161161

162162
console.print(f"\n[green]Using [bold]{installation_method}[/] installation method[/]")
163163

@@ -175,7 +175,9 @@ def add(server_name, force=False, alias=None):
175175
# Get all available arguments from the server metadata
176176
all_arguments = server_metadata.get("arguments", {})
177177

178-
# Process environment variables to store in config
178+
required_args = {}
179+
# Process variables to store in config
180+
processed_variables = {}
179181
processed_env = {}
180182

181183
# First, prompt for all defined arguments even if they're not in env_vars
@@ -196,6 +198,7 @@ def add(server_name, force=False, alias=None):
196198

197199
# Add required indicator
198200
if is_required:
201+
required_args[arg_name] = arg_info
199202
prompt_text += " (required)"
200203
else:
201204
prompt_text += " (optional, press Enter to skip)"
@@ -216,13 +219,12 @@ def add(server_name, force=False, alias=None):
216219
)
217220
if user_value != env_value:
218221
# User provided a different value
219-
processed_env[arg_name] = user_value
222+
processed_variables[arg_name] = user_value
220223
else:
221-
# User kept the environment value
222-
processed_env[arg_name] = f"${{{arg_name}}}"
224+
# User use environment value
225+
processed_variables[arg_name] = env_value
223226
except click.Abort:
224-
# Keep environment reference on abort
225-
processed_env[arg_name] = f"${{{arg_name}}}"
227+
pass
226228
else:
227229
# No environment value
228230
try:
@@ -234,7 +236,7 @@ def add(server_name, force=False, alias=None):
234236
or "key" in arg_name.lower()
235237
or "secret" in arg_name.lower(),
236238
)
237-
processed_env[arg_name] = user_value
239+
processed_variables[arg_name] = user_value
238240
else:
239241
# Optional argument can be skipped
240242
user_value = click.prompt(
@@ -247,43 +249,36 @@ def add(server_name, force=False, alias=None):
247249
)
248250
# Only add non-empty values to the environment
249251
if user_value and user_value.strip():
250-
processed_env[arg_name] = user_value
252+
processed_variables[arg_name] = user_value
251253
# Explicitly don't add anything if the user leaves it blank
252254
except click.Abort:
253255
if is_required:
254256
console.print(f"[yellow]Warning: Required argument {arg_name} not provided.[/]")
255-
# Store as environment reference even if missing
256-
processed_env[arg_name] = f"${{{arg_name}}}"
257257

258258
# Resume progress display
259259
progress = Progress(SpinnerColumn(), TextColumn("[bold green]{task.description}[/]"), console=console)
260260
progress.start()
261261
progress.add_task(f"Configuring {server_name}...", total=None)
262262

263-
# Now process any remaining environment variables from the installation method
263+
# Now process the replacement of environment variables
264264
for key, value in env_vars.items():
265-
# Skip if we already processed this key
266-
if key in processed_env:
267-
continue
265+
processed_env[key] = value
268266

269267
if isinstance(value, str) and value.startswith("${") and value.endswith("}"):
270-
env_var_name = value[2:-1] # Extract variable name from ${NAME}
271-
is_required = env_var_name in required_args
272-
273268
# For required arguments, prompt the user if not in environment
274-
env_value = os.environ.get(env_var_name, "")
269+
env_value = os.environ.get(key, "")
275270

276-
if not env_value and is_required:
271+
if not env_value and key in required_args and key not in processed_variables:
277272
progress.stop()
278-
console.print(f"[yellow]Warning:[/] Required argument {env_var_name} is not set in environment")
273+
console.print(f"[yellow]Warning:[/] Required argument {key} is not set in environment")
279274

280275
# Prompt for the value
281-
arg_info = required_args.get(env_var_name, {})
276+
arg_info = required_args.get(key, {})
282277
description = arg_info.get("description", "")
283278
try:
284279
user_value = click.prompt(
285-
f"Enter value for {env_var_name} ({description})",
286-
hide_input="token" in env_var_name.lower() or "key" in env_var_name.lower(),
280+
f"Enter value for {key} ({description})",
281+
hide_input="token" in key.lower() or "key" in key.lower(),
287282
)
288283
processed_env[key] = user_value
289284
except click.Abort:
@@ -298,19 +293,33 @@ def add(server_name, force=False, alias=None):
298293
progress.add_task(f"Configuring {server_name}...", total=None)
299294
else:
300295
# Store reference to environment variable
301-
processed_env[key] = value
296+
processed_env[key] = processed_variables.get(key, env_value)
302297
else:
303298
processed_env[key] = value
304299

300+
# replace arguments with values
301+
processed_args = []
302+
for arg in install_args:
303+
matched = re.match(r"\$\{(.*?)\}", arg)
304+
if matched:
305+
original, key = matched.group(0), matched.group(1)
306+
if key not in processed_variables:
307+
# not required, remove the argument from args
308+
continue
309+
replaced_arg = arg.replace(original, processed_variables.get(key, arg))
310+
processed_args.append(replaced_arg)
311+
else:
312+
processed_args.append(arg)
313+
305314
# Get actual MCP execution command, args, and env from the selected installation method
306315
# This ensures we use the actual server command information instead of placeholders
307316
if selected_method:
308317
mcp_command = selected_method.get("command", install_command)
309-
mcp_args = selected_method.get("args", install_args)
318+
mcp_args = processed_args
310319
# Env vars are already processed above
311320
else:
312321
mcp_command = install_command
313-
mcp_args = install_args
322+
mcp_args = processed_args
314323

315324
# Create server configuration using ServerConfig
316325
server_config = ServerConfig(
@@ -319,7 +328,7 @@ def add(server_name, force=False, alias=None):
319328
description=description,
320329
command=mcp_command, # Use the actual MCP server command
321330
args=mcp_args, # Use the actual MCP server arguments
322-
env_vars=processed_env,
331+
env=processed_env,
323332
# Use the simplified installation method
324333
installation=installation_method,
325334
)

src/mcpm/utils/server_config.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"""Server configuration utilities for MCPM"""
22

3-
import os
4-
from typing import Dict, List, Optional, ClassVar
3+
from typing import ClassVar, Dict, List, Optional
54

65
from pydantic import BaseModel, Field, model_validator
76

@@ -20,7 +19,7 @@ class ServerConfig(BaseModel):
2019
args: List[str]
2120

2221
# Optional fields
23-
env_vars: Dict[str, str] = Field(default_factory=dict)
22+
env: Dict[str, str] = Field(default_factory=dict)
2423
display_name: Optional[str] = None
2524
description: str = ""
2625
installation: Optional[str] = None
@@ -81,15 +80,15 @@ def get_filtered_env_vars(self, env: Dict[str, str]) -> Dict[str, str]:
8180
Returns:
8281
Dictionary of non-empty environment variables
8382
"""
84-
if not self.env_vars:
83+
if not self.env:
8584
return {}
8685

8786
# Use provided environment without falling back to os.environ
8887
environment = env
8988

9089
# Filter out empty environment variables
9190
non_empty_env = {}
92-
for key, value in self.env_vars.items():
91+
for key, value in self.env.items():
9392
# For environment variable references like ${VAR_NAME}, check if the variable exists
9493
# and has a non-empty value. If it doesn't exist or is empty, exclude it.
9594
if value is not None and isinstance(value, str):

tests/conftest.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,68 @@
22
Pytest configuration for MCPM tests
33
"""
44

5+
import json
6+
import os
57
import sys
8+
import tempfile
69
from pathlib import Path
710

11+
import pytest
12+
813
# Add the src directory to the path for all tests
914
sys.path.insert(0, str(Path(__file__).parent.parent))
15+
16+
from mcpm.clients.managers.windsurf import WindsurfManager
17+
from mcpm.utils.config import ConfigManager
18+
19+
20+
@pytest.fixture
21+
def temp_config_file():
22+
"""Create a temporary Windsurf config file for testing"""
23+
with tempfile.NamedTemporaryFile(delete=False, suffix=".json") as f:
24+
# Create a basic config with a test server
25+
config = {
26+
"mcpServers": {
27+
"test-server": {
28+
"command": "npx",
29+
"args": ["-y", "@modelcontextprotocol/server-test"],
30+
"version": "1.0.0",
31+
"path": "/path/to/server",
32+
"display_name": "Test Server",
33+
}
34+
}
35+
}
36+
f.write(json.dumps(config).encode("utf-8"))
37+
temp_path = f.name
38+
39+
yield temp_path
40+
# Clean up
41+
os.unlink(temp_path)
42+
43+
44+
@pytest.fixture
45+
def config_manager():
46+
"""Create a ClientConfigManager with a temp config for testing"""
47+
with tempfile.TemporaryDirectory() as temp_dir:
48+
config_path = os.path.join(temp_dir, "config.json")
49+
# Create ConfigManager with the temp path
50+
config_mgr = ConfigManager(config_path=config_path)
51+
# Create ClientConfigManager that will use this ConfigManager internally
52+
from mcpm.clients.client_config import ClientConfigManager
53+
54+
client_mgr = ClientConfigManager()
55+
# Override its internal config_manager with our temp one
56+
client_mgr.config_manager = config_mgr
57+
yield client_mgr
58+
59+
60+
@pytest.fixture
61+
def windsurf_manager(temp_config_file):
62+
"""Create a WindsurfManager instance using the temp config file"""
63+
return WindsurfManager(config_path=temp_config_file)
64+
65+
66+
@pytest.fixture
67+
def empty_windsurf_manager(empty_config_file):
68+
"""Create a WindsurfManager instance with an empty config"""
69+
return WindsurfManager(config_path=empty_config_file)

tests/test_add.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from unittest.mock import Mock
2+
3+
from click.testing import CliRunner
4+
5+
from mcpm.clients.client_registry import ClientRegistry
6+
from mcpm.commands.add import add
7+
from mcpm.utils.repository import RepositoryManager
8+
9+
10+
def test_add_server(windsurf_manager, monkeypatch):
11+
"""Test add server"""
12+
monkeypatch.setattr(ClientRegistry, "get_active_client", Mock(return_value="windsurf"))
13+
monkeypatch.setattr(ClientRegistry, "get_active_client_manager", Mock(return_value=windsurf_manager))
14+
monkeypatch.setattr(
15+
RepositoryManager,
16+
"_fetch_servers",
17+
Mock(
18+
return_value={
19+
"server-test": {
20+
"installations": {
21+
"npm": {
22+
"type": "npm",
23+
"command": "npx",
24+
"args": ["-y", "@modelcontextprotocol/server-test", "--fmt", "${fmt}"],
25+
"env": {"API_KEY": "${API_KEY}"},
26+
}
27+
},
28+
"arguments": {
29+
"fmt": {"type": "string", "description": "Output format", "required": True},
30+
"API_KEY": {"type": "string", "description": "API key", "required": True},
31+
},
32+
}
33+
}
34+
),
35+
)
36+
runner = CliRunner()
37+
result = runner.invoke(add, ["server-test", "--force", "--alias", "test"], input="\njson\n\ntest-api-key\n")
38+
assert result.exit_code == 0
39+
40+
# Check that the server was added
41+
server = windsurf_manager.get_server("test")
42+
assert server is not None
43+
assert server.command == "npx"
44+
assert server.args == ["-y", "@modelcontextprotocol/server-test", "--fmt", "json"]
45+
assert server.env["API_KEY"] == "test-api-key"

0 commit comments

Comments
 (0)