{"repo":"NousResearch/hermes-agent","public_id":"1036","display_label":"MCP tool visibility and reload after mcp_servers config changes","summary":"Members concern MCP tools not being picked up or visible without restarting Hermes: two pull requests propose auto-reloading MCP tools when the mcp_servers config changes, and one bug report describes tools showing as connected but without visible names or schemas until restart. The two PR titles are identical; the exact relationship between the PRs and the bug is not established by the titles alone.","subsystem":"mcp","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":1474,"merged_at":"2026-03-16T02:03:34+00:00"},"resolution":{"state":"resolved","by":"pr-1474"},"freshness":{"state":"stale","last_verified_at":"2026-03-16T02:03:34+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":1036,"pr_number":1474,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-03-16T02:03:34+00:00"}],"members":[{"kind":"issue","number":1036,"title":"[Bug]: MCP tools appear as connected/available, but names and schemas are not visible until Hermes is restarted","url":null,"state":"closed"},{"kind":"pr","number":1048,"title":"fix: auto-reload MCP tools when mcp_servers config changes without restart","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#1474","number":1474}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":1474,"title":"fix: auto-reload MCP tools when mcp_servers config changes without restart","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","tests/test_cli_mcp_config_watch.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -3484,6 +3484,56 @@ def _show_insights(self, command: str = \"/insights\"):\n         except Exception as e:\n             print(f\"  Error generating insights: {e}\")\n \n+    def _check_config_mcp_changes(self) -> None:\n+        \"\"\"Detect mcp_servers changes in config.yaml and auto-reload MCP connections.\n+\n+        Called from process_loop every CONFIG_WATCH_INTERVAL seconds.\n+        Compares config.yaml mtime + mcp_servers section against the last\n+        known state.  When a change is detected, triggers _reload_mcp() and\n+        informs the user so they know the tool list has been refreshed.\n+        \"\"\"\n+        import time\n+        import yaml as _yaml\n+\n+        CONFIG_WATCH_INTERVAL = 5.0  # seconds between config.yaml stat() calls\n+\n+        now = time.monotonic()\n+        if now - self._last_config_check < CONFIG_WATCH_INTERVAL:\n+            return\n+        self._last_config_check = now\n+\n+        from hermes_cli.config import get_config_path as _get_config_path\n+        cfg_path = _get_config_path()\n+        if not cfg_path.exists():\n+            return\n+\n+        try:\n+            mtime = cfg_path.stat().st_mtime\n+        except OSError:\n+            return\n+\n+        if mtime == self._config_mtime:\n+            return  # File unchanged — fast path\n+\n+        # File changed — check whether mcp_servers section changed\n+        self._config_mtime = mtime\n+        try:\n+            with open(cfg_path, encoding=\"utf-8\") as f:\n+                new_cfg = _yaml.safe_load(f) or {}\n+        except Exception:\n+            return\n+\n+        new_mcp = new_cfg.get(\"mcp_servers\") or {}\n+        if new_mcp == self._config_mcp_servers:\n+            return  # mcp_servers unchanged (some other section was edited)\n+\n+        self._config_mcp_servers = new_mcp\n+        # Notify user and reload\n+        print()\n+        print(\"🔄 MCP server config changed — reloading connections...\")\n+        with self._busy_command(self._slow_command_status(\"/reload-mcp\")):\n+            self._reload_mcp()\n+\n     def _reload_mcp(self):\n         \"\"\"Reload MCP servers: disconnect all, re-read config.yaml, reconnect.\n ","@@ -4749,6 +4799,12 @@ def run(self):\n         self._interrupt_queue = queue.Queue()   # For messages typed while agent is running\n         self._should_exit = False\n         self._last_ctrl_c_time = 0  # Track double Ctrl+C for force exit\n+        # Config file watcher — detect mcp_servers changes and auto-reload\n+        from hermes_cli.config import get_config_path as _get_config_path\n+        _cfg_path = _get_config_path()\n+        self._config_mtime: float = _cfg_path.stat().st_mtime if _cfg_path.exists() else 0.0\n+        self._config_mcp_servers: dict = self.config.get(\"mcp_servers\") or {}\n+        self._last_config_check: float = 0.0  # monotonic time of last check\n \n         # Clarify tool state: interactive question/answer with the user.\n         # When the agent calls the clarify tool, _clarify_state is set and","@@ -5682,6 +5738,9 @@ def process_loop():\n                     try:\n                         user_input = self._pending_input.get(timeout=0.1)\n                     except queue.Empty:\n+                        # Periodic config watcher — auto-reload MCP on mcp_servers change\n+                        if not self._agent_running:\n+                            self._check_config_mcp_changes()\n                         continue\n                     \n                     if not user_input:"]},{"file":"tests/test_cli_mcp_config_watch.py","hunks":["@@ -0,0 +1,103 @@\n+\"\"\"Tests for automatic MCP reload when config.yaml mcp_servers section changes.\"\"\"\n+import time\n+from pathlib import Path\n+from unittest.mock import MagicMock, patch\n+\n+\n+def _make_cli(tmp_path, mcp_servers=None):\n+    \"\"\"Create a minimal HermesCLI instance with mocked config.\"\"\"\n+    import cli as cli_mod\n+    obj = object.__new__(cli_mod.HermesCLI)\n+    obj.config = {\"mcp_servers\": mcp_servers or {}}\n+    obj._agent_running = False\n+    obj._last_config_check = 0.0\n+    obj._config_mcp_servers = mcp_servers or {}\n+\n+    cfg_file = tmp_path / \"config.yaml\"\n+    cfg_file.write_text(\"mcp_servers: {}\\n\")\n+    obj._config_mtime = cfg_file.stat().st_mtime\n+\n+    obj._reload_mcp = MagicMock()\n+    obj._busy_command = MagicMock()\n+    obj._busy_command.return_value.__enter__ = MagicMock(return_value=None)\n+    obj._busy_command.return_value.__exit__ = MagicMock(return_value=False)\n+    obj._slow_command_status = MagicMock(return_value=\"reloading...\")\n+\n+    return obj, cfg_file\n+\n+\n+class TestMCPConfigWatch:\n+\n+    def test_no_change_does_not_reload(self, tmp_path):\n+        \"\"\"If mtime and mcp_servers unchanged, _reload_mcp is NOT called.\"\"\"\n+        obj, cfg_file = _make_cli(tmp_path)\n+\n+        with patch(\"hermes_cli.config.get_config_path\", return_value=cfg_file):\n+            obj._check_config_mcp_changes()\n+\n+        obj._reload_mcp.assert_not_called()\n+\n+    def test_mtime_change_with_same_mcp_servers_does_not_reload(self, tmp_path):\n+        \"\"\"If file mtime changes but mcp_servers is identical, no reload.\"\"\"\n+        import yaml\n+        obj, cfg_file = _make_cli(tmp_path, mcp_servers={\"fs\": {\"command\": \"npx\"}})\n+\n+        # Write same mcp_servers but touch the file\n+        cfg_file.write_text(yaml.dump({\"mcp_servers\": {\"fs\": {\"command\": \"npx\"}}}))\n+        # Force mtime to appear changed\n+        obj._config_mtime = 0.0\n+\n+        with patch(\"hermes_cli.config.get_config_path\", return_value=cfg_file):\n+            obj._check_config_mcp_changes()\n+\n+        obj._reload_mcp.assert_not_called()\n+\n+    def test_new_mcp_server_triggers_reload(self, tmp_path):\n+        \"\"\"Adding a new MCP server to config triggers auto-reload.\"\"\"\n+        import yaml\n+        obj, cfg_file = _make_cli(tmp_path, mcp_servers={})\n+\n+        # Simulate user adding a new MCP server to config.yaml\n+        cfg_file.write_text(yaml.dump({\"mcp_servers\": {\"github\": {\"url\": \"https://mcp.github.com\"}}}))\n+        obj._config_mtime = 0.0  # force stale mtime\n+\n+        with patch(\"hermes_cli.config.get_config_path\", return_value=cfg_file):\n+            obj._check_config_mcp_changes()\n+\n+        obj._reload_mcp.assert_called_once()\n+\n+    def test_removed_mcp_server_triggers_reload(self, tmp_path):\n+        \"\"\"Removing an MCP server from config triggers auto-reload.\"\"\"\n+        import yaml\n+        obj, cfg_file = _make_cli(tmp_path, mcp_servers={\"github\": {\"url\": \"https://mcp.github.com\"}})\n+\n+        # Simulate user removing the server\n+        cfg_file.write_text(yaml.dump({\"mcp_servers\": {}}))\n+        obj._config_mtime = 0.0\n+\n+        with patch(\"hermes_cli.config.get_config_path\", return_value=cfg_file):\n+            obj._check_config_mcp_changes()\n+\n+        obj._reload_mcp.assert_called_once()\n+\n+    def test_interval_throttle_skips_check(self, tmp_path):\n+        \"\"\"If called within CONFIG_WATCH_INTERVAL, stat() is skipped.\"\"\"\n+        obj, cfg_file = _make_cli(tmp_path)\n+        obj._last_config_check = time.monotonic()  # just checked\n+\n+        with patch(\"hermes_cli.config.get_config_path\", return_value=cfg_file), \\\n+             patch.object(Path, \"stat\") as mock_stat:\n+            obj._check_config_mcp_changes()\n+            mock_stat.assert_not_called()\n+\n+        obj._reload_mcp.assert_not_called()\n+\n+    def test_missing_config_file_does_not_crash(self, tmp_path):\n+        \"\"\"If config.yaml doesn't exist, _check_config_mcp_changes is a no-op.\"\"\"\n+        obj, cfg_file = _make_cli(tmp_path)\n+        missing = tmp_path / \"nonexistent.yaml\"\n+\n+        with patch(\"hermes_cli.config.get_config_path\", return_value=missing):\n+            obj._check_config_mcp_changes()  # should not raise\n+\n+        obj._reload_mcp.assert_not_called()\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}