Skip to content

Commit 9a002cb

Browse files
Relay stdio stderr into errlog when it is not a real file
Jupyter's stderr reports a descriptor that points at the launch terminal, and streams such as io.StringIO have none, so handing errlog to the child either hid startup crashes or failed the spawn. Inherit a real file; otherwise pipe stderr and copy it into errlog.write(), including the lines written while the server exits. Co-authored-by: Evan Liu <evanliuuu@users.noreply.github.com>
1 parent d7845c0 commit 9a002cb

6 files changed

Lines changed: 527 additions & 15 deletions

File tree

‎docs/client/transports.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ Describe the process with `StdioServerParameters` and hand it to `Client`:
114114

115115
Entering the block spawns the process. Leaving it shuts the subprocess down: close stdin, wait, kill if it lingers. You never clean it up yourself.
116116

117-
The child's stderr goes to yours. To send it somewhere else, build the transport yourself with `stdio_client` (from `mcp`) and pass that instead: `Client(stdio_client(server, errlog=log_file))`.
117+
The child's stderr goes to yours; in a Jupyter notebook, that is the cell's output. To send it somewhere else, build the transport yourself with `stdio_client` (from `mcp`) and pass that instead: `Client(stdio_client(server, errlog=log_file))`. A file is handed to the child as its stderr. Any other text stream, such as an `io.StringIO`, gets the child's output through its `write()` while the client is connected.
118118

