Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions hermes_cli/mcp_startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,28 @@ def start_background_mcp_discovery(*, logger, thread_name: str) -> None:
if not _has_configured_mcp_servers():
return

# Capture the caller's context-local HERMES_HOME override (profile
# scoping in multi-profile processes like the dashboard/desktop
# backend) and re-install it inside the discovery thread. ContextVars
# do not propagate into bare threads, so without this a session
# "switched" to profile X would discover the LAUNCH profile's
# mcp_servers instead (#67605). The config gate above already runs on
# the caller's thread, so it sees the same override.
try:
from hermes_constants import get_hermes_home_override

home_override = get_hermes_home_override()
except Exception:
home_override = None

def _discover() -> None:
token = None
try:
from hermes_constants import set_hermes_home_override

token = set_hermes_home_override(home_override)
except Exception:
token = None
try:
_discover_mcp_tools_without_interactive_oauth()
try:
Expand All @@ -73,6 +94,13 @@ def _discover() -> None:
except Exception:
logger.debug("Background MCP tool discovery failed", exc_info=True)
finally:
if token is not None:
try:
from hermes_constants import reset_hermes_home_override

reset_hermes_home_override(token)
except Exception:
pass
with _mcp_discovery_lock:
global _mcp_discovery_thread, _mcp_discovery_started
_mcp_discovery_thread = None
Expand Down
107 changes: 102 additions & 5 deletions tests/test_tui_gateway_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ def test_session_context_uses_session_cwd(monkeypatch, tmp_path):
session_key = "cwd-key"
project = tmp_path / "project"
project.mkdir()
(project / ".git").mkdir()
launcher = tmp_path / "apps" / "desktop"
launcher.mkdir(parents=True)

Expand Down Expand Up @@ -566,6 +567,100 @@ def _write_profile_cfg(home: Path, cwd: str | None) -> Path:
return home


def test_profile_scoped_mcp_discovery_uses_target_home(monkeypatch, tmp_path):
"""MCP discovery must start under the selected profile's HERMES_HOME."""
from hermes_cli import mcp_startup
from hermes_constants import get_hermes_home
from tui_gateway import entry

profile_home = tmp_path / "profiles" / "sheepyr"
profile_home.mkdir(parents=True)

(profile_home / "config.yaml").write_text(
"mcp_servers:\n"
" bluesky_sheepyr:\n"
" command: test-command\n",
encoding="utf-8",
)

monkeypatch.setenv("HERMES_HOME", str(tmp_path / "default"))
token = set_hermes_home_override(str(profile_home))

seen = []

monkeypatch.setattr(mcp_startup, "_mcp_discovery_started", False)
monkeypatch.setattr(mcp_startup, "_mcp_discovery_thread", None)
# ensure_mcp_discovery_started flips this module global; monkeypatch it so
# the enablement doesn't leak into sibling tests in this file.
monkeypatch.setattr(entry, "_mcp_discovery_enabled", False)
monkeypatch.setattr(
mcp_startup,
"_discover_mcp_tools_without_interactive_oauth",
lambda: seen.append(str(get_hermes_home())),
)

try:
entry.ensure_mcp_discovery_started()
thread = mcp_startup._mcp_discovery_thread
assert thread is not None
thread.join(timeout=2)
finally:
reset_hermes_home_override(token)
mcp_startup._mcp_discovery_thread = None
mcp_startup._mcp_discovery_started = False

assert seen == [str(profile_home)]


def test_profile_scoped_agent_build_starts_mcp_discovery_in_profile_home(
monkeypatch, tmp_path
):
"""Agent construction must start MCP discovery under the selected profile."""
import threading

from hermes_constants import get_hermes_home

profile_home = tmp_path / "profiles" / "sheepyr"
profile_home.mkdir(parents=True)

monkeypatch.setenv("HERMES_HOME", str(tmp_path / "default"))

seen = []
built = threading.Event()

monkeypatch.setattr(
server,
"_make_agent",
lambda *args, **kwargs: built.set()
or type("Agent", (), {"model": "test"})(),
)
monkeypatch.setattr(
"tui_gateway.entry.ensure_mcp_discovery_started",
lambda: seen.append(str(get_hermes_home())),
)
monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None)
monkeypatch.setattr(server, "_SlashWorker", lambda *args: None)
monkeypatch.setattr(server, "_attach_worker", lambda *args: None)
monkeypatch.setattr(server, "_config_model_target", lambda: ("", ""))

