Skip to content

fix(stdio): use os.dup to avoid closing real stdin/stdout on server exit#2643

Closed
jrlprost wants to merge 1 commit into
modelcontextprotocol:mainfrom
jrlprost:fix/stdio-server-closes-real-stdin
Closed

fix(stdio): use os.dup to avoid closing real stdin/stdout on server exit#2643
jrlprost wants to merge 1 commit into
modelcontextprotocol:mainfrom
jrlprost:fix/stdio-server-closes-real-stdin

Conversation

@jrlprost
Copy link
Copy Markdown

Fixes #1933.

Root cause

stdio_server() wraps sys.stdin.buffer in a TextIOWrapper. When that wrapper is garbage-collected after the context manager exits, Python's finalizer calls close() on it, which propagates down and closes sys.stdin.buffer (fd 0). Any subsequent print() or sys.stdin.read() then raises ValueError: I/O operation on closed file.

The existing comment ("Purposely not using context managers for these, as we don't want to close standard process handles") shows the intent was correct, but the wrapper's __del__ closes the buffer regardless.

Fix

Duplicate the file descriptor with os.dup() before wrapping. Our TextIOWrapper now owns a private copy of the fd; closing it leaves the original sys.stdin/sys.stdout untouched.

Falls back to the original buffer when the stream is not backed by a real fd (e.g. BytesIO in tests), matching prior behaviour in that case. The dup'd wrappers are explicitly closed in a finally block after the task group exits.

Changes

  • src/mcp/server/stdio.py: os.dup() the fd; track ownership with stdin_created/stdout_created; close in finally.
  • tests/server/test_stdio.py: regression test using real OS pipes — asserts sys.stdin.buffer and sys.stdout.buffer are still open after stdio_server() exits.

Testing

uv run --frozen pytest tests/server/test_stdio.py -v  # 3/3 pass
uv run --frozen ruff check .                           # no issues
uv run --frozen pyright src/mcp/server/stdio.py tests/server/test_stdio.py  # 0 errors

Branch coverage for src/mcp/server/stdio.py: 100 %.

The except io.UnsupportedOperation branch is covered by the existing test_stdio_server_invalid_utf8 test (BytesIO stdin). The new test covers the try success path with real pipe fds.

TextIOWrapper wrapping sys.stdin.buffer closes the underlying buffer when
the wrapper is garbage-collected.  After stdio_server() exits, this made
any subsequent print()/sys.stdin.read() raise ValueError on the closed fd.

Duplicate the file descriptor with os.dup() so our TextIOWrapper owns a
private copy; closing it leaves the original process stdin/stdout intact.
Falls back to the original buffer when the stream has no real fd (e.g.
BytesIO in tests), matching existing behaviour in that case.

Fixes modelcontextprotocol#1933.
@maxisbey
Copy link
Copy Markdown
Contributor

closing as there's already an existing PR for this issue

@maxisbey maxisbey closed this May 19, 2026
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

2 participants