Skip to content

Commit 82f6f73

Browse files
committed
fix: pipe stderr asynchronously to support Jupyter notebook environments
In Jupyter notebook environments, sys.stderr cannot reliably be used as a subprocess file descriptor. This causes stdio_client to fail when trying to start MCP servers from within Jupyter notebooks. Changes: - Always pipe stderr (subprocess.PIPE) instead of passing sys.stderr directly to the subprocess - Add async stderr_reader task that reads stderr output and forwards it to the errlog stream (or ANSI-colored print output in Jupyter) - Add _is_jupyter_notebook() helper to detect Jupyter environments via isinstance check against ZMQInteractiveShell - Remove errlog parameter from _create_platform_compatible_process and create_windows_process since stderr is now always piped - Update win32 utilities to always use subprocess.PIPE for stderr Fixes #156
1 parent 19fe9fa commit 82f6f73

3 files changed

Lines changed: 51 additions & 9 deletions

File tree

src/mcp/client/stdio.py

Lines changed: 46 additions & 4 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
@@ -24,6 +25,22 @@
2425

2526
logger = logging.getLogger(__name__)
2627

28+
29+
def _is_jupyter_notebook() -> bool:
30+
"""Check if running in a Jupyter notebook environment.
31+
32+
Returns True when running inside Jupyter Notebook, JupyterLab, or
33+
any IPython kernel that uses ZMQ for communication.
34+
"""
35+
try:
36+
from ipykernel.zmqshell import ZMQInteractiveShell # type: ignore
37+
from IPython import get_ipython # type: ignore
38+
39+
return isinstance(get_ipython(), ZMQInteractiveShell) # type: ignore[no-untyped-def]
40+
except Exception:
41+
return False
42+
43+
2744
# Environment variables to inherit by default
2845
DEFAULT_INHERITED_ENV_VARS = (
2946
[
@@ -123,7 +140,6 @@ async def stdio_client(server: StdioServerParameters, errlog: TextIO = sys.stder
123140
command=command,
124141
args=server.args,
125142
env=({**get_default_environment(), **server.env} if server.env is not None else get_default_environment()),
126-
errlog=errlog,
127143
cwd=server.cwd,
128144
)
129145
except OSError:
@@ -177,9 +193,32 @@ async def stdin_writer():
177193
except anyio.ClosedResourceError: # pragma: no cover
178194
await anyio.lowlevel.checkpoint()
179195

196+
async def stderr_reader():
197+
"""Read stderr from the process and forward to errlog.
198+
199+
In Jupyter notebook environments, sys.stderr cannot reliably be used
200+
as a subprocess file descriptor. By always piping stderr and reading
201+
it asynchronously, we ensure output is visible in all environments:
202+
Jupyter gets ANSI-colored print output; other environments write
203+
directly to the errlog stream (defaulting to sys.stderr).
204+
"""
205+
assert process.stderr, "Opened process is missing stderr"
206+
207+
in_jupyter = _is_jupyter_notebook()
208+
try:
209+
async for line in process.stderr:
210+
text = line.decode(errors="replace").rstrip("\n")
211+
if in_jupyter:
212+
print(f"\033[91m{text}\033[0m")
213+
else:
214+
print(text, file=errlog)
215+
except anyio.ClosedResourceError: # pragma: lax no cover
216+
await anyio.lowlevel.checkpoint()
217+
180218
async with anyio.create_task_group() as tg, process:
181219
tg.start_soon(stdout_reader)
182220
tg.start_soon(stdin_writer)
221+
tg.start_soon(stderr_reader)
183222
try:
184223
yield read_stream, write_stream
185224
finally:
@@ -230,21 +269,24 @@ async def _create_platform_compatible_process(
230269
command: str,
231270
args: list[str],
232271
env: dict[str, str] | None = None,
233-
errlog: TextIO = sys.stderr,
234272
cwd: Path | str | None = None,
235273
):
236274
"""Creates a subprocess in a platform-compatible way.
237275
238276
Unix: Creates process in a new session/process group for killpg support
239277
Windows: Creates process in a Job Object for reliable child termination
278+
279+
Stderr is always piped so that the caller can read it asynchronously.
280+
This is required because sys.stderr may not be a valid subprocess file
281+
descriptor in environments such as Jupyter notebooks.
240282
"""
241283
if sys.platform == "win32": # pragma: no cover
242-
process = await create_windows_process(command, args, env, errlog, cwd)
284+
process = await create_windows_process(command, args, env, cwd=cwd)
243285
else: # pragma: lax no cover
244286
process = await anyio.open_process(
245287
[command, *args],
246288
env=env,
247-
stderr=errlog,
289+
stderr=subprocess.PIPE,
248290
cwd=cwd,
249291
start_new_session=True,
250292
)

src/mcp/os/win32/utilities.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ async def create_windows_process(
173173
creationflags=subprocess.CREATE_NO_WINDOW # type: ignore
174174
if hasattr(subprocess, "CREATE_NO_WINDOW")
175175
else 0,
176-
stderr=errlog,
176+
stderr=subprocess.PIPE,
177177
cwd=cwd,
178178
)
179179
except NotImplementedError:
@@ -184,7 +184,7 @@ async def create_windows_process(
184184
process = await anyio.open_process(
185185
[command, *args],
186186
env=env,
187-
stderr=errlog,
187+
stderr=subprocess.PIPE,
188188
cwd=cwd,
189189
)
190190

@@ -209,7 +209,7 @@ async def _create_windows_fallback_process(
209209
[command, *args],
210210
stdin=subprocess.PIPE,
211211
stdout=subprocess.PIPE,
212-
stderr=errlog,
212+
stderr=subprocess.PIPE,
213213
env=env,
214214
cwd=cwd,
215215
bufsize=0, # Unbuffered output
@@ -221,7 +221,7 @@ async def _create_windows_fallback_process(
221221
[command, *args],
222222
stdin=subprocess.PIPE,
223223
stdout=subprocess.PIPE,
224-
stderr=errlog,
224+
stderr=subprocess.PIPE,
225225
env=env,
226226
cwd=cwd,
227227
bufsize=0,

tests/issues/test_1027_win_unreachable_cleanup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ def echo(text: str) -> str:
195195
# This test manually manages the process to verify stdin-based shutdown
196196
# Start the server process
197197
process = await _create_platform_compatible_process(
198-
command=sys.executable, args=[server_script], env=None, errlog=sys.stderr, cwd=None
198+
command=sys.executable, args=[server_script], env=None, cwd=None
199199
)
200200

201201
# Wait for server to start

0 commit comments

Comments
 (0)