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
37 changes: 32 additions & 5 deletions src/mcp/client/stdio.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import logging
import os
import subprocess
import sys
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager, suppress
Expand Down Expand Up @@ -127,7 +128,6 @@ async def stdio_client(
command=command,
args=server.args,
env=get_default_environment() | (server.env or {}),
errlog=errlog,
cwd=server.cwd,
)

Expand Down Expand Up @@ -182,6 +182,27 @@ async def stdin_writer() -> None:
finally:
writer_done.set()

async def stderr_reader() -> None:
# The server's stderr is a pipe, never errlog's fd: errlog may be a
# stream without a fileno (e.g. Jupyter's sys.stderr), which the
# subprocess spawn cannot consume (gh-156). Forward it here instead.
assert process.stderr, "Opened process is missing stderr"

def forward(chunk: str) -> None:
# errlog is an arbitrary TextIO; keep the blocking write off the loop.
errlog.write(chunk)
errlog.flush()

stderr = TextReceiveStream(process.stderr, encoding=server.encoding, errors=server.encoding_error_handler)
try:
async for chunk in stderr:
await anyio.to_thread.run_sync(forward, chunk)
except (anyio.ClosedResourceError, anyio.BrokenResourceError, ConnectionError):
# Teardown noise during shutdown, a real failure otherwise; stderr
# carries only diagnostics, so the session is unaffected either way.
if not shutting_down:
logger.exception("Reading from the MCP server's stderr failed mid-session")

async def shutdown() -> None:
"""Winds the transport down: stop traffic, flush, stop the server, release the streams."""
# Unblock the reader into its drain: a server stuck writing stdout cannot
Expand All @@ -201,6 +222,7 @@ async def shutdown() -> None:
async with anyio.create_task_group() as tg:
tg.start_soon(stdout_reader)
tg.start_soon(stdin_writer)
tg.start_soon(stderr_reader)
try:
yield read_stream, write_stream
finally:
Expand Down Expand Up @@ -252,7 +274,7 @@ async def _stop_server_process(process: ServerProcess) -> None:
The escalation order is spec text; timeouts and tree-wide scope are SDK policy:
https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#shutdown
"""
assert process.stdin and process.stdout, "server process is spawned with pipes"
assert process.stdin and process.stdout and process.stderr, "server process is spawned with pipes"

await _close_pipe(process.stdin)
if not await _wait_for_process_exit(process, PROCESS_TERMINATION_TIMEOUT):
Expand All @@ -265,6 +287,8 @@ async def _stop_server_process(process: ServerProcess) -> None:
close_process_job(process)
# A kill survivor can hold the stdout pipe open; poison the reader anyway.
await _close_pipe(process.stdout)
# Same for stderr: the reader only ever sees EOF, never a close it made.
await _close_pipe(process.stderr)
_close_subprocess_transport(process)


Expand Down Expand Up @@ -330,20 +354,23 @@ async def _create_platform_compatible_process(
command: str,
args: list[str],
env: dict[str, str] | None = None,
errlog: TextIO = sys.stderr,
cwd: Path | str | None = None,
) -> ServerProcess:
"""Spawns the server in its own kill scope.

A new session/process group on POSIX, a Job Object on Windows.

The server's stderr is always a pipe: the caller's errlog stream may not
have a fileno (e.g. Jupyter's sys.stderr), so it cannot be handed to the
subprocess directly. `stdio_client` forwards the pipe to errlog instead.
"""
if sys.platform == "win32": # pragma: no cover
return await create_windows_process(command, args, env, errlog, cwd)
return await create_windows_process(command, args, env, cwd)
else: # pragma: lax no cover
return await anyio.open_process(
[command, *args],
env=env,
stderr=errlog,
stderr=subprocess.PIPE,
cwd=cwd,
start_new_session=True,
)
Expand Down
17 changes: 10 additions & 7 deletions src/mcp/os/win32/utilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import weakref
from contextlib import suppress
from pathlib import Path
from typing import BinaryIO, TextIO, TypeAlias, cast
from typing import BinaryIO, TypeAlias, cast

import anyio
from anyio.abc import Process
Expand Down 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,6 @@ async def create_windows_process(
command: str,
args: list[str],
env: dict[str, str] | None = None,
errlog: TextIO | None = sys.stderr,
cwd: Path | str | None = None,
) -> Process | FallbackProcess:
"""Creates a subprocess with Job Object support for tree termination.
Expand All @@ -149,21 +150,24 @@ async def create_windows_process(
with it; children spawned before the assignment completes are not captured
(see the inline note below).

The server's stderr is always a pipe (see `_create_platform_compatible_process`):
the caller's errlog stream is forwarded it by `stdio_client` instead.

Returns:
Process | FallbackProcess: The spawned process with async stdin/stdout streams.
Process | FallbackProcess: The spawned process with async stdin/stdout/stderr streams.
"""
try:
process = await anyio.open_process(
[command, *args],
env=env,
# Ensure we don't create console windows for each process
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
stderr=errlog,
stderr=subprocess.PIPE,
cwd=cwd,
)
except NotImplementedError:
# Windows event loops without async subprocess support (SelectorEventLoop)
process = await _create_windows_fallback_process(command, args, env, errlog, cwd)
process = await _create_windows_fallback_process(command, args, env, cwd)

# Children spawned before the assignment completes land outside the job
# (membership is inherited at CreateProcess, never acquired retroactively);
Expand All @@ -177,15 +181,14 @@ async def _create_windows_fallback_process(
command: str,
args: list[str],
env: dict[str, str] | None = None,
errlog: TextIO | None = sys.stderr,
cwd: Path | str | None = None,
) -> FallbackProcess:
"""Spawns via subprocess.Popen and wraps it in FallbackProcess."""
popen_obj = subprocess.Popen(
[command, *args],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=errlog,
stderr=subprocess.PIPE,
env=env,
cwd=cwd,
bufsize=0, # Unbuffered output
Expand Down
Loading
Loading