119119
!!! warning
120120
The child does **not** inherit your environment. It gets a minimal allow-list (`HOME`, `LOGNAME`,

‎src/mcp/client/stdio.py‎

Lines changed: 87 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
process nor hang on one.
99
"""
1010

11+
import io
1112
import logging
1213
import os
14+
import subprocess
1315
import sys
1416
from collections.abc import AsyncGenerator
1517
from contextlib import asynccontextmanager, suppress
@@ -69,6 +71,9 @@
6971
# Time for the writer to flush accepted messages before stdin closes.
7072
_WRITER_FLUSH_TIMEOUT = 0.5
7173

74+
# Time for the stderr relay to copy what an exited server left in the pipe.
75+
_STDERR_DRAIN_TIMEOUT = 0.5
76+
7277
# How often to poll returncode while waiting for the process to die.
7378
_EXIT_POLL_INTERVAL = 0.01
7479

@@ -105,7 +110,7 @@ class StdioServerParameters(BaseModel):
105110
"""The working directory to use when spawning the process."""
106111

107112
encoding: str = "utf-8"
108-
"""Text encoding for messages to and from the server."""
113+
"""Text encoding for messages to and from the server, and for its relayed stderr."""
109114

110115
encoding_error_handler: Literal["strict", "ignore", "replace"] = "strict"
111116
"""Encoding error handler; see https://docs.python.org/3/library/codecs.html#error-handlers."""
@@ -117,17 +122,24 @@ async def stdio_client(
117122
) -> AsyncGenerator[TransportStreams, None]:
118123
"""Spawns an MCP server subprocess and connects to it over stdin/stdout.
119124
125+
The server's stderr goes to `errlog`. A real file is handed to the server
126+
as its stderr. Any other text stream (an `io.StringIO`, Jupyter's
127+
`sys.stderr`, a logging shim) receives the server's output through
128+
`write()`, decoded with `server.encoding` (undecodable bytes are replaced),
129+
until shutdown.
130+
120131
Raises:
121132
OSError: If the server process cannot be spawned.
122133
ValueError: If the spawn parameters are invalid (embedded NUL bytes).
123134
"""
124135
command = await _get_executable_command(server.command)
136+
relay_stderr = _needs_stderr_relay(errlog)
125137

126138
process = await _create_platform_compatible_process(
127139
command=command,
128140
args=server.args,
129141
env=get_default_environment() | (server.env or {}),
130-
errlog=errlog,
142+
errlog=subprocess.PIPE if relay_stderr else errlog,
131143
cwd=server.cwd,
132144
)
133145

@@ -138,6 +150,7 @@ async def stdio_client(
138150

139151
shutting_down = False
140152
writer_done = anyio.Event()
153+
stderr_relayed = anyio.Event() if relay_stderr else None
141154

142155
async def stdout_reader() -> None:
143156
assert process.stdout, "Opened process is missing stdout"
@@ -193,14 +206,16 @@ async def shutdown() -> None:
193206
await writer_done.wait()
194207
if flush_scope.cancelled_caught:
195208
await anyio.lowlevel.cancel_shielded_checkpoint() # resync coverage on 3.11 (gh-106749)
196-
await _stop_server_process(process)
209+
await _stop_server_process(process, stderr_relayed)
197210
await _aclose_all(read_stream, write_stream, read_stream_writer, write_stream_reader)
198211
# One pass so unblocked tasks exit via their except paths before the cancel.
199212
await anyio.lowlevel.checkpoint()
200213

201214
async with anyio.create_task_group() as tg:
202215
tg.start_soon(stdout_reader)
203216
tg.start_soon(stdin_writer)
217+
if stderr_relayed is not None:
218+
tg.start_soon(_relay_stderr, process, errlog, server.encoding, stderr_relayed)
204219
try:
205220
yield read_stream, write_stream
206221
finally:
@@ -216,6 +231,63 @@ async def shutdown() -> None:
216231
await anyio.lowlevel.cancel_shielded_checkpoint()
217232

218233

234+
def _needs_stderr_relay(errlog: TextIO) -> bool:
235+
"""Whether the server's stderr has to reach `errlog` through `write()`.
236+
237+
A file's descriptor is handed to the server, as before. Any other text
238+
stream only shows what goes through `write()`, even when `fileno()`
239+
succeeds: Jupyter's leads to the terminal behind the notebook.
240+
"""
241+
if isinstance(errlog, io.TextIOBase) and not isinstance(errlog, io.TextIOWrapper):
242+
return True
243+
try:
244+
errlog.fileno()
245+
except (AttributeError, io.UnsupportedOperation):
246+
# No descriptor (pytest's capsys, a logging shim). An int such as
247+
# `subprocess.DEVNULL`, or a binary stream such as `io.BytesIO`, still
248+
# goes to the spawn, which accepts or rejects it up front.
249+
return hasattr(errlog, "write") and not isinstance(errlog, io.RawIOBase | io.BufferedIOBase)
250+
return False
251+
252+
253+
async def _relay_stderr(process: ServerProcess, errlog: TextIO, encoding: str, done: anyio.Event) -> None:
254+
"""Copies the server's stderr into `errlog` until the pipe ends or shutdown closes it.
255+
256+
A write `errlog` rejects loses only that chunk, and only the first is logged.
257+
The pipe is read to the end regardless, or a chatty server would block on it.
258+
`errlog` is flushed once at the end: Jupyter flushes on its own timer, and
259+
an explicit flush there waits on another thread.
260+
"""
261+
assert process.stderr, "Opened process is missing stderr"
262+
263+
write_failed = False
264+
try:
265+
# Shielded like the stdout drain: a cancelled caller must not leave the
266+
# server blocked on a full pipe. Shutdown ends the read by closing it.
267+
with anyio.CancelScope(shield=True):
268+
# Diagnostics must not take the transport down, so bad bytes are replaced.
269+
async for text in TextReceiveStream(process.stderr, encoding=encoding, errors="replace"):
270+
try:
271+
errlog.write(text)
272+
except (OSError, ValueError):
273+
if not write_failed:
274+
write_failed = True
275+
# A broken errlog is often sys.stderr, where logging reports its own failures.
276+
with suppress(OSError, ValueError):
277+
logger.exception(
278+
"Writing the MCP server's stderr to errlog failed; later failures are not logged"
279+
)
280+
except (anyio.ClosedResourceError, anyio.BrokenResourceError, OSError):
281+
pass # shutdown closed the pipe under the read, or the pipe broke
282+
finally:
283+
# Logging shims installed as sys.stderr may only implement write().
284+
flush = getattr(errlog, "flush", None)
285+
if flush is not None:
286+
with suppress(OSError, ValueError):
287+
flush()
288+
done.set()
289+
290+
219291
def _parse_line(line: str) -> SessionMessage | Exception:
220292
"""Parses one stdout line, returning parse errors as values for the session to surface."""
221293
try:
@@ -246,7 +318,7 @@ async def _drain_stdout(process: ServerProcess) -> None:
246318
await process.stdout.receive()
247319

248320

249-
async def _stop_server_process(process: ServerProcess) -> None:
321+
async def _stop_server_process(process: ServerProcess, stderr_relayed: anyio.Event | None = None) -> None:
250322
"""Closes stdin, waits out the grace period, then kills the whole tree.
251323
252324
The escalation order is spec text; timeouts and tree-wide scope are SDK policy:
@@ -263,8 +335,17 @@ async def _stop_server_process(process: ServerProcess) -> None:
263335

264336
# Reaps surviving Windows job members now, not at GC; no-op on POSIX.
265337
close_process_job(process)
266-
# A kill survivor can hold the stdout pipe open; poison the reader anyway.
338+
if stderr_relayed is not None:
339+
# Wait before closing the pipe. Closing it discards unread output, such
340+
# as the server's last words.
341+
with anyio.move_on_after(_STDERR_DRAIN_TIMEOUT) as drain_scope:
342+
await stderr_relayed.wait()
343+
if drain_scope.cancelled_caught:
344+
await anyio.lowlevel.cancel_shielded_checkpoint() # resync coverage on 3.11 (gh-106749)
345+
# A kill survivor can hold a pipe open; poison the readers anyway.
267346
await _close_pipe(process.stdout)
347+
if process.stderr is not None:
348+
await _close_pipe(process.stderr)
268349
_close_subprocess_transport(process)
269350

270351

@@ -330,7 +411,7 @@ async def _create_platform_compatible_process(
330411
command: str,
331412
args: list[str],
332413
env: dict[str, str] | None = None,
333-
errlog: TextIO = sys.stderr,
414+
errlog: TextIO | int = sys.stderr,
334415
cwd: Path | str | None = None,
335416
) -> ServerProcess:
336417
"""Spawns the server in its own kill scope.

‎src/mcp/os/win32/utilities.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,11 @@ def __init__(self, popen_obj: subprocess.Popen[bytes]) -> None:
9292
self.popen: subprocess.Popen[bytes] = popen_obj
9393
stdin = popen_obj.stdin
9494
stdout = popen_obj.stdout
95+
stderr = popen_obj.stderr
9596

9697
self.stdin = FileWriteStream(cast(BinaryIO, stdin)) if stdin else None
9798
self.stdout = FileReadStream(cast(BinaryIO, stdout)) if stdout else None
99+
self.stderr = FileReadStream(cast(BinaryIO, stderr)) if stderr else None
98100

99101
async def wait(self) -> int:
100102
"""Waits for exit by polling the Popen.
@@ -137,7 +139,7 @@ async def create_windows_process(
137139
command: str,
138140
args: list[str],
139141
env: dict[str, str] | None = None,
140-
errlog: TextIO | None = sys.stderr,
142+
errlog: TextIO | int | None = sys.stderr,
141143
cwd: Path | str | None = None,
142144
) -> Process | FallbackProcess:
143145
"""Creates a subprocess with Job Object support for tree termination.
@@ -177,7 +179,7 @@ async def _create_windows_fallback_process(
177179
command: str,
178180
args: list[str],
179181
env: dict[str, str] | None = None,
180-
errlog: TextIO | None = sys.stderr,
182+
errlog: TextIO | int | None = sys.stderr,
181183
cwd: Path | str | None = None,
182184
) -> FallbackProcess:
183185
"""Spawns via subprocess.Popen and wraps it in FallbackProcess."""

0 commit comments

Comments
 (0)