chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
"""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"})]
|
||||
)
|
||||
Reference in New Issue
Block a user