Skip to content

Commit 9fc1d36

Browse files
committed
feat: add Jupyter notebook detection and stderr helpers
- Add _is_jupyter_notebook() to detect Jupyter/IPython environments - Add _print_stderr() to handle stderr output with IPython display support
1 parent 5983a65 commit 9fc1d36

File tree

3 files changed

+247
-13
lines changed

3 files changed

+247
-13
lines changed

src/mcp/client/stdio/__init__.py

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
import os
3+
import subprocess
34
import sys
45
from contextlib import asynccontextmanager
56
from pathlib import Path
@@ -48,6 +49,47 @@
4849
PROCESS_TERMINATION_TIMEOUT = 2.0
4950

5051

52+
def _is_jupyter_notebook() -> bool:
53+
"""
54+
Detect if running in a Jupyter notebook or IPython environment.
55+
56+
Returns:
57+
bool: True if running in Jupyter/IPython, False otherwise
58+
"""
59+
try:
60+
from IPython import get_ipython
61+
62+
ipython = get_ipython()
63+
return ipython is not None and ipython.__class__.__name__ in ("ZMQInteractiveShell", "TerminalInteractiveShell")
64+
except ImportError:
65+
return False
66+
67+
68+
def _print_stderr(line: str, errlog: TextIO) -> None:
69+
"""
70+
Print stderr output, using IPython's display system if in Jupyter notebook.
71+
72+
Args:
73+
line: The line to print
74+
errlog: The fallback TextIO stream (used when not in Jupyter)
75+
"""
76+
if _is_jupyter_notebook():
77+
try:
78+
from IPython.display import HTML, display
79+
80+
# Use IPython's display system with red color for stderr
81+
# This ensures proper rendering in Jupyter notebooks
82+
display(HTML(f'<pre style="color: red;">{line}</pre>'))
83+
except Exception:
84+
# If IPython display fails, fall back to regular print
85+
# Log the error but continue (non-critical)
86+
logger.debug("Failed to use IPython display for stderr, falling back to print", exc_info=True)
87+
print(line, file=errlog)
88+
else:
89+
# Not in Jupyter, use standard stderr redirection
90+
print(line, file=errlog)
91+
92+
5193
def get_default_environment() -> dict[str, str]:
5294
"""
5395
Returns a default environment object including only environment variables deemed
@@ -107,6 +149,18 @@ async def stdio_client(server: StdioServerParameters, errlog: TextIO = sys.stder
107149
"""
108150
Client transport for stdio: this will connect to a server by spawning a
109151
process and communicating with it over stdin/stdout.
152+
153+
This function automatically handles stderr output in a way that is compatible
154+
with Jupyter notebook environments. When running in Jupyter, stderr output
155+
is displayed using IPython's display system with red color formatting.
156+
When not in Jupyter, stderr is redirected to the provided errlog stream
157+
(defaults to sys.stderr).
158+
159+
Args:
160+
server: Parameters for the server process to spawn
161+
errlog: TextIO stream for stderr output when not in Jupyter (defaults to sys.stderr).
162+
This parameter is kept for backward compatibility but may be ignored
163+
when running in Jupyter notebook environments.
110164
"""
111165
read_stream: MemoryObjectReceiveStream[SessionMessage | Exception]
112166
read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception]
@@ -179,12 +233,49 @@ async def stdin_writer():
179233
except anyio.ClosedResourceError: # pragma: no cover
180234
await anyio.lowlevel.checkpoint()
181235

236+
async def stderr_reader():
237+
"""Read stderr from the process and display it appropriately."""
238+
if not process.stderr:
239+
return
240+
241+
try:
242+
buffer = ""
243+
async for chunk in TextReceiveStream(
244+
process.stderr,
245+
encoding=server.encoding,
246+
errors=server.encoding_error_handler,
247+
):
248+
lines = (buffer + chunk).split("\n")
249+
buffer = lines.pop()
250+
251+
for line in lines:
252+
if line.strip(): # Only print non-empty lines
253+
try:
254+
_print_stderr(line, errlog)
255+
except Exception:
256+
# Log errors but continue (non-critical)
257+
logger.debug("Failed to print stderr line", exc_info=True)
258+
259+
# Print any remaining buffer content
260+
if buffer.strip():
261+
try:
262+
_print_stderr(buffer, errlog)
263+
except Exception:
264+
logger.debug("Failed to print final stderr buffer", exc_info=True)
265+
except anyio.ClosedResourceError: # pragma: no cover
266+
await anyio.lowlevel.checkpoint()
267+
except Exception:
268+
# Log errors but continue (non-critical)
269+
logger.debug("Error reading stderr", exc_info=True)
270+
182271
async with (
183272
anyio.create_task_group() as tg,
184273
process,
185274
):
186275
tg.start_soon(stdout_reader)
187276
tg.start_soon(stdin_writer)
277+
if process.stderr:
278+
tg.start_soon(stderr_reader)
188279
try:
189280
yield read_stream, write_stream
190281
finally:
@@ -244,14 +335,19 @@ async def _create_platform_compatible_process(
244335
245336
Unix: Creates process in a new session/process group for killpg support
246337
Windows: Creates process in a Job Object for reliable child termination
338+
339+
Note: stderr is piped (not redirected) to allow async reading for Jupyter
340+
notebook compatibility. The errlog parameter is kept for backward compatibility
341+
but is only used when not in Jupyter environments.
247342
"""
248343
if sys.platform == "win32": # pragma: no cover
249-
process = await create_windows_process(command, args, env, errlog, cwd)
344+
process = await create_windows_process(command, args, env, errlog, cwd, pipe_stderr=True)
250345
else:
346+
# Pipe stderr instead of redirecting to allow async reading
251347
process = await anyio.open_process(
252348
[command, *args],
253349
env=env,
254-
stderr=errlog,
350+
stderr=subprocess.PIPE,
255351
cwd=cwd,
256352
start_new_session=True,
257353
) # pragma: no cover

