Skip to content

Commit 49e2546

Browse files
BabyChrist666claude
andcommitted
Fix pre-commit formatting and add test coverage for Jupyter stderr support
- Add missing blank line after _is_jupyter_environment function (ruff E302) - Change triple-quoted strings from ''' to """ in tests (ruff Q000) - Remove unused Callable import - Remove dead code branch in stderr_reader (non-Jupyter path was unreachable) - Add tests for _is_jupyter_environment with mocked IPython (IPKernelApp, ZMQInteractiveShell) - Add tests for stderr_reader in Jupyter mode with mocked _is_jupyter_environment Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fca46fd commit 49e2546

File tree

2 files changed

+106
-18
lines changed

2 files changed

+106
-18
lines changed

src/mcp/client/stdio.py

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import sys
55
from contextlib import asynccontextmanager
66
from pathlib import Path
7-
from typing import Callable, Literal, TextIO
7+
from typing import Literal, TextIO
88

99
import anyio
1010
import anyio.lowlevel
@@ -51,6 +51,7 @@ def _is_jupyter_environment() -> bool:
5151
pass
5252
return False
5353

54+
5455
# Environment variables to inherit by default
5556
DEFAULT_INHERITED_ENV_VARS = (
5657
[
@@ -215,32 +216,25 @@ async def stdin_writer():
215216
await anyio.lowlevel.checkpoint()
216217

217218
async def stderr_reader():
218-
"""Read stderr from the process and output it appropriately.
219+
"""Read stderr from the process and print it to notebook output.
219220
220221
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.
222+
to make it visible in the notebook output.
223223
224224
See: https://github.com/modelcontextprotocol/python-sdk/issues/156
225225
"""
226226
if not process.stderr:
227-
return
227+
return # pragma: no cover
228228

229229
try:
230230
async for chunk in TextReceiveStream(
231231
process.stderr,
232232
encoding=server.encoding,
233233
errors=server.encoding_error_handler,
234234
):
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:
235+
# Use ANSI red color for stderr visibility in Jupyter
236+
print(f"\033[91m{chunk}\033[0m", end="", flush=True)
237+
except anyio.ClosedResourceError: # pragma: no cover
244238
await anyio.lowlevel.checkpoint()
245239

246240
async with anyio.create_task_group() as tg, process:

tests/client/test_stdio.py

Lines changed: 98 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import tempfile
66
import textwrap
77
import time
8+
from unittest.mock import MagicMock, patch
89

910
import anyio
1011
import pytest
@@ -648,13 +649,13 @@ async def test_stderr_captured_in_process(self):
648649
"""Test that stderr output from a subprocess can be captured."""
649650
# Create a script that writes to stderr
650651
script = textwrap.dedent(
651-
'''
652+
"""
652653
import sys
653654
sys.stderr.write("test error message\\n")
654655
sys.stderr.flush()
655656
# Exit immediately
656657
sys.exit(0)
657-
'''
658+
"""
658659
)
659660

