diff --git a/src/plyngent/runtime/mcp_client.py b/src/plyngent/runtime/mcp_client.py index 771370d..d0252bb 100644 --- a/src/plyngent/runtime/mcp_client.py +++ b/src/plyngent/runtime/mcp_client.py @@ -11,11 +11,15 @@ import asyncio import importlib.metadata import json import os +import subprocess +import sys from collections import deque from dataclasses import dataclass from typing import TYPE_CHECKING, cast if TYPE_CHECKING: + from typing import Any + from plyngent.config.models import McpConfig, McpServerConfig MCP_PROTOCOL_VERSION = "2025-06-18" @@ -45,6 +49,19 @@ def _client_version() -> str: return "0.0.0" +def _isolated_spawn_kwargs() -> dict[str, Any]: + """Spawn kwargs that keep a server out of our process/console group. + + Ctrl+C in the terminal is delivered to every process in the foreground + process group (POSIX) / console process group (Windows), so without this a + single Ctrl+C during a turn also killed every MCP server — killing in-flight + tool calls and leaving the connection dead until ``/mcp reconnect``. + """ + if sys.platform == "win32": + return {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} + return {"start_new_session": True} + + class McpConnectionError(RuntimeError): """Server process failed to spawn, handshake, or answer in time.""" @@ -143,6 +160,7 @@ class McpServerConnection: stderr=asyncio.subprocess.PIPE, env=os.environ | self._config.env, cwd=self._config.cwd or None, + **_isolated_spawn_kwargs(), ) except (OSError, ValueError) as exc: self.error = f"spawn {self._config.command!r}: {exc}" diff --git a/tests/test_runtime/test_mcp_client.py b/tests/test_runtime/test_mcp_client.py index aeb1525..10caa3e 100644 --- a/tests/test_runtime/test_mcp_client.py +++ b/tests/test_runtime/test_mcp_client.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import sys from pathlib import Path @@ -97,6 +98,26 @@ async def test_close_terminates_subprocess() -> None: assert proc.returncode is not None +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX process groups only") +async def test_server_spawned_outside_our_process_group() -> None: + """Regression: a terminal Ctrl+C must not reach the MCP server. + + The tty delivers SIGINT to every process in the foreground process group, so + a server spawned in *our* group died on any Ctrl+C during a turn (killing + in-flight tool calls and leaving the connection dead). ``start_new_session`` + moves the server into its own session/group. + """ + connection = McpServerConnection("fake", _server_config()) + await connection.start() + try: + proc = connection._proc + assert proc is not None + assert os.getpgid(proc.pid) != os.getpgid(0) + assert os.getsid(proc.pid) != os.getsid(0) + finally: + await connection.aclose() + + async def test_manager_start_status_and_call() -> None: manager = McpManager(McpConfig(servers={"fake": _server_config()}, disable=[])) await manager.ensure_started()