diff --git a/hermes_cli/main.py b/hermes_cli/main.py index df87f93555..c74b7945e2 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -5146,6 +5146,8 @@ For more help on a command: mcp_add_p.add_argument("--command", help="Stdio command (e.g. npx)") mcp_add_p.add_argument("--args", nargs="*", default=[], help="Arguments for stdio command") mcp_add_p.add_argument("--auth", choices=["oauth", "header"], help="Auth method") + mcp_add_p.add_argument("--preset", help="Known MCP preset name") + mcp_add_p.add_argument("--env", nargs="*", default=[], help="Environment variables for stdio servers (KEY=VALUE)") mcp_rm_p = mcp_sub.add_parser("remove", aliases=["rm"], help="Remove an MCP server") mcp_rm_p.add_argument("name", help="Server name to remove") diff --git a/hermes_cli/mcp_config.py b/hermes_cli/mcp_config.py index cf2dde0892..b21234ce0a 100644 --- a/hermes_cli/mcp_config.py +++ b/hermes_cli/mcp_config.py @@ -9,7 +9,6 @@ configuration in ~/.hermes/config.yaml under the ``mcp_servers`` key. """ import asyncio -import getpass import logging import os import re @@ -28,6 +27,11 @@ from hermes_constants import display_hermes_home logger = logging.getLogger(__name__) +_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +_MCP_PRESETS: Dict[str, Dict[str, Any]] = {} + # ─── UI Helpers ─────────────────────────────────────────────────────────────── @@ -98,6 +102,59 @@ def _env_key_for_server(name: str) -> str: return f"MCP_{name.upper().replace('-', '_')}_API_KEY" +def _parse_env_assignments(raw_env: Optional[List[str]]) -> Dict[str, str]: + """Parse ``KEY=VALUE`` strings from CLI args into an env dict.""" + parsed: Dict[str, str] = {} + for item in raw_env or []: + text = str(item or "").strip() + if not text: + continue + if "=" not in text: + raise ValueError(f"Invalid --env value '{text}' (expected KEY=VALUE)") + key, value = text.split("=", 1) + key = key.strip() + if not key: + raise ValueError(f"Invalid --env value '{text}' (missing variable name)") + if not _ENV_VAR_NAME_RE.match(key): + raise ValueError(f"Invalid --env variable name '{key}'") + parsed[key] = value + return parsed + + +def _apply_mcp_preset( + name: str, + *, + preset_name: Optional[str], + url: Optional[str], + command: Optional[str], + cmd_args: List[str], + server_config: Dict[str, Any], +) -> tuple[Optional[str], Optional[str], List[str], bool]: + """Apply a known MCP preset when transport details were omitted.""" + if not preset_name: + return url, command, cmd_args, False + + preset = _MCP_PRESETS.get(preset_name) + if not preset: + raise ValueError(f"Unknown MCP preset: {preset_name}") + + if url or command: + return url, command, cmd_args, False + + url = preset.get("url") + command = preset.get("command") + cmd_args = list(preset.get("args") or []) + + if url: + server_config["url"] = url + if command: + server_config["command"] = command + if cmd_args: + server_config["args"] = cmd_args + + return url, command, cmd_args, True + + # ─── Discovery (temporary connect) ─────────────────────────────────────────── def _probe_single_server( @@ -166,13 +223,35 @@ def cmd_mcp_add(args): command = getattr(args, "command", None) cmd_args = getattr(args, "args", None) or [] auth_type = getattr(args, "auth", None) + preset_name = getattr(args, "preset", None) + raw_env = getattr(args, "env", None) + + server_config: Dict[str, Any] = {} + try: + explicit_env = _parse_env_assignments(raw_env) + url, command, cmd_args, _preset_applied = _apply_mcp_preset( + name, + preset_name=preset_name, + url=url, + command=command, + cmd_args=list(cmd_args), + server_config=server_config, + ) + except ValueError as exc: + _error(str(exc)) + return + + if url and explicit_env: + _error("--env is only supported for stdio MCP servers (--command or stdio presets)") + return # Validate transport if not url and not command: - _error("Must specify --url or --command ") + _error("Must specify --url , --command , or --preset ") _info("Examples:") _info(' hermes mcp add ink --url "https://mcp.ml.ink/mcp"') _info(' hermes mcp add github --command npx --args @modelcontextprotocol/server-github') + _info(' hermes mcp add myserver --preset mypreset') return # Check if server already exists @@ -183,13 +262,15 @@ def cmd_mcp_add(args): return # Build initial config - server_config: Dict[str, Any] = {} if url: server_config["url"] = url else: server_config["command"] = command if cmd_args: server_config["args"] = cmd_args + if explicit_env: + server_config["env"] = explicit_env + # ── Authentication ──────────────────────────────────────────────── @@ -627,6 +708,7 @@ def mcp_command(args): _info("hermes mcp serve Run as MCP server") _info("hermes mcp add --url Add an MCP server") _info("hermes mcp add --command Add a stdio server") + _info("hermes mcp add --preset Add from a known preset") _info("hermes mcp remove Remove a server") _info("hermes mcp list List servers") _info("hermes mcp test Test connection") diff --git a/tests/hermes_cli/test_mcp_config.py b/tests/hermes_cli/test_mcp_config.py index 91a5f988cc..9647a0b95b 100644 --- a/tests/hermes_cli/test_mcp_config.py +++ b/tests/hermes_cli/test_mcp_config.py @@ -46,6 +46,8 @@ def _make_args(**kwargs): "command": None, "args": None, "auth": None, + "preset": None, + "env": None, "mcp_action": None, } defaults.update(kwargs) @@ -269,6 +271,145 @@ class TestMcpAdd: config = load_config() assert config["mcp_servers"]["broken"]["enabled"] is False + def test_add_stdio_server_with_env(self, tmp_path, capsys, monkeypatch): + """Stdio servers can persist explicit environment variables.""" + fake_tools = [FakeTool("search", "Search repos")] + + def mock_probe(name, config, **kw): + assert config["env"] == { + "MY_API_KEY": "secret123", + "DEBUG": "true", + } + return [(t.name, t.description) for t in fake_tools] + + monkeypatch.setattr( + "hermes_cli.mcp_config._probe_single_server", mock_probe + ) + monkeypatch.setattr("builtins.input", lambda _: "") + + from hermes_cli.mcp_config import cmd_mcp_add + + cmd_mcp_add(_make_args( + name="github", + command="npx", + args=["@mcp/github"], + env=["MY_API_KEY=secret123", "DEBUG=true"], + )) + out = capsys.readouterr().out + assert "Saved" in out + + from hermes_cli.config import load_config + + config = load_config() + srv = config["mcp_servers"]["github"] + assert srv["env"] == { + "MY_API_KEY": "secret123", + "DEBUG": "true", + } + + def test_add_stdio_server_rejects_invalid_env_name(self, capsys): + """Invalid environment variable names are rejected up front.""" + from hermes_cli.mcp_config import cmd_mcp_add + + cmd_mcp_add(_make_args( + name="github", + command="npx", + args=["@mcp/github"], + env=["BAD-NAME=value"], + )) + out = capsys.readouterr().out + assert "Invalid --env variable name" in out + + def test_add_http_server_rejects_env_flag(self, capsys): + """The --env flag is only valid for stdio transports.""" + from hermes_cli.mcp_config import cmd_mcp_add + + cmd_mcp_add(_make_args( + name="ink", + url="https://mcp.ml.ink/mcp", + env=["DEBUG=true"], + )) + out = capsys.readouterr().out + assert "only supported for stdio MCP servers" in out + + def test_add_preset_fills_transport(self, tmp_path, capsys, monkeypatch): + """A preset fills in command/args when no explicit transport given.""" + monkeypatch.setattr( + "hermes_cli.mcp_config._MCP_PRESETS", + {"testmcp": {"command": "npx", "args": ["-y", "test-mcp-server"], "display_name": "Test MCP"}}, + ) + fake_tools = [FakeTool("do_thing", "Does a thing")] + + def mock_probe(name, config, **kw): + assert name == "myserver" + assert config["command"] == "npx" + assert config["args"] == ["-y", "test-mcp-server"] + assert "env" not in config + return [(t.name, t.description) for t in fake_tools] + + monkeypatch.setattr( + "hermes_cli.mcp_config._probe_single_server", mock_probe + ) + monkeypatch.setattr("builtins.input", lambda _: "") + + from hermes_cli.mcp_config import cmd_mcp_add + from hermes_cli.config import read_raw_config + + cmd_mcp_add(_make_args(name="myserver", preset="testmcp")) + out = capsys.readouterr().out + assert "Saved" in out + + config = read_raw_config() + srv = config["mcp_servers"]["myserver"] + assert srv["command"] == "npx" + assert srv["args"] == ["-y", "test-mcp-server"] + assert "env" not in srv + + def test_preset_does_not_override_explicit_command(self, tmp_path, capsys, monkeypatch): + """Explicit transports win over presets.""" + monkeypatch.setattr( + "hermes_cli.mcp_config._MCP_PRESETS", + {"testmcp": {"command": "npx", "args": ["-y", "test-mcp-server"], "display_name": "Test MCP"}}, + ) + fake_tools = [FakeTool("search", "Search repos")] + + def mock_probe(name, config, **kw): + assert config["command"] == "uvx" + assert config["args"] == ["custom-server"] + assert "env" not in config + return [(t.name, t.description) for t in fake_tools] + + monkeypatch.setattr( + "hermes_cli.mcp_config._probe_single_server", mock_probe + ) + monkeypatch.setattr("builtins.input", lambda _: "") + + from hermes_cli.mcp_config import cmd_mcp_add + from hermes_cli.config import read_raw_config + + cmd_mcp_add(_make_args( + name="custom", + preset="testmcp", + command="uvx", + args=["custom-server"], + )) + out = capsys.readouterr().out + assert "Saved" in out + + config = read_raw_config() + srv = config["mcp_servers"]["custom"] + assert srv["command"] == "uvx" + assert srv["args"] == ["custom-server"] + assert "env" not in srv + + def test_unknown_preset_rejected(self, capsys): + """An unknown preset name is rejected with a clear error.""" + from hermes_cli.mcp_config import cmd_mcp_add + + cmd_mcp_add(_make_args(name="foo", preset="nonexistent")) + out = capsys.readouterr().out + assert "Unknown MCP preset" in out + # --------------------------------------------------------------------------- # Tests: cmd_mcp_test