{"repo":"NousResearch/hermes-agent","public_id":"2537","display_label":"MCP shutdown 'Event loop is closed' errors with HTTP servers","summary":"Members center on an 'Event loop is closed' unhandled exception reported during shutdown with HTTP MCP servers, plus pull requests that address suppressing that message. One broader member also bundles other MCP stability items (reload timeout, shutdown cleanup, OAuth non-blocking), so it extends beyond the shared shutdown theme.","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":4757,"merged_at":"2026-04-03T09:29:20+00:00"},"resolution":{"state":"resolved","by":"pr-4757"},"freshness":{"state":"stale","last_verified_at":"2026-04-03T09:29:20+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":2537,"pr_number":4757,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-03T09:29:20+00:00"},{"issue_number":4462,"pr_number":4757,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-03T09:29:20+00:00"}],"members":[{"kind":"issue","number":2537,"title":"bug(mcp): Unhandled exception 'Event loop is closed' on shutdown with HTTP MCP servers","url":null,"state":"closed"},{"kind":"pr","number":2538,"title":"fix(mcp): suppress benign 'Event loop is closed' noise on shutdown","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#4757","number":4757}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":4757,"title":"fix(mcp): stability fix pack — reload timeout, shutdown cleanup, event loop handler, OAuth non-blocking","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","tests/tools/test_mcp_oauth.py","tests/tools/test_mcp_stability.py","tools/mcp_oauth.py","tools/mcp_tool.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -5016,11 +5016,18 @@ def _check_config_mcp_changes(self) -> None:\n             return  # mcp_servers unchanged (some other section was edited)\n \n         self._config_mcp_servers = new_mcp\n-        # Notify user and reload\n+        # Notify user and reload.  Run in a separate thread with a hard\n+        # timeout so a hung MCP server cannot block the process_loop\n+        # indefinitely (which would freeze the entire TUI).\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+        _reload_thread = threading.Thread(\n+            target=self._reload_mcp, daemon=True\n+        )\n+        _reload_thread.start()\n+        _reload_thread.join(timeout=30)\n+        if _reload_thread.is_alive():\n+            print(\"  ⚠️  MCP reload timed out (30s). Some servers may not have reconnected.\")\n \n     def _reload_mcp(self):\n         \"\"\"Reload MCP servers: disconnect all, re-read config.yaml, reconnect."]},{"file":"tests/tools/test_mcp_oauth.py","hunks":["@@ -9,10 +9,13 @@\n \n from tools.mcp_oauth import (\n     HermesTokenStorage,\n+    OAuthNonInteractiveError,\n     build_oauth_auth,\n     remove_oauth_tokens,\n     _find_free_port,\n     _can_open_browser,\n+    _is_interactive,\n+    _wait_for_callback,\n )\n \n ","@@ -236,3 +239,99 @@ def test_removes_files(self, tmp_path, monkeypatch):\n     def test_no_error_when_files_missing(self, tmp_path, monkeypatch):\n         monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n         remove_oauth_tokens(\"nonexistent\")  # should not raise\n+\n+\n+# ---------------------------------------------------------------------------\n+# Non-interactive / startup-safety tests (issue #4462)\n+# ---------------------------------------------------------------------------\n+\n+class TestIsInteractive:\n+    \"\"\"_is_interactive() detects headless/daemon/container environments.\"\"\"\n+\n+    def test_false_when_stdin_not_tty(self, monkeypatch):\n+        mock_stdin = MagicMock()\n+        mock_stdin.isatty.return_value = False\n+        monkeypatch.setattr(\"tools.mcp_oauth.sys.stdin\", mock_stdin)\n+        assert _is_interactive() is False\n+\n+    def test_true_when_stdin_is_tty(self, monkeypatch):\n+        mock_stdin = MagicMock()\n+        mock_stdin.isatty.return_value = True\n+        monkeypatch.setattr(\"tools.mcp_oauth.sys.stdin\", mock_stdin)\n+        assert _is_interactive() is True\n+\n+    def test_false_when_stdin_has_no_isatty(self, monkeypatch):\n+        \"\"\"Some environments replace stdin with an object without isatty().\"\"\"\n+        mock_stdin = object()  # no isatty attribute\n+        monkeypatch.setattr(\"tools.mcp_oauth.sys.stdin\", mock_stdin)\n+        assert _is_interactive() is False\n+\n+\n+class TestWaitForCallbackNoBlocking:\n+    \"\"\"_wait_for_callback() must never call input() — it raises instead.\"\"\"\n+\n+    def test_raises_on_timeout_instead_of_input(self):\n+        \"\"\"When no auth code arrives, raises OAuthNonInteractiveError.\"\"\"\n+        import tools.mcp_oauth as mod\n+        import asyncio\n+\n+        mod._oauth_port = _find_free_port()\n+\n+        async def instant_sleep(_seconds):\n+            pass\n+\n+        with patch.object(mod.asyncio, \"sleep\", instant_sleep):\n+            with patch(\"builtins.input\", side_effect=AssertionError(\"input() must not be called\")):\n+                with pytest.raises(OAuthNonInteractiveError, match=\"callback timed out\"):\n+                    asyncio.run(_wait_for_callback())\n+\n+\n+class TestBuildOAuthAuthNonInteractive:\n+    \"\"\"build_oauth_auth() in non-interactive mode.\"\"\"\n+\n+    def test_noninteractive_without_cached_tokens_warns(self, tmp_path, monkeypatch, caplog):\n+        \"\"\"Without cached tokens, non-interactive mode logs a clear warning.\"\"\"\n+        try:\n+            from mcp.client.auth import OAuthClientProvider\n+        except ImportError:\n+            pytest.skip(\"MCP SDK auth not available\")\n+\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        mock_stdin = MagicMock()\n+        mock_stdin.isatty.return_value = False\n+        monkeypatch.setattr(\"tools.mcp_oauth.sys.stdin\", mock_stdin)\n+\n+        import logging\n+        with caplog.at_level(logging.WARNING, logger=\"tools.mcp_oauth\"):\n+            auth = build_oauth_auth(\"atlassian\", \"https://mcp.atlassian.com/v1/mcp\")\n+\n+        assert auth is not None\n+        assert \"no cached tokens found\" in caplog.text.lower()\n+        assert \"non-interactive\" in caplog.text.lower()\n+\n+    def test_noninteractive_with_cached_tokens_no_warning(self, tmp_path, monkeypatch, caplog):\n+        \"\"\"With cached tokens, non-interactive mode logs no 'no cached tokens' warning.\"\"\"\n+        try:\n+            from mcp.client.auth import OAuthClientProvider\n+        except ImportError:\n+            pytest.skip(\"MCP SDK auth not available\")\n+\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        mock_stdin = MagicMock()\n+        mock_stdin.isatty.return_value = False\n+        monkeypatch.setattr(\"tools.mcp_oauth.sys.stdin\", mock_stdin)\n+\n+        # Pre-populate cached tokens\n+        d = tmp_path / \"mcp-tokens\"\n+        d.mkdir(parents=True)\n+        (d / \"atlassian.json\").write_text(json.dumps({\n+            \"access_token\": \"[redacted:secret-assignment]\",\n+            \"token_type\": \"[redacted:secret-assignment]\",\n+        }))\n+\n+        import logging\n+        with caplog.at_level(logging.WARNING, logger=\"tools.mcp_oauth\"):\n+            auth = build_oauth_auth(\"atlassian\", \"https://mcp.atlassian.com/v1/mcp\")\n+\n+        assert auth is not None\n+        assert \"no cached tokens found\" not in caplog.text.lower()"]},{"file":"tests/tools/test_mcp_stability.py","hunks":["@@ -0,0 +1,143 @@\n+\"\"\"Tests for MCP stability fixes — event loop handler, PID tracking, shutdown robustness.\"\"\"\n+\n+import asyncio\n+import os\n+import signal\n+import threading\n+from unittest.mock import patch, MagicMock\n+\n+import pytest\n+\n+\n+# ---------------------------------------------------------------------------\n+# Fix 1: MCP event loop exception handler\n+# ---------------------------------------------------------------------------\n+\n+class TestMCPLoopExceptionHandler:\n+    \"\"\"_mcp_loop_exception_handler suppresses benign 'Event loop is closed'.\"\"\"\n+\n+    def test_suppresses_event_loop_closed(self):\n+        from tools.mcp_tool import _mcp_loop_exception_handler\n+        loop = MagicMock()\n+        context = {\"exception\": RuntimeError(\"Event loop is closed\")}\n+        # Should NOT call default handler\n+        _mcp_loop_exception_handler(loop, context)\n+        loop.default_exception_handler.assert_not_called()\n+\n+    def test_forwards_other_runtime_errors(self):\n+        from tools.mcp_tool import _mcp_loop_exception_handler\n+        loop = MagicMock()\n+        context = {\"exception\": RuntimeError(\"some other error\")}\n+        _mcp_loop_exception_handler(loop, context)\n+        loop.default_exception_handler.assert_called_once_with(context)\n+\n+    def test_forwards_non_runtime_errors(self):\n+        from tools.mcp_tool import _mcp_loop_exception_handler\n+        loop = MagicMock()\n+        context = {\"exception\": ValueError(\"bad value\")}\n+        _mcp_loop_exception_handler(loop, context)\n+        loop.default_exception_handler.assert_called_once_with(context)\n+\n+    def test_forwards_contexts_without_exception(self):\n+        from tools.mcp_tool import _mcp_loop_exception_handler\n+        loop = MagicMock()\n+        context = {\"message\": \"just a message\"}\n+        _mcp_loop_exception_handler(loop, context)\n+        loop.default_exception_handler.assert_called_once_with(context)\n+\n+    def test_handler_installed_on_mcp_loop(self):\n+        \"\"\"_ensure_mcp_loop installs the exception handler on the new loop.\"\"\"\n+        import tools.mcp_tool as mcp_mod\n+        try:\n+            mcp_mod._ensure_mcp_loop()\n+            with mcp_mod._lock:\n+                loop = mcp_mod._mcp_loop\n+            assert loop is not None\n+            assert loop.get_exception_handler() is mcp_mod._mcp_loop_exception_handler\n+        finally:\n+            mcp_mod._stop_mcp_loop()\n+\n+\n+# ---------------------------------------------------------------------------\n+# Fix 2: stdio PID tracking\n+# ---------------------------------------------------------------------------\n+\n+class TestStdioPidTracking:\n+    \"\"\"_snapshot_child_pids and _stdio_pids track subprocess PIDs.\"\"\"\n+\n+    def test_snapshot_returns_set(self):\n+        from tools.mcp_tool import _snapshot_child_pids\n+        result = _snapshot_child_pids()\n+        assert isinstance(result, set)\n+        # All elements should be ints\n+        for pid in result:\n+            assert isinstance(pid, int)\n+\n+    def test_stdio_pids_starts_empty(self):\n+        from tools.mcp_tool import _stdio_pids, _lock\n+        with _lock:\n+            # Might have residual state from other tests, just check type\n+            assert isinstance(_stdio_pids, set)\n+\n+    def test_kill_orphaned_noop_when_empty(self):\n+        \"\"\"_kill_orphaned_mcp_children does nothing when no PIDs tracked.\"\"\"\n+        from tools.mcp_tool import _kill_orphaned_mcp_children, _stdio_pids, _lock\n+\n+        with _lock:\n+            _stdio_pids.clear()\n+\n+        # Should not raise\n+        _kill_orphaned_mcp_children()\n+\n+    def test_kill_orphaned_handles_dead_pids(self):\n+        \"\"\"_kill_orphaned_mcp_children gracefully handles already-dead PIDs.\"\"\"\n+        from tools.mcp_tool import _kill_orphaned_mcp_children, _stdio_pids, _lock\n+\n+        # Use a PID that definitely doesn't exist\n+        fake_pid = 999999999\n+        with _lock:\n+            _stdio_pids.add(fake_pid)\n+\n+        # Should not raise (ProcessLookupError is caught)\n+        _kill_orphaned_mcp_children()\n+\n+        with _lock:\n+            assert fake_pid not in _stdio_pids\n+\n+\n+# ---------------------------------------------------------------------------\n+# Fix 3: MCP reload timeout (cli.py)\n+# ---------------------------------------------------------------------------\n+\n+class TestMCPReloadTimeout:\n+    \"\"\"_check_config_mcp_changes uses a timeout on _reload_mcp.\"\"\"\n+\n+    def test_reload_timeout_does_not_block_forever(self, tmp_path, monkeypatch):\n+        \"\"\"If _reload_mcp hangs, the config watcher times out and returns.\"\"\"\n+        import time\n+\n+        # Create a mock HermesCLI-like object with the needed attributes\n+        class FakeCLI:\n+            _config_mtime = 0.0\n+            _config_mcp_servers = {}\n+            _last_config_check = 0.0\n+            _command_running = False\n+            config = {}\n+            agent = None\n+\n+            def _reload_mcp(self):\n+                # Simulate a hang — sleep longer than the timeout\n+                time.sleep(60)\n+\n+            def _slow_command_status(self, cmd):\n+                return cmd\n+\n+        # This test verifies the timeout mechanism exists in the code\n+        # by checking that _check_config_mcp_changes doesn't call\n+        # _reload_mcp directly (it uses a thread now)\n+        import inspect\n+        from cli import HermesCLI\n+        source = inspect.getsource(HermesCLI._check_config_mcp_changes)\n+        # The fix adds threading.Thread for _reload_mcp\n+        assert \"Thread\" in source or \"thread\" in source.lower(), \\\n+            \"_check_config_mcp_changes should use a thread for _reload_mcp\""]},{"file":"tools/mcp_oauth.py","hunks":["@@ -5,6 +5,12 @@\n authorization.  The SDK handles all of the heavy lifting: PKCE generation,\n metadata discovery, dynamic client registration, token exchange, and refresh.\n \n+Startup safety:\n+    The callback handler never calls blocking ``input()`` on the event loop.\n+    In non-interactive environments (no TTY, SSH, headless), the OAuth flow\n+    raises ``OAuthNonInteractiveError`` instead of blocking, so that the\n+    server degrades gracefully and other MCP servers are not affected.\n+\n Usage in mcp_tool.py::\n \n     from tools.mcp_oauth import build_oauth_auth","@@ -19,6 +25,7 @@\n import logging\n import os\n import socket\n+import sys\n import threading\n import webbrowser\n from http.server import BaseHTTPRequestHandler, HTTPServer","@@ -28,6 +35,11 @@\n \n logger = logging.getLogger(__name__)\n \n+\n+class OAuthNonInteractiveError(RuntimeError):\n+    \"\"\"Raised when OAuth requires user interaction but the environment is non-interactive.\"\"\"\n+    pass\n+\n _TOKEN_DIR_NAME = \"[redacted:secret-assignment]\"\n \n ","@@ -164,7 +176,13 @@ async def _redirect_to_browser(auth_url: str) -> None:\n \n \n async def _wait_for_callback() -> tuple[str, str | None]:\n-    \"\"\"Start a local HTTP server on the pre-registered port and wait for the OAuth redirect.\"\"\"\n+    \"\"\"Start a local HTTP server on the pre-registered port and wait for the OAuth redirect.\n+\n+    If the callback times out, raises ``OAuthNonInteractiveError`` instead of\n+    calling blocking ``input()`` — the old ``input()`` call would block the\n+    entire MCP asyncio event loop, preventing all other MCP servers from\n+    connecting and potentially hanging Hermes startup indefinitely.\n+    \"\"\"\n     global _oauth_port\n     port = _oauth_port or _find_free_port()\n     HandlerClass, result = _make_callback_handler()","@@ -186,8 +204,10 @@ def _serve():\n     code = result[\"auth_code\"] or \"\"\n     state = result[\"state\"]\n     if not code:\n-        print(\"  Browser callback timed out. Paste the authorization code manually:\")\n-        code = input(\"  Code: \").strip()\n+        raise OAuthNonInteractiveError(\n+            \"OAuth browser callback timed out after 120 seconds. \"\n+            \"Run 'hermes mcp auth <server-name>' to authorize interactively.\"\n+        )\n     return code, state\n \n ","@@ -199,6 +219,17 @@ def _can_open_browser() -> bool:\n     return True\n \n \n+def _is_interactive() -> bool:\n+    \"\"\"Check if the current environment can support interactive OAuth flows.\n+\n+    Returns False in headless/daemon/container environments where no user\n+    can interact with a browser or paste an auth code.\n+    \"\"\"\n+    if not hasattr(sys.stdin, \"isatty\") or not sys.stdin.isatty():\n+        return False\n+    return True\n+\n+\n # ---------------------------------------------------------------------------\n # Public API\n # ---------------------------------------------------------------------------","@@ -209,6 +240,11 @@ def build_oauth_auth(server_name: str, server_url: str):\n     Uses the MCP SDK's ``OAuthClientProvider`` which handles discovery,\n     registration, PKCE, token exchange, and refresh automatically.\n \n+    In non-interactive environments (no TTY), this still returns a provider\n+    so that **cached tokens and refresh flows work**.  Only the interactive\n+    authorization-code grant will fail fast with a clear error instead of\n+    blocking the event loop.\n+\n     Returns an ``OAuthClientProvider`` instance (implements ``httpx.Auth``),\n     or ``None`` if the MCP SDK auth module is not available.\n     \"\"\"","@@ -219,6 +255,25 @@ def build_oauth_auth(server_name: str, server_url: str):\n         logger.warning(\"MCP SDK auth module not available — OAuth disabled\")\n         return None\n \n+    storage = HermesTokenStorage(server_name)\n+    interactive = _is_interactive()\n+\n+    if not interactive:\n+        # Check whether cached tokens exist.  If they do, the SDK can still\n+        # use them (and refresh them) without any user interaction.  If not,\n+        # we still build the provider — the callback_handler will raise\n+        # OAuthNonInteractiveError if a fresh authorization is actually\n+        # needed, which surfaces as a clean connection failure for this\n+        # server only (other MCP servers are unaffected).\n+        has_cached = storage._read_json(storage._tokens_path()) is not None\n+        if not has_cached:\n+            logger.warning(\n+                \"MCP server '%s' requires OAuth but no cached tokens found \"\n+                \"and environment is non-interactive. The server will fail to \"\n+                \"connect. Run 'hermes mcp auth %s' to authorize interactively.\",\n+                server_name, server_name,\n+            )\n+\n     global _oauth_port\n     _oauth_port = _find_free_port()\n     redirect_uri = f\"http://127.0.0.1:{_oauth_port}/callback\"","@@ -232,14 +287,36 @@ def build_oauth_auth(server_name: str, server_url: str):\n         token_endpoint_auth_method=\"none\",\n     )\n \n-    storage = HermesTokenStorage(server_name)\n+    # In non-interactive mode, the redirect handler logs the URL and the\n+    # callback handler raises immediately — no blocking, no input().\n+    redirect_handler = _redirect_to_browser\n+    callback_handler = _wait_for_callback\n+\n+    if not interactive:\n+        async def _noninteractive_redirect(auth_url: str) -> None:\n+            logger.warning(\n+                \"MCP server '%s' needs OAuth authorization (non-interactive, \"\n+                \"cannot open browser). URL: %s\",\n+                server_name, auth_url,\n+            )\n+\n+        async def _noninteractive_callback() -> tuple[str, str | None]:\n+            raise OAuthNonInteractiveError(\n+                f\"MCP server '{server_name}' requires interactive OAuth \"\n+                f\"authorization but the environment is non-interactive \"\n+                f\"(no TTY). Run 'hermes mcp auth {server_name}' to \"\n+                f\"authorize, then restart.\"\n+            )\n+\n+        redirect_handler = _noninteractive_redirect\n+        callback_handler = _noninteractive_callback\n \n     return OAuthClientProvider(\n         server_url=server_url,\n         client_metadata=client_metadata,\n         storage=storage,\n-        redirect_handler=_redirect_to_browser,\n-        callback_handler=_wait_for_callback,\n+        redirect_handler=redirect_handler,\n+        callback_handler=callback_handler,\n         timeout=120.0,\n     )\n "]},{"file":"tools/mcp_tool.py","hunks":["@@ -842,13 +842,25 @@ async def _run_stdio(self, config: dict):\n         sampling_kwargs = self._sampling.session_kwargs() if self._sampling else {}\n         if _MCP_NOTIFICATION_TYPES and _MCP_MESSAGE_HANDLER_SUPPORTED:\n             sampling_kwargs[\"message_handler\"] = self._make_message_handler()\n+\n+        # Snapshot child PIDs before spawning so we can track the new one.\n+        pids_before = _snapshot_child_pids()\n         async with stdio_client(server_params) as (read_stream, write_stream):\n+            # Capture the newly spawned subprocess PID for force-kill cleanup.\n+            new_pids = _snapshot_child_pids() - pids_before\n+            if new_pids:\n+                with _lock:\n+                    _stdio_pids.update(new_pids)\n             async with ClientSession(read_stream, write_stream, **sampling_kwargs) as session:\n                 await session.initialize()\n                 self.session = session\n                 await self._discover_tools()\n                 self._ready.set()\n                 await self._shutdown_event.wait()\n+        # Context exited cleanly — subprocess was terminated by the SDK.\n+        if new_pids:\n+            with _lock:\n+                _stdio_pids.difference_update(new_pids)\n \n     async def _run_http(self, config: dict):\n         \"\"\"Run the server using HTTP/StreamableHTTP transport.\"\"\"","@@ -863,7 +875,10 @@ async def _run_http(self, config: dict):\n         headers = dict(config.get(\"headers\") or {})\n         connect_timeout = config.get(\"connect_timeout\", _DEFAULT_CONNECT_TIMEOUT)\n \n-        # OAuth 2.1 PKCE: build httpx.Auth handler using the MCP SDK\n+        # OAuth 2.1 PKCE: build httpx.Auth handler using the MCP SDK.\n+        # If OAuth setup fails (e.g. non-interactive environment without\n+        # cached tokens), re-raise so this server is reported as failed\n+        # without blocking other MCP servers from connecting.\n         _oauth_auth = None\n         if self._auth_type == \"oauth\":\n             try:","@@ -871,6 +886,7 @@ async def _run_http(self, config: dict):\n                 _oauth_auth = build_oauth_auth(self.name, url)\n             except Exception as exc:\n                 logger.warning(\"MCP OAuth setup failed for '%s': %s\", self.name, exc)\n+                raise\n \n         sampling_kwargs = self._sampling.session_kwargs() if self._sampling else {}\n         if _MCP_NOTIFICATION_TYPES and _MCP_MESSAGE_HANDLER_SUPPORTED:","@@ -1044,9 +1060,56 @@ async def shutdown(self):\n _mcp_loop: Optional[asyncio.AbstractEventLoop] = None\n _mcp_thread: Optional[threading.Thread] = None\n \n-# Protects _mcp_loop, _mcp_thread, and _servers from concurrent access.\n+# Protects _mcp_loop, _mcp_thread, _servers, and _stdio_pids.\n _lock = threading.Lock()\n \n+# PIDs of stdio MCP server subprocesses.  Tracked so we can force-kill\n+# them on shutdown if the graceful cleanup (SDK context-manager teardown)\n+# fails or times out.  PIDs are added after connection and removed on\n+# normal server shutdown.\n+_stdio_pids: set = set()\n+\n+\n+def _snapshot_child_pids() -> set:\n+    \"\"\"Return a set of current child process PIDs.\n+\n+    Uses /proc on Linux, falls back to psutil, then empty set.\n+    Used by _run_stdio to identify the subprocess spawned by stdio_client.\n+    \"\"\"\n+    my_pid = os.getpid()\n+\n+    # Linux: read from /proc\n+    try:\n+        children_path = f\"/proc/{my_pid}/task/{my_pid}/children\"\n+        with open(children_path) as f:\n+            return {int(p) for p in f.read().split() if p.strip()}\n+    except (FileNotFoundError, OSError, ValueError):\n+        pass\n+\n+    # Fallback: psutil\n+    try:\n+        import psutil\n+        return {c.pid for c in psutil.Process(my_pid).children()}\n+    except Exception:\n+        pass\n+\n+    return set()\n+\n+\n+def _mcp_loop_exception_handler(loop, context):\n+    \"\"\"Suppress benign 'Event loop is closed' noise during shutdown.\n+\n+    When the MCP event loop is stopped and closed, httpx/httpcore async\n+    transports may fire __del__ finalizers that call call_soon() on the\n+    dead loop.  asyncio catches that RuntimeError and routes it here.\n+    We silence it because the connection is being torn down anyway; all\n+    other exceptions are forwarded to the default handler.\n+    \"\"\"\n+    exc = context.get(\"exception\")\n+    if isinstance(exc, RuntimeError) and \"Event loop is closed\" in str(exc):\n+        return  # benign shutdown race — suppress\n+    loop.default_exception_handler(context)\n+\n \n def _ensure_mcp_loop():\n     \"\"\"Start the background event loop thread if not already running.\"\"\"","@@ -1055,6 +1118,7 @@ def _ensure_mcp_loop():\n         if _mcp_loop is not None and _mcp_loop.is_running():\n             return\n         _mcp_loop = asyncio.new_event_loop()\n+        _mcp_loop.set_exception_handler(_mcp_loop_exception_handler)\n         _mcp_thread = threading.Thread(\n             target=_mcp_loop.run_forever,\n             name=\"mcp-event-loop\",","@@ -2057,6 +2121,29 @@ async def _shutdown():\n     _stop_mcp_loop()\n \n \n+def _kill_orphaned_mcp_children() -> None:\n+    \"\"\"Best-effort kill of MCP stdio subprocesses that survived loop shutdown.\n+\n+    After the MCP event loop is stopped, stdio server subprocesses *should*\n+    have been terminated by the SDK's context-manager cleanup.  If the loop\n+    was stuck or the shutdown timed out, orphaned children may remain.\n+\n+    Only kills PIDs tracked in ``_stdio_pids`` — never arbitrary children.\n+    \"\"\"\n+    import signal as _signal\n+\n+    with _lock:\n+        pids = list(_stdio_pids)\n+        _stdio_pids.clear()\n+\n+    for pid in pids:\n+        try:\n+            os.kill(pid, _signal.SIGKILL)\n+            logger.debug(\"Force-killed orphaned MCP stdio process %d\", pid)\n+        except (ProcessLookupError, PermissionError, OSError):\n+            pass  # Already exited or inaccessible\n+\n+\n def _stop_mcp_loop():\n     \"\"\"Stop the background event loop and join its thread.\"\"\"\n     global _mcp_loop, _mcp_thread","@@ -2069,4 +2156,10 @@ def _stop_mcp_loop():\n         loop.call_soon_threadsafe(loop.stop)\n         if thread is not None:\n             thread.join(timeout=5)\n-        loop.close()\n+        try:\n+            loop.close()\n+        except Exception:\n+            pass\n+        # After closing the loop, any stdio subprocesses that survived the\n+        # graceful shutdown are now orphaned.  Force-kill them.\n+        _kill_orphaned_mcp_children()\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}