fix(mcp): resolve ${ENV} in discovery probe so header auth works (#38571)

`hermes mcp add --auth header` built `Authorization: Bearer ${MCP_X_API_KEY}`
and passed it straight to the discovery probe without interpolation, so the
probe sent the literal placeholder and auth-requiring servers (e.g. n8n)
returned 401. Runtime tool loading worked because `_load_mcp_config()`
interpolates, but the four CLI probe call sites (add/test/login/configure)
all used unresolved config.

Resolve `${ENV}` inside `_probe_single_server` via a new
`_resolve_mcp_server_config()` (load_hermes_dotenv + _interpolate_env_vars),
mirroring runtime loading. This covers all four call sites, not just add.

Also strip a leading `Bearer ` from pasted tokens before saving to
`MCP_*_API_KEY`, so a token pasted with the prefix doesn't produce
`Bearer Bearer <jwt>` (also a 401).

Reported with a precise root-cause analysis in #37792.

Co-authored-by: ThyFriendlyFox <116314616+ThyFriendlyFox@users.noreply.github.com>
This commit is contained in:
Teknium
2026-06-03 17:49:39 -07:00
committed by GitHub
parent 5a22cd427d
commit b0a52d74ac
3 changed files with 136 additions and 0 deletions

View File

@ -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 = _prompt("API key / Bearer token", password=True)
if api_key:
api_key = _strip_bearer_prefix(api_key)
save_env_value(env_key, api_key)
_success(f"Saved to {display_hermes_home()}/.env as {env_key}")