{"repo":"NousResearch/hermes-agent","public_id":"25478","display_label":"MCP server headers, env vars, and auth fixes","summary":"This 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.","subsystem":"docs","counts":{"members":14,"open_issues":4,"open_issues_distinct":4,"open_prs":3,"closed_issues":1,"merged_prs":1,"attempt_prs":8},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":38571},"resolution":{"state":"partially_resolved","by":"pr-38571"},"freshness":{"state":"verified","last_verified_at":"2026-06-04T00:49:39+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.1257142857142857},"fix_edges":[{"issue_number":37792,"pr_number":38571,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":25478,"title":"[Docs]: MCP user-guide misses required X-Workspace-slug header for Plane MCP — server returns 400 without it","url":null,"state":"open"},{"kind":"issue","number":30320,"title":"[Bug]: specific mcp url header can t be added easily","url":null,"state":"open"},{"kind":"issue","number":37792,"title":"Identified reproducible Hermes MCP issue while configuring n8n MCP server","url":null,"state":"closed"},{"kind":"issue","number":53646,"title":"fix(mcp): load per-server env_file before CLI probe interpolation","url":null,"state":"open"},{"kind":"issue","number":61562,"title":"[Bug]: MCP UI Token","url":null,"state":"open"},{"kind":"pr","number":26303,"title":"docs(mcp): show multi-header HTTP config","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":37926,"title":"fix(mcp): resolve ${VAR} header placeholders before the discovery probe","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"b0a52d74ac44615c228284b43550e768580882ef","number":0},{"kind":"pr","value":"#38571","number":38571},{"kind":"pr","value":"#37792","number":37792}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":38571,"title":"fix(mcp): resolve ${ENV} in discovery probe so header auth works","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/mcp_config.py","scripts/release.py","tests/hermes_cli/test_mcp_config.py"],"fix_hunks":[{"file":"hermes_cli/mcp_config.py","hunks":["@@ -109,6 +109,21 @@ def _env_key_for_server(name: str) -> str:\n     return f\"MCP_{name.upper().replace('-', '_')}_API_KEY\"\n \n \n+def _strip_bearer_prefix(token: str) -> str:\n+    \"\"\"Strip a leading ``Bearer `` from a pasted token.\n+\n+    The header template stores ``Authorization: Bearer ${MCP_X_API_KEY}``, so\n+    if a user pastes a token that already includes the ``Bearer `` prefix the\n+    server receives ``Bearer Bearer <jwt>`` → 401. Normalize on save. (#37792)\n+    \"\"\"\n+    if not isinstance(token, str):\n+        return token\n+    stripped = token.strip()\n+    if stripped[:7].lower() == \"bearer \":\n+        return stripped[7:].strip()\n+    return stripped\n+\n+\n def _parse_env_assignments(raw_env: Optional[List[str]]) -> Dict[str, str]:\n     \"\"\"Parse ``KEY=VALUE`` strings from CLI args into an env dict.\"\"\"\n     parsed: Dict[str, str] = {}","@@ -164,6 +179,27 @@ def _apply_mcp_preset(\n \n # ─── Discovery (temporary connect) ───────────────────────────────────────────\n \n+def _resolve_mcp_server_config(config: dict) -> dict:\n+    \"\"\"Resolve ``${ENV}`` placeholders in a server config before connecting.\n+\n+    Mirrors ``_load_mcp_config()`` in ``tools/mcp_tool.py``: load\n+    ``~/.hermes/.env`` into ``os.environ`` and recursively interpolate any\n+    ``${VAR}`` placeholders. The CLI builds header templates like\n+    ``Authorization: Bearer ${MCP_X_API_KEY}`` but the probe path never\n+    resolved them, so the discovery probe sent the literal placeholder and\n+    auth-requiring servers (e.g. n8n) returned 401 — while runtime tool\n+    loading worked because it interpolates. (#37792)\n+    \"\"\"\n+    from tools.mcp_tool import _interpolate_env_vars\n+\n+    try:\n+        from hermes_cli.env_loader import load_hermes_dotenv\n+        load_hermes_dotenv()\n+    except Exception:  # pragma: no cover — defensive\n+        pass\n+    return _interpolate_env_vars(config)\n+\n+\n def _probe_single_server(\n     name: str, config: dict, connect_timeout: float = 30\n ) -> List[Tuple[str, str]]:","@@ -179,6 +215,8 @@ def _probe_single_server(\n         _stop_mcp_loop,\n     )\n \n+    config = _resolve_mcp_server_config(config)\n+\n     _ensure_mcp_loop()\n \n     tools_found: List[Tuple[str, str]] = []","@@ -340,6 +378,7 @@ def cmd_mcp_add(args):\n                 else:\n                     api_key = [redacted:secret-assignment]\"API key / Bearer token\", password=True)\n                     if api_key:\n+                        api_key = [redacted:secret-assignment]\n                         save_env_value(env_key, api_key)\n                         _success(f\"Saved to {display_hermes_home()}/.env as {env_key}\")\n "]},{"file":"scripts/release.py","hunks":["@@ -46,6 +46,7 @@\n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n     \"[redacted:email]\": \"vladkvlchk\",\n+    \"[redacted:email]\": \"ThyFriendlyFox\",\n     \"[redacted:email]\": \"truenorth-lj\",\n     \"[redacted:email]\": \"nepenth\",\n     \"[redacted:email]\": \"benfrank241\","]},{"file":"tests/hermes_cli/test_mcp_config.py","hunks":["@@ -483,6 +483,102 @@ def test_interpolate_non_string(self):\n         assert _interpolate_env_vars(None) is None\n \n \n+# ---------------------------------------------------------------------------\n+# Tests: probe-path env resolution (#37792)\n+# ---------------------------------------------------------------------------\n+\n+class TestProbeEnvResolution:\n+    \"\"\"The probe path must resolve ``${ENV}`` before connecting, so the\n+    discovery probe behaves like runtime tool loading. Regression for #37792\n+    where `hermes mcp add --auth header` sent a literal\n+    ``Authorization: Bearer ${MCP_X_API_KEY}`` and got 401.\"\"\"\n+\n+    def test_resolve_interpolates_header(self, monkeypatch):\n+        from hermes_cli.mcp_config import _resolve_mcp_server_config\n+\n+        monkeypatch.setenv(\"MCP_N8N_API_KEY\", \"jwt-token-xyz\")\n+        resolved = _resolve_mcp_server_config({\n+            \"url\": \"http://localhost:5678/mcp-server/http\",\n+            \"headers\": {\"Authorization\": \"Bearer ${MCP_N8N_API_KEY}\"},\n+        })\n+        assert resolved[\"headers\"][\"Authorization\"] == \"Bearer jwt-token-xyz\"\n+\n+    def test_resolve_leaves_unset_var_literal(self, monkeypatch):\n+        from hermes_cli.mcp_config import _resolve_mcp_server_config\n+\n+        monkeypatch.delenv(\"MCP_UNSET_API_KEY\", raising=False)\n+        resolved = _resolve_mcp_server_config({\n+            \"headers\": {\"Authorization\": \"Bearer ${MCP_UNSET_API_KEY}\"},\n+        })\n+        # Unresolved placeholder stays literal (no crash) — matches\n+        # _interpolate_env_vars semantics.\n+        assert resolved[\"headers\"][\"Authorization\"] == \"Bearer ${MCP_UNSET_API_KEY}\"\n+\n+    def test_probe_resolves_before_connect(self, monkeypatch):\n+        \"\"\"_probe_single_server must pass the RESOLVED config to _connect_server.\"\"\"\n+        import hermes_cli.mcp_config as mc\n+\n+        monkeypatch.setenv(\"MCP_N8N_API_KEY\", \"jwt-token-xyz\")\n+\n+        seen = {}\n+\n+        class _FakeTool:\n+            name = \"do_thing\"\n+            description = \"a tool\"\n+\n+        class _FakeServer:\n+            _tools = [_FakeTool()]\n+\n+            async def shutdown(self):\n+                return None\n+\n+        async def _fake_connect(name, config):\n+            seen[\"config\"] = config\n+            return _FakeServer()\n+\n+        monkeypatch.setattr(\"tools.mcp_tool._connect_server\", _fake_connect)\n+\n+        tools = mc._probe_single_server(\"n8n\", {\n+            \"url\": \"http://localhost:5678/mcp-server/http\",\n+            \"headers\": {\"Authorization\": \"Bearer ${MCP_N8N_API_KEY}\"},\n+        })\n+\n+        assert tools == [(\"do_thing\", \"a tool\")]\n+        assert seen[\"config\"][\"headers\"][\"Authorization\"] == \"Bearer jwt-token-xyz\"\n+\n+\n+class TestStripBearerPrefix:\n+    \"\"\"Pasted tokens that already include ``Bearer `` would otherwise produce\n+    ``Bearer Bearer <jwt>`` once the header template adds its own prefix.\"\"\"\n+\n+    def test_bare_token_unchanged(self):\n+        from hermes_cli.mcp_config import _strip_bearer_prefix\n+\n+        assert _strip_bearer_prefix(\"eyJabc123\") == \"eyJabc123\"\n+\n+    def test_strips_bearer_prefix(self):\n+        from hermes_cli.mcp_config import _strip_bearer_prefix\n+\n+        assert _strip_bearer_prefix(\"Bearer eyJabc123\") == \"eyJabc123\"\n+\n+    def test_strips_case_insensitive_and_whitespace(self):\n+        from hermes_cli.mcp_config import _strip_bearer_prefix\n+\n+        assert _strip_bearer_prefix(\"bearer eyJabc123\") == \"eyJabc123\"\n+        assert _strip_bearer_prefix(\"  Bearer   eyJabc123  \") == \"eyJabc123\"\n+\n+    def test_does_not_strip_without_space(self):\n+        from hermes_cli.mcp_config import _strip_bearer_prefix\n+\n+        # \"BearerToken\" is a token that happens to start with \"Bearer\", not a prefix.\n+        assert _strip_bearer_prefix(\"BearerToken\") == \"BearerToken\"\n+\n+    def test_non_string_passthrough(self):\n+        from hermes_cli.mcp_config import _strip_bearer_prefix\n+\n+        assert _strip_bearer_prefix(None) is None  # type: ignore[arg-type]\n+\n+\n # ---------------------------------------------------------------------------\n # Tests: config helpers\n # ---------------------------------------------------------------------------\n"]}]},{"kind":"pr","number":53647,"title":"fix(mcp): load per-server env_file before probe interpolation","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":61599,"title":"fix(web,mcp): add headers support to MCP server create API and dashboard UI","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":61602,"title":"fix(web,mcp): add headers support to MCP server create API and dashboard UI","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":62306,"title":"docs(mcp): show provider-specific request headers","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":74809,"title":"fix(mcp): isolate per-server env_file resolution","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"46b8ebefbaaa0542bbe8a7c16a0b43403d8a9559","number":0},{"kind":"commit","value":"22c5684b983eac6a81ee015ae80296c4b3dbf5bb","number":0},{"kind":"commit","value":"a688e7d5f","number":0}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":80975,"title":"fix(mcp): append token query param for HTTP/SSE and redact URLs in dashboard (#61562)","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"c6bf6e75c","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}