fix: preserve stdio streams in server transport#2734
Conversation
StantonMatt
left a comment
There was a problem hiding this comment.
I checked 52ed34a locally on Windows/Python 3.13.13:
uv run --frozen pytest tests/server/test_stdio.py -q
# 2 passed
uv run --frozen ruff check src/mcp/server/stdio.py tests/server/test_stdio.py
uv run --frozen pyright src/mcp/server/stdio.py tests/server/test_stdio.py
# 0 errors
git diff --check origin/main...HEADThe behavior-level assertion that the process wrappers stay open after stdio_server() exits is useful for #1933.
One edge to decide on: the default path now requires sys.stdin / sys.stdout to expose fileno(). A default-path probe with in-memory TextIOWrapper(io.BytesIO(...)) streams now fails immediately with UnsupportedOperation: fileno; the previous test_stdio_server_invalid_utf8 shape used that kind of wrapper before this branch switched it to TemporaryFile. If the project only wants the default path to support real process stdio, this is fine. If preserving fileno-less fake/default streams matters for tests or embedders, this probably needs a fallback path.
|
Thanks for checking this on Windows. I kept the real-stdio path on duplicated file descriptors, but added a fallback for fileno-less in-memory stdin/stdout replacements so the old test/embedding shape continues to work. Added a regression test for that path too.\n\nValidated locally:\n\n |
|
One more CI follow-up: the Python 3.14 lowest-direct jobs exposed an AnyIO subprocess startup SyntaxWarning being forwarded through stderr before the expected clean-exit marker. I adjusted the stdio interaction test to keep proving the child exited cleanly while tolerating dependency startup warnings before that marker.\n\nAdditional local validation:\n\n |
StantonMatt
left a comment
There was a problem hiding this comment.
Rechecked current head bf3fabf on Windows/Python 3.13.13.
The original fileno-less default-path crash I reported is fixed: an in-memory TextIOWrapper(io.BytesIO(...)) stdin/stdout replacement now lets stdio_server() read the JSON-RPC request successfully. The real-file-descriptor path also preserves the original wrappers after the context; my temporary-file probe could still write to stdout after stdio_server() exited.
One remaining edge to decide on: the fileno-less fallback reads successfully, but it still closes the replacement sys.stdin / sys.stdout wrappers after the context exits. My probe result was:
{
"message_id": 123,
"stdin_closed": True,
"stdout_closed": True,
"stdout_write_error": "ValueError: I/O operation on closed file.",
"stdin_read_error": "ValueError: I/O operation on closed file.",
}So if the intended compatibility guarantee is only "fileno-less default streams do not crash", this head covers it. If the intent is also "preserve replacement stdio wrappers" for embedders/tests that provide in-memory streams, the fallback probably still needs to avoid wrapping/closing sys.stdin.buffer and sys.stdout.buffer directly.
Checks I ran:
uv run --frozen pytest tests\server\test_stdio.py tests\interaction\transports\test_stdio.py -q-> 5 passeduv run --frozen ruff check src\mcp\server\stdio.py tests\server\test_stdio.py tests\interaction\transports\test_stdio.pyuv run --frozen pyright src\mcp\server\stdio.py tests\server\test_stdio.py-> 0 errorsgit diff --check origin/main...HEAD
I also tried the broader command from the author note, uv run --frozen pyright src\mcp\server\stdio.py tests\server\test_stdio.py tests\interaction\transports\test_stdio.py; that still reports the existing errlog=errlog type issue in tests/interaction/transports/test_stdio.py, on a line this branch does not change.
Summary
sys.stdin/sys.stdoutwrappers remain usable after the server exitsFixes #1933
Test plan
uv run --frozen pytest tests/server/test_stdio.py -quv run --frozen ruff check src/mcp/server/stdio.py tests/server/test_stdio.pyuv run --frozen pyright src/mcp/server/stdio.py tests/server/test_stdio.py