Skip to content

Commit 3ba87ad

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 <davenport.ianc@gmail.com>
1 parent beacd6e commit 3ba87ad

File tree

2 files changed

+131
-3
lines changed

2 files changed

+131
-3
lines changed

src/mcp/client/stdio/__init__.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,17 +186,29 @@ async def stdin_writer():
186186
try:
187187
yield read_stream, write_stream
188188
finally:
189-
# Clean up process to prevent any dangling orphaned processes
189+
# MCP spec: stdio shutdown sequence
190+
# 1. Close input stream to server
191+
# 2. Wait for server to exit, or send SIGTERM if it doesn't exit in time
192+
# 3. Send SIGKILL if still not exited
193+
if process.stdin:
194+
try:
195+
await process.stdin.aclose()
196+
except Exception:
197+
# stdin might already be closed, which is fine
198+
pass
199+
190200
try:
191-
process.terminate()
201+
# Give the process time to exit gracefully after stdin closes
192202
with anyio.fail_after(PROCESS_TERMINATION_TIMEOUT):
193203
await process.wait()
194204
except TimeoutError:
195-
# If process doesn't terminate in time, force kill it
205+
# Process didn't exit from stdin closure, use platform-specific termination
206+
# which handles SIGTERM -> SIGKILL escalation
196207
await _terminate_process_tree(process)
197208
except ProcessLookupError:
198209
# Process already exited, which is fine
199210
pass
211+
200212
await read_stream.aclose()
201213
await write_stream.aclose()
202214
await read_stream_writer.aclose()

tests/client/test_stdio.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,3 +525,119 @@ def handle_term(sig, frame):
525525
os.unlink(marker_file)
526526
except OSError:
527527
pass
528+
529+
530+
@pytest.mark.anyio
531+
async def test_stdio_client_graceful_stdin_exit():
532+
"""
533+
Test that a process exits gracefully when stdin is closed,
534+
without needing SIGTERM or SIGKILL.
535+
"""
536+
# Create a Python script that exits when stdin is closed
537+
script_content = textwrap.dedent(
538+
"""
539+
import sys
540+
541+
# Read from stdin until it's closed
542+
try:
543+
while True:
544+
line = sys.stdin.readline()
545+
if not line: # EOF/stdin closed
546+
break
547+
except:
548+
pass
549+
550+
# Exit gracefully
551+
sys.exit(0)
552+
"""
553+
)
554+
555+
server_params = StdioServerParameters(
556+
command=sys.executable,
557+
args=["-c", script_content],
558+
)
559+
560+
start_time = time.time()
561+
562+
# Use anyio timeout to prevent test from hanging forever
563+
with anyio.move_on_after(5.0) as cancel_scope:
564+
async with stdio_client(server_params) as (read_stream, write_stream):
565+
# Let the process start and begin reading stdin
566+
await anyio.sleep(0.2)
567+
# Exit context triggers cleanup - process should exit from stdin closure
568+
pass
569+
570+
if cancel_scope.cancelled_caught:
571+
pytest.fail(
572+
"stdio_client cleanup timed out after 5.0 seconds. "
573+
"Process should have exited gracefully when stdin was closed."
574+
)
575+
576+
end_time = time.time()
577+
elapsed = end_time - start_time
578+
579+
# Should complete quickly with just stdin closure (no signals needed)
580+
assert elapsed < 3.0, (
581+
f"stdio_client cleanup took {elapsed:.1f} seconds for stdin-aware process. "
582+
f"Expected < 3.0 seconds since process should exit on stdin closure."
583+
)
584+
585+
586+
@pytest.mark.anyio
587+
async def test_stdio_client_stdin_close_ignored():
588+
"""
589+
Test that when a process ignores stdin closure, the shutdown sequence
590+
properly escalates to SIGTERM.
591+
"""
592+
# Create a Python script that ignores stdin closure but responds to SIGTERM
593+
script_content = textwrap.dedent(
594+
"""
595+
import signal
596+
import sys
597+
import time
598+
599+
# Set up SIGTERM handler to exit cleanly
600+
def sigterm_handler(signum, frame):
601+
sys.exit(0)
602+
603+
signal.signal(signal.SIGTERM, sigterm_handler)
604+
605+
# Close stdin immediately to simulate ignoring it
606+
sys.stdin.close()
607+
608+
# Keep running until SIGTERM
609+
while True:
610+
time.sleep(0.1)
611+
"""
612+
)
613+
614+
server_params = StdioServerParameters(
615+
command=sys.executable,
616+
args=["-c", script_content],
617+
)
618+
619+
start_time = time.time()
620+
621+
# Use anyio timeout to prevent test from hanging forever
622+
with anyio.move_on_after(7.0) as cancel_scope:
623+
async with stdio_client(server_params) as (read_stream, write_stream):
624+
# Let the process start
625+
await anyio.sleep(0.2)
626+
# Exit context triggers cleanup
627+
pass
628+
629+
if cancel_scope.cancelled_caught:
630+
pytest.fail(
631+
"stdio_client cleanup timed out after 7.0 seconds. "
632+
"Process should have been terminated via SIGTERM escalation."
633+
)
634+
635+
end_time = time.time()
636+
elapsed = end_time - start_time
637+
638+
# Should take ~2 seconds (stdin close timeout) before SIGTERM is sent
639+
# Total time should be between 2-4 seconds
640+
assert 1.5 < elapsed < 4.5, (
641+
f"stdio_client cleanup took {elapsed:.1f} seconds for stdin-ignoring process. "
642+
f"Expected between 2-4 seconds (2s stdin timeout + termination time)."
643+
)

0 commit comments

Comments
 (0)