Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/client/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ Describe the process with `StdioServerParameters` and hand it to `Client`:

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.

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))`.
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.

!!! warning
The child does **not** inherit your environment. It gets a minimal allow-list (`HOME`, `LOGNAME`,
Expand Down
93 changes: 87 additions & 6 deletions src/mcp/client/stdio.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
process nor hang on one.
"""

import io
import logging
import os
import subprocess
import sys
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager, suppress
Expand Down Expand Up @@ -69,6 +71,9 @@
# Time for the writer to flush accepted messages before stdin closes.
_WRITER_FLUSH_TIMEOUT = 0.5

# Time for the stderr relay to copy what an exited server left in the pipe.
_STDERR_DRAIN_TIMEOUT = 0.5

# How often to poll returncode while waiting for the process to die.
_EXIT_POLL_INTERVAL = 0.01

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

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

encoding_error_handler: Literal["strict", "ignore", "replace"] = "strict"
"""Encoding error handler; see https://docs.python.org/3/library/codecs.html#error-handlers."""
Expand All @@ -117,17 +122,24 @@ async def stdio_client(
) -> AsyncGenerator[TransportStreams, None]:
"""Spawns an MCP server subprocess and connects to it over stdin/stdout.

The server's stderr goes to `errlog`. A real file is handed to the server
as its stderr. Any other text stream (an `io.StringIO`, Jupyter's
`sys.stderr`, a logging shim) receives the server's output through
`write()`, decoded with `server.encoding` (undecodable bytes are replaced),
until shutdown.

Raises:
OSError: If the server process cannot be spawned.
ValueError: If the spawn parameters are invalid (embedded NUL bytes).
"""
command = await _get_executable_command(server.command)
relay_stderr = _needs_stderr_relay(errlog)

process = await _create_platform_compatible_process(
command=command,
args=server.args,
env=get_default_environment() | (server.env or {}),
errlog=errlog,
errlog=subprocess.PIPE if relay_stderr else errlog,
cwd=server.cwd,
)

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

shutting_down = False
writer_done = anyio.Event()
stderr_relayed = anyio.Event() if relay_stderr else None

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

async with anyio.create_task_group() as tg:
tg.start_soon(stdout_reader)
tg.start_soon(stdin_writer)
if stderr_relayed is not None:
tg.start_soon(_relay_stderr, process, errlog, server.encoding, stderr_relayed)
try:
yield read_stream, write_stream
finally:
Expand All @@ -216,6 +231,63 @@ async def shutdown() -> None:
await anyio.lowlevel.cancel_shielded_checkpoint()


def _needs_stderr_relay(errlog: TextIO) -> bool:
"""Whether the server's stderr has to reach `errlog` through `write()`.

A file's descriptor is handed to the server, as before. Any other text
stream only shows what goes through `write()`, even when `fileno()`
succeeds: Jupyter's leads to the terminal behind the notebook.
"""
if isinstance(errlog, io.TextIOBase) and not isinstance(errlog, io.TextIOWrapper):
return True
try:
errlog.fileno()
except (AttributeError, io.UnsupportedOperation):
# No descriptor (pytest's capsys, a logging shim). An int such as
# `subprocess.DEVNULL`, or a binary stream such as `io.BytesIO`, still
# goes to the spawn, which accepts or rejects it up front.
return hasattr(errlog, "write") and not isinstance(errlog, io.RawIOBase | io.BufferedIOBase)
return False


async def _relay_stderr(process: ServerProcess, errlog: TextIO, encoding: str, done: anyio.Event) -> None:
"""Copies the server's stderr into `errlog` until the pipe ends or shutdown closes it.

A write `errlog` rejects loses only that chunk, and only the first is logged.
The pipe is read to the end regardless, or a chatty server would block on it.
`errlog` is flushed once at the end: Jupyter flushes on its own timer, and
an explicit flush there waits on another thread.
"""
assert process.stderr, "Opened process is missing stderr"

write_failed = False
try:
# Shielded like the stdout drain: a cancelled caller must not leave the
# server blocked on a full pipe. Shutdown ends the read by closing it.
with anyio.CancelScope(shield=True):
# Diagnostics must not take the transport down, so bad bytes are replaced.
async for text in TextReceiveStream(process.stderr, encoding=encoding, errors="replace"):
try:
errlog.write(text)
except (OSError, ValueError):
if not write_failed:
write_failed = True
# A broken errlog is often sys.stderr, where logging reports its own failures.
with suppress(OSError, ValueError):
logger.exception(
"Writing the MCP server's stderr to errlog failed; later failures are not logged"
)
except (anyio.ClosedResourceError, anyio.BrokenResourceError, OSError):
pass # shutdown closed the pipe under the read, or the pipe broke
finally:
# Logging shims installed as sys.stderr may only implement write().
flush = getattr(errlog, "flush", None)
if flush is not None:
with suppress(OSError, ValueError):
flush()
done.set()


def _parse_line(line: str) -> SessionMessage | Exception:
"""Parses one stdout line, returning parse errors as values for the session to surface."""
try:
Expand Down Expand Up @@ -246,7 +318,7 @@ async def _drain_stdout(process: ServerProcess) -> None:
await process.stdout.receive()


async def _stop_server_process(process: ServerProcess) -> None:
async def _stop_server_process(process: ServerProcess, stderr_relayed: anyio.Event | None = None) -> None:
"""Closes stdin, waits out the grace period, then kills the whole tree.

The escalation order is spec text; timeouts and tree-wide scope are SDK policy:
Expand All @@ -263,8 +335,17 @@ async def _stop_server_process(process: ServerProcess) -> None:

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


Expand Down Expand Up @@ -330,7 +411,7 @@ async def _create_platform_compatible_process(
command: str,
args: list[str],
env: dict[str, str] | None = None,
errlog: TextIO = sys.stderr,
errlog: TextIO | int = sys.stderr,
cwd: Path | str | None = None,
) -> ServerProcess:
"""Spawns the server in its own kill scope.
Expand Down
6 changes: 4 additions & 2 deletions src/mcp/os/win32/utilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,11 @@ def __init__(self, popen_obj: subprocess.Popen[bytes]) -> None:
self.popen: subprocess.Popen[bytes] = popen_obj
stdin = popen_obj.stdin
stdout = popen_obj.stdout
stderr = popen_obj.stderr

self.stdin = FileWriteStream(cast(BinaryIO, stdin)) if stdin else None
self.stdout = FileReadStream(cast(BinaryIO, stdout)) if stdout else None
self.stderr = FileReadStream(cast(BinaryIO, stderr)) if stderr else None

async def wait(self) -> int:
"""Waits for exit by polling the Popen.
Expand Down Expand Up @@ -137,7 +139,7 @@ async def create_windows_process(
command: str,
args: list[str],
env: dict[str, str] | None = None,
errlog: TextIO | None = sys.stderr,
errlog: TextIO | int | None = sys.stderr,
cwd: Path | str | None = None,
) -> Process | FallbackProcess:
"""Creates a subprocess with Job Object support for tree termination.
Expand Down Expand Up @@ -177,7 +179,7 @@ async def _create_windows_fallback_process(
command: str,
args: list[str],
env: dict[str, str] | None = None,
errlog: TextIO | None = sys.stderr,
errlog: TextIO | int | None = sys.stderr,
cwd: Path | str | None = None,
) -> FallbackProcess:
"""Spawns via subprocess.Popen and wraps it in FallbackProcess."""
Expand Down
Loading
Loading