Skip to content

Commit 0c8d500

Browse files
fix(stdio): handle bufferless text streams in fallback
_claim_fd's fallback path dereferences stream.buffer unconditionally, but _is_backed_by_fd returns False when a stream has no .buffer attribute (e.g. io.StringIO). This raises AttributeError before the transport serves a single message. Fix by checking for the buffer attribute and returning None when the stream is bufferless. The caller then wraps the text stream in place since there is no binary layer to re-encode. Closes #1933 Co-authored-by: openhands <openhands@all-hands.dev>
1 parent f1b6589 commit 0c8d500

2 files changed

Lines changed: 57 additions & 4 deletions

File tree

‎src/mcp/server/stdio.py‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ def _restore_fd(fd: int, private_fd: int) -> bool:
105105

106106
def _claim_fd(
107107
fd: int, stream: TextIO, mode: Literal["rb", "wb"], open_diversion: Callable[[], int]
108-
) -> tuple[BinaryIO, Callable[[], None] | None]:
108+
) -> tuple[BinaryIO | None, Callable[[], None] | None]:
109109
"""Claim a standard stream: divert fd and serve the wire from a private duplicate.
110110
111111
Best-effort: when descriptors cannot be duplicated or diverted, serves the
@@ -115,7 +115,12 @@ def _claim_fd(
115115
RuntimeError: fd is already claimed by another transport in this process.
116116
"""
117117
if not _is_backed_by_fd(stream, fd):
118-
return stream.buffer, None
118+
if hasattr(stream, "buffer"):
119+
return stream.buffer, None
120+
# Bufferless text stream (e.g. io.StringIO): serve the text stream in place.
121+
# There is no binary layer to re-encode and nothing for _UnownedTextWrapper to
122+
# protect from close, so register no teardown and let the caller wrap it directly.
123+
return None, None
119124
claim = _StreamClaim(fd)
120125
with _claims_lock:
121126
if fd in _claims:
@@ -173,10 +178,18 @@ async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio.
173178
try:
174179
if not stdin:
175180
stdin_buffer, restore_stdin = _claim_fd(0, sys.stdin, "rb", _open_stdin_diversion)
176-
stdin = anyio.wrap_file(_UnownedTextWrapper(stdin_buffer, encoding="utf-8", errors="replace"))
181+
if stdin_buffer is not None:
182+
stdin = anyio.wrap_file(_UnownedTextWrapper(stdin_buffer, encoding="utf-8", errors="replace"))
183+
else:
184+
# Bufferless text stream (e.g. io.StringIO): serve in place.
185+
stdin = anyio.wrap_file(sys.stdin)
177186
if not stdout:
178187
stdout_buffer, restore_stdout = _claim_fd(1, sys.stdout, "wb", _open_stdout_diversion)
179-
stdout = anyio.wrap_file(_UnownedTextWrapper(stdout_buffer, encoding="utf-8"))
188+
if stdout_buffer is not None:
189+
stdout = anyio.wrap_file(_UnownedTextWrapper(stdout_buffer, encoding="utf-8"))
190+
else:
191+
# Bufferless text stream (e.g. io.StringIO): serve in place.
192+
stdout = anyio.wrap_file(sys.stdout)
180193

181194
read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0)
182195
write_stream, write_stream_reader = create_context_streams[SessionMessage](0)

‎tests/server/test_stdio.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,3 +692,43 @@ def test_mcpserver_run_stdio_serves_a_modern_connection(monkeypatch: pytest.Monk
692692
# resultType is modern-only: proves the request was served at the discovered version.
693693
assert responses[1].result["tools"] == []
694694
assert responses[1].result["resultType"] == "complete"
695+
696+
697+
@pytest.mark.anyio
698+
async def test_stdio_server_serves_bufferless_text_streams_in_place() -> None:
699+
"""stdio_server() serves when sys.stdin/stdout are replaced with bufferless text streams.
700+
701+
Regression for the issue where _claim_fd's fallback dereferences `.buffer` on a stream
702+
that has none (e.g. io.StringIO), raising AttributeError before serving any message.
703+
Bufferless streams are already text; there is no binary layer to re-encode and nothing
704+
for _UnownedTextWrapper to protect from close, so the text stream is served in place.
705+
"""
706+
# Replace sys.stdin/stdout with bufferless StringIO - the exact shape that crashed.
707+
original_stdin = sys.stdin
708+
original_stdout = sys.stdout
709+
try:
710+
sys.stdin = io.StringIO(
711+
JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
712+
.model_dump_json(by_alias=True, exclude_none=True)
713+
+ "\n"
714+
)
715+
sys.stdout = io.StringIO()
716+
717+
request = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
718+
response = JSONRPCResponse(jsonrpc="2.0", id=1, result={})
719+
720+
with anyio.fail_after(5):
721+
async with stdio_server() as (read_stream, write_stream):
722+
async with read_stream:
723+
received = await read_stream.receive()
724+
assert isinstance(received, SessionMessage)
725+
assert received.message == request
726+
727+
await write_stream.send(SessionMessage(response))
728+
await write_stream.aclose()
729+
730+
# The transport wrote to the replaced stdout; nothing leaked to the real stdout.
731+
assert sys.stdout.getvalue()
732+
finally:
733+
sys.stdin = original_stdin
734+
sys.stdout = original_stdout

0 commit comments

Comments
 (0)