Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ mcpm share "npx -y @modelcontextprotocol/server-everything" --retry 3

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

Expand Down
2 changes: 2 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,8 @@ mcpm share "npx -y @modelcontextprotocol/server-everything" --retry 3

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

Expand Down
41 changes: 41 additions & 0 deletions src/mcpm/commands/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

import click
from rich.console import Console
from rich.prompt import Prompt

from mcpm.utils.config import NODE_EXECUTABLES, ConfigManager
from mcpm.utils.repository import RepositoryManager

console = Console()
Expand All @@ -21,6 +23,45 @@ def config():
pass


@config.command()
@click.help_option("-h", "--help")
def set():
"""Set MCPM configuration.

Example:

\b
mcpm config set
"""
set_key = Prompt.ask("Configuration key to set", choices=["node_executable"], default="node_executable")
node_executable = Prompt.ask(
"Select default node executable, it will be automatically applied when adding npx server with mcpm add",
choices=NODE_EXECUTABLES,
)
config_manager = ConfigManager()
config_manager.set_config(set_key, node_executable)
console.print(f"[green]Default node executable set to:[/] {node_executable}")


@config.command()
@click.argument("name", required=True)
@click.help_option("-h", "--help")
def get(name):
"""Get MCPM configuration.

Example:

\b
mcpm config get node_executable
"""
config_manager = ConfigManager()
current_config = config_manager.get_config()
if name not in current_config:
console.print(f"[red]Configuration '{name}' not set or not supported.[/]")
return
console.print(f"[green]{name}:[/] {current_config[name]}")


@config.command()
@click.help_option("-h", "--help")
def clear_cache():
Expand Down
22 changes: 20 additions & 2 deletions src/mcpm/commands/target_operations/common.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from rich.console import Console

from mcpm.clients.client_registry import ClientRegistry
from mcpm.core.schema import ServerConfig
from mcpm.core.schema import ServerConfig, STDIOServerConfig
from mcpm.profile.profile_config import ProfileConfigManager
from mcpm.utils.config import ConfigManager
from mcpm.utils.config import NODE_EXECUTABLES, ConfigManager
from mcpm.utils.display import print_active_scope, print_no_active_scope
from mcpm.utils.scope import ScopeType, extract_from_scope, parse_server

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


def _replace_node_executable(server_config: ServerConfig) -> ServerConfig:
if not isinstance(server_config, STDIOServerConfig):
return server_config
command = server_config.command.strip()
if command not in NODE_EXECUTABLES:
return server_config
config = ConfigManager().get_config()
config_node_executable = config.get("node_executable")
if not config_node_executable:
return server_config
if config_node_executable != command:
console.print(f"[bold cyan]Replace node executable {command} with {config_node_executable}[/]")
server_config.command = config_node_executable
return server_config


