Skip to content

Commit 9b5158c

Browse files
committed
fix: use non-blocking stdout writes in stdio_server to prevent event loop deadlock
When a tool returns a response larger than the OS pipe buffer (64 KB on macOS), stdout_writer blocks the entire event loop on write() because anyio.wrap_file delegates to a synchronous write on a blocking fd. Fix: set stdout fd to non-blocking mode and write in 4 KB chunks via os.write(), catching BlockingIOError (EAGAIN) and yielding to the event loop before retrying. Custom stdout overrides use the original path. Closes #547
1 parent 2fe56e5 commit 9b5158c

File tree

1 file changed

+41
-3
lines changed

1 file changed

+41
-3
lines changed

src/mcp/server/stdio.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ async def run_server():
1717
```
1818
"""
1919

20+
import os
2021
import sys
2122
from contextlib import asynccontextmanager
2223
from io import TextIOWrapper
@@ -28,6 +29,26 @@ async def run_server():
2829
from mcp import types
2930
from mcp.shared.message import SessionMessage
3031

32+
# Chunk size for non-blocking stdout writes. Small enough to avoid
33+
# filling the OS pipe buffer (64 KB on macOS) in a single syscall.
34+
_WRITE_CHUNK = 4096
35+
36+
37+
async def _write_nonblocking(fd: int, data: bytes) -> None:
38+
"""Write *data* to a non-blocking fd, yielding on EAGAIN.
39+
40+
Writes in small chunks so the event loop stays responsive even when
41+
the MCP client reads slowly and the pipe buffer fills up.
42+
"""
43+
mv = memoryview(data)
44+
while mv:
45+
try:
46+
n = os.write(fd, mv[:_WRITE_CHUNK])
47+
mv = mv[n:]
48+
except BlockingIOError:
49+
# Pipe full — yield to event loop and retry.
50+
await anyio.sleep(0.005)
51+
3152

3253
@asynccontextmanager
3354
async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio.AsyncFile[str] | None = None):
@@ -40,7 +61,14 @@ async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio.
4061
# re-wrap the underlying binary stream to ensure UTF-8.
4162
if not stdin:
4263
stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8"))
64+
# For the default stdout (no custom override), use non-blocking I/O
65+
# directly on the file descriptor to prevent the event loop from
66+
# blocking when the OS pipe buffer is full (macOS: 64 KB).
67+
stdout_fd: int | None = None
4368
if not stdout:
69+
stdout_fd = sys.stdout.buffer.fileno()
70+
os.set_blocking(stdout_fd, False)
71+
# Still create the wrapped stdout for the type signature / fallback
4472
stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8"))
4573

4674
read_stream: MemoryObjectReceiveStream[SessionMessage | Exception]
@@ -71,9 +99,19 @@ async def stdout_writer():
7199
try:
72100
async with write_stream_reader:
73101
async for session_message in write_stream_reader:
74-
json = session_message.message.model_dump_json(by_alias=True, exclude_none=True)
75-
await stdout.write(json + "\n")
76-
await stdout.flush()
102+
json_str = session_message.message.model_dump_json(
103+
by_alias=True, exclude_none=True
104+
)
105+
if stdout_fd is not None:
106+
# Non-blocking write directly to fd — never blocks
107+
# the event loop, yields on pipe-full (EAGAIN).
108+
await _write_nonblocking(
109+
stdout_fd, (json_str + "\n").encode("utf-8")
110+
)
111+
else:
112+
# Custom stdout provided — use original path.
113+
await stdout.write(json_str + "\n")
114+
await stdout.flush()
77115
except anyio.ClosedResourceError: # pragma: no cover
78116
await anyio.lowlevel.checkpoint()
79117

0 commit comments

Comments
 (0)