Repository navigation
Open
Labels
bugSomething isn't workingSomething isn't working
Description
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
mcp2.0.0,anyio4.14.2- CPython 3.14.10
- Windows 11. Linux not tried. The
stdout_reader→_drain_stdoutpath inmcp/client/stdio.pyis 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.
Metadata
Metadata
Assignees
Labels
bugSomething isn't workingSomething isn't working
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.sunhouy commentedon Nov 5, 2026
I spent a while on this one and have a fix up in #3819, but it is closed by the intake gate since I'm not assigned to the issue. That part is fine, I'm not asking for the assignment here. What might be useful either way is what I found, because it changes the discriminators in the second comment.
The part I could measure is the reader task.
stdout_readercloses the read stream from insideasync with read_stream_writer:, but thefinallyruns_drain_stdoutfirst, and that drain iswith anyio.CancelScope(shield=True): while True: await process.stdout.receive(). So when EOF arrives on a server that is still alive, the drain parks on pipes that stay open until the process exits. The read stream is not closed during that window, and nothing else on that path closes it.stdin_writerhas the mirror case and closes the stream explicitly with a comment saying it does so "so the server sees the connection end instead of a request hanging forever"; the read side never got the equivalent.What I could not do is reproduce the original hang here, which is why I did not touch 2.0.0. On Linux the request fails promptly on
maintoo, and on2.0.0from PyPI it also fails rather than hangs in the versions I tried, so the OS difference the issue was filed from is doing work I can't see. Two things would settle it, and both are cheap for whoever still has the 2.0.0 environment:py-spy dumpof the client whilelist_toolsis pending. Ifstdout_readeris sitting inreceive()under the shield, it is the drain, not a spin, and the CPU discriminator in the second comment frames the choice the wrong way round: a parked drain looks exactly like an idle loop from outside.EndOfStreamand no fix on the read side helps until that is understood.The second comment's question about intent, should a closed stdout end a session whose process is alive, is the one the fix answers with yes, on the grounds that
stdin_writeralready assumes it in the other direction and that a session which can no longer receive anything has nothing left to offer the caller. If that reading is wrong, the change in #3819 is the wrong shape and the drain should get a timeout instead of the process getting terminated early.On testing: with a real server that closes stdout after the handshake and sleeps,
list_tools()hangs until the caller's timeout before the change and raisesMCPError(-32000, 'Connection closed')immediately after. Intests/client/test_stdio.pythe case is pinned with the existingFakeProcess:pytest tests/client tests/sharedgoes from 1793 to 1794 passed with the same 5 environment failures (blockbusteronos.stat) before and after.media-ctrl commentedon Nov 5, 2026
Correction to my previous comment, and I agree with your reading of the drain.
I said 2.0.0 sits between 1.30.0 and main as the release that carries the hang. That was wrong and I am retracting it. I installed the released wheel and ran the same reproducer on Linux:
So on POSIX the hang does not reproduce on 2.0.0 either. With main at af4c6f4 failing the same way, and 1.30.0 failing earlier with ClosedResourceError out of the post-handshake send, every version I can measure fails rather than hangs. That removes the release-to-release framing and puts the whole thing on the platform, which is what you already suspected.
Your correction to the discriminator is right and mine was badly put.
_drain_stdoutparks inawait process.stdout.receive()under a shield that suppresses EndOfStream and the resource errors. A parked drain looks identical to "the pipe never reached EOF" from outside, because both are idle. So "core pinned means spinning" was not a valid split, and the observation I proposed cannot separate those two owners at all.py-spy dumpis the better instrument precisely because it can show the frame,receive()under the shield versus an awaited future, instead of inferring it from CPU. I do not have a Windows host, so I cannot run that comparison here.On intent, I would also answer yes: a closed stdout ends the session even with the process alive. Not because either side is self evidently right, but because
stdin_writeralready committed to that assumption in the mirror direction, and a session that can no longer receive cannot answer a caller. If it turns out that closing fd 1 in the child never surfaces as EOF on Windows, then no read side change is sufficient on its own and the drain needs a bound as well, which is the one part of your patch I would keep regardless of how the EOF question resolves.