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
@@ -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
229267async def test_messages_split_and_packed_across_chunks_are_reframed (monkeypatch : pytest .MonkeyPatch ) -> None :
230268 """Framing survives arbitrary chunk boundaries.
0 commit comments