fix(mcp): retain profile secret scope during discovery

(cherry picked from commit f344095fcf16c5d154ef2d207cce9b13339bf2e4)
This commit is contained in:
Nick Seelert
2026-08-15 00:11:25 -04:00
committed by Teknium
parent 2952dc62bc
commit 89fb1d028e
2 changed files with 48 additions and 12 deletions

View File

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

View File

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