Skip to content

stdio client: requests hang forever after the server closes stdout but keeps running #3804

Open

Description

@gowthamrao

Summary

If a stdio server closes its stdout after the handshake but does not exit, every later request from ClientSession waits forever. End-of-file on stdout should end the session and fail pending requests (for example with CONNECTION_CLOSED), as an exited process does. Instead list_tools() (or any call) never returns, unless the caller sets its own timeout.

Reproducer (self-contained: a 15-line raw stdio server written to a temp file)

import asyncio
import os
import sys
import tempfile
import textwrap
import time

from mcp import ClientSession
from mcp.client.stdio import StdioServerParameters, stdio_client

SERVER = textwrap.dedent(
    '''
    import json, os, sys, time
    for raw in sys.stdin:
        msg = json.loads(raw)
        if msg.get("method") == "initialize":
            reply = {"jsonrpc": "2.0", "id": msg["id"], "result": {
                "protocolVersion": msg["params"]["protocolVersion"],
                "capabilities": {"tools": {}},
                "serverInfo": {"name": "closes-stdout", "version": "0"}}}
            sys.stdout.write(json.dumps(reply) + "\n")
            sys.stdout.flush()
            sys.stdout.close()
            os.close(1)
            time.sleep(600)
    '''
)

async def main() -> None:
    path = os.path.join(tempfile.mkdtemp(), "closes_stdout.py")
    with open(path, "w", encoding="utf-8") as handle:
        handle.write(SERVER)
    params = StdioServerParameters(command=sys.executable, args=[path])
    async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
        await session.initialize()
        started = time.monotonic()
        try:
            await asyncio.wait_for(session.list_tools(), timeout=20)
            print("list_tools returned")
        except TimeoutError:
            print(f"list_tools still pending after {time.monotonic() - started:.0f} s (stdout closed, process alive)")

asyncio.run(main())

Output:

list_tools still pending after 20 s (stdout closed, process alive)

Expected vs actual

Expected Actual
After stdout EOF the session ends and pending and later requests fail promptly with a connection-closed error requests wait until the caller's own timeout; with none, forever

A wedged server (stdout closed by a crash in a logging library, a misbehaving wrapper, an fd mix-up) hangs the host application.

Environment

  • mcp 2.0.0, anyio 4.14.2
  • CPython 3.14.10
  • Windows 11. Linux not tried. The stdout_reader → _drain_stdout path in mcp/client/stdio.py is where EOF should end the session.

Workaround

Pass read_timeout_seconds to ClientSession and a per-request read_timeout_seconds on call_tool, so the hang becomes a REQUEST_TIMEOUT.

Activity

  1. yashaskn8 commented on Nov 3, 2026

    @yashaskn8

    Thanks for the detailed report and the self-contained reproducer.

    I looked through the stdio client implementation in v2.0.0 and noticed two things that might help narrow this down.

    First, there's a small escaping issue in the posted reproducer. Since SERVER uses a regular triple-quoted string, the "\n" inside it becomes an actual newline in the generated Python file, causing a SyntaxError. Using a raw string (r'''...''') or escaping it as "\\n" should fix that.

    Second, JSONRPCDispatcher.run() already handles read-stream EOF by closing the dispatcher and resolving pending requests with CONNECTION_CLOSED.

    The part I'd investigate next is the transport boundary in stdio_client.stdout_reader(). The read-stream sender is closed only after _drain_stdout() finishes. If that drain blocks, the session may never receive the EOF signal.

    However, it's also possible that the Windows subprocess pipe never reports EOF to the reader in the first place. Those would require different fixes.

    Could you confirm whether the corrected reproducer still hangs on Windows, whether the child process remains alive, and whether stdout_reader() reaches _drain_stdout()?

    A useful regression test would complete initialization, start a pending list_tools() request, close the server's stdout while leaving the process alive, and verify that both pending and subsequent requests fail with CONNECTION_CLOSED.

    I'd prefer to confirm which part of the transport is blocking before proposing a patch, so we don't accidentally fix only the symptom or regress normal subprocess shutdown. The analysis and proposed tests should be validated against the Windows reproducer before implementation.

    If this analysis aligns with the maintainers' expectations, I'd be happy to take ownership of this issue and work on a fix with appropriate regression tests. Would you be open to assigning it to me?

  2. media-ctrl commented on Nov 4, 2026

    @media-ctrl

    On current main this no longer hangs, which is worth knowing before anyone spends a cycle fixing 2.0.0 specifically.

    I ran the repro unchanged apart from the client construction (ClientSession(streams[0], streams[1]), to match the three-value context manager), on main at af4c6f4:

    initialize ok
    list_tools raised MCPError after 0.0s: MCPError(-32000, 'Connection closed', None)
    

    So the behaviour you ask for, session ends and requests fail promptly with a connection-closed error, is what main does today. On the released 1.30.0 wheel the same repro also does not hang, but it fails earlier and differently: initialize raises ClosedResourceError from the session's post-handshake send. That puts 2.0.0 between those two states as the release that carries the hang.

    The mechanism, which is also where a fix on 2.0.0 would have to go. The dispatcher already owns the right outcome: _CLOSED_OUTCOME = ErrorData(code=CONNECTION_CLOSED, message="Connection closed") at src/mcp/shared/jsonrpc_dispatcher.py:80, raised for post-close sends at 340. So when the read stream closes, waiting callers become CONNECTION_CLOSED rather than waiting on a future nobody resolves. What matters is which code path closes it on this transport. stdin_writer does it explicitly in the mirror case, closing the read stream with the comment "so the server sees the connection end instead of a request hanging forever" (stdio.py:181). The read side has no equivalent: stdout_reader calls _drain_stdout from its finally at 160, inside async with read_stream_writer: at 147, and _drain_stdout is a while True: await process.stdout.receive() under a shield that suppresses EndOfStream, ClosedResourceError, BrokenResourceError, ConnectionError and OSError. None of those ends that loop.

    Which gives a cheap discriminator if it still reproduces for you on main. Watch the client process while list_tools is pending. If a core is pinned, the drain loop is spinning on a repeated suppressed exception and never returning, so the read stream never closes and no waiter is settled. If CPU is idle, the pipe never reached EOF at the client and the problem is upstream of the session. Those are different owners, and one observation separates them.

    Since this is Windows 11 and the POSIX path is all I could measure, the Windows FallbackProcess stdout is the most likely place for the frame to differ, because closing fd 1 in the child does not necessarily surface as the same EOF that the asyncio subprocess transport reports on Linux. If you can rerun on main and say which of the two states you see, that should close this quickly.

    One question on intent, since it decides the fix: should a closed stdout end the session even when the process is alive and still reading stdin? stdin_writer already assumes yes in the mirror case, so making the two sides symmetric is easier to defend than arguing which of them is right.

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 working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions