Skip to content

Don't close real process stdio in stdio_server (#1933)#3090

Open
steps-re wants to merge 3 commits into
modelcontextprotocol:mainfrom
steps-re:fix/stdio-preserve-real-std-handles
Open

Don't close real process stdio in stdio_server (#1933)#3090
steps-re wants to merge 3 commits into
modelcontextprotocol:mainfrom
steps-re:fix/stdio-preserve-real-std-handles

Conversation

@steps-re

Copy link
Copy Markdown

The bug

transport="stdio" closes the real process stdin/stdout. Any code that runs after the server exits raises:

ValueError: I/O operation on closed file.

A minimal reproduction is just a print() after run(transport="stdio") returns.

Root cause

src/mcp/server/stdio.py re-wraps sys.stdin.buffer / sys.stdout.buffer in a TextIOWrapper to force UTF-8:

stdin  = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer,  encoding="utf-8", errors="replace"))
stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8"))

The comment says these purposely avoid a context manager so the standard handles stay open. But a TextIOWrapper closes the buffer it wraps in its __del__ finalizer. Once the wrapper is garbage-collected, sys.stdin.buffer / sys.stdout.buffer are closed, so the real sys.stdin / sys.stdout are dead and every later read or write fails. The wrapper defeats the very thing the comment intends.

The fix

Dup the underlying fd and wrap the copy, so the wrapper only ever closes the duplicate, never the real handle. os.dup is Windows-safe. The dup'd wrappers are closed on exit so their fds don't leak.

binary  = open(os.dup(std.fileno()), mode + "b", closefd=True)
wrapper = TextIOWrapper(binary, encoding="utf-8", errors=errors)

When sys.std* has no real fd (pytest capture, embedded interpreters, injected in-memory streams), it falls back to wrapping .buffer directly and detach()es the wrapper on exit, which severs it from the buffer without closing it, so the finalizer can no longer close the real handle either. This keeps the existing in-memory / monkeypatched test paths working. This matches the approach the issue author proposed.

Test

Adds a regression test in tests/server/test_stdio.py: after async with stdio_server(): ... exits, the real sys.stdin / sys.stdout buffers must not be closed, and a write to stdout after exit must succeed. It fails on main (AssertionError: stdio_server closed the real stdin buffer) and passes with the fix.

All stdio tests pass (pytest tests/ -k stdio → 51 passed, 5 Windows-only skipped), including the real-subprocess round-trip interaction test. ruff check, ruff format --check, and pyright are clean on the changed files.

Fixes #1933


This was developed with AI assistance (Claude Code). I understand and have tested the change; happy to walk through any part.

…textprotocol#1933)

stdio_server re-wraps sys.stdin.buffer/sys.stdout.buffer in a TextIOWrapper
to force UTF-8. The wrapper's __del__ finalizer closes the buffer it wraps, so
once the wrapper is garbage-collected the real sys.stdin/sys.stdout is closed
and any code that runs after the server exits raises
"ValueError: I/O operation on closed file." on print() or a stdout write.

Dup the underlying fd (os.dup, Windows-safe) and wrap the copy, so the wrapper
only ever closes the duplicate; close those wrappers on exit to free the dup'd
fds. When sys.std* has no real fd (pytest capture, embedded interpreters,
injected in-memory streams) fall back to wrapping .buffer and detach() the
wrapper on exit, which severs it from the buffer without closing it so the
finalizer can no longer close the real handle either.

Adds a regression test asserting the real stdin/stdout buffers survive after
the transport exits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/mcp/server/stdio.py
steps-re and others added 2 commits July 13, 2026 17:46
The no-fd fallback reached for std.buffer, which raises AttributeError on
bufferless text streams such as io.StringIO. Guard with hasattr and wrap the
text stream directly in that case (nothing to tear down). Adds a regression
test. Addresses the cubic review on modelcontextprotocol#3090.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The bare `async with read_stream, write_stream: pass` left an uncovered
exceptional-exit arc, failing the repo 100% coverage gate (surfaced on the
3.14 CI shard). Replace with explicit aclose() calls in both stdio server
tests. Streams/behavior unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@steps-re

Copy link
Copy Markdown
Author

@cubic-dev-ai good catch, and already addressed. The StringIO/bufferless-text-stream case is fixed in 9a57007: in the no-fileno() fallback, if the stream has no .buffer we now return anyio.wrap_file(std) around the text stream directly (and don't register it for teardown, since we didn't create it), keeping the .buffer re-wrap + detach() path only where a buffer exists.

Covered by test_stdio_server_bufferless_text_streams (monkeypatches sys.stdin/sys.stdout to io.StringIO), added in b04f535. Full stdio suite passes locally: 52 passed, 5 skipped.

@cubic-dev-ai

cubic-dev-ai Bot commented Jul 14, 2026

Copy link
Copy Markdown

@cubic-dev-ai good catch, and already addressed. The StringIO/bufferless-text-stream case is fixed in 9a57007: in the no-fileno() fallback, if the stream has no .buffer we now return anyio.wrap_file(std) around the text stream directly (and don't register it for teardown, since we didn't create it), keeping the .buffer re-wrap + detach() path only where a buffer exists.

Covered by test_stdio_server_bufferless_text_streams (monkeypatches sys.stdin/sys.stdout to io.StringIO), added in b04f535. Full stdio suite passes locally: 52 passed, 5 skipped.

@steps-re Nice, this looks solid. The hasattr(std, "buffer") guard in the fallback path is exactly what that case needed — io.StringIO (and any other io.TextIOBase without a backing binary buffer) will hit the direct anyio.wrap_file(std) branch instead of blowing up with AttributeError. And since you don't register anything in to_detach, there's nothing to tear down on exit for those streams, which is correct since you didn't create them.

The test covers it well: monkeypatched io.StringIO objects exercise the new branch end-to-end, and the temporary <-> wrap_std contract is verified by the first regression test via tempfile.TemporaryFile + real fds. Both paths are now covered.

Thanks for the clean follow-up — resolves the concern cleanly.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Using transport="stdio" closes real stdio, causing ValueError after server exits

1 participant