MCP server headers, env vars, and auth fixes
NousResearch/hermes-agent · cluster 25478 · docs
Data as of
7 open items. Fix merged in #38571. Check it covers your case.
The short answer
- Known?
- Yes. 14 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #38571. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #38571. That is separate from the fix event. - Still open
- 4 issues and 3 PRs.
Next step: read #38571, then check whether your report is one of the 4 still open.
Read #38571Pick up a Lab taskThis group covers fixes and documentation around MCP server configuration, specifically HTTP header support, environment variable resolution before discovery probes, per-server env_file isolation, and authenticated HTTP server handling in the dashboard and API.
Open issues4
Open PRs3
Fixes on record
-
#38571 fix(mcp): resolve ${ENV} in discovery probe so header auth works
files:
hermes_cli/mcp_config.py,scripts/release.py,tests/hermes_cli/test_mcp_config.pyShow the diff
-
hermes_cli/mcp_config.py@@ -109,6 +109,21 @@ def _env_key_for_server(name: str) -> str: return f"MCP_{name.upper().replace('-', '_')}_API_KEY" +def _strip_bearer_prefix(token: str) -> str: + """Strip a leading ``Bearer `` from a pasted token. + + The header template stores ``Authorization: Bearer ${MCP_X_API_KEY}``, so + if a user pastes a token that already includes the ``Bearer `` prefix the + server receives ``Bearer Bearer <jwt>`` → 401. Normalize on save. (#37792) + """ + if not isinstance(token, str): + return token + stripped = token.strip() + if stripped[:7].lower() == "bearer ": + return stripped[7:].strip() + return stripped + + 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] = {} @@ -164,6 +179,27 @@ def _apply_mcp_preset( # ─── Discovery (temporary connect) ─────────────────────────────────────────── +def _resolve_mcp_server_config(config: dict) -> dict: + """Resolve ``${ENV}`` placeholders in a server config before connecting. + + Mirrors ``_load_mcp_config()`` in ``tools/mcp_tool.py``: load + ``~/.hermes/.env`` into ``os.environ`` and recursively interpolate any + ``${VAR}`` placeholders. The CLI builds header templates like + ``Authorization: Bearer ${MCP_X_API_KEY}`` but the probe path never + resolved them, so the discovery probe sent the literal placeholder and + auth-requiring servers (e.g. n8n) returned 401 — while runtime tool + loading worked because it interpolates. (#37792) + """ + from tools.mcp_tool import _interpolate_env_vars + + try: + from hermes_cli.env_loader import load_hermes_dotenv + load_hermes_dotenv() + except Exception: # pragma: no cover — defensive + pass + return _interpolate_env_vars(config) + + def _probe_single_server( name: str, config: dict, connect_timeout: float = 30 ) -> List[Tuple[str, str]]: @@ -179,6 +215,8 @@ def _probe_single_server( _stop_mcp_loop, ) + config = _resolve_mcp_server_config(config) + _ensure_mcp_loop() tools_found: List[Tuple[str, str]] = [] @@ -340,6 +378,7 @@ def cmd_mcp_add(args): else: api_key = [redacted:secret-assignment]"API key / Bearer token", password=True) if api_key: + api_key = [redacted:secret-assignment] save_env_value(env_key, api_key) _success(f"Saved to {display_hermes_home()}/.env as {env_key}") -
scripts/release.py@@ -46,6 +46,7 @@ # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { "[redacted:email]": "vladkvlchk", + "[redacted:email]": "ThyFriendlyFox", "[redacted:email]": "truenorth-lj", "[redacted:email]": "nepenth", "[redacted:email]": "benfrank241", -
tests/hermes_cli/test_mcp_config.py@@ -483,6 +483,102 @@ def test_interpolate_non_string(self): assert _interpolate_env_vars(None) is None +# --------------------------------------------------------------------------- +# Tests: probe-path env resolution (#37792) +# --------------------------------------------------------------------------- + +class TestProbeEnvResolution: + """The probe path must resolve ``${ENV}`` before connecting, so the + discovery probe behaves like runtime tool loading. Regression for #37792 + where `hermes mcp add --auth header` sent a literal + ``Authorization: Bearer ${MCP_X_API_KEY}`` and got 401.""" + + def test_resolve_interpolates_header(self, monkeypatch): + from hermes_cli.mcp_config import _resolve_mcp_server_config + + monkeypatch.setenv("MCP_N8N_API_KEY", "jwt-token-xyz") + resolved = _resolve_mcp_server_config({ + "url": "http://localhost:5678/mcp-server/http", + "headers": {"Authorization": "Bearer ${MCP_N8N_API_KEY}"}, + }) + assert resolved["headers"]["Authorization"] == "Bearer jwt-token-xyz" + + def test_resolve_leaves_unset_var_literal(self, monkeypatch): + from hermes_cli.mcp_config import _resolve_mcp_server_config + + monkeypatch.delenv("MCP_UNSET_API_KEY", raising=False) + resolved = _resolve_mcp_server_config({ + "headers": {"Authorization": "Bearer ${MCP_UNSET_API_KEY}"}, + }) + # Unresolved placeholder stays literal (no crash) — matches + # _interpolate_env_vars semantics. + assert resolved["headers"]["Authorization"] == "Bearer ${MCP_UNSET_API_KEY}" + + def test_probe_resolves_before_connect(self, monkeypatch): + """_probe_single_server must pass the RESOLVED config to _connect_server.""" + import hermes_cli.mcp_config as mc + + monkeypatch.setenv("MCP_N8N_API_KEY", "jwt-token-xyz") + + seen = {} + + class _FakeTool: + name = "do_thing" + description = "a tool" + + class _FakeServer: + _tools = [_FakeTool()] + + async def shutdown(self): + return None + + async def _fake_connect(name, config): + seen["config"] = config + return _FakeServer() + + monkeypatch.setattr("tools.mcp_tool._connect_server", _fake_connect) + + tools = mc._probe_single_server("n8n", { + "url": "http://localhost:5678/mcp-server/http", + "headers": {"Authorization": "Bearer ${MCP_N8N_API_KEY}"}, + }) + + assert tools == [("do_thing", "a tool")] + assert seen["config"]["headers"]["Authorization"] == "Bearer jwt-token-xyz" + + +class TestStripBearerPrefix: + """Pasted tokens that already include ``Bearer `` would otherwise produce + ``Bearer Bearer <jwt>`` once the header template adds its own prefix.""" + + def test_bare_token_unchanged(self): + from hermes_cli.mcp_config import _strip_bearer_prefix + + assert _strip_bearer_prefix("eyJabc123") == "eyJabc123" + + def test_strips_bearer_prefix(self): + from hermes_cli.mcp_config import _strip_bearer_prefix + + assert _strip_bearer_prefix("Bearer eyJabc123") == "eyJabc123" + + def test_strips_case_insensitive_and_whitespace(self): + from hermes_cli.mcp_config import _strip_bearer_prefix + + assert _strip_bearer_prefix("bearer eyJabc123") == "eyJabc123" + assert _strip_bearer_prefix(" Bearer eyJabc123 ") == "eyJabc123" + + def test_does_not_strip_without_space(self): + from hermes_cli.mcp_config import _strip_bearer_prefix + + # "BearerToken" is a token that happens to start with "Bearer", not a prefix. + assert _strip_bearer_prefix("BearerToken") == "BearerToken" + + def test_non_string_passthrough(self): + from hermes_cli.mcp_config import _strip_bearer_prefix + + assert _strip_bearer_prefix(None) is None # type: ignore[arg-type] + + # --------------------------------------------------------------------------- # Tests: config helpers # ---------------------------------------------------------------------------
-
5 PRs closed without merging
From the close comments: closed without comment 2 · done elsewhere 1 · partial fix 1 · stale or abandoned 1
- #26303 docs(mcp): show multi-header HTTP config · closed without comment
-
#37926 fix(mcp): resolve ${VAR} header placeholders before the discovery probe · done elsewhere →
b0a52d7,#38571,#37792 - #61599 fix(web,mcp): add headers support to MCP server create API and dashboard UI · closed without comment
- #61602 fix(web,mcp): add headers support to MCP server create API and dashboard UI · stale or abandoned
-
#74809 fix(mcp): isolate per-server env_file resolution · partial fix →
46b8ebe,22c5684,a688e7d
Closed issues · 1
- Closed#37792Identified reproducible Hermes MCP issue while configuring n8n MCP server
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.13: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- docs
- Merged PRs
- #38571
- Closed issues
- 1
- Attempts
- 5 PRs closed without merging
- Fix link checked
- 4 Jun 2026