diff --git a/CHANGELOG.md b/CHANGELOG.md index d303bc66..249abdec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Fixed +- **The MCP servers/tools list now reflects the active profile instead of the default one.** When viewing MCP servers or tools while a non-default profile was active, the list was read from the ambient (default) profile's config rather than the profile you were actually in, so it could show the wrong profile's MCP servers. Both the server-list and tool-list reads are now scoped to the active profile's config home, matching where the MCP write handlers save. Deployments that point config at an external path via `HERMES_CONFIG_PATH` now also read that override consistently on the active-home path. Thanks @rodboev. (#5772, #5619) + - **Mobile/touch: the chat no longer jumps backward while a reply streams and you're scrolled up reading history (non-virtualized transcripts).** Companion to the desktop and virtualized fixes (#5637/#5638/#5742) for the one code path they didn't cover: with transcript virtualization turned off (#4325 opt-out), a re-render rebuilt every row from scratch, and a fresh off-screen tall user row under `content-visibility: auto` (active on coarse-pointer devices) reserved only its flat placeholder height instead of its real height — so `scrollHeight` shrank, the browser clamped `scrollTop`, and the viewport jumped toward the top. The rebuild now reserves each user row's real height, measured from the still-laid-out rows just before the wipe (with a CJK-aware estimate as the backstop for never-painted rows and a `max(remembered, estimate)` floor so a partial paint can't under-reserve). Desktop is unchanged (the placeholder is inert there). Thanks @allenliang2022. (#5751, #5744) - **Desktop: the reading position no longer jumps up while a streaming reply grows above it.** When you scrolled up to read earlier history during an active streaming reply, each live activity refresh restored a stale absolute scroll offset (captured before the reply grew content above the viewport), yanking the reader up by a few hundred pixels. The desktop restore now holds the reader on the same content by realigning to a surviving above-viewport row's measured offset (the app's own `scrollTop += delta` idiom) rather than the stale absolute value — with a top-spacer-delta fallback when the anchor row is gone, and a raw fallback when neither is measurable. Content growing *below* a scrolled-up reader no longer drifts them down either. Touch/tablet behavior is unchanged (the browser's native scroll anchoring holds there). Thanks @allenliang2022. (#5742, #5637) diff --git a/api/config.py b/api/config.py index 96ff96ef..6c93ce19 100644 --- a/api/config.py +++ b/api/config.py @@ -703,6 +703,13 @@ def get_config_for_profile_home(profile_home: "Path | str | None") -> dict: target = Path(profile_home).expanduser() except Exception: return get_config() + try: + from api.profiles import get_active_hermes_home + + if Path(get_active_hermes_home()).expanduser() == target: + return get_config() + except Exception: + pass # If the ambient resolver already points at this profile home, defer to # get_config() so in-memory overrides (monkeypatched cfg) are honored. This # MUST run before the nonexistent-home guard below: a matching ambient home diff --git a/api/routes.py b/api/routes.py index eff7e3c3..81314e57 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2822,6 +2822,7 @@ from api.config import ( _load_yaml_config_file, _save_yaml_config_file, reload_config, + get_config_for_profile_home, _cfg_lock, PENDING_BG_TASK_COMPLETIONS, ) @@ -24181,7 +24182,7 @@ def _mcp_tools_from_registry(server_summaries): def _handle_mcp_tools_list(handler): """List known MCP tools from already-available runtime inventory only.""" - cfg = get_config() + cfg = get_config_for_profile_home(get_active_hermes_home()) servers = cfg.get("mcp_servers", {}) if not isinstance(servers, dict): servers = {} @@ -24695,7 +24696,7 @@ def _handle_notes_item(handler, parsed): def _handle_mcp_servers_list(handler): """List configured MCP servers with safe, read-only runtime visibility.""" - cfg = get_config() + cfg = get_config_for_profile_home(get_active_hermes_home()) servers = cfg.get("mcp_servers", {}) if not isinstance(servers, dict): servers = {} diff --git a/tests/test_issue3294_profile_toolset_scoping.py b/tests/test_issue3294_profile_toolset_scoping.py index d3e4b40d..6d9d3905 100644 --- a/tests/test_issue3294_profile_toolset_scoping.py +++ b/tests/test_issue3294_profile_toolset_scoping.py @@ -108,6 +108,31 @@ def test_matching_home_defers_to_get_config(_two_profiles): ] +def test_active_home_with_external_config_override_defers_to_get_config(_two_profiles, monkeypatch, tmp_path): + """The active home still honors HERMES_CONFIG_PATH when the override lives + outside that home.""" + cfg, default_home, profile_b_home = _two_profiles + from api import profiles + + active_home = tmp_path / "active-home" + override_path = tmp_path / "override-dir" / "config.yaml" + active_home.mkdir() + _write_cfg(active_home, ["wrong-active-home-file"]) + override_path.parent.mkdir() + import yaml + + override_path.write_text( + yaml.safe_dump({"platform_toolsets": {"cli": ["override-config"]}}, sort_keys=False), + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_CONFIG_PATH", str(override_path)) + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: active_home) + cfg.reload_config() + + same = cfg.get_config_for_profile_home(active_home) + assert same.get("platform_toolsets", {}).get("cli") == ["override-config"] + + def test_matching_ambient_home_that_does_not_exist_still_defers_to_get_config(_two_profiles, monkeypatch, tmp_path): """Regression (#4516 gate): the nonexistent-home guard must run AFTER the ambient-resolver short-circuit. A home that matches the ambient config path diff --git a/tests/test_issue538_mcp_management.py b/tests/test_issue538_mcp_management.py index 77c3ea72..dcbaa446 100644 --- a/tests/test_issue538_mcp_management.py +++ b/tests/test_issue538_mcp_management.py @@ -1,6 +1,7 @@ """Tests for issue #538 — MCP server management API.""" import json, pytest from unittest.mock import patch, MagicMock, call +import yaml from api.routes import ( _handle_mcp_servers_list, _handle_mcp_server_update, @@ -42,17 +43,33 @@ SAMPLE_MCP = { class TestMcpList: """GET /api/mcp/servers — list with masked secrets.""" - @patch('api.routes.get_config') - def test_returns_servers_list(self, mock_cfg): + @patch('api.routes.get_config_for_profile_home') + @patch('api.routes.get_active_hermes_home') + def test_returns_servers_list(self, mock_home, mock_cfg): + mock_home.return_value = sentinel_home = object() mock_cfg.return_value = {'mcp_servers': SAMPLE_MCP} h = _make_handler() _handle_mcp_servers_list(h) assert h.send_response.called status = h.send_response.call_args[0][0] assert status == 200 + mock_cfg.assert_called_once_with(sentinel_home) - @patch('api.routes.get_config') - def test_empty_config(self, mock_cfg): + @patch('api.routes.get_config_for_profile_home') + @patch('api.routes.get_active_hermes_home') + def test_reads_active_profile_home_for_servers(self, mock_home, mock_cfg): + mock_home.return_value = sentinel_home = object() + mock_cfg.return_value = {'mcp_servers': {'active': SAMPLE_MCP['searxng']}} + h = _make_handler() + _handle_mcp_servers_list(h) + payload = _json_payload(h) + assert [srv['name'] for srv in payload['servers']] == ['active'] + mock_cfg.assert_called_once_with(sentinel_home) + + @patch('api.routes.get_config_for_profile_home') + @patch('api.routes.get_active_hermes_home') + def test_empty_config(self, mock_home, mock_cfg): + mock_home.return_value = object() mock_cfg.return_value = {} h = _make_handler() _handle_mcp_servers_list(h) @@ -65,8 +82,10 @@ class TestMcpList: assert payload['reload_required'] is True @patch('api.routes._mcp_runtime_status_by_name') - @patch('api.routes.get_config') - def test_list_payload_includes_status_tool_counts_and_safe_invalid_config(self, mock_cfg, mock_runtime): + @patch('api.routes.get_config_for_profile_home') + @patch('api.routes.get_active_hermes_home') + def test_list_payload_includes_status_tool_counts_and_safe_invalid_config(self, mock_home, mock_cfg, mock_runtime): + mock_home.return_value = object() mock_cfg.return_value = { 'mcp_servers': { 'searxng': {'command': 'mcp-searxng', 'args': ['--port', '8888']}, @@ -123,6 +142,41 @@ class TestMcpList: """YAML numeric false-y values should not show a disabled server as enabled.""" assert _parse_mcp_enabled(0) is False + def test_active_home_list_reads_external_config_override_used_by_writes(self, monkeypatch, tmp_path): + """HERMES_CONFIG_PATH outside the active home remains the read/write authority.""" + from api import config, profiles, routes + + active_home = tmp_path / 'active-home' + override_path = tmp_path / 'override-dir' / 'config.yaml' + active_home.mkdir() + override_path.parent.mkdir() + active_home.joinpath('config.yaml').write_text( + yaml.safe_dump({'mcp_servers': {'wrong-home': {'command': 'wrong'}}}, sort_keys=False), + encoding='utf-8', + ) + override_path.write_text( + yaml.safe_dump({'mcp_servers': {'override-srv': {'command': 'override'}}}, sort_keys=False), + encoding='utf-8', + ) + monkeypatch.setenv('HERMES_CONFIG_PATH', str(override_path)) + monkeypatch.setattr(profiles, 'get_active_hermes_home', lambda: active_home) + monkeypatch.setattr(routes, 'get_active_hermes_home', lambda: active_home) + monkeypatch.setattr(routes, '_mcp_runtime_status_by_name', lambda: {}) + config.reload_config() + + h = _make_handler() + _handle_mcp_servers_list(h) + payload = _json_payload(h) + assert [srv['name'] for srv in payload['servers']] == ['override-srv'] + + h = _make_handler() + h.command = 'PUT' + _handle_mcp_server_update(h, 'new-srv', {'command': 'new-command'}) + saved = yaml.safe_load(override_path.read_text(encoding='utf-8')) + active_home_saved = yaml.safe_load(active_home.joinpath('config.yaml').read_text(encoding='utf-8')) + assert 'new-srv' in saved['mcp_servers'] + assert 'new-srv' not in active_home_saved['mcp_servers'] + class TestMcpSave: """PUT /api/mcp/servers/ — add or update.""" diff --git a/tests/test_issue697_mcp_tool_inventory.py b/tests/test_issue697_mcp_tool_inventory.py index 4dfd4ba1..decfe92c 100644 --- a/tests/test_issue697_mcp_tool_inventory.py +++ b/tests/test_issue697_mcp_tool_inventory.py @@ -29,8 +29,10 @@ def _read(relative_path: str) -> str: class TestMcpToolInventoryApi: @patch("api.routes._mcp_runtime_status_by_name") - @patch("api.routes.get_config") - def test_endpoint_returns_sanitized_registered_mcp_tools(self, mock_cfg, mock_runtime): + @patch("api.routes.get_config_for_profile_home") + @patch("api.routes.get_active_hermes_home") + def test_endpoint_returns_sanitized_registered_mcp_tools(self, mock_home, mock_cfg, mock_runtime): + mock_home.return_value = sentinel_home = object() mock_cfg.return_value = { "mcp_servers": { "web-reader": {"url": "http://localhost:3001/mcp", "headers": {"Authorization": "Bearer secret-token"}}, @@ -72,6 +74,7 @@ class TestMcpToolInventoryApi: {"name": "url", "type": "string", "required": True, "description": "URL to fetch"}, {"name": "limit", "type": "integer", "required": False, "description": "Maximum bytes"}, ] + mock_cfg.assert_called_once_with(sentinel_home) raw = json.dumps(payload) assert "secret-token" not in raw assert "default" not in raw