99
1010import errno
1111import gc
12+ import io
1213import logging
1314import math
1415import os
2526import pytest
2627import trio
2728import trio .testing
28- from anyio .streams .memory import MemoryObjectReceiveStream
29+ from anyio .streams .memory import MemoryObjectReceiveStream , MemoryObjectSendStream
2930
3031from mcp .client import stdio
3132from mcp .client ._transport import ReadStream
@@ -136,7 +137,9 @@ def __init__(
136137 stdin_send_gate : anyio .Event | None = None ,
137138 stdout_eof_error : Exception | None = None ,
138139 stdout_aclose_error : Exception | None = None ,
140+ stderr_eof_error : Exception | None = None ,
139141 on_stdout_receive : Callable [[], None ] | None = None ,
142+ with_stderr : bool = False ,
140143 ) -> None :
141144 self ._stdout_send , stdout_receive = anyio .create_memory_object_stream [bytes ](math .inf )
142145 self .stdout = _FakeStdout (
@@ -145,6 +148,16 @@ def __init__(
145148 aclose_error = stdout_aclose_error ,
146149 on_receive = self ._dispatch_stdout_receive ,
147150 )
151+ self ._stderr_send : MemoryObjectSendStream [bytes ] | None = None
152+ self .stderr : _FakeStdout | None = None
153+ if with_stderr :
154+ self ._stderr_send , stderr_receive = anyio .create_memory_object_stream [bytes ](math .inf )
155+ self .stderr = _FakeStdout (
156+ stderr_receive ,
157+ eof_error = stderr_eof_error ,
158+ aclose_error = None ,
159+ on_receive = lambda : None ,
160+ )
148161 self .pid = 424242
149162 self .written : list [bytes ] = []
150163 self .stdin_closed = anyio .Event ()
@@ -170,10 +183,21 @@ def close_stdout(self) -> None:
170183 """End the fake process's stdout, as the kernel does when it dies."""
171184 self ._stdout_send .close ()
172185
186+ async def feed_stderr (self , data : bytes ) -> None :
187+ """Make `data` readable on the fake process's stderr."""
188+ assert self ._stderr_send is not None
189+ await self ._stderr_send .send (data )
190+
191+ def close_stderr (self ) -> None :
192+ """End the fake process's stderr, as the kernel does when it dies."""
193+ if self ._stderr_send is not None :
194+ self ._stderr_send .close ()
195+
173196 def exit (self , code : int = 0 ) -> None :
174197 """Die: set the exit code and EOF stdout, as the kernel does."""
175198 self .returncode = code
176199 self .close_stdout ()
200+ self .close_stderr ()
177201
178202 def pending_stdout_chunks (self ) -> int :
179203 """How many fed chunks the client has not yet pulled off the fake stdout."""
@@ -225,6 +249,42 @@ async def _next_message(read_stream: ReadStream[SessionMessage | Exception]) ->
225249 return received .message
226250
227251
252+ @pytest .mark .anyio
253+ async def test_server_stderr_is_forwarded_to_errlog (monkeypatch : pytest .MonkeyPatch ) -> None :
254+ """Server stderr is piped and forwarded, so notebook-like errlogs still see it."""
255+ process = FakeProcess (with_stderr = True )
256+ install_fake_process (monkeypatch , process )
257+ errlog = io .StringIO ()
258+
259+ async with stdio_client (FAKE_PARAMS , errlog = errlog ):
260+ await process .feed_stderr (b"starting server\n " )
261+ with anyio .fail_after (1 ):
262+ while errlog .getvalue () != "starting server\n " :
263+ await anyio .sleep (0 )
264+ process .exit ()
265+
266+
267+ @pytest .mark .anyio
268+ async def test_mid_session_stderr_failure_is_logged (
269+ monkeypatch : pytest .MonkeyPatch ,
270+ caplog : pytest .LogCaptureFixture ,
271+ ) -> None :
272+ """A broken stderr pipe is logged, but does not escape the client context."""
273+ process = FakeProcess (
274+ on_stdin_close = lambda : process .exit (0 ),
275+ stderr_eof_error = ConnectionError ("stderr pipe failed" ),
276+ with_stderr = True ,
277+ )
278+ install_fake_process (monkeypatch , process )
279+
280+ with anyio .fail_after (5 ):
281+ async with stdio_client (FAKE_PARAMS ):
282+ process .close_stderr ()
283+ with anyio .fail_after (1 ):
284+ while "stderr failed mid-session" not in caplog .text :
285+ await anyio .sleep (0 )
286+
287+
228288@pytest .mark .anyio
229289async def test_messages_split_and_packed_across_chunks_are_reframed (monkeypatch : pytest .MonkeyPatch ) -> None :
230290 """Framing survives arbitrary chunk boundaries.
0 commit comments