Skip to content

support logging to stderr in Jupyter Notebook Environments. #156

Description

@grll

Is your feature request related to a problem? Please describe.
I want to make mcp python-sdk jupyter notebook compatible. When running in a notebook environment, MCP work but do not output stderr as it does normally.

For instance in jupyter notebook:

import mcp
import os
from mcp.client.stdio import stdio_client

serverparams = mcp.StdioServerParameters(
    command="uv",
    args=["--quiet", "run", "../src/echo.py"],
    env={"UV_PYTHON": "3.12", **os.environ},
)

async with stdio_client(serverparams) as (read, write):
    async with mcp.ClientSession(read, write) as session:
        await session.initialize()
        tools = await session.list_tools()
        print(tools)

Outputs:

meta=None nextCursor=None tools=[Tool(name='echo_tool', description='Echo the input text\n\n    Args:\n        text (str): The text to echo\n\n    Returns:\n        str: The echoed text\n    ', inputSchema={'properties': {'text': {'title': 'Text', 'type': 'string'}}, 'required': ['text'], 'title': 'echo_toolArguments', 'type': 'object'})]

while running the server without jupyter notebook:

❯ uv run --quiet src/echo.py
starting echo server

stderr is correctly displayed.

This is a big problem mostly because if the server is crashing or the command is wrong you have no way to know what's wrong: nothing is logged and the jupyter notebook cell just hangs.

Describe the solution you'd like
I found the culprit being the use of:

process = await anyio.open_process(
        [server.command, *server.args],
        env=server.env if server.env is not None else get_default_environment(),
        stderr=sys.stderr,
 )

In particular sys.stderr here is not working in the jupyter / ipython context. Instead I would suggest a working change as follow:

  1. remove the stderr params from the process and handle process.stderr in an async function as stdout / stdin is handled.
  2. to that effect, use a stderr_reader async function like the following:
    async def stderr_reader():
        assert process.stderr, "Opened process is missing stderr"
        try:
            async for line in process.stderr:
                if is_jupyter_notebook():
                    print(f"\033[91m {line.decode().strip()}")
                else:
                    # redirect to stderr as before
                    print(line.decode().strip(), file=sys.stderr)
        except anyio.ClosedResourceError:
            await anyio.lowlevel.checkpoint()

This would result in the same behavior as before while allowing the stderr to be logged in the jupyter notebook context.

Additional context
Jupyter notebook support support is also requested for mcpadapt which bring MCP server tools in any agentic framework, as many agentic framework demonstrate usage in jupyter notebooks.
https://github.com/grll/mcpadapt

