Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings
86 changes: 83 additions & 3 deletions 86 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 19 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
Morty Proxy This is a proxified and sanitized view of the page, visit original site.