From a275f06c692eddc1f2b1d15b1b4b6c1e53873737 Mon Sep 17 00:00:00 2001 From: JonthanaHanh <92574114+JonthanaHanh@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:37:26 +0700 Subject: [PATCH 1/3] fix(catalog): wire api_key auth headers for http MCP servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an optional-mcps manifest declares transport.type=http with auth.type=api_key, install_entry() correctly prompts for the key and saves it to .env, but _build_server_config() only handled the oauth case — the api_key case produced a bare url entry with no headers, so every request to the server was unauthenticated (→ 401). Import and call _bearer_auth_headers(entry.name) from mcp_config.py to produce the same Authorization: Bearer ${MCP__API_KEY} template used by the manual 'hermes mcp add --url' path. Closes #70632. --- hermes_cli/mcp_catalog.py | 4 +++ tests/hermes_cli/test_mcp_catalog.py | 38 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/hermes_cli/mcp_catalog.py b/hermes_cli/mcp_catalog.py index f2a5303abe947..d53858ffc10ab 100644 --- a/hermes_cli/mcp_catalog.py +++ b/hermes_cli/mcp_catalog.py @@ -490,6 +490,10 @@ def _build_server_config( cfg["url"] = t.url if entry.auth.type == "oauth": cfg["auth"] = "oauth" + elif entry.auth.type == "api_key": + from hermes_cli.mcp_config import _bearer_auth_headers + + cfg["headers"] = _bearer_auth_headers(entry.name) return cfg diff --git a/tests/hermes_cli/test_mcp_catalog.py b/tests/hermes_cli/test_mcp_catalog.py index 0c440f4909bc8..0eba845763f8b 100644 --- a/tests/hermes_cli/test_mcp_catalog.py +++ b/tests/hermes_cli/test_mcp_catalog.py @@ -216,6 +216,21 @@ def test_transport_env_absent_leaves_config_without_env_key(self, catalog_dir): cfg = _build_server_config(_entry("demo"), None) assert "env" not in cfg + def test_http_api_key_builds_bearer_headers_template(self, catalog_dir): + body = _basic_manifest( + transport={"type": "http", "url": "https://mcp.example.com/sse"}, + auth={ + "type": "api_key", + "env": [{"name": "MCP_DEMO_API_KEY", "prompt": "key", "secret": True}], + }, + ) + _write_manifest(catalog_dir, "demo", body) + from hermes_cli.mcp_catalog import _build_server_config + + cfg = _build_server_config(_entry("demo"), None) + assert cfg["url"] == "https://mcp.example.com/sse" + assert cfg["headers"] == {"Authorization": "Bearer ${MCP_DEMO_API_KEY}"} + def test_transport_env_bad_shape_rejected(self, catalog_dir): body = _basic_manifest() body["transport"]["env"] = ["DISABLE_TELEMETRY=true"] # list, not mapping @@ -334,6 +349,29 @@ def test_install_http_oauth_writes_auth_marker(self, catalog_dir): assert server["url"] == "https://mcp.example.com/sse" assert server["auth"] == "oauth" + def test_install_http_api_key_writes_bearer_headers(self, catalog_dir, monkeypatch): + body = _basic_manifest( + transport={"type": "http", "url": "https://mcp.example.com/sse"}, + auth={ + "type": "api_key", + "env": [{"name": "MCP_DEMO_API_KEY", "prompt": "key", "secret": True}], + }, + ) + _write_manifest(catalog_dir, "demo", body) + + from hermes_cli import mcp_catalog + + monkeypatch.setattr(mcp_catalog, "_prompt_input", lambda *a, **kw: "secret-val") + + from hermes_cli.mcp_catalog import install_entry + from hermes_cli.config import load_config + + install_entry(_entry("demo"), enable=True) + + server = load_config()["mcp_servers"]["demo"] + assert server["url"] == "https://mcp.example.com/sse" + assert server["headers"] == {"Authorization": "Bearer secret-val"} + def test_install_required_env_missing_raises(self, catalog_dir, monkeypatch): body = _basic_manifest( auth={ From 63127495276aeacd0c03f140f7ad3e3e6820acca Mon Sep 17 00:00:00 2001 From: JonthanaHanh <92574114+JonthanaHanh@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:49:44 +0700 Subject: [PATCH 2/3] fix(desktop): handle string display_metadata in async_delegation_complete When resuming a session that contains async_delegation_complete messages, display_metadata may arrive from the gateway/database as a JSON string rather than a parsed object. The 'in' operator on a string throws TypeError, crashing session resume. Parse the string to an object before using 'in', with a try/catch fallback for malformed JSON. Closes #70635. --- apps/desktop/src/lib/chat-messages.test.ts | 28 ++++++++++++++++++++++ apps/desktop/src/lib/chat-messages.ts | 9 +++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/lib/chat-messages.test.ts b/apps/desktop/src/lib/chat-messages.test.ts index e86ba5593ecc7..966d62d9bc8b3 100644 --- a/apps/desktop/src/lib/chat-messages.test.ts +++ b/apps/desktop/src/lib/chat-messages.test.ts @@ -191,6 +191,34 @@ describe('toChatMessages', () => { 'background agent work finished' ]) }) + + it('handles async_delegation_complete with string display_metadata from database', () => { + const [message] = toChatMessages([ + { + role: 'assistant', + content: 'done', + display_kind: 'async_delegation_complete', + display_metadata: '{"delegation_id":"deleg_abc","task_count":3,"completed_count":3}', + timestamp: 1 + } + ]) + + expect(chatMessageText(message)).toBe('3 background agents finished') + }) + + it('handles async_delegation_complete with object display_metadata', () => { + const [message] = toChatMessages([ + { + role: 'assistant', + content: 'done', + display_kind: 'async_delegation_complete', + display_metadata: { delegation_id: 'deleg_abc', task_count: 1 }, + timestamp: 1 + } + ]) + + expect(chatMessageText(message)).toBe('1 background agent finished') + }) }) describe('renderMediaTags', () => { diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index 8541c0bb80988..a0a54281a79d6 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -317,9 +317,14 @@ function timelineDisplayContent(message: SessionMessage, content: string): strin } if (message.display_kind === 'async_delegation_complete') { + // display_metadata may arrive as a JSON string from the database + const meta = + typeof message.display_metadata === 'string' + ? (() => { try { return JSON.parse(message.display_metadata) } catch { return undefined } })() + : message.display_metadata const count = - message.display_metadata && 'task_count' in message.display_metadata - ? message.display_metadata.task_count + meta && typeof meta === 'object' && 'task_count' in meta + ? meta.task_count : undefined return count === undefined From eebe211f59e73e4758132e8d997f987d14e12a93 Mon Sep 17 00:00:00 2001 From: JonthanaHanh <92574114+JonthanaHanh@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:56:30 +0700 Subject: [PATCH 3/3] fix(compressor): gc.collect() after compression to reclaim memory Python's arena allocator keeps memory pages in the process heap even after objects are freed from the messages list. Over long sessions (3+ hours), this causes unbounded RSS growth (observed 2.5 GB VmPeak) leading to systemd-oomd kills. Add gc.collect() at the end of ContextCompressor.compress() to reclaim memory from compressed-away message dicts immediately after the summarization pass. Closes #70684. --- agent/context_compressor.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 65684ced454a8..5f4b1e58ae0f3 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -5428,6 +5428,14 @@ def _window_row(idx: int, msg: Dict[str, Any]): _strip_persistence_markers(compressed) self._last_compression_made_progress = True + # Reclaim memory from compressed-away message dicts. Python's arena + # allocator keeps pages in the process heap even after objects are freed + # from the list; without an explicit collect, RSS grows unbounded over + # long sessions. (#70684) + import gc + + gc.collect() + return compressed