feat: add --env and --preset support to hermes mcp add
- Add --env KEY=VALUE for passing environment variables to stdio MCP servers - Add --preset for known MCP server templates (empty for now, extensible) - Validate env var names, reject --env for HTTP servers - Explicit --command/--url overrides preset defaults - Remove unused getpass import Based on PR #7936 by @syaor4n (stitch preset removed, generic infra kept).
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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 <endpoint> or --command <cmd>")
|
||||
_error("Must specify --url <endpoint>, --command <cmd>, or --preset <name>")
|
||||
_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 <name> --url <endpoint> Add an MCP server")
|
||||
_info("hermes mcp add <name> --command <cmd> Add a stdio server")
|
||||
_info("hermes mcp add <name> --preset <preset> Add from a known preset")
|
||||
_info("hermes mcp remove <name> Remove a server")
|
||||
_info("hermes mcp list List servers")
|
||||
_info("hermes mcp test <name> Test connection")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user