From 5c2d098bb0acc7fbb359725d1d91b34fae303bcc Mon Sep 17 00:00:00 2001 From: Turgut Kural <58116817+TurgutKural@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:11:57 +0300 Subject: [PATCH] feat(mcp): add opt-out for automatic MCP reload on config change (cache-safe) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The automatic MCP reload added in #1474 watches config.yaml's mcp_servers section every 5s and reloads on any change. Every reload rebuilds the agent tool surface and INVALIDATES the provider prompt cache — the next message re-sends the full input prefix, which is expensive on long-context / high-reasoning models. When config.yaml is rewritten frequently (external tooling, multiple Hermes instances, or a flapping MCP server that rewrites config), this causes silent, repeated cache-breaking reloads. Add `mcp.auto_reload_on_config_change` (default: true, backward compatible). When set to false: - The config change is still DETECTED (watcher keeps running). - No automatic reload happens. - The user is told the config changed, that new settings are NOT yet applied, and how to apply them on their own terms with /reload-mcp — including the explicit warning that /reload-mcp invalidates the prompt cache. Manual /reload-mcp is unaffected and still works for users who want to apply changes deliberately. Tests: extend TestMCPConfigWatch with test_optout_disables_auto_reload. Co-authored-by: Turgut Kural --- cli.py | 46 ++++++++++++++++++++++++-- hermes_cli/config.py | 8 +++++ tests/cli/test_cli_mcp_config_watch.py | 39 ++++++++++++++++++++-- 3 files changed, 88 insertions(+), 5 deletions(-) diff --git a/cli.py b/cli.py index 3756fc7fa502..9b5a83cd47cd 100644 --- a/cli.py +++ b/cli.py @@ -9992,13 +9992,26 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): print(f" Error generating insights: {e}") def _check_config_mcp_changes(self) -> None: - """Detect mcp_servers changes in config.yaml and auto-reload MCP connections. + """Detect mcp_servers changes in config.yaml and react. Called from process_loop every CONFIG_WATCH_INTERVAL seconds. Compares config.yaml mtime + mcp_servers section against the last - known state. When a change is detected, triggers _reload_mcp() and - informs the user so they know the tool list has been refreshed. + known state. When a change is detected: + + * By default (``mcp.auto_reload_on_config_change: true``) it + auto-triggers ``_reload_mcp()`` and informs the user — legacy + behaviour from #1474. + * When opted out (``mcp.auto_reload_on_config_change: false``) it + does NOT reload. Instead it notifies the user that the config + changed and that they can apply it with ``/reload-mcp`` — while + warning that ``/reload-mcp`` rebuilds the tool surface and + **invalidates the provider prompt cache** (the next message + re-sends the full input prefix, expensive on long-context / + high-reasoning models). This stops silent cache-breaking reloads + when config.yaml is rewritten frequently by external tooling or + other Hermes instances. """ + import yaml as _yaml CONFIG_WATCH_INTERVAL = 5.0 # seconds between config.yaml stat() calls @@ -10042,7 +10055,34 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): if new_mcp == self._config_mcp_servers: return # mcp_servers unchanged (some other section was edited) + # Detected a change in the mcp_servers section. By default we + # auto-reload (legacy behaviour), but if the user has opted out we + # notify instead of reloading — because every reload rebuilds the + # agent tool surface and INVALIDATES the provider prompt cache (the + # next message re-sends the full input prefix, which is expensive on + # long-context / high-reasoning models). + try: + from hermes_cli.config import load_config as _load_cfg + _cfg = _load_cfg() + _mcp = _cfg.get("mcp") if isinstance(_cfg, dict) else None + _auto = _mcp.get("auto_reload_on_config_change", True) if isinstance(_mcp, dict) else True + except Exception: + _auto = True + self._config_mcp_servers = new_mcp + + if not _auto: + # Notify the user that the config changed but do NOT auto-reload. + # They can apply the new settings on their own terms with + # /reload-mcp — which we explicitly warn may invalidate the cache. + print() + print("🔄 MCP server config changed — reload skipped (auto-reload disabled).") + print(" New settings are NOT applied yet. To apply them now, run:") + print(" /reload-mcp") + print(" ⚠️ Note: /reload-mcp rebuilds the tool set and invalidates the") + print(" provider prompt cache (next message re-sends full input tokens).") + return + # Notify user and reload. Run in a separate thread with a hard # timeout so a hung MCP server cannot block the process_loop # indefinitely (which would freeze the entire TUI). diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 8fb9ad63172b..9b8d9c5427be 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1670,6 +1670,14 @@ DEFAULT_CONFIG = { "timeout": 30, "extra_body": {}, "reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default) + # Auto-reload MCP connections when config.yaml's mcp_servers section + # changes at runtime (default on, matches pre-#1474 behaviour). + # Set to False to stop the automatic reload: every automatic reload + # rebuilds the agent tool surface and INVALIDATES the provider + # prompt cache (the next message re-sends the full input prefix), + # which is expensive on long-context / high-reasoning models. + # MCP servers can still be reloaded manually via /reload-mcp. + "auto_reload_on_config_change": True, }, "title_generation": { "enabled": True, diff --git a/tests/cli/test_cli_mcp_config_watch.py b/tests/cli/test_cli_mcp_config_watch.py index 067ecc4cff76..e5a81669d082 100644 --- a/tests/cli/test_cli_mcp_config_watch.py +++ b/tests/cli/test_cli_mcp_config_watch.py @@ -4,11 +4,14 @@ from pathlib import Path from unittest.mock import MagicMock, patch -def _make_cli(tmp_path, mcp_servers=None): +def _make_cli(tmp_path, mcp_servers=None, extra_config=None): """Create a minimal HermesCLI instance with mocked config.""" import cli as cli_mod obj = object.__new__(cli_mod.HermesCLI) - obj.config = {"mcp_servers": mcp_servers or {}} + cfg = {"mcp_servers": mcp_servers or {}} + if extra_config: + cfg.update(extra_config) + obj.config = cfg obj._agent_running = False obj._last_config_check = 0.0 obj._config_mcp_servers = mcp_servers or {} @@ -101,3 +104,35 @@ class TestMCPConfigWatch: obj._check_config_mcp_changes() # should not raise obj._reload_mcp.assert_not_called() + + def test_optout_disables_auto_reload(self, tmp_path, capsys): + """When mcp.auto_reload_on_config_change is False, a changed + mcp_servers section must NOT trigger an automatic reload — but the + change is still detected and the user is told how to apply it. + + This protects the provider prompt cache: every automatic reload + rebuilds the agent tool surface and invalidates cached prefixes. + """ + import yaml + obj, cfg_file = _make_cli( + tmp_path, + mcp_servers={}, + ) + + # Simulate a changed mcp_servers section + cfg_file.write_text(yaml.dump({"mcp_servers": {"github": {"url": "https://mcp.github.com"}}})) + obj._config_mtime = 0.0 # force stale mtime + + # Opt out via the loaded config (the watcher reads load_config(), + # not obj.config, so we patch the loader). + mocked_cfg = {"mcp": {"auto_reload_on_config_change": False}} + with patch("hermes_cli.config.get_config_path", return_value=cfg_file), \ + patch("hermes_cli.config.load_config", return_value=mocked_cfg): + obj._check_config_mcp_changes() + + obj._reload_mcp.assert_not_called() + + out = capsys.readouterr().out + assert "reload skipped" in out + assert "/reload-mcp" in out + assert "prompt cache" in out