Activity

  1. grll commented on Jan 17, 2025

    @grll
    ContributorAuthor

    @dsp-ant if that sounds reasonable to you happy to put together a PR

  2. grll commented on Jan 17, 2025

    @grll
    ContributorAuthor

    actually another simpler solution is also just to explicitly set stderr to None:

    process = await anyio.open_process(
            [server.command, *server.args],
            env=server.env if server.env is not None else get_default_environment(),
            stderr=None,
     )
  3. dsp-ant commented on Jan 23, 2025

    @dsp-ant
    Member

    @grll Thanks for reporting this. I am curious as to 'why' jupyter notebooks have problems with capturing sys.stderr? I am open for fixes assuming they are not breaking backwards compatibility.

    Notable, jupyter integration should probably rely on Logging to correctly capture and display logs. Now sadly, no server actually correctly implements this and we likely need better integration with standard logging facilities in Python.

  4. added this to the SDK 1.2.x milestone on Jan 23, 2025
  5. added
    P3Nice to haves, rare edge cases
    on Sep 26, 2025
  6. removed theissue type on Oct 7, 2025
  7. Ashutosh0x commented on Feb 8, 2026

    @Ashutosh0x

    Hi! I'd like to work on this issue if it's still available. Improved logging integration in Jupyter environments (and generally) sounds very useful. I can investigate and submit a PR to allow better logging configuration, including stderr support as suggested.

  8. added a commit that references this issue on Feb 8, 2026
    cee78d3
  9. Ashutosh0x commented on Feb 8, 2026

    @Ashutosh0x

    Hi @grll, I've added support for Jupyter stderr logging in #2016. The SDK now automatically detects Jupyter environments and pipes stderr to ensure logs are visible in the notebook.

  10. added a commit that references this issue on Feb 9, 2026
    e9c5fd2
  11. dgenio commented on Feb 28, 2026

    @dgenio

    Hi — I opened PR #1707 to address this, but it's now stale due to the stdio module restructuring (package → single file). I'd like to open a fresh, simpler PR if contributions are still welcome here.

    Proposed approach: Instead of the StderrCapture class from my original PR, I'd take a minimal approach:

    • Pipe stderr from the subprocess (instead of inheriting or suppressing it)
    • Spawn an async task to read stderr lines and forward them to errlog (the existing stderr stream parameter)
    • This preserves stderr output in Jupyter/notebook environments where sys.stderr may not behave like a real file descriptor

    This keeps the change small and focused on the core problem: stderr is lost in environments like Jupyter where subprocess.PIPE + explicit forwarding is needed.

    Would love to hear if this direction works for you before I start the new PR. Thanks!

  12. RahilOp commented on Aug 7, 2026

    @RahilOp

    I'd like to work on this. The issue is that passing to doesn't surface server stderr in Jupyter because Jupyter replaces sys.stderr with an ipykernel stream that isn't inherited by the child process. I'll change the client to leave stderr attached to the process and forward into the parent (or Jupyter's display hook) with an async reader. I'll include tests for both normal and Jupyter-like environments.

  13. RahilOp commented on Aug 7, 2026

    @RahilOp

    Stepping back from this one to take on a higher-impact client reliability issue instead. Thanks!

  14. maisymylod commented on Aug 18, 2026

    @maisymylod

    I hit this outside Jupyter too, which may be worth adding to the repro: io.StringIO as errlog fails the same way. Anything without a real file descriptor does, because stdio_client passes errlog straight to anyio.open_process(stderr=...) and the child needs a descriptor to inherit. Under ipykernel, fileno() raises io.UnsupportedOperation. It bit me while capturing server logs in a test, and the failure mode is bad in the way @grll describes: when a server crashes on startup you get a hang and no output.

    The approach I'd take is the minimal one @dgenio outlined above: check whether errlog has a usable descriptor, inherit it when it does so the existing path is untouched, and otherwise spawn with subprocess.PIPE and forward into errlog from a reader task.

    Two things I'd want a maintainer's read on:

    1. Shutdown ordering. Once the server dies its stderr pipe is at EOF with at most a buffer left, and those last lines are exactly the ones you want when a server dies. Is a bounded wait (0.5s) on the reader before the task group cancel acceptable, or would you rather drop them and keep shutdown unconditional?
    2. ipykernel with capture_fd_output enabled does expose a descriptor, and that path already routes back to the notebook, so I'd keep it on the inherit path rather than detecting Jupyter explicitly. Does that match your intent?

    I have this written with tests, including one that reproduces this with a real subprocess and fails on main. Given the ready for work label I'm happy to leave it if this is queued for a maintainer; if you'd rather take it from outside, I'd be glad to pick it up.

    Disclosure: AI tooling was used to write and push the changes. The diagnosis, the approach, and the findings are my own, and I can speak to any of it.

  15. af520428 commented on Aug 19, 2026

    @af520428

    Hi — I can also reproduce the io.StringIO case from the latest comment: any errlog without a real file descriptor fails the same way, because stdio_client passes it straight to open_process(stderr=...).
    My plan: when errlog has no inheritable file descriptor, spawn the server with stderr as a pipe and forward decoded lines to errlog from an async reader task, keeping the current direct-inheritance behavior for real file descriptors. I've implemented this locally with tests for io.StringIO and for the Windows SelectorEventLoop fallback path. Happy to open a PR once this is assigned.

  16. mikemikimike commented on Aug 27, 2026

    @mikemikimike

    Implemented in PR #3403.

    • Changed: stdio_client now pipes and forwards stderr when errlog has no usable file descriptor, while preserving direct inheritance for normal file-backed streams.
    • Tests: focused stdio transport tests pass; Ruff and diff checks pass.
    • Validation: the full stdio test module has one unrelated Windows process-tree teardown timeout.
  17. SushantTusharJoshi commented on Aug 27, 2026

    @SushantTusharJoshi

    i hit this running mcp servers from notebooks during development. stderr just vanishes completely which makes debugging painful. you get no startup errors, no connection failures, nothing. eventually figured out it's because jupyter's OutStream doesn't have a real file descriptor so the child process can't inherit it.

    the fix i landed on (branch fix/jupyter-stderr-logging) checks if errlog.fileno() raises, and if so spawns the server with stderr=PIPE and runs an async reader task that forwards chunks back to errlog. the important bit was making sure the normal path (real fd, real terminal) stays completely untouched. no pipe, no extra task, just the existing direct inheritance.

    one edge case worth noting: the errlog can close before the server exits (like when a notebook cell finishes), so the reader needs to handle that gracefully instead of crashing the transport. i have a test for that specific case.

    code is on #3405 if it gets reopened, otherwise hopefully the approach helps.

  18. leogrox commented on Oct 3, 2026

    @leogrox

    I looked into why this still happens on current main, and it turns out there are two different failures.

    On Linux and macOS, ipykernel's sys.stderr.fileno() returns a copy of the kernel's original fd 2, so the server inherits the terminal Jupyter was started from and nothing shows up in the cell. I checked this with a real kernel driven through jupyter_client: the server's stderr landed in that terminal and never reached the iopub stream. On Windows, fileno() raises instead, so the spawn itself fails. The same failure hits io.StringIO, pytest's capsys, and logging shims like the one in #854.

    Because of the first case, checking whether fileno() works (the approach in #3268) wouldn't fix the default Jupyter setup on Linux and macOS. The descriptor is valid there, it just points past the notebook.

    What I'd suggest:

    • Keep handing the descriptor to the server when errlog is a real file (a TextIOWrapper with a working fileno()), so nothing changes for people logging to the terminal or to a file.
    • For any other stream, spawn with stderr=PIPE and copy the output into errlog.write() from a task.
    • On shutdown, wait a short, bounded time for that pipe to reach EOF before closing it. Closing it first drops whatever the server writes while exiting, which is what cubic flagged on Forward server stderr when errlog has no file descriptor #3268.

    I have this on a branch with tests: leogrox/python-sdk@main...fix/stdio-stderr-relay-156

    The full suite passes with 100% coverage, the stdio tests pass on 3.10 through 3.14, and I also confirmed the fix end to end in a real Jupyter kernel. If you're open to an outside PR for this, I'm happy to open it. If you'd rather write the fix yourselves, I hope the notes above save some time.

    I used Claude Code to help investigate and write the change, and I've reviewed it myself.

  19. SmailsZX commented on Oct 6, 2026

    @SmailsZX

    I see there's a proposed fix from @leogrox. If that's not going to be opened as a PR, I'd like to work on this. Is it still available?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3Nice to haves, rare edge casesbugSomething isn't workinggood first issueGood for newcomersready for workEnough information for someone to start working on

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions