Skip to content
86 changes: 83 additions & 3 deletions src/mcp/client/stdio.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
import os
import subprocess
import sys
from contextlib import asynccontextmanager
from pathlib import Path
Expand All @@ -24,6 +25,33 @@

logger = logging.getLogger(__name__)


def _is_jupyter_environment() -> bool:
"""Detect if code is running in a Jupyter notebook environment.

In Jupyter environments, sys.stderr doesn't work as expected when passed
to subprocess, so we need to handle stderr differently.

Returns:
bool: True if running in Jupyter/IPython notebook environment
"""
try:
# Check for IPython kernel
from IPython import get_ipython # type: ignore[reportMissingImports]

ipython = get_ipython() # type: ignore[reportUnknownVariableType]
if ipython is not None:
# Check if it's a notebook kernel (not just IPython terminal)
if "IPKernelApp" in ipython.config: # type: ignore[reportUnknownMemberType]
return True
# Also check for ZMQInteractiveShell which indicates notebook
if ipython.__class__.__name__ == "ZMQInteractiveShell": # type: ignore[reportUnknownMemberType]
return True
except (ImportError, AttributeError):
pass
return False


# Environment variables to inherit by default
DEFAULT_INHERITED_ENV_VARS = (
[
Expand Down Expand Up @@ -105,6 +133,12 @@ class StdioServerParameters(BaseModel):
async def stdio_client(server: StdioServerParameters, errlog: TextIO = sys.stderr):
"""Client transport for stdio: this will connect to a server by spawning a
process and communicating with it over stdin/stdout.

Args:
server: Parameters for the server process
errlog: TextIO stream for stderr output. In Jupyter environments,
stderr is captured and printed to work around Jupyter's
limitations with subprocess stderr handling.
"""
read_stream: MemoryObjectReceiveStream[SessionMessage | Exception]
read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception]
Expand All @@ -115,16 +149,20 @@ async def stdio_client(server: StdioServerParameters, errlog: TextIO = sys.stder
read_stream_writer, read_stream = anyio.create_memory_object_stream(0)
write_stream, write_stream_reader = anyio.create_memory_object_stream(0)

# Detect Jupyter environment for stderr handling
is_jupyter = _is_jupyter_environment()

try:
command = _get_executable_command(server.command)

# Open process with stderr piped for capture
# Open process with stderr handling based on environment
process = await _create_platform_compatible_process(
command=command,
args=server.args,
env=({**get_default_environment(), **server.env} if server.env is not None else get_default_environment()),
errlog=errlog,
cwd=server.cwd,
capture_stderr=is_jupyter,
)
except OSError:
# Clean up streams if process creation fails
Expand Down Expand Up @@ -177,9 +215,34 @@ async def stdin_writer():
except anyio.ClosedResourceError: # pragma: no cover
await anyio.lowlevel.checkpoint()

async def stderr_reader():
"""Read stderr from the process and print it to notebook output.

In Jupyter environments, stderr is captured as a pipe and printed
to make it visible in the notebook output.

See: https://github.com/modelcontextprotocol/python-sdk/issues/156
"""
if not process.stderr:
return # pragma: no cover

try:
async for chunk in TextReceiveStream(
process.stderr,
encoding=server.encoding,
errors=server.encoding_error_handler,
):
# Use ANSI red color for stderr visibility in Jupyter
print(f"\033[91m{chunk}\033[0m", end="", flush=True)
except anyio.ClosedResourceError: # pragma: no cover
await anyio.lowlevel.checkpoint()

async with anyio.create_task_group() as tg, process:
tg.start_soon(stdout_reader)
tg.start_soon(stdin_writer)
# Only start stderr reader if we're capturing stderr (Jupyter mode)
if is_jupyter and process.stderr:
tg.start_soon(stderr_reader)
try:
yield read_stream, write_stream
finally:
Expand Down Expand Up @@ -232,19 +295,36 @@ async def _create_platform_compatible_process(
env: dict[str, str] | None = None,
errlog: TextIO = sys.stderr,
cwd: Path | str | None = None,
capture_stderr: bool = False,
):
"""Creates a subprocess in a platform-compatible way.

Unix: Creates process in a new session/process group for killpg support
Windows: Creates process in a Job Object for reliable child termination

Args:
command: The executable command to run
args: Command line arguments
env: Environment variables for the process
errlog: TextIO stream for stderr (used when capture_stderr=False)
cwd: Working directory for the process
capture_stderr: If True, stderr is captured as a pipe for async reading.
This is needed for Jupyter environments where passing
sys.stderr directly doesn't work properly.

Returns:
Process with stdin, stdout, and optionally stderr streams
"""
# Determine stderr handling: PIPE for capture, or redirect to errlog
stderr_target = subprocess.PIPE if capture_stderr else errlog

if sys.platform == "win32": # pragma: no cover
process = await create_windows_process(command, args, env, errlog, cwd)
process = await create_windows_process(command, args, env, stderr_target, cwd)
else: # pragma: lax no cover
process = await anyio.open_process(
[command, *args],
env=env,
stderr=errlog,
stderr=stderr_target,
cwd=cwd,
start_new_session=True,
)
Expand Down
19 changes: 12 additions & 7 deletions src/mcp/os/win32/utilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ class FallbackProcess:
"""A fallback process wrapper for Windows to handle async I/O
when using subprocess.Popen, which provides sync-only FileIO objects.

This wraps stdin and stdout into async-compatible
This wraps stdin, stdout, and optionally stderr into async-compatible
streams (FileReadStream, FileWriteStream),
so that MCP clients expecting async streams can work properly.
"""
Expand All @@ -75,10 +75,12 @@ def __init__(self, popen_obj: subprocess.Popen[bytes]):
self.popen: subprocess.Popen[bytes] = popen_obj
self.stdin_raw = popen_obj.stdin # type: ignore[assignment]
self.stdout_raw = popen_obj.stdout # type: ignore[assignment]
self.stderr = popen_obj.stderr # type: ignore[assignment]
self.stderr_raw = popen_obj.stderr # type: ignore[assignment]

self.stdin = FileWriteStream(cast(BinaryIO, self.stdin_raw)) if self.stdin_raw else None
self.stdout = FileReadStream(cast(BinaryIO, self.stdout_raw)) if self.stdout_raw else None
# Wrap stderr as async stream if it was captured as PIPE
self.stderr = FileReadStream(cast(BinaryIO, self.stderr_raw)) if self.stderr_raw else None

async def __aenter__(self):
"""Support async context manager entry."""
Expand All @@ -99,12 +101,14 @@ async def __aexit__(
await self.stdin.aclose()
if self.stdout:
await self.stdout.aclose()
if self.stderr:
await self.stderr.aclose()
if self.stdin_raw:
self.stdin_raw.close()
if self.stdout_raw:
self.stdout_raw.close()
if self.stderr:
self.stderr.close()
if self.stderr_raw:
self.stderr_raw.close()

async def wait(self):
"""Async wait for process completion."""
Expand Down Expand Up @@ -133,7 +137,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 in a Windows-compatible way with Job Object support.
Expand All @@ -150,7 +154,8 @@ async def create_windows_process(
command (str): The executable to run
args (list[str]): List of command line arguments
env (dict[str, str] | None): Environment variables
errlog (TextIO | None): Where to send stderr output (defaults to sys.stderr)
errlog: Where to send stderr output. Can be a TextIO stream (like sys.stderr),
subprocess.PIPE (-1) for capturing stderr, or None.
cwd (Path | str | None): Working directory for the subprocess

Returns:
Expand Down Expand Up @@ -191,7 +196,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:
"""Create a subprocess using subprocess.Popen as a fallback when anyio fails.
Expand Down
Loading
Loading