Skip to content

Commit 052edfe

Browse files
committed
fix(stdio): serve bufferless std streams as text instead of crashing
_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>
1 parent a4f4ccd commit 052edfe

2 files changed

Lines changed: 45 additions & 4 deletions

File tree

src/mcp/server/stdio.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,19 +103,34 @@ def _restore_fd(fd: int, private_fd: int) -> bool:
103103
return True
104104

105105

106+
def _text_transport(stream: TextIO, buffer: BinaryIO | None, errors: str | None = None) -> anyio.AsyncFile[str]:
107+
"""Serve the wire as UTF-8 text.
108+
109+
A stream with no buffer at all (io.StringIO under a test harness, or an
110+
embedded host that replaced sys.stdout) is already text and owns no binary
111+
layer to re-encode or to protect from close, so it is served in place.
112+
"""
113+
if buffer is None:
114+
return anyio.wrap_file(stream)
115+
return anyio.wrap_file(_UnownedTextWrapper(buffer, encoding="utf-8", errors=errors))
116+
117+
106118
def _claim_fd(
107119
fd: int, stream: TextIO, mode: Literal["rb", "wb"], open_diversion: Callable[[], int]
108-
) -> tuple[BinaryIO, Callable[[], None] | None]:
120+
) -> tuple[BinaryIO | None, Callable[[], None] | None]:
109121
"""Claim a standard stream: divert fd and serve the wire from a private duplicate.
110122
111123
Best-effort: when descriptors cannot be duplicated or diverted, serves the
112124
sys stream's buffer in place, exactly as v1 did, with the claim held.
113125
126+
Returns a None buffer when the stream exposes no binary layer, which means the
127+
caller must serve it as text; every other path returns a binary stream.
128+
114129
Raises:
115130
RuntimeError: fd is already claimed by another transport in this process.
116131
"""
117132
if not _is_backed_by_fd(stream, fd):
118-
return stream.buffer, None
133+
return getattr(stream, "buffer", None), None
119134
claim = _StreamClaim(fd)
120135
with _claims_lock:
121136
if fd in _claims:
@@ -173,10 +188,10 @@ async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio.
173188
try:
174189
if not stdin:
175190
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"))
191+
stdin = _text_transport(sys.stdin, stdin_buffer, errors="replace")
177192
if not stdout:
178193
stdout_buffer, restore_stdout = _claim_fd(1, sys.stdout, "wb", _open_stdout_diversion)
179-
stdout = anyio.wrap_file(_UnownedTextWrapper(stdout_buffer, encoding="utf-8"))
194+
stdout = _text_transport(sys.stdout, stdout_buffer)
180195

181196
read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0)
182197
write_stream, write_stream_reader = create_context_streams[SessionMessage](0)

tests/server/test_stdio.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,32 @@ async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch) -> Non
9797
assert second.message == valid
9898

9999

100+
@pytest.mark.anyio
101+
async def test_stdio_server_serves_bufferless_std_streams_in_place(monkeypatch: pytest.MonkeyPatch) -> None:
102+
"""A sys.stdin/sys.stdout with no .buffer is served as text rather than crashing.
103+
104+
Test harnesses and embedded hosts routinely replace the std streams with
105+
io.StringIO, which exposes no binary layer for the claim path to re-encode.
106+
"""
107+
request = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
108+
stdout = io.StringIO()
109+
monkeypatch.setattr(sys, "stdin", io.StringIO(request.model_dump_json(by_alias=True, exclude_none=True) + "\n"))
110+
monkeypatch.setattr(sys, "stdout", stdout)
111+
112+
with anyio.fail_after(5):
113+
async with stdio_server() as (read_stream, write_stream):
114+
async with read_stream: # pragma: no branch
115+
received = await read_stream.receive()
116+
assert isinstance(received, SessionMessage)
117+
assert received.message == request
118+
119+
response = JSONRPCResponse(jsonrpc="2.0", id=1, result={})
120+
async with write_stream:
121+
await write_stream.send(SessionMessage(response))
122+
123+
assert jsonrpc_message_adapter.validate_json(stdout.getvalue(), by_name=False) == response
124+
125+
100126
@contextmanager
101127
def _pipe_planted_on_fd0(monkeypatch: pytest.MonkeyPatch) -> Iterator[tuple[int, int]]:
102128
"""Plants a fresh pipe on fd 0 and rebinds sys.stdin over it; yields (read_fd, write_fd).

0 commit comments

Comments
 (0)