Skip to content

Commit 49d2394

Browse files
felixweinbergerjingx8885surya-prakash-susarla
committed
Fix child process cleanup in stdio termination
When terminating MCP servers, child processes were being orphaned because only the parent process was killed. This caused resource leaks and prevented proper cleanup, especially with tools like npx that spawn child processes for the actual server implementation. This was happening on both POSIX and Windows systems - however because of implementation details, resolving this is non-trivial and requires introducing psutil to introduce cross-platform utilities for dealing with children and process trees. This addresses critical issues where MCP servers using process spawning tools would leave zombie processes running after client shutdown. resolves #850 resolves #729 Co-authored-by: jingx8885 <[email protected]> Co-authored-by: Surya Prakash Susarla <[email protected]>
1 parent 7af9e65 commit 49d2394

File tree

5 files changed

+397
-2
lines changed

5 files changed

+397
-2
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ dependencies = [
3232
"pydantic-settings>=2.5.2",
3333
"uvicorn>=0.23.1; sys_platform != 'emscripten'",
3434
"jsonschema>=4.20.0",
35+
"psutil>=5.9.0,<6.0.0",
3536
]
3637

3738
[project.optional-dependencies]

src/mcp/client/stdio/__init__.py

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66

77
import anyio
88
import anyio.lowlevel
9+
import anyio.to_thread
10+
import psutil
11+
from anyio.abc import Process
912
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
1013
from anyio.streams.text import TextReceiveStream
1114
from pydantic import BaseModel, Field
@@ -14,6 +17,7 @@
1417
from mcp.shared.message import SessionMessage
1518

1619
from .win32 import (
20+
FallbackProcess,
1721
create_windows_process,
1822
get_windows_executable_command,
1923
)
@@ -184,7 +188,7 @@ async def stdin_writer():
184188
await process.wait()
185189
except TimeoutError:
186190
# If process doesn't terminate in time, force kill it
187-
process.kill()
191+
await _terminate_process_with_children(process)
188192
except ProcessLookupError:
189193
# Process already exited, which is fine
190194
pass
@@ -224,6 +228,70 @@ async def _create_platform_compatible_process(
224228
if sys.platform == "win32":
225229
process = await create_windows_process(command, args, env, errlog, cwd)
226230
else:
227-
process = await anyio.open_process([command, *args], env=env, stderr=errlog, cwd=cwd)
231+
process = await anyio.open_process(
232+
[command, *args],
233+
env=env,
234+
stderr=errlog,
235+
cwd=cwd,
236+
start_new_session=True,
237+
)
228238

229239
return process
240+
241+
242+
async def _terminate_process_with_children(process: Process | FallbackProcess, timeout: float = 2.0) -> None:
243+
"""
244+
Terminate a process and all its children using psutil.
245+
246+
This provides consistent behavior across platforms and properly
247+
handles process trees without shell commands.
248+
249+
Platform behavior:
250+
- On Unix: psutil.terminate() sends SIGTERM, allowing graceful shutdown
251+
- On Windows: psutil.terminate() calls TerminateProcess() which is immediate
252+
and doesn't allow cleanup handlers to run. This can cause ResourceWarnings
253+
for subprocess.Popen objects that don't get to clean up.
254+
"""
255+
pid = getattr(process, "pid", None)
256+
if pid is None:
257+
popen = getattr(process, "popen", None)
258+
if popen:
259+
pid = getattr(popen, "pid", None)
260+
261+
if not pid:
262+
# Process has no PID, cannot terminate
263+
return
264+
265+
try:
266+
parent = psutil.Process(pid)
267+
children = parent.children(recursive=True)
268+
269+
# First, try graceful termination for all children
270+
for child in children:
271+
try:
272+
child.terminate()
273+
except psutil.NoSuchProcess:
274+
pass
275+
276+
# Then, also terminate the parent process
277+
try:
278+
parent.terminate()
279+
except psutil.NoSuchProcess:
280+
return
281+
282+
# Wait for processes to exit gracefully, force kill any that remain
283+
all_procs = children + [parent]
284+
_, alive = await anyio.to_thread.run_sync(lambda: psutil.wait_procs(all_procs, timeout=timeout))
285+
for proc in alive:
286+
try:
287+
proc.kill()
288+
except psutil.NoSuchProcess:
289+
pass
290+
291+
# Wait a bit more for force-killed processes
292+
if alive:
293+
await anyio.to_thread.run_sync(lambda: psutil.wait_procs(alive, timeout=0.5))
294+
295+
except psutil.NoSuchProcess:
296+
# Process already terminated
297+
pass

src/mcp/client/stdio/win32.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,11 @@ def kill(self) -> None:
103103
"""Kill the subprocess immediately (alias for terminate)."""
104104
self.terminate()
105105

106+
@property
107+
def pid(self) -> int:
108+
"""Return the process ID."""
109+
return self.popen.pid
110+
106111

107112
# ------------------------
108113
# Updated function

0 commit comments

Comments
 (0)