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:
syaor4n
2026-04-11 15:28:23 -07:00
committed by Teknium
parent 758c4ad1ef
commit 689c515090
3 changed files with 228 additions and 3 deletions

View File

@@ -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")

View File

@@ -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")

View File

@@ -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