Skip to content

Commit 09546ab

Browse files
committed
fix(server): flush stdout and ensure line-buffering before restoring fd 1 (#3821)
1 parent 77001c9 commit 09546ab

3 files changed

Lines changed: 67 additions & 1 deletion

File tree

‎src/mcp/server/stdio.py‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ def _open_stdout_diversion() -> int:
9393

9494
def _restore_fd(fd: int, private_fd: int) -> bool:
9595
"""Point fd back at the wire; the Windows handle rebind never affects the outcome."""
96+
if fd == 1:
97+
with suppress(Exception):
98+
sys.stdout.flush()
99+
if hasattr(sys.stdout, "reconfigure"):
100+
with suppress(Exception):
101+
sys.stdout.reconfigure(line_buffering=True)
96102
try:
97103
os.dup2(private_fd, fd)
98104
except OSError:
@@ -123,6 +129,17 @@ def _claim_fd(
123129
_claims[fd] = claim
124130

125131
def release() -> None:
132+
if fd == 1:
133+
with suppress(Exception):
134+
sys.stdout.flush()
135+
with suppress(Exception):
136+
stream.flush()
137+
if hasattr(sys.stdout, "reconfigure"):
138+
with suppress(Exception):
139+
sys.stdout.reconfigure(line_buffering=True)
140+
if hasattr(stream, "reconfigure"):
141+
with suppress(Exception):
142+
stream.reconfigure(line_buffering=True)
126143
if claim.private_fd is None or _restore_fd(fd, claim.private_fd):
127144
with _claims_lock:
128145
del _claims[fd]
@@ -138,6 +155,11 @@ def release() -> None:
138155
except OSError:
139156
return stream.buffer, release
140157
try:
158+
if fd == 1:
159+
with suppress(Exception):
160+
sys.stdout.flush()
161+
with suppress(Exception):
162+
stream.flush()
141163
os.dup2(diversion_fd, fd)
142164
except OSError:
143165
# The divert did not land; ensure fd carries the wire (a Windows dup2 can
@@ -176,7 +198,7 @@ async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio.
176198
stdin = anyio.wrap_file(_UnownedTextWrapper(stdin_buffer, encoding="utf-8", errors="replace"))
177199
if not stdout:
178200
stdout_buffer, restore_stdout = _claim_fd(1, sys.stdout, "wb", _open_stdout_diversion)
179-
stdout = anyio.wrap_file(_UnownedTextWrapper(stdout_buffer, encoding="utf-8"))
201+
stdout = anyio.wrap_file(_UnownedTextWrapper(stdout_buffer, encoding="utf-8", line_buffering=True))
180202

181203
read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0)
182204
write_stream, write_stream_reader = create_context_streams[SessionMessage](0)

‎tests/conftest.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ def blockbuster() -> Iterator[None]:
4242
bb.functions["io.BufferedReader.read"].can_block_in(
4343
"mcp/server/mcpserver/utilities/types.py", ("to_image_content", "to_audio_content")
4444
)
45+
# Stdio transport flushes stdout before restoring fd 1.
46+
for name in ("os.write", "io.BufferedWriter.write", "io.TextIOWrapper.write"):
47+
if name in bb.functions:
48+
bb.functions[name].can_block_in("mcp/server/stdio.py", ("release", "_restore_fd"))
4549
bb.activate()
4650
try:
4751
yield

‎tests/server/test_stdio.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,46 @@ async def test_stdio_server_takes_stdout_off_the_descriptor_table_while_serving(
344344
assert os.path.sameopenfile(1, out_w)
345345

346346

347+
@pytest.mark.anyio
348+
async def test_stdio_server_flushes_stdout_and_ensures_line_buffering_before_restore(
349+
monkeypatch: pytest.MonkeyPatch,
350+
) -> None:
351+
"""Unflushed writes to sys.stdout during stdio_server flush to diversion before fd 1 restore.
352+
353+
Regression for issue #3821: without a flush before restoring fd 1, buffered writes to
354+
sys.stdout made while fd 1 was diverted leak onto the wire when flushed after restore,
355+
corrupting client JSON-RPC streams. Also verifies line-buffering is ensured on restore.
356+
"""
357+
response = JSONRPCResponse(jsonrpc="2.0", id=1, result={})
358+
with _pipe_planted_on_fd1(monkeypatch) as (out_r, out_w), _pipe_planted_on_fd2() as err_r:
359+
# sys.stdout begins without line-buffering (pipe default)
360+
assert getattr(sys.stdout, "line_buffering", None) is False
361+
362+
with anyio.fail_after(5):
363+
async with stdio_server(stdin=anyio.AsyncFile(io.StringIO())) as (read_stream, write_stream):
364+
read_stream.close()
365+
366+
# Buffered write to sys.stdout without explicit flush
367+
sys.stdout.write("stray buffered log\n")
368+
369+
await write_stream.send(SessionMessage(response))
370+
line = await _read_from(out_r)
371+
assert jsonrpc_message_adapter.validate_json(line.decode().strip()) == response
372+
await write_stream.aclose()
373+
374+
# The unflushed write must have been flushed to stderr before fd 1 was restored
375+
assert await _read_from(err_r) == b"stray buffered log\n"
376+
377+
# After restore, line buffering is ensured on sys.stdout
378+
assert getattr(sys.stdout, "line_buffering", None) is True
379+
380+
# And subsequent flush on sys.stdout does not write the stray log to the wire
381+
sys.stdout.flush()
382+
os.set_blocking(out_r, False)
383+
with pytest.raises(BlockingIOError):
384+
os.read(out_r, 4096)
385+
386+
347387
@pytest.mark.anyio
348388
async def test_stdio_server_diverts_stdout_to_the_null_device_when_stderr_is_unusable(
349389
monkeypatch: pytest.MonkeyPatch,

0 commit comments

Comments
 (0)