660661
server_params = StdioServerParameters(
@@ -674,7 +675,7 @@ async def test_stderr_with_continuous_output(self):
674675
"""Test that continuous stderr output doesn't block the client."""
675676
# Create a script that writes to stderr continuously then exits
676677
script = textwrap.dedent(
677-
'''
678+
"""
678679
import sys
679680
import time
680681
@@ -685,7 +686,7 @@ async def test_stderr_with_continuous_output(self):
685686
686687
# Exit after writing
687688
sys.exit(0)
688-
'''
689+
"""
689690
)
690691

691692
server_params = StdioServerParameters(
@@ -699,3 +700,96 @@ async def test_stderr_with_continuous_output(self):
699700
await anyio.sleep(1.0) # Wait for stderr output
700701

701702
assert not cancel_scope.cancelled_caught, "stdio_client should handle continuous stderr output"
703+
704+
def test_jupyter_detection_ipkernel_app(self):
705+
"""Test that _is_jupyter_environment returns True for IPKernelApp config."""
706+
mock_ipython_instance = MagicMock()
707+
mock_ipython_instance.config = {"IPKernelApp": {}}
708+
mock_ipython_instance.__class__ = type("TerminalInteractiveShell", (), {})
709+
710+
mock_ipython_module = MagicMock()
711+
mock_ipython_module.get_ipython = MagicMock(return_value=mock_ipython_instance)
712+
713+
with patch.dict("sys.modules", {"IPython": mock_ipython_module}):
714+
result = _is_jupyter_environment()
715+
assert result is True
716+
717+
def test_jupyter_detection_zmq_shell(self):
718+
"""Test that _is_jupyter_environment returns True for ZMQInteractiveShell."""
719+
mock_ipython_instance = MagicMock()
720+
mock_ipython_instance.config = {}
721+
mock_ipython_instance.__class__ = type("ZMQInteractiveShell", (), {})
722+
723+
mock_ipython_module = MagicMock()
724+
mock_ipython_module.get_ipython = MagicMock(return_value=mock_ipython_instance)
725+
726+
with patch.dict("sys.modules", {"IPython": mock_ipython_module}):
727+
result = _is_jupyter_environment()
728+
assert result is True
729+
730+
def test_jupyter_detection_non_notebook_ipython(self):
731+
"""Test that _is_jupyter_environment returns False for plain IPython terminal."""
732+
mock_ipython_instance = MagicMock()
733+
mock_ipython_instance.config = {}
734+
mock_ipython_instance.__class__ = type("TerminalInteractiveShell", (), {})
735+
736+
mock_ipython_module = MagicMock()
737+
mock_ipython_module.get_ipython = MagicMock(return_value=mock_ipython_instance)
738+
739+
with patch.dict("sys.modules", {"IPython": mock_ipython_module}):
740+
result = _is_jupyter_environment()
741+
assert result is False
742+
743+
@pytest.mark.anyio
744+
async def test_stderr_reader_jupyter_mode(self):
745+
"""Test that stderr is captured and printed in Jupyter mode."""
746+
script = textwrap.dedent(
747+
"""
748+
import sys
749+
sys.stderr.write("jupyter error output\\n")
750+
sys.stderr.flush()
751+
sys.exit(0)
752+
"""
753+
)
754+
755+
server_params = StdioServerParameters(
756+
command=sys.executable,
757+
args=["-c", script],
758+
)
759+
760+
# Mock _is_jupyter_environment to return True to exercise stderr_reader
761+
with patch("mcp.client.stdio._is_jupyter_environment", return_value=True):
762+
with anyio.move_on_after(5.0) as cancel_scope:
763+
async with stdio_client(server_params) as (read_stream, write_stream):
764+
await anyio.sleep(1.0)
765+
766+
assert not cancel_scope.cancelled_caught, "stdio_client should not hang in Jupyter mode"
767+
768+
@pytest.mark.anyio
769+
async def test_stderr_reader_jupyter_mode_continuous(self):
770+
"""Test that continuous stderr output is handled in Jupyter mode."""
771+
script = textwrap.dedent(
772+
"""
773+
import sys
774+
import time
775+
776+
for i in range(3):
777+
sys.stderr.write(f"jupyter stderr line {i}\\n")
778+
sys.stderr.flush()
779+
time.sleep(0.05)
780+
781+
sys.exit(0)
782+
"""
783+
)
784+
785+
server_params = StdioServerParameters(
786+
command=sys.executable,
787+
args=["-c", script],
788+
)
789+
790+
with patch("mcp.client.stdio._is_jupyter_environment", return_value=True):
791+
with anyio.move_on_after(5.0) as cancel_scope:
792+
async with stdio_client(server_params) as (read_stream, write_stream):
793+
await anyio.sleep(1.0)
794+
795+
assert not cancel_scope.cancelled_caught, "stdio_client should handle continuous Jupyter stderr"

0 commit comments

Comments
 (0)