def client_add_server(client: str, server_config: ServerConfig, force: bool = False) -> bool:
client_manager = ClientRegistry.get_client_manager(client)
if not client_manager:
Expand All @@ -41,6 +57,7 @@ def client_add_server(client: str, server_config: ServerConfig, force: bool = Fa
console.print(f"[bold red]Error:[/] Server '{server_config.name}' already exists in {client}.")
console.print("Use --force to override.")
return False
server_config = _replace_node_executable(server_config)
success = client_manager.add_server(server_config)

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

Expand Down
4 changes: 3 additions & 1 deletion src/mcpm/utils/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
MCPM_AUTH_HEADER = "X-MCPM-SECRET"
MCPM_PROFILE_HEADER = "X-MCPM-PROFILE"

NODE_EXECUTABLES = ["npx", "bunx", "pnpm dlx", "yarn dlx"]


class ConfigManager:
"""Manages MCP basic configuration
Expand All @@ -35,7 +37,7 @@ class ConfigManager:
def __init__(self, config_path: str = DEFAULT_CONFIG_FILE):
self.config_path = config_path
self.config_dir = os.path.dirname(config_path)
self._config = None
self._config = {}
self._ensure_dirs()
self._load_config()

Expand Down
14 changes: 1 addition & 13 deletions src/mcpm/utils/scope.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,4 @@
import sys

if sys.version_info >= (3, 11):
from enum import StrEnum
else:
from enum import Enum

class StrEnum(str, Enum):
"""String enumeration for Python versions before 3.11."""

def __str__(self) -> str:
return self.value

from enum import StrEnum

CLIENT_PREFIX = "@"
PROFILE_PREFIX = "%"
Expand Down
25 changes: 20 additions & 5 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@
import sys
import tempfile
from pathlib import Path
from unittest.mock import Mock

import pytest

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

from mcpm.clients.client_registry import ClientRegistry
from mcpm.clients.managers.claude_desktop import ClaudeDesktopManager
from mcpm.clients.managers.windsurf import WindsurfManager
from mcpm.utils.config import ConfigManager
Expand Down Expand Up @@ -43,12 +45,21 @@ def temp_config_file():


@pytest.fixture
def config_manager():
def config_manager(monkeypatch):
"""Create a ClientConfigManager with a temp config for testing"""
with tempfile.TemporaryDirectory() as temp_dir:
config_path = os.path.join(temp_dir, "config.json")
tmp_config_path = os.path.join(temp_dir, "config.json")
# Create ConfigManager with the temp path
config_mgr = ConfigManager(config_path=config_path)

_original_init = ConfigManager.__init__

def _mock_init(self, config_path=tmp_config_path):
_original_init(self, config_path)
self.config_path = tmp_config_path

monkeypatch.setattr(ConfigManager, "__init__", _mock_init)

config_mgr = ConfigManager()
# Create ClientConfigManager that will use this ConfigManager internally
from mcpm.clients.client_config import ClientConfigManager

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


@pytest.fixture
def windsurf_manager(temp_config_file):
def windsurf_manager(temp_config_file, monkeypatch, config_manager):
"""Create a WindsurfManager instance using the temp config file"""
return WindsurfManager(config_path=temp_config_file)
windsurf_manager = WindsurfManager(config_path=temp_config_file)
monkeypatch.setattr(ClientRegistry, "get_active_client_manager", Mock(return_value=windsurf_manager))
monkeypatch.setattr(ClientRegistry, "get_client_manager", Mock(return_value=windsurf_manager))
monkeypatch.setattr(ClientRegistry, "get_active_target", Mock(return_value="@windsurf"))
return windsurf_manager


@pytest.fixture
Expand Down
58 changes: 42 additions & 16 deletions tests/test_add.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from click.testing import CliRunner

from mcpm.clients.client_registry import ClientRegistry
from mcpm.commands.target_operations.add import add
from mcpm.core.schema import RemoteServerConfig
from mcpm.utils.config import ConfigManager
Expand All @@ -11,9 +10,6 @@

def test_add_server(windsurf_manager, monkeypatch):
"""Test add server"""
monkeypatch.setattr(ClientRegistry, "get_active_client_manager", Mock(return_value=windsurf_manager))
monkeypatch.setattr(ClientRegistry, "get_client_manager", Mock(return_value=windsurf_manager))
monkeypatch.setattr(ClientRegistry, "get_active_target", Mock(return_value="@windsurf"))
monkeypatch.setattr(
RepositoryManager,
"_fetch_servers",
Expand Down Expand Up @@ -53,9 +49,6 @@ def test_add_server(windsurf_manager, monkeypatch):

def test_add_server_with_missing_arg(windsurf_manager, monkeypatch):
"""Test add server with a missing argument that should be replaced with empty string"""
monkeypatch.setattr(ClientRegistry, "get_active_client_manager", Mock(return_value=windsurf_manager))
monkeypatch.setattr(ClientRegistry, "get_client_manager", Mock(return_value=windsurf_manager))
monkeypatch.setattr(ClientRegistry, "get_active_target", Mock(return_value="@windsurf"))
monkeypatch.setattr(
RepositoryManager,
"_fetch_servers",
Expand Down Expand Up @@ -117,10 +110,6 @@ def test_add_server_with_missing_arg(windsurf_manager, monkeypatch):

def test_add_server_with_empty_args(windsurf_manager, monkeypatch):
"""Test add server with missing arguments that should be replaced with empty strings"""
monkeypatch.setattr(ClientRegistry, "get_active_client", Mock(return_value="windsurf"))
monkeypatch.setattr(ClientRegistry, "get_active_client_manager", Mock(return_value=windsurf_manager))
monkeypatch.setattr(ClientRegistry, "get_client_manager", Mock(return_value=windsurf_manager))
monkeypatch.setattr(ClientRegistry, "get_active_target", Mock(return_value="@windsurf"))
monkeypatch.setattr(
RepositoryManager,
"_fetch_servers",
Expand Down Expand Up @@ -211,11 +200,6 @@ def test_add_sse_server_to_claude_desktop(claude_desktop_manager, monkeypatch):

def test_add_profile_to_client(windsurf_manager, monkeypatch):
profile_name = "work"
client_name = "windsurf"

monkeypatch.setattr(ClientRegistry, "get_active_target", Mock(return_value="@" + client_name))
monkeypatch.setattr(ClientRegistry, "get_client_manager", Mock(return_value=windsurf_manager))
monkeypatch.setattr(ClientRegistry, "get_active_client_manager", Mock(return_value=windsurf_manager))
monkeypatch.setattr(ConfigManager, "get_router_config", Mock(return_value={"host": "localhost", "port": 8080}))

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

profile_server = windsurf_manager.get_server("work")
assert profile_server is not None


def test_add_server_with_configured_npx(windsurf_manager, monkeypatch):
monkeypatch.setattr(ConfigManager, "get_config", Mock(return_value={"node_executable": "bunx"}))
monkeypatch.setattr(
RepositoryManager,
"_fetch_servers",
Mock(
return_value={
"server-test": {
"installations": {
"npm": {
"type": "npm",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-test", "--fmt", "${fmt}"],
"env": {"API_KEY": "${API_KEY}"},
}
},
"arguments": {
"fmt": {"type": "string", "description": "Output format", "required": True},
"API_KEY": {"type": "string", "description": "API key", "required": True},
},
}
}
),
)

# Mock Rich's progress display to prevent 'Only one live display may be active at once' error
with patch("rich.progress.Progress.__enter__", return_value=Mock()), \
patch("rich.progress.Progress.__exit__"), \
patch("prompt_toolkit.PromptSession.prompt", side_effect=["json", "test-api-key"]):
runner = CliRunner()
result = runner.invoke(add, ["server-test", "--force", "--alias", "test"])
assert result.exit_code == 0

# Check that the server was added with alias
server = windsurf_manager.get_server("test")
assert server is not None
# Should use configured node executable
assert server.command == "bunx"
assert server.args == ["-y", "@modelcontextprotocol/server-test", "--fmt", "json"]
assert server.env["API_KEY"] == "test-api-key"
16 changes: 0 additions & 16 deletions tests/test_clients/test_windsurf.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@

from mcpm.clients.managers.windsurf import WindsurfManager
from mcpm.core.schema import ServerConfig, STDIOServerConfig
from mcpm.utils.config import ConfigManager


class TestBaseClientManagerViaWindsurf:
Expand Down Expand Up @@ -43,21 +42,6 @@ def sample_server_config(self):
env={"API_KEY": "sample-key"},
)

@pytest.fixture
def config_manager(self):
"""Create a ClientConfigManager with a temp config for testing"""
with tempfile.TemporaryDirectory() as temp_dir:
config_path = os.path.join(temp_dir, "config.json")
# Create ConfigManager with the temp path
config_mgr = ConfigManager(config_path=config_path)
# Create ClientConfigManager that will use this ConfigManager internally
from mcpm.clients.client_config import ClientConfigManager

client_mgr = ClientConfigManager()
# Override its internal config_manager with our temp one
client_mgr.config_manager = config_mgr
yield client_mgr

def test_list_servers(self, windsurf_manager):
"""Test list_servers method from BaseClientManager"""
# list_servers returns a list of server names
Expand Down
Loading