ready = threading.Event()
sid = "test-sid"
session = {
"agent_ready": ready,
"session_key": "test-key",
"profile_home": str(profile_home),
}

server._sessions[sid] = session
try:
server._start_agent_build(sid, session)
assert built.wait(timeout=2)
finally:
server._sessions.pop(sid, None)

assert seen == [str(profile_home)]


def test_profile_configured_cwd_reads_target_profile(tmp_path):
"""A profile's own terminal.cwd is read from its config.yaml."""
project = tmp_path / "proj"
Expand Down Expand Up @@ -10700,12 +10795,14 @@ def test_session_most_recent_handles_db_unavailable(monkeypatch):
# ── verification.status ──────────────────────────────────────────────


def test_verification_status_returns_recorded_evidence(tmp_path):
home = tmp_path / ".hermes"
home.mkdir()
token = set_hermes_home_override(home)
def test_verification_status_returns_recorded_evidence(tmp_path, monkeypatch):
profile_home = tmp_path / "profiles" / "verify"
profile_home.mkdir(parents=True)
monkeypatch.setattr(server, "_profile_home", lambda p: profile_home if p == "verify" else None)
token = set_hermes_home_override(profile_home)
project = tmp_path / "project"
project.mkdir()
(project / ".git").mkdir()
(project / "package.json").write_text(
json.dumps({"scripts": {"test": "vitest"}}),
encoding="utf-8",
Expand All @@ -10726,7 +10823,7 @@ def test_verification_status_returns_recorded_evidence(tmp_path):
{
"id": "1",
"method": "verification.status",
"params": {"cwd": str(project), "session_id": "sid"},
"params": {"cwd": str(project), "session_id": "sid", "profile": "verify"},
}
)
finally:
Expand Down
46 changes: 39 additions & 7 deletions tests/test_tui_gateway_ws.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@
from tui_gateway import ws as ws_mod


def test_ws_startup_starts_background_mcp_discovery(monkeypatch):
"""The desktop app and dashboard chat reach the agent through this WS
sidecar, not through tui_gateway.entry.main() (which spawns the discovery
thread for the stdio TUI). handle_ws must start discovery itself, otherwise
_make_agent's wait_for_mcp_discovery no-ops and the agent snapshots an
MCP-less tool list. Regression test for #38945."""
def test_ws_does_not_own_mcp_discovery_startup(monkeypatch):
"""WebSocket transport must not start MCP discovery itself.

MCP discovery ownership belongs to the profile-scoped agent build path.
The WS layer only establishes the transport and emits gateway readiness.
"""
calls = []

monkeypatch.setattr(
mcp_startup,
"start_background_mcp_discovery",
Expand All @@ -41,7 +42,7 @@ async def close(self):
finally:
server._sessions.clear()

assert calls == [{"logger": ws_mod._log, "thread_name": "tui-ws-mcp-discovery"}]
assert calls == []


def _run_disconnect(monkeypatch, seed):
Expand Down Expand Up @@ -188,6 +189,37 @@ async def send_text(self, line):
loop.close()


def test_ws_starts_mcp_discovery_before_ready(monkeypatch):
import tui_gateway.entry as entry

calls = []
events = []

monkeypatch.setattr(server, "_WS_ORPHAN_REAP_GRACE_S", 0)
monkeypatch.setattr(entry, "ensure_mcp_discovery_started", lambda: calls.append("mcp"))

class FakeWS:
async def accept(self):
events.append("accept")

async def send_text(self, line):
if '"gateway.ready"' in line:
events.append(f"ready_after_{len(calls)}")

async def receive_text(self):
raise ws_mod._WebSocketDisconnect()

async def close(self):
pass

asyncio.run(ws_mod.handle_ws(FakeWS()))

# Discovery moved to profile-aware agent construction. WebSocket transport
# should not start MCP discovery before a profile has been bound.
assert calls == []
assert events == ["accept", "ready_after_0"]


def test_ws_transport_serializes_concurrent_sends():
active_sends = 0
max_active_sends = 0
Expand Down
Loading
Loading