Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
29 changes: 29 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,33 @@ def config():
pass


@config.command()
@click.help_option("-h", "--help")
def set():
"""Set MCPM configuration."""
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."""
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 excutable {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
34 changes: 13 additions & 21 deletions tests/test_remove.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,8 @@
from mcpm.commands.target_operations.remove import remove


def test_remove_server_success(windsurf_manager, monkeypatch):
def test_remove_server_success(windsurf_manager):
"""Test successful server removal"""
# Setup mocks
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_client_info", Mock(return_value={"name": "windsurf"}))
monkeypatch.setattr(ClientRegistry, "get_active_target", Mock(return_value="@windsurf"))

# Mock server info
mock_server = Mock()
Expand All @@ -31,12 +26,17 @@ def test_remove_server_success(windsurf_manager, monkeypatch):
windsurf_manager.remove_server.assert_called_once_with("server-test")


def test_remove_server_not_found(windsurf_manager, monkeypatch):
def test_remove_server_not_found(windsurf_manager):
"""Test removal of non-existent 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_client_info", Mock(return_value={"name": "windsurf"}))
monkeypatch.setattr(ClientRegistry, "get_active_target", Mock(return_value="@windsurf"))
# Mock server not found
windsurf_manager.get_server = Mock(return_value=None)

# Run the command with force flag
runner = CliRunner()
result = runner.invoke(remove, ["server-test", "--force"])

assert result.exit_code == 0 # Command exits successfully but with error message
assert "Server 'server-test' not found in windsurf" in result.output

# Mock server not found
windsurf_manager.get_server = Mock(return_value=None)
Expand All @@ -60,12 +60,8 @@ def test_remove_server_unsupported_client(monkeypatch):
assert "Client 'unsupported' not found." in result.output


def test_remove_server_cancelled(windsurf_manager, monkeypatch):
def test_remove_server_cancelled(windsurf_manager):
"""Test removal when user cancels the confirmation"""
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_client_info", Mock(return_value={"name": "windsurf"}))
monkeypatch.setattr(ClientRegistry, "get_active_target", Mock(return_value="@windsurf"))

# Mock server info
mock_server = Mock()
Expand All @@ -85,12 +81,8 @@ def test_remove_server_cancelled(windsurf_manager, monkeypatch):
windsurf_manager.remove_server.assert_not_called()


def test_remove_server_failure(windsurf_manager, monkeypatch):
def test_remove_server_failure(windsurf_manager):
"""Test removal when the removal operation fails"""
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_client_info", Mock(return_value={"name": "windsurf"}))
monkeypatch.setattr(ClientRegistry, "get_active_target", Mock(return_value="@windsurf"))

# Mock server info
mock_server = Mock()
Expand Down
Loading
Loading