49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
142 lines
5.8 KiB
Python
142 lines
5.8 KiB
Python
"""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"})]
|
|
)
|