src/mcp/os/win32/utilities.py

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ class FallbackProcess:
7070
A fallback process wrapper for Windows to handle async I/O
7171
when using subprocess.Popen, which provides sync-only FileIO objects.
7272
73-
This wraps stdin and stdout into async-compatible
73+
This wraps stdin, stdout, and stderr into async-compatible
7474
streams (FileReadStream, FileWriteStream),
7575
so that MCP clients expecting async streams can work properly.
7676
"""
@@ -79,10 +79,12 @@ def __init__(self, popen_obj: subprocess.Popen[bytes]):
7979
self.popen: subprocess.Popen[bytes] = popen_obj
8080
self.stdin_raw = popen_obj.stdin # type: ignore[assignment]
8181
self.stdout_raw = popen_obj.stdout # type: ignore[assignment]
82-
self.stderr = popen_obj.stderr # type: ignore[assignment]
82+
self.stderr_raw = popen_obj.stderr # type: ignore[assignment]
8383

8484
self.stdin = FileWriteStream(cast(BinaryIO, self.stdin_raw)) if self.stdin_raw else None
8585
self.stdout = FileReadStream(cast(BinaryIO, self.stdout_raw)) if self.stdout_raw else None
86+
# Wrap stderr in async stream if it's piped (for Jupyter compatibility)
87+
self.stderr = FileReadStream(cast(BinaryIO, self.stderr_raw)) if self.stderr_raw else None
8688

8789
async def __aenter__(self):
8890
"""Support async context manager entry."""
@@ -103,12 +105,14 @@ async def __aexit__(
103105
await self.stdin.aclose()
104106
if self.stdout:
105107
await self.stdout.aclose()
108+
if self.stderr:
109+
await self.stderr.aclose()
106110
if self.stdin_raw:
107111
self.stdin_raw.close()
108112
if self.stdout_raw:
109113
self.stdout_raw.close()
110-
if self.stderr:
111-
self.stderr.close()
114+
if self.stderr_raw:
115+
self.stderr_raw.close()
112116

113117
async def wait(self):
114118
"""Async wait for process completion."""
@@ -139,6 +143,7 @@ async def create_windows_process(
139143
env: dict[str, str] | None = None,
140144
errlog: TextIO | None = sys.stderr,
141145
cwd: Path | str | None = None,
146+
pipe_stderr: bool = False,
142147
) -> Process | FallbackProcess:
143148
"""
144149
Creates a subprocess in a Windows-compatible way with Job Object support.
@@ -155,15 +160,20 @@ async def create_windows_process(
155160
command (str): The executable to run
156161
args (list[str]): List of command line arguments
157162
env (dict[str, str] | None): Environment variables
158-
errlog (TextIO | None): Where to send stderr output (defaults to sys.stderr)
163+
errlog (TextIO | None): Where to send stderr output (defaults to sys.stderr).
164+
Only used when pipe_stderr is False.
159165
cwd (Path | str | None): Working directory for the subprocess
166+
pipe_stderr (bool): If True, pipe stderr instead of redirecting to errlog.
167+
This allows async reading of stderr for Jupyter compatibility.
160168
161169
Returns:
162170
Process | FallbackProcess: Async-compatible subprocess with stdin and stdout streams
163171
"""
164172
job = _create_job_object()
165173
process = None
166174

175+
stderr_target = subprocess.PIPE if pipe_stderr else errlog
176+
167177
try:
168178
# First try using anyio with Windows-specific flags to hide console window
169179
process = await anyio.open_process(
@@ -173,18 +183,18 @@ async def create_windows_process(
173183
creationflags=subprocess.CREATE_NO_WINDOW # type: ignore
174184
if hasattr(subprocess, "CREATE_NO_WINDOW")
175185
else 0,
176-
stderr=errlog,
186+
stderr=stderr_target,
177187
cwd=cwd,
178188
)
179189
except NotImplementedError:
180190
# If Windows doesn't support async subprocess creation, use fallback
181-
process = await _create_windows_fallback_process(command, args, env, errlog, cwd)
191+
process = await _create_windows_fallback_process(command, args, env, errlog, cwd, pipe_stderr=pipe_stderr)
182192
except Exception:
183193
# Try again without creation flags
184194
process = await anyio.open_process(
185195
[command, *args],
186196
env=env,
187-
stderr=errlog,
197+
stderr=stderr_target,
188198
cwd=cwd,
189199
)
190200

@@ -198,19 +208,30 @@ async def _create_windows_fallback_process(
198208
env: dict[str, str] | None = None,
199209
errlog: TextIO | None = sys.stderr,
200210
cwd: Path | str | None = None,
211+
pipe_stderr: bool = False,
201212
) -> FallbackProcess:
202213
"""
203214
Create a subprocess using subprocess.Popen as a fallback when anyio fails.
204215
205216
This function wraps the sync subprocess.Popen in an async-compatible interface.
217+
218+
Args:
219+
command: The executable to run
220+
args: List of command line arguments
221+
env: Environment variables
222+
errlog: Where to send stderr output (only used when pipe_stderr is False)
223+
cwd: Working directory for the subprocess
224+
pipe_stderr: If True, pipe stderr instead of redirecting to errlog
206225
"""
226+
stderr_target = subprocess.PIPE if pipe_stderr else errlog
227+
207228
try:
208229
# Try launching with creationflags to avoid opening a new console window
209230
popen_obj = subprocess.Popen(
210231
[command, *args],
211232
stdin=subprocess.PIPE,
212233
stdout=subprocess.PIPE,
213-
stderr=errlog,
234+
stderr=stderr_target,
214235
env=env,
215236
cwd=cwd,
216237
bufsize=0, # Unbuffered output
@@ -222,7 +243,7 @@ async def _create_windows_fallback_process(
222243
[command, *args],
223244
stdin=subprocess.PIPE,
224245
stdout=subprocess.PIPE,
225-
stderr=errlog,
246+
stderr=stderr_target,
226247
env=env,
227248
cwd=cwd,
228249
bufsize=0,

0 commit comments

Comments
 (0)