From 6cf8179318e3fa05ee6826f8c02258a98d1db2af Mon Sep 17 00:00:00 2001 From: Sherlock Date: Mon, 4 May 2026 04:06:37 +0800 Subject: [PATCH 1/3] fix: align Context logging methods with MCP spec data type (#2366) Co-authored-by: Claude Opus 4.6 Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com> --- src/mcp/server/stdio.py | 46 ++++++++++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/src/mcp/server/stdio.py b/src/mcp/server/stdio.py index 5c1459dff..eb322157e 100644 --- a/src/mcp/server/stdio.py +++ b/src/mcp/server/stdio.py @@ -17,6 +17,8 @@ async def run_server(): ``` """ +import io +import os import sys from contextlib import asynccontextmanager from io import TextIOWrapper @@ -34,14 +36,32 @@ async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio. """Server transport for stdio: this communicates with an MCP client by reading from the current process' stdin and writing to stdout. """ - # Purposely not using context managers for these, as we don't want to close - # standard process handles. Encoding of stdin/stdout as text streams on - # python is platform-dependent (Windows is particularly problematic), so we - # re-wrap the underlying binary stream to ensure UTF-8. + # When stdin/stdout are not provided, duplicate the underlying file descriptors + # so that closing the wrappers does not close the real sys.stdin/sys.stdout. + # Encoding of stdin/stdout as text streams on Python is platform-dependent + # (Windows is particularly problematic), so we re-wrap the underlying binary + # stream to ensure UTF-8. + _stdin_wrapper: TextIOWrapper | None = None + _stdout_wrapper: TextIOWrapper | None = None + if not stdin: - stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace")) + try: + stdin_fd = os.dup(sys.stdin.fileno()) + _stdin_wrapper = TextIOWrapper(os.fdopen(stdin_fd, "rb"), encoding="utf-8", errors="replace") + stdin = anyio.wrap_file(_stdin_wrapper) + except (AttributeError, io.UnsupportedOperation): + # sys.stdin has no real fd (e.g. BytesIO in tests) — wrap buffer directly. + # Closing this wrapper also closes the buffer, but that is harmless in + # that context because there is no real fd to leak. + stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace")) if not stdout: - stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8")) + try: + stdout_fd = os.dup(sys.stdout.fileno()) + _stdout_wrapper = TextIOWrapper(os.fdopen(stdout_fd, "wb"), encoding="utf-8") + stdout = anyio.wrap_file(_stdout_wrapper) + except (AttributeError, io.UnsupportedOperation): + # sys.stdout has no real fd — wrap buffer directly (same reasoning as above). + stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8")) read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0) write_stream, write_stream_reader = create_context_streams[SessionMessage](0) @@ -71,7 +91,13 @@ async def stdout_writer(): except anyio.ClosedResourceError: # pragma: no cover await anyio.lowlevel.checkpoint() - async with anyio.create_task_group() as tg: - tg.start_soon(stdin_reader) - tg.start_soon(stdout_writer) - yield read_stream, write_stream + try: + async with anyio.create_task_group() as tg: + tg.start_soon(stdin_reader) + tg.start_soon(stdout_writer) + yield read_stream, write_stream + finally: + if _stdout_wrapper is not None: + _stdout_wrapper.close() + if _stdin_wrapper is not None: + _stdin_wrapper.close() From 954eafb09b2864c2e2ee08c90f87010ba38e4394 Mon Sep 17 00:00:00 2001 From: Sherlock Date: Mon, 4 May 2026 04:06:40 +0800 Subject: [PATCH 2/3] fix: align Context logging methods with MCP spec data type (#2366) Co-authored-by: Claude Opus 4.6 Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com> --- tests/server/test_stdio.py | 40 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/server/test_stdio.py b/tests/server/test_stdio.py index 677a99356..35632cabb 100644 --- a/tests/server/test_stdio.py +++ b/tests/server/test_stdio.py @@ -1,5 +1,7 @@ import io +import os import sys +import tempfile from io import TextIOWrapper import anyio @@ -63,6 +65,44 @@ async def test_stdio_server(): assert received_responses[1] == JSONRPCResponse(jsonrpc="2.0", id=4, result={}) +@pytest.mark.anyio +async def test_stdio_server_does_not_close_real_stdio(monkeypatch: pytest.MonkeyPatch): + """stdio_server() must not close the real sys.stdin/sys.stdout after exiting. + + Regression test for https://github.com/modelcontextprotocol/python-sdk/issues/1933. + """ + # Substitute sys.stdin/stdout with real file-backed streams so that fileno() + # works and we can verify the fds remain open after the server exits. + with tempfile.TemporaryFile() as tf_in, tempfile.TemporaryFile() as tf_out: + fake_stdin = TextIOWrapper(tf_in, encoding="utf-8") + fake_stdout = TextIOWrapper(tf_out, encoding="utf-8") + monkeypatch.setattr(sys, "stdin", fake_stdin) + monkeypatch.setattr(sys, "stdout", fake_stdout) + + real_stdin_fd = sys.stdin.fileno() + real_stdout_fd = sys.stdout.fileno() + + with anyio.fail_after(5): + async with stdio_server() as (read_stream, write_stream): + await write_stream.aclose() + await read_stream.aclose() + + # os.fstat() raises OSError if the fd has been closed; success means the fd + # is still open. A plain `is not None` check would be vacuously true, so we + # use try/except to make the intent explicit. + try: + os.fstat(real_stdin_fd) + except OSError: + pytest.fail("stdio_server() closed the real stdin file descriptor") + try: + os.fstat(real_stdout_fd) + except OSError: + pytest.fail("stdio_server() closed the real stdout file descriptor") + # The Python wrappers we set as sys.stdin/stdout must not be closed either. + assert not sys.stdin.closed + assert not sys.stdout.closed + + @pytest.mark.anyio async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch): """Non-UTF-8 bytes on stdin must not crash the server. From c9a74724e27dda28675ea650e2704737504445c0 Mon Sep 17 00:00:00 2001 From: Deep PR Forge Local Date: Mon, 4 May 2026 04:34:53 +0800 Subject: [PATCH 3/3] Fix stdio regression coverage --- tests/server/test_stdio.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/tests/server/test_stdio.py b/tests/server/test_stdio.py index 35632cabb..c09b008f7 100644 --- a/tests/server/test_stdio.py +++ b/tests/server/test_stdio.py @@ -87,17 +87,10 @@ async def test_stdio_server_does_not_close_real_stdio(monkeypatch: pytest.Monkey await write_stream.aclose() await read_stream.aclose() - # os.fstat() raises OSError if the fd has been closed; success means the fd - # is still open. A plain `is not None` check would be vacuously true, so we - # use try/except to make the intent explicit. - try: - os.fstat(real_stdin_fd) - except OSError: - pytest.fail("stdio_server() closed the real stdin file descriptor") - try: - os.fstat(real_stdout_fd) - except OSError: - pytest.fail("stdio_server() closed the real stdout file descriptor") + # os.fstat() raises OSError if the fd has been closed; successful calls + # prove stdio_server() did not close the real process descriptors. + os.fstat(real_stdin_fd) + os.fstat(real_stdout_fd) # The Python wrappers we set as sys.stdin/stdout must not be closed either. assert not sys.stdin.closed assert not sys.stdout.closed