From 89fb1d028e2c41fdc9540e71f8812dca29132cd9 Mon Sep 17 00:00:00 2001 From: Nick Seelert <131294333+nickseelert@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:11:25 -0400 Subject: [PATCH] fix(mcp): retain profile secret scope during discovery (cherry picked from commit f344095fcf16c5d154ef2d207cce9b13339bf2e4) --- hermes_cli/mcp_startup.py | 20 ++++++-------- tests/hermes_cli/test_mcp_startup.py | 40 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/hermes_cli/mcp_startup.py b/hermes_cli/mcp_startup.py index b5ab4282a2..1fc6ce07e9 100644 --- a/hermes_cli/mcp_startup.py +++ b/hermes_cli/mcp_startup.py @@ -4,10 +4,9 @@ from __future__ import annotations import threading from contextlib import nullcontext +from contextvars import copy_context from typing import Optional -from hermes_constants import get_hermes_home_override, reset_hermes_home_override, set_hermes_home_override - _mcp_discovery_lock = threading.Lock() _mcp_discovery_started = False _mcp_discovery_thread: Optional[threading.Thread] = None @@ -95,15 +94,13 @@ def start_background_mcp_discovery(*, logger, thread_name: str) -> None: if not _has_configured_mcp_servers(): return - # Re-install the caller's context-local HERMES_HOME override (multi-profile dashboard/desktop - # backends) inside the thread: ContextVars don't propagate into bare threads, so a session - # switched to profile X would otherwise discover the LAUNCH profile's mcp_servers. - # The config gate above already runs on the caller's thread, so it sees the same override. See - # #67605. - home_override = get_hermes_home_override() - + # Bare threads start from an empty context: run discovery under a copy of the caller's, so + # the context-local HERMES_HOME override (multi-profile dashboard/desktop backends, #67605) + # AND the profile's secret scope reach it. Without the scope a session switched to profile + # X would discover the LAUNCH profile's mcp_servers, and ``${TOKEN}`` interpolation / the + # stdio child env would fail closed (multiplex) or resolve the launch profile's value. + # The config gate above already runs on the caller's thread, so it sees the same context. def _discover() -> None: - token = set_hermes_home_override(home_override) try: _discover_mcp_tools_without_interactive_oauth() try: @@ -114,12 +111,11 @@ def start_background_mcp_discovery(*, logger, thread_name: str) -> None: except Exception: logger.debug("Background MCP tool discovery failed", exc_info=True) finally: - reset_hermes_home_override(token) with _mcp_discovery_lock: global _mcp_discovery_thread _mcp_discovery_thread = None - thread = threading.Thread(target=_discover, name=thread_name, daemon=True) + thread = threading.Thread(target=copy_context().run, args=(_discover,), name=thread_name, daemon=True) _mcp_discovery_thread = thread thread.start() diff --git a/tests/hermes_cli/test_mcp_startup.py b/tests/hermes_cli/test_mcp_startup.py index de4cde92b5..f6b751c3d5 100644 --- a/tests/hermes_cli/test_mcp_startup.py +++ b/tests/hermes_cli/test_mcp_startup.py @@ -236,6 +236,46 @@ def test_background_mcp_discovery_suppresses_interactive_oauth(monkeypatch): assert state["active"] is False +def test_background_mcp_discovery_propagates_profile_secret_scope(monkeypatch): + """A dashboard-profile discovery thread must retain that profile's secrets.""" + from agent.secret_scope import current_secret_scope, reset_secret_scope, set_secret_scope + + seen = [] + monkeypatch.setitem( + sys.modules, + "hermes_cli.config", + types.SimpleNamespace( + read_raw_config=lambda: {"mcp_servers": {"demo": {"url": "https://mcp.example.test/mcp"}}}, + ), + ) + monkeypatch.setitem( + sys.modules, + "tools.mcp_oauth", + types.SimpleNamespace(suppress_interactive_oauth=lambda: nullcontext()), + ) + monkeypatch.setitem( + sys.modules, + "tools.mcp_tool_discovery", + types.SimpleNamespace( + discover_mcp_tools=lambda: seen.append(dict(current_secret_scope() or {})), + ), + ) + + expected_scope = {"MCP_DEMO_TOKEN": "profile-only-secret"} + token = set_secret_scope(expected_scope) + try: + mcp_startup.start_background_mcp_discovery( + logger=types.SimpleNamespace(debug=lambda *_a, **_k: None), + thread_name="test-mcp-discovery", + ) + assert mcp_startup._mcp_discovery_thread is not None + mcp_startup._mcp_discovery_thread.join(timeout=1.0) + finally: + reset_secret_scope(token) + + assert seen == [expected_scope] + + def test_portable_only_mcp_configuration_opens_startup_gate(monkeypatch): monkeypatch.setitem( sys.modules,