Skip to content

Commit 3c77974

Browse files
feat: support custom node runtime config (#186)
* feat: support custom node runtime config * test: fix test * Update src/mcpm/commands/config.py Co-authored-by: qodo-merge-pro[bot] <151058649+qodo-merge-pro[bot]@users.noreply.github.com> * Update src/mcpm/commands/target_operations/common.py Co-authored-by: qodo-merge-pro[bot] <151058649+qodo-merge-pro[bot]@users.noreply.github.com> * Update src/mcpm/commands/config.py Co-authored-by: qodo-merge-pro[bot] <151058649+qodo-merge-pro[bot]@users.noreply.github.com> * fix: help doc --------- Co-authored-by: qodo-merge-pro[bot] <151058649+qodo-merge-pro[bot]@users.noreply.github.com>
1 parent c72f637 commit 3c77974

File tree

11 files changed

+145
-109
lines changed

11 files changed

+145
-109
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,8 @@ mcpm share "npx -y @modelcontextprotocol/server-everything" --retry 3
175175

176176
```bash
177177
mcpm config clear-cache # Clear MCPM's registry cache. Cache defaults to refresh every 1 hour.
178+
mcpm config set # Set global MCPM configuration, currently only support node_executable
179+
mcpm config get <name> # Get global MCPM configuration
178180
mcpm inspector # Launch the MCPM Inspector UI to examine server configs
179181
```
180182

README.zh-CN.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,8 @@ mcpm share "npx -y @modelcontextprotocol/server-everything" --retry 3
208208

209209
```bash
210210
mcpm config clear-cache # 清除 MCPM 的注册表缓存。缓存默认每 1 小时刷新一次。
211+
mcpm config set # 设置 MCPM 的全局配置,目前仅支持 node_executable
212+
mcpm config get <name> # 获取 MCPM 的全局配置
211213
mcpm inspector # 启动 MCPM 检查器 UI 以检查服务器配置
212214
```
213215

src/mcpm/commands/config.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44

55
import click
66
from rich.console import Console
7+
from rich.prompt import Prompt
78

9+
from mcpm.utils.config import NODE_EXECUTABLES, ConfigManager
810
from mcpm.utils.repository import RepositoryManager
911

1012
console = Console()
@@ -21,6 +23,45 @@ def config():
2123
pass
2224

2325

26+
@config.command()
27+
@click.help_option("-h", "--help")
28+
def set():
29+
"""Set MCPM configuration.
30+
31+
Example:
32+
33+
\b
34+
mcpm config set
35+
"""
36+
set_key = Prompt.ask("Configuration key to set", choices=["node_executable"], default="node_executable")
37+
node_executable = Prompt.ask(
38+
"Select default node executable, it will be automatically applied when adding npx server with mcpm add",
39+
choices=NODE_EXECUTABLES,
40+
)
41+
config_manager = ConfigManager()
42+
config_manager.set_config(set_key, node_executable)
43+
console.print(f"[green]Default node executable set to:[/] {node_executable}")
44+
45+
46+
@config.command()
47+
@click.argument("name", required=True)
48+
@click.help_option("-h", "--help")
49+
def get(name):
50+
"""Get MCPM configuration.
51+
52+
Example:
53+
54+
\b
55+
mcpm config get node_executable
56+
"""
57+
config_manager = ConfigManager()
58+
current_config = config_manager.get_config()
59+
if name not in current_config:
60+
console.print(f"[red]Configuration '{name}' not set or not supported.[/]")
61+
return
62+
console.print(f"[green]{name}:[/] {current_config[name]}")
63+
64+
2465
@config.command()
2566
@click.help_option("-h", "--help")
2667
def clear_cache():

src/mcpm/commands/target_operations/common.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from rich.console import Console
22

33
from mcpm.clients.client_registry import ClientRegistry
4-
from mcpm.core.schema import ServerConfig
4+
from mcpm.core.schema import ServerConfig, STDIOServerConfig
55
from mcpm.profile.profile_config import ProfileConfigManager
6-
from mcpm.utils.config import ConfigManager
6+
from mcpm.utils.config import NODE_EXECUTABLES, ConfigManager
77
from mcpm.utils.display import print_active_scope, print_no_active_scope
88
from mcpm.utils.scope import ScopeType, extract_from_scope, parse_server
99

@@ -32,6 +32,22 @@ def determine_target(target: str) -> tuple[ScopeType | None, str | None, str | N
3232
return scope_type, scope, server_name
3333

3434

35+
def _replace_node_executable(server_config: ServerConfig) -> ServerConfig:
36+
if not isinstance(server_config, STDIOServerConfig):
37+
return server_config
38+
command = server_config.command.strip()
39+
if command not in NODE_EXECUTABLES:
40+
return server_config
41+
config = ConfigManager().get_config()
42+
config_node_executable = config.get("node_executable")
43+
if not config_node_executable:
44+
return server_config
45+
if config_node_executable != command:
46+
console.print(f"[bold cyan]Replace node executable {command} with {config_node_executable}[/]")
47+
server_config.command = config_node_executable
48+
return server_config
49+
50+
3551
def client_add_server(client: str, server_config: ServerConfig, force: bool = False) -> bool:
3652
client_manager = ClientRegistry.get_client_manager(client)
3753
if not client_manager:
@@ -41,6 +57,7 @@ def client_add_server(client: str, server_config: ServerConfig, force: bool = Fa
4157
console.print(f"[bold red]Error:[/] Server '{server_config.name}' already exists in {client}.")
4258
console.print("Use --force to override.")
4359
return False
60+
server_config = _replace_node_executable(server_config)
4461
success = client_manager.add_server(server_config)
4562

4663
return success
@@ -73,6 +90,7 @@ def profile_add_server(profile: str, server_config: ServerConfig, force: bool =
7390
console.print(f"[bold red]Error:[/] Server '{server_config.name}' already exists in {profile}.")
7491
console.print("Use --force to override.")
7592
return False
93+
server_config = _replace_node_executable(server_config)
7694
success = profile_manager.set_profile(profile, server_config)
7795
return success
7896

src/mcpm/utils/config.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
MCPM_AUTH_HEADER = "X-MCPM-SECRET"
2525
MCPM_PROFILE_HEADER = "X-MCPM-PROFILE"
2626

27+
NODE_EXECUTABLES = ["npx", "bunx", "pnpm dlx", "yarn dlx"]
28+
2729

2830
class ConfigManager:
2931
"""Manages MCP basic configuration
@@ -35,7 +37,7 @@ class ConfigManager:
3537
def __init__(self, config_path: str = DEFAULT_CONFIG_FILE):
3638
self.config_path = config_path
3739
self.config_dir = os.path.dirname(config_path)
38-
self._config = None
40+
self._config = {}
3941
self._ensure_dirs()
4042
self._load_config()
4143

src/mcpm/utils/scope.py

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,4 @@
1-
import sys
2-
3-
if sys.version_info >= (3, 11):
4-
from enum import StrEnum
5-
else:
6-
from enum import Enum
7-
8-
class StrEnum(str, Enum):
9-
"""String enumeration for Python versions before 3.11."""
10-
11-
def __str__(self) -> str:
12-
return self.value
13-
1+
from enum import StrEnum
142

153
CLIENT_PREFIX = "@"
164
PROFILE_PREFIX = "%"

tests/conftest.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77
import sys
88
import tempfile
99
from pathlib import Path
10+
from unittest.mock import Mock
1011

1112
import pytest
1213

1314
# Add the src directory to the path for all tests
1415
sys.path.insert(0, str(Path(__file__).parent.parent))
1516

17+
from mcpm.clients.client_registry import ClientRegistry
1618
from mcpm.clients.managers.claude_desktop import ClaudeDesktopManager
1719
from mcpm.clients.managers.windsurf import WindsurfManager
1820
from mcpm.utils.config import ConfigManager
@@ -43,12 +45,21 @@ def temp_config_file():
4345

4446

4547
@pytest.fixture
46-
def config_manager():
48+
def config_manager(monkeypatch):
4749
"""Create a ClientConfigManager with a temp config for testing"""
4850
with tempfile.TemporaryDirectory() as temp_dir:
49-
config_path = os.path.join(temp_dir, "config.json")
51+
tmp_config_path = os.path.join(temp_dir, "config.json")
5052
# Create ConfigManager with the temp path
51-
config_mgr = ConfigManager(config_path=config_path)
53+
54+
_original_init = ConfigManager.__init__
55+
56+
def _mock_init(self, config_path=tmp_config_path):
57+
_original_init(self, config_path)
58+
self.config_path = tmp_config_path
59+
60+
monkeypatch.setattr(ConfigManager, "__init__", _mock_init)
61+
62+
config_mgr = ConfigManager()
5263
# Create ClientConfigManager that will use this ConfigManager internally
5364
from mcpm.clients.client_config import ClientConfigManager
5465

@@ -59,9 +70,13 @@ def config_manager():
5970

6071

6172
@pytest.fixture
62-
def windsurf_manager(temp_config_file):
73+
def windsurf_manager(temp_config_file, monkeypatch, config_manager):
6374
"""Create a WindsurfManager instance using the temp config file"""
64-
return WindsurfManager(config_path=temp_config_file)
75+
windsurf_manager = WindsurfManager(config_path=temp_config_file)
76+
monkeypatch.setattr(ClientRegistry, "get_active_client_manager", Mock(return_value=windsurf_manager))
77+
monkeypatch.setattr(ClientRegistry, "get_client_manager", Mock(return_value=windsurf_manager))
78+
monkeypatch.setattr(ClientRegistry, "get_active_target", Mock(return_value="@windsurf"))
79+
return windsurf_manager
6580

6681

6782
@pytest.fixture

tests/test_add.py

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from click.testing import CliRunner
44

5-
from mcpm.clients.client_registry import ClientRegistry
65
from mcpm.commands.target_operations.add import add
76
from mcpm.core.schema import RemoteServerConfig
87
from mcpm.utils.config import ConfigManager
@@ -11,9 +10,6 @@
1110

1211
def test_add_server(windsurf_manager, monkeypatch):
1312
"""Test add server"""
14-
monkeypatch.setattr(ClientRegistry, "get_active_client_manager", Mock(return_value=windsurf_manager))
15-
monkeypatch.setattr(ClientRegistry, "get_client_manager", Mock(return_value=windsurf_manager))
16-
monkeypatch.setattr(ClientRegistry, "get_active_target", Mock(return_value="@windsurf"))
1713
monkeypatch.setattr(
1814
RepositoryManager,
1915
"_fetch_servers",
@@ -53,9 +49,6 @@ def test_add_server(windsurf_manager, monkeypatch):
5349

5450
def test_add_server_with_missing_arg(windsurf_manager, monkeypatch):
5551
"""Test add server with a missing argument that should be replaced with empty string"""
56-
monkeypatch.setattr(ClientRegistry, "get_active_client_manager", Mock(return_value=windsurf_manager))
57-
monkeypatch.setattr(ClientRegistry, "get_client_manager", Mock(return_value=windsurf_manager))
58-
monkeypatch.setattr(ClientRegistry, "get_active_target", Mock(return_value="@windsurf"))
5952
monkeypatch.setattr(
6053
RepositoryManager,
6154
"_fetch_servers",
@@ -117,10 +110,6 @@ def test_add_server_with_missing_arg(windsurf_manager, monkeypatch):
117110

118111
def test_add_server_with_empty_args(windsurf_manager, monkeypatch):
119112
"""Test add server with missing arguments that should be replaced with empty strings"""
120-
monkeypatch.setattr(ClientRegistry, "get_active_client", Mock(return_value="windsurf"))
121-
monkeypatch.setattr(ClientRegistry, "get_active_client_manager", Mock(return_value=windsurf_manager))
122-
monkeypatch.setattr(ClientRegistry, "get_client_manager", Mock(return_value=windsurf_manager))
123-
monkeypatch.setattr(ClientRegistry, "get_active_target", Mock(return_value="@windsurf"))
124113
monkeypatch.setattr(
125114
RepositoryManager,
126115
"_fetch_servers",
@@ -211,11 +200,6 @@ def test_add_sse_server_to_claude_desktop(claude_desktop_manager, monkeypatch):
211200

212201
def test_add_profile_to_client(windsurf_manager, monkeypatch):
213202
profile_name = "work"
214-
client_name = "windsurf"
215-
216-
monkeypatch.setattr(ClientRegistry, "get_active_target", Mock(return_value="@" + client_name))
217-
monkeypatch.setattr(ClientRegistry, "get_client_manager", Mock(return_value=windsurf_manager))
218-
monkeypatch.setattr(ClientRegistry, "get_active_client_manager", Mock(return_value=windsurf_manager))
219203
monkeypatch.setattr(ConfigManager, "get_router_config", Mock(return_value={"host": "localhost", "port": 8080}))
220204

221205
# test cli
@@ -226,3 +210,45 @@ def test_add_profile_to_client(windsurf_manager, monkeypatch):
226210

227211
profile_server = windsurf_manager.get_server("work")
228212
assert profile_server is not None
213+
214+
215+
def test_add_server_with_configured_npx(windsurf_manager, monkeypatch):
216+
monkeypatch.setattr(ConfigManager, "get_config", Mock(return_value={"node_executable": "bunx"}))
217+
monkeypatch.setattr(
218+
RepositoryManager,
219+
"_fetch_servers",
220+
Mock(
221+
return_value={
222+
"server-test": {
223+
"installations": {
224+
"npm": {
225+
"type": "npm",
226+
"command": "npx",
227+
"args": ["-y", "@modelcontextprotocol/server-test", "--fmt", "${fmt}"],
228+
"env": {"API_KEY": "${API_KEY}"},
229+
}
230+
},
231+
"arguments": {
232+
"fmt": {"type": "string", "description": "Output format", "required": True},
233+
"API_KEY": {"type": "string", "description": "API key", "required": True},
234+
},
235+
}
236+
}
237+
),
238+
)
239+
240+
# Mock Rich's progress display to prevent 'Only one live display may be active at once' error
241+
with patch("rich.progress.Progress.__enter__", return_value=Mock()), \
242+
patch("rich.progress.Progress.__exit__"), \
243+
patch("prompt_toolkit.PromptSession.prompt", side_effect=["json", "test-api-key"]):
244+
runner = CliRunner()
245+
result = runner.invoke(add, ["server-test", "--force", "--alias", "test"])
246+
assert result.exit_code == 0
247+
248+
# Check that the server was added with alias
249+
server = windsurf_manager.get_server("test")
250+
assert server is not None
251+
# Should use configured node executable
252+
assert server.command == "bunx"
253+
assert server.args == ["-y", "@modelcontextprotocol/server-test", "--fmt", "json"]
254+
assert server.env["API_KEY"] == "test-api-key"

tests/test_clients/test_windsurf.py

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414

1515
from mcpm.clients.managers.windsurf import WindsurfManager
1616
from mcpm.core.schema import ServerConfig, STDIOServerConfig
17-
from mcpm.utils.config import ConfigManager
1817

1918

2019
class TestBaseClientManagerViaWindsurf:
@@ -43,21 +42,6 @@ def sample_server_config(self):
4342
env={"API_KEY": "sample-key"},
4443
)
4544

46-
@pytest.fixture
47-
def config_manager(self):
48-
"""Create a ClientConfigManager with a temp config for testing"""
49-
with tempfile.TemporaryDirectory() as temp_dir:
50-
config_path = os.path.join(temp_dir, "config.json")
51-
# Create ConfigManager with the temp path
52-
config_mgr = ConfigManager(config_path=config_path)
53-
# Create ClientConfigManager that will use this ConfigManager internally
54-
from mcpm.clients.client_config import ClientConfigManager
55-
56-
client_mgr = ClientConfigManager()
57-
# Override its internal config_manager with our temp one
58-
client_mgr.config_manager = config_mgr
59-
yield client_mgr
60-
6145
def test_list_servers(self, windsurf_manager):
6246
"""Test list_servers method from BaseClientManager"""
6347
# list_servers returns a list of server names

0 commit comments

Comments
 (0)