Skip to content

Commit 84c4b0f

Browse files
Implement MCP spec-compliant stdio shutdown sequence
The MCP specification recommends closing stdin first to allow servers to exit gracefully before resorting to signals. This approach gives well-behaved servers the opportunity to detect stdin closure and perform clean shutdown without forceful termination. The shutdown sequence now follows a graceful escalation path: first closing stdin and waiting 2 seconds for voluntary exit, then sending SIGTERM if needed, and finally using SIGKILL as a last resort. This minimizes the risk of data loss or corruption while ensuring cleanup always completes. This unified approach works consistently across all platforms and improves compatibility with MCP servers that monitor stdin for lifecycle management. resolves #765 Co-authored-by: davenpi <[email protected]>
1 parent 1c820a9 commit 84c4b0f

File tree

3 files changed

+161
-10
lines changed

3 files changed

+161
-10
lines changed

src/mcp/client/stdio/__init__.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -177,17 +177,38 @@ async def stdin_writer():
177177
try:
178178
yield read_stream, write_stream
179179
finally:
180-
# Clean up process to prevent any dangling orphaned processes
180+
# MCP spec: stdio shutdown sequence
181+
# 1. Close input stream to server
182+
# 2. Wait for server to exit, or send SIGTERM if it doesn't exit in time
183+
# 3. Send SIGKILL if still not exited
184+
if process.stdin:
185+
try:
186+
await process.stdin.aclose()
187+
except Exception:
188+
# stdin might already be closed, which is fine
189+
pass
190+
181191
try:
182-
process.terminate()
192+
# Give the process time to exit gracefully after stdin closes
183193
with anyio.fail_after(2.0):
184194
await process.wait()
185195
except TimeoutError:
186-
# If process doesn't terminate in time, force kill it
187-
process.kill()
196+
# Process didn't exit from stdin closure, escalate to SIGTERM
197+
try:
198+
process.terminate()
199+
with anyio.fail_after(2.0):
200+
await process.wait()
201+
except TimeoutError:
202+
# Process didn't respond to SIGTERM, force kill it
203+
process.kill()
204+
await process.wait()
205+
except ProcessLookupError:
206+
# Process already exited, which is fine
207+
pass
188208
except ProcessLookupError:
189209
# Process already exited, which is fine
190210
pass
211+
191212
await read_stream.aclose()
192213
await write_stream.aclose()
193214
await read_stream_writer.aclose()

src/mcp/client/stdio/win32.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from pathlib import Path
99
from typing import BinaryIO, TextIO, cast
1010

11-
from anyio import to_thread
11+
import anyio
1212
from anyio.streams.file import FileReadStream, FileWriteStream
1313

1414

@@ -73,9 +73,18 @@ async def __aexit__(
7373
exc_val: BaseException | None,
7474
exc_tb: object | None,
7575
) -> None:
76-
"""Terminate and wait on process exit inside a thread."""
77-
self.popen.terminate()
78-
await to_thread.run_sync(self.popen.wait)
76+
"""Clean up process and streams.
77+
78+
Attempts to terminate the process, but doesn't fail if termination
79+
is not possible (e.g., process already dead or being handled elsewhere).
80+
"""
81+
try:
82+
self.popen.terminate()
83+
with anyio.move_on_after(0.5):
84+
await self.wait()
85+
except (ProcessLookupError, OSError):
86+
# Process already dead or being handled elsewhere
87+
pass
7988

