Skip to content

Commit 2cd08fd

Browse files
committed
fix: detect stdin EOF on parent death for stdio transport
Add a background monitor that uses select.poll() to detect POLLHUP on stdin's file descriptor. When the parent process dies and the pipe's write end closes, the monitor cancels the task group, triggering a clean shutdown. The anyio.wrap_file async iterator may not propagate EOF promptly because it runs readline() in a worker thread. The poll-based monitor detects the hang-up at the OS level independent of the worker thread. Only enabled on non-Windows platforms where select.poll() is available.
1 parent 31a38b5 commit 2cd08fd

File tree

1 file changed

+49
-0
lines changed

1 file changed

+49
-0
lines changed

src/mcp/server/stdio.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,50 @@ async def run_server():
2828
from mcp import types
2929
from mcp.shared.message import SessionMessage
3030

31+
# How often to check for stdin EOF (seconds)
32+
STDIN_EOF_CHECK_INTERVAL = 0.1
33+
34+
35+
def _create_stdin_eof_monitor(
36+
tg: anyio.abc.TaskGroup,
37+
):
38+
"""Create a platform-appropriate stdin EOF monitor.
39+
40+
Returns an async callable that monitors stdin for EOF and cancels the task
41+
group when detected, or None if monitoring is not supported on this platform.
42+
43+
When the parent process dies, stdin reaches EOF. The anyio.wrap_file async
44+
iterator may not detect this promptly because it runs readline() in a worker
45+
thread. This monitor polls the underlying file descriptor directly using
46+
OS-level I/O, and cancels the task group when EOF is detected, ensuring the
47+
server shuts down cleanly.
48+
"""
49+
if sys.platform == "win32":
50+
return None
51+
52+
import select
53+
54+
try:
55+
fd = sys.stdin.buffer.fileno()
56+
except Exception:
57+
return None
58+
59+
async def monitor():
60+
poll_obj = select.poll()
61+
poll_obj.register(fd, select.POLLIN | select.POLLHUP)
62+
try:
63+
while True:
64+
await anyio.sleep(STDIN_EOF_CHECK_INTERVAL)
65+
events = poll_obj.poll(0)
66+
for _, event_mask in events:
67+
if event_mask & (select.POLLHUP | select.POLLERR | select.POLLNVAL):
68+
tg.cancel_scope.cancel()
69+
return
70+
finally:
71+
poll_obj.unregister(fd)
72+
73+
return monitor
74+
3175

3276
@asynccontextmanager
3377
async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio.AsyncFile[str] | None = None):
@@ -80,4 +124,9 @@ async def stdout_writer():
80124
async with anyio.create_task_group() as tg:
81125
tg.start_soon(stdin_reader)
82126
tg.start_soon(stdout_writer)
127+
128+
eof_monitor = _create_stdin_eof_monitor(tg)
129+
if eof_monitor is not None:
130+
tg.start_soon(eof_monitor)
131+
83132
yield read_stream, write_stream

0 commit comments

Comments
 (0)