Skip to content

stdio_server: text a tool prints still reaches the client, because sys.stdout is flushed after fd 1 is restored #3821

Open

Description

@fabriziosalmi

Summary

Since #3117, stdio_server() points fd 1 at stderr while it serves, so that stray output from handlers misses the wire. Text written with print() still reaches the client. It goes into sys.stdout's own buffer, which is block-buffered when stdout is a pipe. That buffer is flushed when the interpreter exits, after stdio_server() has given fd 1 back to the client's pipe. The client then reads the text as a JSON-RPC message.

Reproduction

mcp 2.3.0, Python 3.13.16, macOS 26.3.

import asyncio
import sys

from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from mcp.server.mcpserver import MCPServer

if sys.argv[1:] == ["server"]:
    server = MCPServer("repro")

    @server.tool()
    def noisy() -> str:
        print("stray output from a tool")  # e.g. a library that prints
        return "done"

    server.run("stdio")
else:
    async def main():
        params = StdioServerParameters(command=sys.executable, args=[__file__, "server"])
        async with stdio_client(params) as (read, write):
            async with ClientSession(read, write) as session:
                await session.initialize()
                result = await session.call_tool("noisy", {})
                print("result:", result.content[0].text)

    asyncio.run(main())

Running python repro.py, the call succeeds, and the client also logs:

pydantic_core._pydantic_core.ValidationError: 1 validation error for union[JSONRPCRequest,JSONRPCNotification,JSONRPCResponse,JSONRPCError]
  Invalid JSON: expected value at line 1 column 1 [type=json_invalid, input_value='stray output from a tool', input_type=str]

Cause

While fd 1 is diverted, the printed text never reaches it: it sits in sys.stdout's buffer. The buffer is written out at interpreter exit, when fd 1 is the protocol pipe again. Adding sys.stdout.flush() right after the print() in the tool removes the error, and the text appears on stderr, which confirms this.

Suggestion

Flush sys.stdout before restoring fd 1, so that pending text goes to the diversion. Making sys.stdout line-buffered while serving (sys.stdout.reconfigure(line_buffering=True)) would also send printed text to stderr when it is printed, rather than at exit.

As a workaround we call sys.stdout.reconfigure(line_buffering=True) before server.run("stdio") and flush after each tool.

Activity

  1. added
    bugSomething isn't working
    v2Affects the v2 line (2.x on main)
    on Nov 5, 2026
  2. ima-d-ice commented on Nov 5, 2026

    @ima-d-ice

    I looked through the implementation in src/mcp/server/stdio.py and I think I understand the root cause.

    The _claim_fd() release callback restores fd 1 without first flushing the original sys.stdout wrapper. Buffered output can therefore be written to the protocol pipe after the descriptor has been restored.

    I think a targeted fix would be to flush the captured stdout stream before restoring fd 1, while ensuring descriptor restoration still happens even if flushing fails.

    I'd also like to add a regression test in tests/server/test_stdio.py that uses print() without flush=True and verifies that the buffered output reaches stderr rather than the JSON-RPC stream.

    Does this approach align with the intended behavior? I'd be interested in contributing the implementation and tests.

    AI assistance disclosure: I used ChatGPT to help with initial code-path analysis and drafting. I'll verify the behavior and take responsibility for the implementation.

  3. media-ctrl commented on Nov 5, 2026

    @media-ctrl

    Confirmed at the fd level on current main, and the distinction matters for where the fix goes.

    _claim_fd() in src/mcp/server/stdio.py does os.dup2(diversion_fd, 1) to point fd 1 at a dup of fd 2, and returns release(). That closure calls _restore_fd(fd, claim.private_fd), which is os.dup2(private_fd, 1). Nothing in the release path touches sys.stdout, so every byte sitting in the interpreter's own wrapper is still pending at the moment fd 1 becomes the wire again. stdio_server()'s finally only calls restore_stdout(). The await stdout.flush() at line 205 cannot help: that flushes the private _UnownedTextWrapper the transport serves, not sys.stdout.

    I reproduced the mechanism with a control on a fresh fixture instead of a real server, so the three cases are separable:

    wire = os.dup(1)                      # private dup of the real stdout == the wire
    os.dup2(diversion_fd, 1)              # _claim_fd()
    print("stray output from a tool")     # a library prints inside a handler
    sys.stdout.flush()                    # mode B only
    os.dup2(wire, 1)                      # release(): _restore_fd()
    # interpreter exits, sys.stdout flushes at shutdown

    Measured, Python 3.13, stdout a pipe:

    mode bytes on the wire bytes on the diversion
    A, current main 'stray output from a tool\n' ''
    B, sys.stdout.flush() before restore '' 'stray output from a tool\n'
    C, reconfigure(line_buffering=True) at claim '' 'stray output from a tool\n'

    One more measurement that explains why this reads as intermittent. The wrapper is block-buffered at 8192 bytes, so output at or above that size flushes into the diversion mid-serve and never reaches the wire. Writing "S" * n:

    print of   4096 bytes -> wire   4096  diversion      0
    print of   8000 bytes -> wire   8000  diversion      0
    print of   8192 bytes -> wire      0  diversion   8192
    print of  40000 bytes -> wire      0  diversion  40000
    

    So a one-line warning from a library always corrupts the stream, and a large debug blob usually does not. The small prints are the ones people believe are harmless, and they are the guaranteed failures.

    For the fix, _claim_fd() already receives stream: TextIO, which is sys.stdout, so the object is in scope inside the release() closure. Flushing stream there, before _restore_fd(), covers both the diverted and the no-diversion path (claim.private_fd is None) with one code path. It also cannot interleave with a protocol frame, because the writer serializes to the private descriptor rather than to sys.stdout.

    Your line-buffering suggestion is the better of the two for latency, but it changes buffering for the rest of the process unless the previous state is saved and put back on release. Flush-on-release is the smaller change if the maintainers want one line.

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

    bugSomething isn't workingv2Affects the v2 line (2.x on main)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions