Skip to content

Commit 8d9fb00

Browse files
Implement MCP spec-compliant stdio shutdown sequence
Following the MCP spec recommendations, the shutdown sequence now: 1. Closes stdin first to allow graceful server exit 2. Waits 2 seconds for the server to exit on its own 3. Only escalates to SIGTERM if the server doesn't exit 4. Finally uses SIGKILL as a last resort This unified approach works consistently across all platforms and gives well-behaved servers a chance to exit cleanly without signals. Co-Authored-By: davenpi <[email protected]> Add tests for MCP spec-compliant stdio shutdown Added two tests to validate the stdin-first shutdown behavior: 1. test_stdio_client_graceful_stdin_exit: Verifies that a well-behaved server exits cleanly when stdin is closed, without needing signals 2. test_stdio_client_stdin_close_ignored: Tests proper escalation to SIGTERM when a process ignores stdin closure These tests ensure the MCP spec shutdown sequence works correctly and provides graceful exit opportunities before using forceful termination.
1 parent 863dd54 commit 8d9fb00

File tree

2 files changed

+141
-4
lines changed

2 files changed

+141
-4
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()

tests/client/test_stdio.py

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

0 commit comments

Comments
 (0)