Skip to content

Forward server stderr when errlog has no file descriptor - #3268

Open
maisymylod wants to merge 1 commit into
modelcontextprotocol:mainfrom
maisymylod:fix/stderr-forwarding-without-fileno
Open

Forward server stderr when errlog has no file descriptor#3268
maisymylod wants to merge 1 commit into
modelcontextprotocol:mainfrom
maisymylod:fix/stderr-forwarding-without-fileno

Conversation

@maisymylod

Copy link
Copy Markdown

Closes #156.

Problem

stdio_client passes errlog straight to anyio.open_process(stderr=...), which needs a real OS file descriptor for the child to inherit. Writers that have none never work:

The spawn either raises io.UnsupportedOperation or sends diagnostics somewhere the caller never sees.

Fix

Check whether errlog has a usable descriptor. If it does, nothing changes: it is inherited exactly as before. If it does not, spawn with subprocess.PIPE and forward the pipe into errlog from a reader task.

Two details worth review:

  • Shutdown drains the forwarder. Once the server is dead its stderr pipe is at EOF with at most a buffer left, so shutdown() waits (bounded, 0.5s) on the reader before the task group cancel takes it out. Without this the last diagnostics, which are exactly the ones you want when a server dies, race the cancel.
  • ipykernel with fd capture stays on the inherit path. It does expose a descriptor once capture_fd_output is on, and that path already routes back to the notebook, so inheriting it remains correct.

A closed errlog (a finished notebook cell) is logged at debug and stops forwarding rather than failing the session.

FallbackProcess gained a stderr attribute so the Windows SelectorEventLoop path works too.

Tests

  • test_server_stderr_output_reaches_an_errlog_without_a_file_descriptor reproduces support logging to stderr in Jupyter Notebook Environments. #156 with a real subprocess. It fails on main with the reported io.UnsupportedOperation.
  • Two in-process tests cover the closed-sink and dead-pipe paths.
  • The existing test_server_stderr_output_reaches_the_errlog_file still passes unchanged, pinning the inherit path.

Full suite passes and src/mcp/client/stdio.py stays at 100% coverage. (tests/server/test_streamable_http_modern.py::test_moderation... fails on a clean checkout too and is unrelated.)

stdio_client hands errlog to the child as an inherited file descriptor.
Writers that have none (Jupyter's ipykernel stream, io.StringIO) cannot be
inherited, so spawning either raised io.UnsupportedOperation or silently sent
the server's diagnostics somewhere the caller never sees.

Detect that case and spawn with a stderr pipe instead, forwarding it into
errlog from a reader task. Shutdown drains the forwarder after the server dies
so the last diagnostics still land. Writers that do own a descriptor keep the
existing inherit path untouched, including ipykernel once fd capture is on.

Fixes modelcontextprotocol#156

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 4 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/mcp/client/stdio.py">

<violation number="1" location="src/mcp/client/stdio.py:233">
P2: Final server stderr bytes can still be dropped during shutdown on asyncio. The new drain wait happens after `_stop_server_process()` closes the subprocess transport, so delaying transport close until after `stderr_done.wait()` in forward-stderr mode would make this drain reliable.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/mcp/client/stdio.py
# The server is dead, so its stderr pipe is at EOF with at most a buffer left;
# let the forwarder finish it before the task group's cancel takes the task out.
if forward_stderr:
with anyio.move_on_after(_STDERR_DRAIN_TIMEOUT):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Final server stderr bytes can still be dropped during shutdown on asyncio. The new drain wait happens after _stop_server_process() closes the subprocess transport, so delaying transport close until after stderr_done.wait() in forward-stderr mode would make this drain reliable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/client/stdio.py, line 233:

<comment>Final server stderr bytes can still be dropped during shutdown on asyncio. The new drain wait happens after `_stop_server_process()` closes the subprocess transport, so delaying transport close until after `stderr_done.wait()` in forward-stderr mode would make this drain reliable.</comment>

<file context>
@@ -193,13 +227,20 @@ async def shutdown() -> None:
+        # The server is dead, so its stderr pipe is at EOF with at most a buffer left;
+        # let the forwarder finish it before the task group's cancel takes the task out.
+        if forward_stderr:
+            with anyio.move_on_after(_STDERR_DRAIN_TIMEOUT):
+                await stderr_done.wait()
         await _aclose_all(read_stream, write_stream, read_stream_writer, write_stream_reader)
</file context>

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

support logging to stderr in Jupyter Notebook Environments.

1 participant