Skip to content

Commit 3d66adf

Browse files
fix: forward stdio server stderr through errlog
1 parent 1cec2d6 commit 3d66adf

2 files changed

Lines changed: 64 additions & 1 deletion

File tree

src/mcp/client/stdio.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import logging
1212
import os
13+
import subprocess
1314
import sys
1415
from collections.abc import AsyncGenerator
1516
from contextlib import asynccontextmanager, suppress
@@ -181,6 +182,21 @@ async def stdin_writer() -> None:
181182
finally:
182183
writer_done.set()
183184

185+
async def stderr_reader() -> None:
186+
stderr = getattr(process, "stderr", None)
187+
if stderr is None:
188+
return
189+
190+
try:
191+
async for chunk in TextReceiveStream(
192+
stderr, encoding=server.encoding, errors=server.encoding_error_handler
193+
):
194+
errlog.write(chunk)
195+
errlog.flush()
196+
except (anyio.ClosedResourceError, anyio.BrokenResourceError, ConnectionError, OSError): # pragma: no cover
197+
if not shutting_down:
198+
logger.exception("Reading from the MCP server's stderr failed mid-session")
199+
184200
async def shutdown() -> None:
185201
"""Winds the transport down: stop traffic, flush, stop the server, release the streams."""
186202
# Unblock the reader into its drain: a server stuck writing stdout cannot
@@ -200,6 +216,7 @@ async def shutdown() -> None:
200216
async with anyio.create_task_group() as tg:
201217
tg.start_soon(stdout_reader)
202218
tg.start_soon(stdin_writer)
219+
tg.start_soon(stderr_reader)
203220
try:
204221
yield read_stream, write_stream
205222
finally:
@@ -264,6 +281,9 @@ async def _stop_server_process(process: ServerProcess) -> None:
264281
close_process_job(process)
265282
# A kill survivor can hold the stdout pipe open; poison the reader anyway.
266283
await _close_pipe(process.stdout)
284+
stderr = getattr(process, "stderr", None)
285+
if stderr is not None:
286+
await _close_pipe(stderr)
267287
_close_subprocess_transport(process)
268288

269289

@@ -342,7 +362,7 @@ async def _create_platform_compatible_process(
342362
return await anyio.open_process(
343363
[command, *args],
344364
env=env,
345-
stderr=errlog,
365+
stderr=subprocess.PIPE,
346366
cwd=cwd,
347367
start_new_session=True,
348368
)

tests/client/test_stdio.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import errno
1111
import gc
12+
import io
1213
import logging
1314
import math
1415
import os
@@ -139,12 +140,19 @@ def __init__(
139140
on_stdout_receive: Callable[[], None] | None = None,
140141
) -> None:
141142
self._stdout_send, stdout_receive = anyio.create_memory_object_stream[bytes](math.inf)
143+
self._stderr_send, stderr_receive = anyio.create_memory_object_stream[bytes](math.inf)
142144
self.stdout = _FakeStdout(
143145
stdout_receive,
144146
eof_error=stdout_eof_error,
145147
aclose_error=stdout_aclose_error,
146148
on_receive=self._dispatch_stdout_receive,
147149
)
150+
self.stderr = _FakeStdout(
151+
stderr_receive,
152+
eof_error=None,
153+
aclose_error=None,
154+
on_receive=lambda: None,
155+
)
148156
self.pid = 424242
149157
self.written: list[bytes] = []
150158
self.stdin_closed = anyio.Event()
@@ -170,10 +178,19 @@ def close_stdout(self) -> None:
170178
"""End the fake process's stdout, as the kernel does when it dies."""
171179
self._stdout_send.close()
172180

181+
async def feed_stderr(self, data: bytes) -> None:
182+
"""Make `data` readable on the fake process's stderr."""
183+
await self._stderr_send.send(data)
184+
185+
def close_stderr(self) -> None:
186+
"""End the fake process's stderr, as the kernel does when it dies."""
187+
self._stderr_send.close()
188+
173189
def exit(self, code: int = 0) -> None:
174190
"""Die: set the exit code and EOF stdout, as the kernel does."""
175191
self.returncode = code
176192
self.close_stdout()
193+
self.close_stderr()
177194

178195
def pending_stdout_chunks(self) -> int:
179196
"""How many fed chunks the client has not yet pulled off the fake stdout."""
@@ -225,6 +242,32 @@ async def _next_message(read_stream: ReadStream[SessionMessage | Exception]) ->
225242
return received.message
226243

227244

245+
@pytest.mark.anyio
246+
async def test_server_stderr_is_forwarded_to_errlog(monkeypatch: pytest.MonkeyPatch) -> None:
247+
"""Server stderr is piped and forwarded, so notebook-like errlogs still see it."""
248+
process = FakeProcess()
249+
install_fake_process(monkeypatch, process)
250+
errlog = io.StringIO()
251+
252+
async with stdio_client(FAKE_PARAMS, errlog=errlog):
253+
await process.feed_stderr(b"starting server\n")
254+
with anyio.fail_after(1):
255+
while errlog.getvalue() != "starting server\n":
256+
await anyio.sleep(0)
257+
process.exit()
258+
259+
260+
@pytest.mark.anyio
261+
async def test_missing_server_stderr_pipe_is_ignored(monkeypatch: pytest.MonkeyPatch) -> None:
262+
"""Windows-compatible process objects may not expose a stderr pipe."""
263+
process = FakeProcess()
264+
process.stderr = None
265+
install_fake_process(monkeypatch, process)
266+
267+
async with stdio_client(FAKE_PARAMS):
268+
process.exit()
269+
270+
228271
@pytest.mark.anyio
229272
async def test_messages_split_and_packed_across_chunks_are_reframed(monkeypatch: pytest.MonkeyPatch) -> None:
230273
"""Framing survives arbitrary chunk boundaries.

0 commit comments

Comments
 (0)