Skip to content

Commit e9c5fd2

Browse files
BabyChrist666claude
andcommitted
feat: support stderr logging in Jupyter notebook environments
Fixes #156 In Jupyter notebook environments, passing sys.stderr directly to subprocess doesn't work as expected - the stderr output is lost and users have no visibility into server errors or crashes. This change: - Adds _is_jupyter_environment() to detect when running in Jupyter/IPython - In Jupyter mode, captures stderr as a pipe and reads it asynchronously - Prints stderr output with red ANSI color for visibility in notebooks - In non-Jupyter environments, behavior remains unchanged (direct stderr) The fix ensures that MCP server error messages and crash logs are visible in Jupyter notebooks, making debugging much easier. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent dda845a commit e9c5fd2

File tree

3 files changed

+182
-12
lines changed

3 files changed

+182
-12
lines changed

src/mcp/client/stdio.py

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import logging
22
import os
3+
import subprocess
34
import sys
45
from contextlib import asynccontextmanager
56
from pathlib import Path
6-
from typing import Literal, TextIO
7+
from typing import Callable, Literal, TextIO
78

89
import anyio
910
import anyio.lowlevel
@@ -24,6 +25,32 @@
2425

2526
logger = logging.getLogger(__name__)
2627

28+
29+
def _is_jupyter_environment() -> bool:
30+
"""Detect if code is running in a Jupyter notebook environment.
31+
32+
In Jupyter environments, sys.stderr doesn't work as expected when passed
33+
to subprocess, so we need to handle stderr differently.
34+
35+
Returns:
36+
bool: True if running in Jupyter/IPython notebook environment
37+
"""
38+
try:
39+
# Check for IPython kernel
40+
from IPython import get_ipython
41+
42+
ipython = get_ipython()
43+
if ipython is not None:
44+
# Check if it's a notebook kernel (not just IPython terminal)
45+
if "IPKernelApp" in ipython.config:
46+
return True
47+
# Also check for ZMQInteractiveShell which indicates notebook
48+
if ipython.__class__.__name__ == "ZMQInteractiveShell":
49+
return True
50+
except (ImportError, AttributeError):
51+
pass
52+
return False
53+
2754
# Environment variables to inherit by default
2855
DEFAULT_INHERITED_ENV_VARS = (
2956
[
@@ -105,6 +132,12 @@ class StdioServerParameters(BaseModel):
105132
async def stdio_client(server: StdioServerParameters, errlog: TextIO = sys.stderr):
106133
"""Client transport for stdio: this will connect to a server by spawning a
107134
process and communicating with it over stdin/stdout.
135+
136+
Args:
137+
server: Parameters for the server process
138+
errlog: TextIO stream for stderr output. In Jupyter environments,
139+
stderr is captured and printed to work around Jupyter's
140+
limitations with subprocess stderr handling.
108141
"""
109142
read_stream: MemoryObjectReceiveStream[SessionMessage | Exception]
110143
read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception]
@@ -115,16 +148,20 @@ async def stdio_client(server: StdioServerParameters, errlog: TextIO = sys.stder
115148
read_stream_writer, read_stream = anyio.create_memory_object_stream(0)
116149
write_stream, write_stream_reader = anyio.create_memory_object_stream(0)
117150

151+
# Detect Jupyter environment for stderr handling
152+
is_jupyter = _is_jupyter_environment()
153+
118154
try:
119155
command = _get_executable_command(server.command)
120156

121-
# Open process with stderr piped for capture
157+
# Open process with stderr handling based on environment
122158
process = await _create_platform_compatible_process(
123159
command=command,
124160
args=server.args,
125161
env=({**get_default_environment(), **server.env} if server.env is not None else get_default_environment()),
126162
errlog=errlog,
127163
cwd=server.cwd,
164+
capture_stderr=is_jupyter,
128165
)
129166
except OSError:
130167
# Clean up streams if process creation fails
@@ -177,9 +214,41 @@ async def stdin_writer():
177214
except anyio.ClosedResourceError: # pragma: no cover
178215
await anyio.lowlevel.checkpoint()
179216

217+
async def stderr_reader():
218+
"""Read stderr from the process and output it appropriately.
219+
220+
In Jupyter environments, stderr is captured as a pipe and printed
221+
to make it visible in the notebook output. In normal environments,
222+
stderr is passed directly to sys.stderr.
223+
224+
See: https://github.com/modelcontextprotocol/python-sdk/issues/156
225+
"""
226+
if not process.stderr:
227+
return
228+
229+
try:
230+
async for chunk in TextReceiveStream(
231+
process.stderr,
232+
encoding=server.encoding,
233+
errors=server.encoding_error_handler,
234+
):
235+
# In Jupyter, print to stdout with red color for visibility
236+
# In normal environments, write to the provided errlog
237+
if is_jupyter:
238+
# Use ANSI red color for stderr in Jupyter
239+
print(f"\033[91m{chunk}\033[0m", end="", flush=True)
240+
else:
241+
errlog.write(chunk)
242+
errlog.flush()
243+
except anyio.ClosedResourceError:
244+
await anyio.lowlevel.checkpoint()
245+
180246
async with anyio.create_task_group() as tg, process:
181247
tg.start_soon(stdout_reader)
182248
tg.start_soon(stdin_writer)
249+
# Only start stderr reader if we're capturing stderr (Jupyter mode)
250+
if is_jupyter and process.stderr:
251+
tg.start_soon(stderr_reader)
183252
try:
184253
yield read_stream, write_stream
185254
finally:
@@ -232,19 +301,36 @@ async def _create_platform_compatible_process(
232301
env: dict[str, str] | None = None,
233302
errlog: TextIO = sys.stderr,
234303
cwd: Path | str | None = None,
304+
capture_stderr: bool = False,
235305
):
236306
"""Creates a subprocess in a platform-compatible way.
237307
238308
Unix: Creates process in a new session/process group for killpg support
239309
Windows: Creates process in a Job Object for reliable child termination
310+
311+
Args:
312+
command: The executable command to run
313+
args: Command line arguments
314+
env: Environment variables for the process
315+
errlog: TextIO stream for stderr (used when capture_stderr=False)
316+
cwd: Working directory for the process
317+
capture_stderr: If True, stderr is captured as a pipe for async reading.
318+
This is needed for Jupyter environments where passing
319+
sys.stderr directly doesn't work properly.
320+
321+
Returns:
322+
Process with stdin, stdout, and optionally stderr streams
240323
"""
324+
# Determine stderr handling: PIPE for capture, or redirect to errlog
325+
stderr_target = subprocess.PIPE if capture_stderr else errlog
326+
241327
if sys.platform == "win32": # pragma: no cover
242-
process = await create_windows_process(command, args, env, errlog, cwd)
328+
process = await create_windows_process(command, args, env, stderr_target, cwd)
243329
else: # pragma: lax no cover
244330
process = await anyio.open_process(
245331
[command, *args],
246332
env=env,
247-
stderr=errlog,
333+
stderr=stderr_target,
248334
cwd=cwd,
249335
start_new_session=True,
250336
)

src/mcp/os/win32/utilities.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import subprocess
66
import sys
77
from pathlib import Path
8-
from typing import BinaryIO, TextIO, cast
8+
from typing import BinaryIO, TextIO, Union, cast
99

1010
import anyio
1111
from anyio import to_thread
@@ -66,7 +66,7 @@ class FallbackProcess:
6666
"""A fallback process wrapper for Windows to handle async I/O
6767
when using subprocess.Popen, which provides sync-only FileIO objects.
6868
69-
This wraps stdin and stdout into async-compatible
69+
This wraps stdin, stdout, and optionally stderr into async-compatible
7070
streams (FileReadStream, FileWriteStream),
7171
so that MCP clients expecting async streams can work properly.
7272
"""
@@ -75,10 +75,12 @@ def __init__(self, popen_obj: subprocess.Popen[bytes]):
7575
self.popen: subprocess.Popen[bytes] = popen_obj
7676
self.stdin_raw = popen_obj.stdin # type: ignore[assignment]
7777
self.stdout_raw = popen_obj.stdout # type: ignore[assignment]
78-
self.stderr = popen_obj.stderr # type: ignore[assignment]
78+
self.stderr_raw = popen_obj.stderr # type: ignore[assignment]
7979

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

8385
async def __aenter__(self):
8486
"""Support async context manager entry."""
@@ -99,12 +101,14 @@ async def __aexit__(
99101
await self.stdin.aclose()
100102
if self.stdout:
101103
await self.stdout.aclose()
104+
if self.stderr:
105+
await self.stderr.aclose()
102106
if self.stdin_raw:
103107
self.stdin_raw.close()
104108
if self.stdout_raw:
105109
self.stdout_raw.close()
106-
if self.stderr:
107-
self.stderr.close()
110+
if self.stderr_raw:
111+
self.stderr_raw.close()
108112

109113
async def wait(self):
110114
"""Async wait for process completion."""
@@ -133,7 +137,7 @@ async def create_windows_process(
133137
command: str,
134138
args: list[str],
135139
env: dict[str, str] | None = None,
136-
errlog: TextIO | None = sys.stderr,
140+
errlog: Union[TextIO, int, None] = sys.stderr,
137141
cwd: Path | str | None = None,
138142
) -> Process | FallbackProcess:
139143
"""Creates a subprocess in a Windows-compatible way with Job Object support.
@@ -150,7 +154,8 @@ async def create_windows_process(
150154
command (str): The executable to run
151155
args (list[str]): List of command line arguments
152156
env (dict[str, str] | None): Environment variables
153-
errlog (TextIO | None): Where to send stderr output (defaults to sys.stderr)
157+
errlog: Where to send stderr output. Can be a TextIO stream (like sys.stderr),
158+
subprocess.PIPE (-1) for capturing stderr, or None.
154159
cwd (Path | str | None): Working directory for the subprocess
155160
156161
Returns:
@@ -191,7 +196,7 @@ async def _create_windows_fallback_process(
191196
command: str,
192197
args: list[str],
193198
env: dict[str, str] | None = None,
194-
errlog: TextIO | None = sys.stderr,
199+
errlog: Union[TextIO, int, None] = sys.stderr,
195200
cwd: Path | str | None = None,
196201
) -> FallbackProcess:
197202
"""Create a subprocess using subprocess.Popen as a fallback when anyio fails.

tests/client/test_stdio.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from mcp.client.stdio import (
1414
StdioServerParameters,
1515
_create_platform_compatible_process,
16+
_is_jupyter_environment,
1617
_terminate_process_tree,
1718
stdio_client,
1819
)
@@ -620,3 +621,81 @@ def sigterm_handler(signum, frame):
620621
f"stdio_client cleanup took {elapsed:.1f} seconds for stdin-ignoring process. "
621622
f"Expected between 2-4 seconds (2s stdin timeout + termination time)."
622623
)
624+
625+
626+
class TestJupyterStderrSupport:
627+
"""Tests for Jupyter notebook stderr logging support.
628+
629+
See: https://github.com/modelcontextprotocol/python-sdk/issues/156
630+
"""
631+
632+
def test_jupyter_detection_not_in_jupyter(self):
633+
"""Test that _is_jupyter_environment returns False when not in Jupyter."""
634+
# In a normal Python environment (like pytest), this should return False
635+
result = _is_jupyter_environment()
636+
assert result is False, "Should not detect Jupyter in normal Python environment"
637+
638+
def test_jupyter_detection_handles_missing_ipython(self):
639+
"""Test that _is_jupyter_environment handles missing IPython gracefully."""
640+
# This test verifies the ImportError handling works
641+
# by calling the function when IPython may or may not be installed
642+
result = _is_jupyter_environment()
643+
# Should return False (not crash) regardless of IPython availability
644+
assert isinstance(result, bool)
645+
646+
@pytest.mark.anyio
647+
async def test_stderr_captured_in_process(self):
648+
"""Test that stderr output from a subprocess can be captured."""
649+
# Create a script that writes to stderr
650+
script = textwrap.dedent(
651+
'''
652+
import sys
653+
sys.stderr.write("test error message\\n")
654+
sys.stderr.flush()
655+
# Exit immediately
656+
sys.exit(0)
657+
'''
658+
)
659+
660+
server_params = StdioServerParameters(
661+
command=sys.executable,
662+
args=["-c", script],
663+
)
664+
665+
# The stdio_client should handle this without hanging
666+
with anyio.move_on_after(3.0) as cancel_scope:
667+
async with stdio_client(server_params) as (read_stream, write_stream):
668+
await anyio.sleep(0.5) # Give process time to write and exit
669+
670+
assert not cancel_scope.cancelled_caught, "stdio_client should not hang on stderr output"
671+
672+
@pytest.mark.anyio
673+
async def test_stderr_with_continuous_output(self):
674+
"""Test that continuous stderr output doesn't block the client."""
675+
# Create a script that writes to stderr continuously then exits
676+
script = textwrap.dedent(
677+
'''
678+
import sys
679+
import time
680+
681+
for i in range(5):
682+
sys.stderr.write(f"stderr line {i}\\n")
683+
sys.stderr.flush()
684+
time.sleep(0.1)
685+
686+
# Exit after writing
687+
sys.exit(0)
688+
'''
689+
)
690+
691+
server_params = StdioServerParameters(
692+
command=sys.executable,
693+
args=["-c", script],
694+
)
695+
696+
# The client should handle continuous stderr without blocking
697+
with anyio.move_on_after(5.0) as cancel_scope:
698+
async with stdio_client(server_params) as (read_stream, write_stream):
699+
await anyio.sleep(1.0) # Wait for stderr output
700+
701+
assert not cancel_scope.cancelled_caught, "stdio_client should handle continuous stderr output"

0 commit comments

Comments
 (0)