"""Concurrency over a single client session: multiple requests in flight at once, in both directions.""" import anyio import pytest from inline_snapshot import snapshot from mcp_types import ( CallToolResult, CreateMessageRequestParams, CreateMessageResult, SamplingMessage, TextContent, ) from mcp import Client from mcp.client import ClientRequestContext from mcp.server.mcpserver import Context, MCPServer pytestmark = pytest.mark.anyio async def test_concurrent_tool_calls_resolve_out_of_order_to_their_own_callers() -> None: """Three tool calls in flight at once on one session each receive their own result, even though the responses come back in the reverse of the order the requests were sent. SDK-defined contract: pins the client request machinery's support for concurrent in-flight calls with out-of-order response correlation. Each handler parks on its own release event after signalling it started; a session that serialized requests would never start the later handlers and the test would time out instead. """ send_order = ["a", "b", "c"] started = {tag: anyio.Event() for tag in send_order} release = {tag: anyio.Event() for tag in send_order} done = {tag: anyio.Event() for tag in send_order} completion_order: list[str] = [] results: dict[str, CallToolResult] = {} server = MCPServer("parking") @server.tool() async def park(tag: str) -> str: started[tag].set() await release[tag].wait() return f"result:{tag}" async with Client(server) as client: async def call_and_record(tag: str) -> None: results[tag] = await client.call_tool("park", {"tag": tag}) completion_order.append(tag) done[tag].set() with anyio.fail_after(5): async with anyio.create_task_group() as task_group: # pragma: no branch # Waiting for each handler to start before issuing the next call fixes the send # order, and leaves all three parked in flight together once the loop finishes. for tag in send_order: task_group.start_soon(call_and_record, tag) await started[tag].wait() # Nothing completed yet: all three calls are genuinely concurrent. assert completion_order == [] # Release in reverse, awaiting each completion so the finish order is forced. for tag in reversed(send_order): release[tag].set() await done[tag].wait() assert completion_order == ["c", "b", "a"] assert results == snapshot( { "c": CallToolResult(content=[TextContent(text="result:c")], structured_content={"result": "result:c"}), "b": CallToolResult(content=[TextContent(text="result:b")], structured_content={"result": "result:b"}), "a": CallToolResult(content=[TextContent(text="result:a")], structured_content={"result": "result:a"}), } ) async def test_overlapping_sampling_requests_are_serviced_concurrently_by_the_client() -> None: """A server tool that fans out two sampling requests at once gets both echoes back: the client runs overlapping inbound `create_message` requests concurrently instead of serializing them in its receive loop. Regression pin for https://github.com/modelcontextprotocol/python-sdk/issues/2489 -- v1's `BaseSession` awaited each inbound request handler inline, so the second sampling callback could not start until the first returned; here both rendezvous before either is released. """ sampling_started = {"x": anyio.Event(), "y": anyio.Event()} sampling_release = anyio.Event() tool_results: list[CallToolResult] = [] server = MCPServer("fan_out_server") @server.tool() async def fan_out(ctx: Context) -> str: echoes: dict[str, str] = {} async def sample(tag: str) -> None: result = await ctx.session.create_message( # pyright: ignore[reportDeprecated] messages=[SamplingMessage(role="user", content=TextContent(text=tag))], max_tokens=10, ) assert isinstance(result.content, TextContent) echoes[tag] = result.content.text async with anyio.create_task_group() as sampler_group: sampler_group.start_soon(sample, "x") sampler_group.start_soon(sample, "y") return f"{echoes['x']} {echoes['y']}" async def sampling_callback( context: ClientRequestContext, params: CreateMessageRequestParams ) -> CreateMessageResult: content = params.messages[0].content assert isinstance(content, TextContent) sampling_started[content.text].set() await sampling_release.wait() return CreateMessageResult( role="assistant", content=TextContent(text=f"echo:{content.text}"), model="test-model", stop_reason="endTurn", ) async with Client(server, sampling_callback=sampling_callback, mode="legacy") as client: with anyio.fail_after(5): async with anyio.create_task_group() as task_group: # pragma: no branch async def invoke_fan_out() -> None: tool_results.append(await client.call_tool("fan_out", {})) task_group.start_soon(invoke_fan_out) # Both sampling callbacks are mid-flight before either may answer -- a client that # serialized inbound requests would never start the second one. await sampling_started["x"].wait() await sampling_started["y"].wait() sampling_release.set() assert tool_results == snapshot( [CallToolResult(content=[TextContent(text="echo:x echo:y")], structured_content={"result": "echo:x echo:y"})] )