Skip to content

fix(stdio): serve bufferless std streams as text instead of crashing - #3090

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

fix(stdio): serve bufferless std streams as text instead of crashing#3090
steps-re wants to merge 1 commit into
modelcontextprotocol:mainfrom
steps-re:fix/stdio-preserve-real-std-handles

Conversation

@steps-re

@steps-re steps-re commented Jul 13, 2026

Copy link
Copy Markdown

What this is now

This PR originally fixed #1933 (stdio_server closing the real process std handles). #3117 has since landed and fixes that properly, with a much better design than what I had here. So I have dropped my original commits entirely and rebased onto current main.

What is left is one narrower bug that #3117 does not cover.

The bug

_claim_fd starts with:

if not _is_backed_by_fd(stream, fd):
    return stream.buffer, None

and _is_backed_by_fd returns False in three cases, one of which is the stream having no .buffer at all:

except (AttributeError, OSError, ValueError):
    return False

So on that path the fallback dereferences the attribute it just proved may be missing. If sys.stdin / sys.stdout have been replaced with io.StringIO, stdio_server() raises before serving a single message:

AttributeError: '_io.StringIO' object has no attribute 'buffer'

Reproduced against a4f4ccd0:

sys.stdin, sys.stdout = io.StringIO(""), io.StringIO()
async with stdio_server() as (r, w):   # AttributeError
    ...

It is not a hypothetical stream shape. Test harnesses do it, and so do embedded hosts that swap the std streams to capture output.

The fix

Return None for the buffer in that one case and serve the text stream in place. A bufferless stream is already text, so there is no binary layer to re-encode and none for _UnownedTextWrapper to protect from close. Every other path still returns a binary stream and is untouched.

Verification

Local, on a4f4ccd0 with the commit applied:

  • repro above raises AttributeError before, exits cleanly after
  • new regression test test_stdio_server_serves_bufferless_std_streams_in_place round-trips a request and a response through io.StringIO streams
  • tests/server/test_stdio.py 18 passed
  • full suite 5582 passed, 9 skipped, 1 xfailed
  • ruff check and ruff format --check clean, pyright 0 errors
  • branch coverage on src/mcp/server/stdio.py back to 100.00%, which the fail_under = 100 gate needs

Disclosure

Written with AI assistance, reviewed and verified by me. Happy to reshape or split this if you would rather have it another way.

@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 Outdated
steps-re added a commit to steps-re/python-sdk that referenced this pull request Jul 13, 2026
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>
@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

_claim_fd falls back to `stream.buffer` whenever the stream is not backed
by the expected descriptor. But _is_backed_by_fd also reports False when
the stream has no `.buffer` at all, so that fallback dereferences an
attribute it just proved might be missing.

A sys.stdin/sys.stdout replaced with io.StringIO (test harnesses, and
embedded hosts that swap the std streams) therefore raised AttributeError
before serving a single message.

Return None for the buffer in that case and serve the text stream in
place: it is already text, so there is no binary layer to re-encode and
none for _UnownedTextWrapper to protect from close.

Signed-off-by: Mike German <mike@stepsventures.com>
@steps-re
steps-re force-pushed the fix/stdio-preserve-real-std-handles branch from b04f535 to 052edfe Compare August 5, 2026 16:09
@steps-re steps-re changed the title Don't close real process stdio in stdio_server (#1933) fix(stdio): serve bufferless std streams as text instead of crashing Aug 5, 2026
@steps-re

steps-re commented Aug 5, 2026

Copy link
Copy Markdown
Author

rebased, and rescoped while doing it.

#3117 landed the real fix for #1933 and did it better than my original commits, so i dropped them rather than carry a competing implementation. this is now a single commit on top of a4f4ccd0 for the one thing #3117 does not cover: _is_backed_by_fd returns False when a stream has no .buffer, and the fallback right below it then does return stream.buffer, None. a StringIO stdin/stdout raises AttributeError before serving anything.

fails-before/passes-after repro is in the description. full suite 5582 passed, ruff and pyright clean, and stdio.py branch coverage is back at 100.00% for the fail_under gate.

title and description updated to match the new scope.

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