8089
# Close the file handles to prevent ResourceWarning
8190
if self.stdin:
@@ -90,8 +99,13 @@ async def __aexit__(
9099
self.stderr.close()
91100

92101
async def wait(self):
93-
"""Async wait for process completion."""
94-
return await to_thread.run_sync(self.popen.wait)
102+
"""
103+
Poll the process status instead of blocking wait
104+
This allows anyio timeouts to work properly
105+
"""
106+
while self.popen.poll() is None:
107+
await anyio.sleep(0.1)
108+
return self.popen.returncode
95109

96110
def terminate(self):
97111
"""Terminate the subprocess immediately."""

tests/client/test_stdio.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,3 +212,119 @@ def sigint_handler(signum, frame):
212212
)
213213
else:
214214
raise
215+
216+
217+
@pytest.mark.anyio
218+
async def test_stdio_client_graceful_stdin_exit():
219+
"""
220+
Test that a process exits gracefully when stdin is closed,
221+
without needing SIGTERM or SIGKILL.
222+
"""
223+
# Create a Python script that exits when stdin is closed
224+
script_content = textwrap.dedent(
225+
"""
226+
import sys
227+
228+
# Read from stdin until it's closed
229+
try:
230+
while True:
231+
line = sys.stdin.readline()
232+
if not line: # EOF/stdin closed
233+
break
234+
except:
235+
pass
236+
237+
# Exit gracefully
238+
sys.exit(0)
239+
"""
240+
)
241+
242+
server_params = StdioServerParameters(
243+
command=sys.executable,
244+
args=["-c", script_content],
245+
)
246+
247+
start_time = time.time()
248+
249+
# Use anyio timeout to prevent test from hanging forever
250+
with anyio.move_on_after(5.0) as cancel_scope:
251+
async with stdio_client(server_params) as (read_stream, write_stream):
252+
# Let the process start and begin reading stdin
253+
await anyio.sleep(0.2)
254+
# Exit context triggers cleanup - process should exit from stdin closure
255+
pass
256+
257+
if cancel_scope.cancelled_caught:
258+
pytest.fail(
259+
"stdio_client cleanup timed out after 5.0 seconds. "
260+
"Process should have exited gracefully when stdin was closed."
261+
)
262+
263+
end_time = time.time()
264+
elapsed = end_time - start_time
265+
266+
# Should complete quickly with just stdin closure (no signals needed)
267+
assert elapsed < 3.0, (
268+
f"stdio_client cleanup took {elapsed:.1f} seconds for stdin-aware process. "
269+
f"Expected < 3.0 seconds since process should exit on stdin closure."
270+
)
271+
272+
273+
@pytest.mark.anyio
274+
async def test_stdio_client_stdin_close_ignored():
275+
"""
276+
Test that when a process ignores stdin closure, the shutdown sequence
277+
properly escalates to SIGTERM.
278+
"""
279+
# Create a Python script that ignores stdin closure but responds to SIGTERM
280+
script_content = textwrap.dedent(
281+
"""
282+
import signal
283+
import sys
284+
import time
285+
286+
# Set up SIGTERM handler to exit cleanly
287+
def sigterm_handler(signum, frame):
288+
sys.exit(0)
289+
290+
signal.signal(signal.SIGTERM, sigterm_handler)
291+
292+
# Close stdin immediately to simulate ignoring it
293+
sys.stdin.close()
294+
295+
# Keep running until SIGTERM
296+
while True:
297+
time.sleep(0.1)
298+
"""
299+
)
300+
301+
server_params = StdioServerParameters(
302+
command=sys.executable,
303+
args=["-c", script_content],
304+
)
305+
306+
start_time = time.time()
307+
308+
# Use anyio timeout to prevent test from hanging forever
309+
with anyio.move_on_after(7.0) as cancel_scope:
310+
async with stdio_client(server_params) as (read_stream, write_stream):
311+
# Let the process start
312+
await anyio.sleep(0.2)
313+
# Exit context triggers cleanup
314+
pass
315+
316+
if cancel_scope.cancelled_caught:
317+
pytest.fail(
318+
"stdio_client cleanup timed out after 7.0 seconds. "
319+
"Process should have been terminated via SIGTERM escalation."
320+
)
321+
322+
end_time = time.time()
323+
elapsed = end_time - start_time
324+
325+
# Should take ~2 seconds (stdin close timeout) before SIGTERM is sent
326+
# Total time should be between 2-4 seconds
327+
assert 1.5 < elapsed < 4.5, (
328+
f"stdio_client cleanup took {elapsed:.1f} seconds for stdin-ignoring process. "
329+
f"Expected between 2-4 seconds (2s stdin timeout + termination time)."
330+
)

0 commit comments

Comments
 (0)