You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
stdio client: requests hang forever after the server closes stdout but keeps running #3804
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)
importasyncioimportosimportsysimporttempfileimporttextwrapimporttimefrommcpimportClientSessionfrommcp.client.stdioimportStdioServerParameters, stdio_clientSERVER=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) '''
)
asyncdefmain() ->None:
path=os.path.join(tempfile.mkdtemp(), "closes_stdout.py")
withopen(path, "w", encoding="utf-8") ashandle:
handle.write(SERVER)
params=StdioServerParameters(command=sys.executable, args=[path])
asyncwithstdio_client(params) as (read, write), ClientSession(read, write) assession:
awaitsession.initialize()
started=time.monotonic()
try:
awaitasyncio.wait_for(session.list_tools(), timeout=20)
print("list_tools returned")
exceptTimeoutError:
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.
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?
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.
Activity
yashaskn8 commentedon Nov 3, 2026
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
SERVERuses 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 withCONNECTION_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 withCONNECTION_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?
media-ctrl commentedon Nov 4, 2026
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 ataf4c6f4: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:
initializeraisesClosedResourceErrorfrom 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")atsrc/mcp/shared/jsonrpc_dispatcher.py:80, raised for post-close sends at 340. So when the read stream closes, waiting callers becomeCONNECTION_CLOSEDrather than waiting on a future nobody resolves. What matters is which code path closes it on this transport.stdin_writerdoes 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_readercalls_drain_stdoutfrom itsfinallyat 160, insideasync with read_stream_writer:at 147, and_drain_stdoutis awhile True: await process.stdout.receive()under a shield that suppressesEndOfStream,ClosedResourceError,BrokenResourceError,ConnectionErrorandOSError. 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_toolsis 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
FallbackProcessstdout 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_writeralready assumes yes in the mirror case, so making the two sides symmetric is easier to defend than arguing which of them is right.