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>
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