Skip to content

Commit d256b92

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

2 files changed

Lines changed: 60 additions & 2 deletions

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: 39 additions & 1 deletion
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
@@ -25,7 +26,7 @@
2526
import pytest
2627
import trio
2728
import trio.testing
28-
from anyio.streams.memory import MemoryObjectReceiveStream
29+
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
2930

3031
from mcp.client import stdio
3132
from mcp.client._transport import ReadStream
@@ -137,6 +138,7 @@ def __init__(
137138
stdout_eof_error: Exception | None = None,
138139
stdout_aclose_error: Exception | None = None,
139140
on_stdout_receive: Callable[[], None] | None = None,
141+
with_stderr: bool = False,
140142
) -> None:
141143
self._stdout_send, stdout_receive = anyio.create_memory_object_stream[bytes](math.inf)
142144
self.stdout = _FakeStdout(
@@ -145,6 +147,16 @@ def __init__(
145147
aclose_error=stdout_aclose_error,
146148
on_receive=self._dispatch_stdout_receive,
147149
)
150+
self._stderr_send: MemoryObjectSendStream[bytes] | None = None
151+
self.stderr: _FakeStdout | None = None
152+
if with_stderr:
153+
self._stderr_send, stderr_receive = anyio.create_memory_object_stream[bytes](math.inf)
154+
self.stderr = _FakeStdout(
155+
stderr_receive,
156+
eof_error=None,
157+
aclose_error=None,
158+
on_receive=lambda: None,
159+
)
148160
self.pid = 424242
149161
self.written: list[bytes] = []
150162
self.stdin_closed = anyio.Event()
@@ -170,10 +182,21 @@ def close_stdout(self) -> None:
170182
"""End the fake process's stdout, as the kernel does when it dies."""
171183
self._stdout_send.close()
172184

185+
async def feed_stderr(self, data: bytes) -> None:
186+
"""Make `data` readable on the fake process's stderr."""
187+
assert self._stderr_send is not None
188+
await self._stderr_send.send(data)
189+
190+
def close_stderr(self) -> None:
191+
"""End the fake process's stderr, as the kernel does when it dies."""
192+
if self._stderr_send is not None:
193+
self._stderr_send.close()
194+
173195
def exit(self, code: int = 0) -> None:
174196
"""Die: set the exit code and EOF stdout, as the kernel does."""
175197
self.returncode = code
176198
self.close_stdout()
199+
self.close_stderr()
177200

178201
def pending_stdout_chunks(self) -> int:
179202
"""How many fed chunks the client has not yet pulled off the fake stdout."""
@@ -225,6 +248,21 @@ async def _next_message(read_stream: ReadStream[SessionMessage | Exception]) ->
225248
return received.message
226249

227250

251+
@pytest.mark.anyio
252+
async def test_server_stderr_is_forwarded_to_errlog(monkeypatch: pytest.MonkeyPatch) -> None:
253+
"""Server stderr is piped and forwarded, so notebook-like errlogs still see it."""
254+
process = FakeProcess(with_stderr=True)
255+
install_fake_process(monkeypatch, process)
256+
errlog = io.StringIO()
257+
258+
async with stdio_client(FAKE_PARAMS, errlog=errlog):
259+
await process.feed_stderr(b"starting server\n")
260+
with anyio.fail_after(1):
261+
while errlog.getvalue() != "starting server\n":
262+
await anyio.sleep(0)
263+
process.exit()
264+
265+
228266
@pytest.mark.anyio
229267
async def test_messages_split_and_packed_across_chunks_are_reframed(monkeypatch: pytest.MonkeyPatch) -> None:
230268
"""Framing survives arbitrary chunk boundaries.

0 commit comments

Comments
 (0)