Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/mcp/client/stdio.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ async def stdout_reader():

session_message = SessionMessage(message)
await read_stream_writer.send(session_message)
except anyio.ClosedResourceError: # pragma: lax no cover
except (anyio.ClosedResourceError, anyio.BrokenResourceError): # pragma: lax no cover

Choose a reason for hiding this comment

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

Noting for future readers: the SSE client (sse.py) avoids this class of race by calling tg.cancel_scope.cancel() in its finally block, which cancels tasks before closing their streams. The stdio client takes a different approach — letting stream closure propagate to tasks and catching the resulting exceptions. Both are valid; this PR correctly handles the full set of exceptions that anyio can raise in this scenario.

await anyio.lowlevel.checkpoint()

async def stdin_writer():
Expand All @@ -174,7 +174,7 @@ async def stdin_writer():
errors=server.encoding_error_handler,
)
)
except anyio.ClosedResourceError: # pragma: no cover
except (anyio.ClosedResourceError, anyio.BrokenResourceError): # pragma: no cover
await anyio.lowlevel.checkpoint()

async with anyio.create_task_group() as tg, process:
Expand Down
38 changes: 38 additions & 0 deletions tests/client/test_stdio.py
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,44 @@ def handle_term(sig, frame):
pass


@pytest.mark.anyio
@pytest.mark.filterwarnings("ignore::ResourceWarning")
async def test_stdio_client_no_broken_resource_error_on_shutdown():
"""Test that exiting stdio_client without consuming the read stream does not
raise BrokenResourceError.

Regression test for https://github.com/modelcontextprotocol/python-sdk/issues/1960.
The race condition occurs when stdout_reader is blocked on send() into a
zero-buffer memory stream and the finally block closes the receiving end,
causing BrokenResourceError instead of ClosedResourceError.
"""
# Server sends a JSON-RPC message and then sleeps, keeping stdout open.
# The client exits without consuming the read stream, triggering the race.
server_script = textwrap.dedent(
"""
import sys
import time

sys.stdout.write('{"jsonrpc":"2.0","id":1,"result":{}}\\n')
sys.stdout.flush()
time.sleep(5.0)
"""
)

server_params = StdioServerParameters(
command=sys.executable,
args=["-c", server_script],
)

# This should exit cleanly without raising an ExceptionGroup
# containing BrokenResourceError.
with anyio.fail_after(10.0):
async with stdio_client(server_params) as (_read_stream, _write_stream):
# Give stdout_reader time to read the message and block on send()
await anyio.sleep(0.3)
# Exit without consuming read_stream - this triggers the race


@pytest.mark.anyio
async def test_stdio_client_graceful_stdin_exit():
"""Test that a process exits gracefully when stdin is closed,
Expand Down