Skip to content

Commit 1ed577f

Browse files
committed
fix(streamable-http): avoid startup race after initialize (#1675)
1 parent e8e6484 commit 1ed577f

2 files changed

Lines changed: 44 additions & 2 deletions

File tree

src/mcp/client/streamable_http.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import anyio
1212
import httpx
13-
from anyio.abc import TaskGroup
13+
from anyio.abc import TaskGroup, TaskStatus
1414
from httpx_sse import EventSource, ServerSentEvent, aconnect_sse
1515
from pydantic import ValidationError
1616

@@ -437,10 +437,13 @@ async def post_writer(
437437
write_stream: ContextSendStream[SessionMessage],
438438
start_get_stream: Callable[[], None],
439439
tg: TaskGroup,
440+
*,
441+
task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
440442
) -> None:
441443
"""Handle writing requests to the server."""
442444
try:
443445
async with write_stream_reader, read_stream_writer, write_stream:
446+
task_status.started()
444447

445448
async def _handle_message(session_message: SessionMessage) -> None:
446449
message = session_message.message
@@ -570,7 +573,7 @@ async def streamable_http_client(
570573
def start_get_stream() -> None:
571574
tg.start_soon(transport.handle_get_stream, client, read_stream_writer)
572575

573-
tg.start_soon(
576+
await tg.start(
574577
transport.post_writer,
575578
client,
576579
write_stream_reader,

tests/shared/test_streamable_http.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1071,6 +1071,45 @@ async def test_streamable_http_client_basic_connection(basic_server: None, basic
10711071
assert result.server_info.name == SERVER_NAME
10721072

10731073

1074+
@pytest.mark.anyio
1075+
async def test_streamable_http_client_no_race_on_consecutive_requests(basic_server: None, basic_server_url: str):
1076+
"""Regression test for a start-up race immediately after initialize().
1077+
1078+
In some cases, the first request after initialize() (e.g. list_tools())
1079+
could behave inconsistently. This test runs multiple short-lived sessions
1080+
to reliably catch any start-up race.
1081+
"""
1082+
for iteration in range(10): # pragma: no branch
1083+
async with streamable_http_client(f"{basic_server_url}/mcp") as (read_stream, write_stream):
1084+
async with ClientSession(read_stream, write_stream) as session:
1085+
await session.initialize()
1086+
1087+
tools = await session.list_tools()
1088+
assert len(tools.tools) == 10, f"Iteration {iteration}: expected 10 tools, got {len(tools.tools)}"
1089+
assert tools.tools[0].name == "test_tool"
1090+
1091+
tools2 = await session.list_tools()
1092+
assert len(tools2.tools) == 10
1093+
1094+
resource = await session.read_resource(uri="foobar://test-iteration")
1095+
assert len(resource.contents) == 1
1096+
1097+
1098+
@pytest.mark.anyio
1099+
async def test_streamable_http_client_rapid_request_sequence(basic_server: None, basic_server_url: str):
1100+
"""Stress test for rapid sequences of requests."""
1101+
async with streamable_http_client(f"{basic_server_url}/mcp") as (read_stream, write_stream):
1102+
async with ClientSession(read_stream, write_stream) as session:
1103+
await session.initialize()
1104+
1105+
for i in range(20):
1106+
tools = await session.list_tools()
1107+
assert len(tools.tools) == 10, f"Request {i}: expected 10 tools, got {len(tools.tools)}"
1108+
1109+
resource = await session.read_resource(uri="foobar://final-test")
1110+
assert len(resource.contents) == 1
1111+
1112+
10741113
@pytest.mark.anyio
10751114
async def test_streamable_http_client_resource_read(initialized_client_session: ClientSession):
10761115
"""Test client resource read functionality."""

0 commit comments

Comments
 (0)