[v2] ClientSession runs on JSONRPCDispatcher; BaseSession removed (#2838)

This commit is contained in:
Max 2026-06-15 14:46:34 +01:00 • committed by GitHub
commit 1012d60004
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 2886 additions and 1734 deletions

View file

@ -2,9 +2,8 @@ import pytest
from pydantic import FileUrl
from mcp import Client
from mcp.client.session import ClientSession
from mcp.client import ClientRequestContext
from mcp.server.mcpserver import Context, MCPServer
from mcp.shared._context import RequestContext
from mcp.types import ListRootsResult, Root, TextContent
@ -20,7 +19,7 @@ async def test_list_roots_callback():
)
async def list_roots_callback(
context: RequestContext[ClientSession],
context: ClientRequestContext,
) -> ListRootsResult:
return callback_return

View file

@ -1,66 +0,0 @@
from typing import Any
from unittest.mock import patch
import anyio
import pytest
from pydantic import TypeAdapter
from mcp.shared.message import SessionMessage
from mcp.shared.session import BaseSession, RequestId, SendResultT
from mcp.types import ClientNotification, ClientRequest, ClientResult, EmptyResult, ErrorData, PingRequest
@pytest.mark.anyio
async def test_send_request_stream_cleanup():
"""Test that send_request properly cleans up streams when an exception occurs.
This test mocks out most of the session functionality to focus on stream cleanup.
"""
# Create a mock session with the minimal required functionality
class TestSession(BaseSession[ClientRequest, ClientNotification, ClientResult, Any, Any]):
async def _send_response(
self, request_id: RequestId, response: SendResultT | ErrorData
) -> None: # pragma: no cover
pass
@property
def _receive_request_adapter(self) -> TypeAdapter[Any]:
return TypeAdapter(object) # pragma: no cover
@property
def _receive_notification_adapter(self) -> TypeAdapter[Any]:
return TypeAdapter(object) # pragma: no cover
# Create streams
write_stream_send, write_stream_receive = anyio.create_memory_object_stream[SessionMessage](1)
read_stream_send, read_stream_receive = anyio.create_memory_object_stream[SessionMessage](1)
# Create the session
session = TestSession(read_stream_receive, write_stream_send)
# Create a test request
request = PingRequest()
# Patch the _write_stream.send method to raise an exception
async def mock_send(*args: Any, **kwargs: Any):
raise RuntimeError("Simulated network error")
# Record the response streams before the test
initial_stream_count = len(session._response_streams)
# Run the test with the patched method
with patch.object(session._write_stream, "send", mock_send):
with pytest.raises(RuntimeError):
await session.send_request(request, EmptyResult)
# Verify that no response streams were leaked
assert len(session._response_streams) == initial_stream_count, (
f"Expected {initial_stream_count} response streams after request, but found {len(session._response_streams)}"
)
# Clean up
await write_stream_send.aclose()
await write_stream_receive.aclose()
await read_stream_send.aclose()
await read_stream_receive.aclose()

View file

@ -1,9 +1,8 @@
import pytest
from mcp import Client
from mcp.client.session import ClientSession
from mcp.client import ClientRequestContext
from mcp.server.mcpserver import Context, MCPServer
from mcp.shared._context import RequestContext
from mcp.types import (
CreateMessageRequestParams,
CreateMessageResult,
@ -26,7 +25,7 @@ async def test_sampling_callback():
)
async def sampling_callback(
context: RequestContext[ClientSession],
context: ClientRequestContext,
params: CreateMessageRequestParams,
) -> CreateMessageResult:
return callback_return
@ -71,7 +70,7 @@ async def test_create_message_backwards_compat_single_content():
)
async def sampling_callback(
context: RequestContext[ClientSession],
context: ClientRequestContext,
params: CreateMessageRequestParams,
) -> CreateMessageResult:
return callback_return

View file

@ -1,22 +1,30 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator, Mapping
from contextlib import AsyncExitStack, asynccontextmanager
from typing import Any
import anyio
import anyio.abc
import anyio.streams.memory
import pytest
from pydantic import FileUrl
from mcp import types
from mcp import MCPError, types
from mcp.client import ClientRequestContext
from mcp.client.session import DEFAULT_CLIENT_INFO, ClientSession
from mcp.shared._context import RequestContext
from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair
from mcp.shared.dispatcher import CallOptions, DispatchContext, OnNotify, OnRequest
from mcp.shared.message import SessionMessage
from mcp.shared.session import RequestResponder
from mcp.shared.transport_context import TransportContext
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
from mcp.types import (
CONNECTION_CLOSED,
INVALID_PARAMS,
LATEST_PROTOCOL_VERSION,
METHOD_NOT_FOUND,
REQUEST_TIMEOUT,
CallToolResult,
Implementation,
InitializedNotification,
@ -420,7 +428,7 @@ async def test_client_capabilities_with_custom_callbacks():
received_capabilities = None
async def custom_sampling_callback( # pragma: no cover
context: RequestContext[ClientSession],
context: ClientRequestContext,
params: types.CreateMessageRequestParams,
) -> types.CreateMessageResult | types.ErrorData:
return types.CreateMessageResult(
@ -430,7 +438,7 @@ async def test_client_capabilities_with_custom_callbacks():
)
async def custom_list_roots_callback( # pragma: no cover
context: RequestContext[ClientSession],
context: ClientRequestContext,
) -> types.ListRootsResult | types.ErrorData:
return types.ListRootsResult(roots=[])
@ -504,7 +512,7 @@ async def test_client_capabilities_with_sampling_tools():
received_capabilities = None
async def custom_sampling_callback( # pragma: no cover
context: RequestContext[ClientSession],
context: ClientRequestContext,
params: types.CreateMessageRequestParams,
) -> types.CreateMessageResult | types.ErrorData:
return types.CreateMessageResult(
@ -751,8 +759,32 @@ async def test_receive_loop_answers_malformed_inbound_request_with_invalid_param
@pytest.mark.anyio
async def test_receive_loop_answers_invalid_params_when_sampling_callback_raises():
"""Same boundary catches exceptions from the request handler itself."""
async def test_receive_loop_answers_unknown_request_method_with_method_not_found():
"""An unknown request method is answered with METHOD_NOT_FOUND, not INVALID_PARAMS (spec-mandated)."""
async with raw_client_session() as (_session, to_client, from_client):
await to_client.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=7, method="x/unknown")))
out = await from_client.receive()
assert isinstance(out.message, JSONRPCError)
assert out.message.id == 7
assert out.message.error == types.ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="x/unknown")
@pytest.mark.anyio
async def test_receive_loop_drops_unknown_notification_method_without_response():
"""An unknown notification method is dropped silently: JSON-RPC forbids responses to notifications."""
async with raw_client_session() as (_session, to_client, from_client):
await to_client.send(SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="x/unknown")))
# The answered follow-up ping proves no response was emitted and the loop survived.
await to_client.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")))
out = await from_client.receive()
assert isinstance(out.message, JSONRPCResponse)
assert out.message.id == 1
@pytest.mark.anyio
async def test_raising_sampling_callback_answers_with_code_zero():
"""A raising sampling callback is answered with code 0 and `str(exc)` (SDK-defined).
Raw streams because the assertion is the outbound `JSONRPCError` envelope itself."""
async def boom(ctx: object, params: object) -> types.CreateMessageResult:
raise RuntimeError("sampling boom")
@ -767,12 +799,13 @@ async def test_receive_loop_answers_invalid_params_when_sampling_callback_raises
)
out = await from_client.receive()
assert isinstance(out.message, JSONRPCError)
assert out.message.error.code == INVALID_PARAMS
assert out.message.error == types.ErrorData(code=0, message="sampling boom")
@pytest.mark.anyio
async def test_receive_loop_logs_and_drops_malformed_notification(caplog: pytest.LogCaptureFixture):
"""A notification that fails ServerNotification validation is logged and dropped."""
"""A malformed notification is logged and dropped without reaching `message_handler` (SDK-defined).
Scripted peer: the typed API cannot emit a method outside the spec's notification union."""
seen: list[object] = []
delivered = anyio.Event()
@ -792,19 +825,54 @@ async def test_receive_loop_logs_and_drops_malformed_notification(caplog: pytest
@pytest.mark.anyio
async def test_receive_loop_forwards_transport_exception_to_message_handler():
async def test_raising_message_handler_on_transport_exception_costs_the_delivery_not_the_connection(
caplog: pytest.LogCaptureFixture,
):
"""A `message_handler` that raises on a transport-level `Exception` item is contained: the
failure is logged and the receive loop keeps serving (SDK-defined). Raw streams because
only a transport can put an `Exception` item on the read stream."""
seen: list[object] = []
delivered = anyio.Event()
async def handler(msg: object) -> None:
seen.append(msg)
delivered.set()
# No checkpoint between set() and the containment log, so after wait() the log entry exists.
raise RuntimeError("handler boom")
async with raw_client_session(message_handler=handler) as (_session, to_client, _):
async with raw_client_session(message_handler=handler) as (_session, to_client, from_client):
exc = ValueError("bad bytes")
await to_client.send(exc)
await delivered.wait()
await to_client.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=9, method="ping")))
out = await from_client.receive()
assert seen == [exc]
assert isinstance(out.message, JSONRPCResponse)
assert out.message.id == 9
assert "message_handler raised on transport exception" in caplog.text
@pytest.mark.anyio
async def test_message_handler_awaiting_session_traffic_on_transport_exception_completes():
"""A `message_handler` that awaits session traffic on a transport `Exception` item completes:
fault deliveries are spawned into the task group, not run inline in the read loop (SDK-defined).
Raw streams because only a transport can put an `Exception` item on the read stream."""
ponged = anyio.Event()
# `session` resolves at call time, after the `as` clause binds it.
async def handler(msg: object) -> None:
assert isinstance(msg, Exception)
await session.send_ping()
ponged.set()
async with raw_client_session(message_handler=handler) as (session, to_client, from_client):
await to_client.send(ValueError("bad bytes"))
# Serve the handler's ping like a transport would; inline delivery would deadlock here.
out = await from_client.receive()
assert isinstance(out.message, JSONRPCRequest)
assert out.message.method == "ping"
await to_client.send(SessionMessage(JSONRPCResponse(jsonrpc="2.0", id=out.message.id, result={})))
await ponged.wait()
@pytest.mark.anyio
@ -814,6 +882,7 @@ async def test_receive_loop_consumes_server_cancelled_without_reaching_message_h
The server dispatcher now emits this on sampling/elicitation timeout, but
ClientSession has no in-flight tracking to act on it, so surfacing it would
only break user handlers that exhaustively match ServerNotification.
Scripted peer: the typed server API cannot emit a bare `notifications/cancelled`.
"""
seen: list[object] = []
delivered = anyio.Event()
@ -841,23 +910,344 @@ async def test_receive_loop_consumes_server_cancelled_without_reaching_message_h
@pytest.mark.anyio
async def test_receive_loop_swallows_progress_callback_exception(caplog: pytest.LogCaptureFixture):
async def test_request_timeout_zero_overrides_session_timeout():
"""`request_read_timeout_seconds=0` is a real per-request timeout (fail at the
first checkpoint, `anyio.fail_after(0)` semantics), not a fall-through to the
session-level timeout. The request is never answered, so falling back to the
30s session timeout would trip the harness's 5s guard instead."""
async with raw_client_session(read_timeout_seconds=30) as (session, _to_client, _from_client):
with pytest.raises(MCPError) as exc_info:
await session.send_request(types.PingRequest(), types.EmptyResult, request_read_timeout_seconds=0.0)
assert exc_info.value.error.code == REQUEST_TIMEOUT
@pytest.mark.anyio
async def test_progress_notification_reaches_request_callback_and_message_handler():
"""A `notifications/progress` for an in-flight request reaches both the `progress_callback` and
`message_handler` (SDK-defined). Scripted peer: the progress token must echo the wire request id."""
updates: list[tuple[float, float | None, str | None]] = []
teed: list[types.ProgressNotification] = []
request_id: types.RequestId | None = None
progressed = anyio.Event()
delivered = anyio.Event()
async def boom(progress: float, total: float | None, message: str | None) -> None:
raise RuntimeError("progress boom")
async def on_progress(progress: float, total: float | None, message: str | None) -> None:
updates.append((progress, total, message))
progressed.set()
async def handler(msg: object) -> None:
# Only the progress notification is teed to the message handler here.
assert isinstance(msg, types.ProgressNotification)
teed.append(msg)
delivered.set()
async with raw_client_session(message_handler=handler) as (session, to_client, _):
# Register the callback under a known token without sending a request.
session._progress_callbacks[42] = boom # pyright: ignore[reportPrivateUsage]
params = {"progressToken": 42, "progress": 0.5}
await to_client.send(
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/progress", params=params))
)
# The progress notification also reaches the message handler after the
# callback runs, so this fires once the callback's exception is handled.
await delivered.wait()
assert "Progress callback raised an exception" in caplog.text
async with raw_client_session(message_handler=handler) as (session, to_client, from_client):
async with anyio.create_task_group() as tg:
async def call() -> None:
await session.send_request(types.PingRequest(), types.EmptyResult, progress_callback=on_progress)
tg.start_soon(call)
request = await from_client.receive()
assert isinstance(request.message, JSONRPCRequest)
request_id = request.message.id
# The request id doubles as the progress token.
params = {"progressToken": request_id, "progress": 0.5, "total": 1.0, "message": "halfway"}
await to_client.send(
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/progress", params=params))
)
await progressed.wait()
await delivered.wait()
await to_client.send(SessionMessage(JSONRPCResponse(jsonrpc="2.0", id=request_id, result={})))
assert updates == [(0.5, 1.0, "halfway")]
assert request_id is not None
assert len(teed) == 1
assert teed[0].params == types.ProgressNotificationParams(
progress_token=request_id, progress=0.5, total=1.0, message="halfway"
)
@pytest.mark.anyio
async def test_dispatcher_keyword_runs_over_direct_dispatch():
"""A session built with dispatcher= works without a stream pair (in-process embedding)."""
client_side, server_side = create_direct_dispatcher_pair()
async def server_on_request(
ctx: DispatchContext[TransportContext], method: str, params: dict[str, object] | None
) -> dict[str, object]:
assert method == "ping"
return {}
notified: list[str] = []
async def server_on_notify(
ctx: DispatchContext[TransportContext], method: str, params: dict[str, object] | None
) -> None:
notified.append(method)
session = ClientSession(dispatcher=client_side)
results: list[types.EmptyResult] = []
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, server_on_request, server_on_notify)
async with session:
results.append(await session.send_ping(meta=None))
# Server-to-client: direct dispatch delivers ping with no params member (no _meta injection).
assert await server_side.send_raw_request("ping", None) == {}
await session.send_notification(types.RootsListChangedNotification())
server_side.close()
assert results == [types.EmptyResult()]
assert notified == ["notifications/roots/list_changed"]
@pytest.mark.anyio
async def test_direct_dispatch_roots_list_reaches_callback_with_synthesized_request_id():
"""A server-initiated roots/list over dispatcher= reaches the registered callback and round-trips
the result; the callback context carries an int request_id (SDK-defined: DirectDispatcher
synthesizes ids)."""
client_side, server_side = create_direct_dispatcher_pair()
contexts: list[ClientRequestContext] = []
async def list_roots(context: ClientRequestContext) -> types.ListRootsResult:
contexts.append(context)
return types.ListRootsResult(roots=[types.Root(uri=FileUrl("file:///workspace"))])
async def server_on_request(
ctx: DispatchContext[TransportContext], method: str, params: dict[str, object] | None
) -> dict[str, object]:
raise NotImplementedError
async def server_on_notify(
ctx: DispatchContext[TransportContext], method: str, params: dict[str, object] | None
) -> None:
raise NotImplementedError
session = ClientSession(dispatcher=client_side, list_roots_callback=list_roots)
result: dict[str, Any] | None = None
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, server_on_request, server_on_notify)
async with session:
result = await server_side.send_raw_request("roots/list", None)
server_side.close()
assert result == {"roots": [{"uri": "file:///workspace"}]}
assert len(contexts) == 1
assert isinstance(contexts[0].request_id, int)
@pytest.mark.anyio
async def test_raising_notification_callbacks_over_direct_dispatch_cost_only_that_delivery(
caplog: pytest.LogCaptureFixture,
):
"""A raising `logging_callback` or `message_handler` is contained in the session, so the
in-process peer's notify() returns normally and the session keeps serving requests
(SDK-defined: DirectDispatcher awaits notification handlers inline in the peer's call).
A raising `logging_callback` skips the `message_handler` tee for that notification."""
client_side, server_side = create_direct_dispatcher_pair()
teed: list[types.ServerNotification] = []
async def logging_callback(params: types.LoggingMessageNotificationParams) -> None:
raise ValueError("logging callback boom")
async def message_handler(
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
) -> None:
assert not isinstance(message, RequestResponder | Exception)
teed.append(message)
raise ValueError("message handler boom")
async def server_on_request(
ctx: DispatchContext[TransportContext], method: str, params: dict[str, object] | None
) -> dict[str, object]:
assert method == "ping"
return {}
async def server_on_notify(
ctx: DispatchContext[TransportContext], method: str, params: dict[str, object] | None
) -> None:
raise NotImplementedError
session = ClientSession(dispatcher=client_side, logging_callback=logging_callback, message_handler=message_handler)
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, server_on_request, server_on_notify)
async with session:
# logging_callback raises: notify() must return, and message_handler is skipped.
await server_side.notify("notifications/message", {"level": "info", "data": "hello"})
# message_handler raises: notify() must return.
await server_side.notify("notifications/tools/list_changed", None)
# The session still serves requests afterwards.
assert await session.send_ping() == types.EmptyResult()
server_side.close()
assert [type(n) for n in teed] == [types.ToolListChangedNotification]
assert caplog.text.count("notification callback for") == 2
assert "notification callback for 'notifications/message' raised" in caplog.text
assert "notification callback for 'notifications/tools/list_changed' raised" in caplog.text
@pytest.mark.anyio
async def test_dispatcher_keyword_send_request_before_enter_raises_runtimeerror():
"""The documented pre-enter RuntimeError holds for dispatcher= sessions too."""
client_side, _server_side = create_direct_dispatcher_pair()
session = ClientSession(dispatcher=client_side)
with anyio.fail_after(5), pytest.raises(RuntimeError) as exc:
await session.send_ping()
assert str(exc.value) == "DirectDispatcher.send_raw_request called before run()"
@pytest.mark.anyio
async def test_dispatcher_keyword_send_request_after_exit_raises_connection_closed():
"""After __aexit__ a dispatcher= session raises MCPError(CONNECTION_CLOSED), matching the JSONRPC path."""
client_side, server_side = create_direct_dispatcher_pair()
async def server_on_request(
ctx: DispatchContext[TransportContext], method: str, params: dict[str, object] | None
) -> dict[str, object]:
assert method == "ping"
return {}
async def server_on_notify(
ctx: DispatchContext[TransportContext], method: str, params: dict[str, object] | None
) -> None:
raise NotImplementedError
session = ClientSession(dispatcher=client_side)
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, server_on_request, server_on_notify)
async with session:
assert await session.send_ping() == types.EmptyResult()
with pytest.raises(MCPError) as exc:
await session.send_ping()
assert exc.value.error.code == CONNECTION_CLOSED
server_side.close()
@pytest.mark.anyio
async def test_dispatcher_keyword_request_timeout_bounds_wait_for_never_run_peer():
"""request_read_timeout_seconds fires even when the peer dispatcher never started running."""
client_side, _server_side = create_direct_dispatcher_pair()
session = ClientSession(dispatcher=client_side)
with anyio.fail_after(5):
async with session:
with pytest.raises(MCPError) as exc:
await session.send_request(types.PingRequest(), types.EmptyResult, request_read_timeout_seconds=0.01)
assert exc.value.error.code == REQUEST_TIMEOUT
@pytest.mark.anyio
async def test_initialize_opts_out_of_cancel_on_abandon_while_other_requests_leave_it_unset():
"""`send_request` passes `cancel_on_abandon=False` for `initialize` — the spec forbids
cancelling it — and leaves the option unset for every other method."""
class RecordingDispatcher:
"""Records `send_raw_request` opts and answers with canned results."""
def __init__(self) -> None:
self.calls: list[tuple[str, CallOptions]] = []
async def run(
self,
on_request: OnRequest,
on_notify: OnNotify,
*,
task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
) -> None:
task_status.started()
await anyio.sleep_forever()
async def send_raw_request(
self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None
) -> dict[str, Any]:
self.calls.append((method, opts or {}))
if method == "initialize":
return InitializeResult(
protocol_version=LATEST_PROTOCOL_VERSION,
capabilities=ServerCapabilities(),
server_info=Implementation(name="mock-server", version="0.1.0"),
).model_dump(by_alias=True, mode="json", exclude_none=True)
return {}
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
pass
dispatcher = RecordingDispatcher()
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
await session.initialize()
await session.send_ping()
opts_by_method = dict(dispatcher.calls)
assert opts_by_method["initialize"].get("cancel_on_abandon") is False
assert "cancel_on_abandon" not in opts_by_method["ping"]
def test_constructor_rejects_streams_and_dispatcher_together():
client_side, _server_side = create_direct_dispatcher_pair()
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](1)
with pytest.raises(ValueError, match="not both"):
ClientSession(s2c_recv, dispatcher=client_side)
s2c_send.close()
s2c_recv.close()
def test_constructor_requires_both_streams_without_dispatcher():
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](1)
with pytest.raises(ValueError, match="read_stream and write_stream are required"):
ClientSession(s2c_recv)
with pytest.raises(ValueError, match="read_stream and write_stream are required"):
ClientSession()
s2c_send.close()
s2c_recv.close()
@pytest.mark.anyio
async def test_aenter_cancelled_while_dispatcher_starts_unwinds_cleanly():
"""Cancellation while `__aenter__` waits for the dispatcher to start unwinds the half-entered
task group cleanly, not via anyio's "exited non-innermost cancel scope" RuntimeError (SDK-defined)."""
class NeverStartsDispatcher:
"""`run()` parks without ever signalling `task_status.started()`."""
async def run(
self,
on_request: OnRequest,
on_notify: OnNotify,
*,
task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
) -> None:
await anyio.sleep_forever()
async def send_raw_request(
self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None
) -> dict[str, Any]:
raise NotImplementedError
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
raise NotImplementedError
session = ClientSession(dispatcher=NeverStartsDispatcher())
async with AsyncExitStack() as stack:
# `start()` is parked forever, so the deadline only ends the wait — any duration is non-racy.
with anyio.move_on_after(0.01) as scope:
await stack.enter_async_context(session)
assert scope.cancelled_caught
# The failed enter must not leave the session half-entered.
assert session._task_group is None
@pytest.mark.anyio
async def test_send_notification_after_close_is_dropped_silently():
"""Post-close `send_notification` is fire-and-forget: the notification is dropped,
not surfaced as a raw transport error (v1 leaked `anyio.ClosedResourceError`)."""
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](4)
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage](4)
try:
async with ClientSession(s2c_recv, c2s_send) as session:
pass
with anyio.fail_after(5):
await session.send_notification(types.RootsListChangedNotification())
with pytest.raises(anyio.EndOfStream):
c2s_recv.receive_nowait() # nothing reached the wire
finally:
for s in (s2c_send, s2c_recv, c2s_send, c2s_recv):
s.close()

View file

@ -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 import Client
from mcp.client import ClientRequestContext
from mcp.server.mcpserver import Context, MCPServer
from mcp.types import (
CallToolResult,
CreateMessageRequestParams,
CreateMessageResult,
SamplingMessage,
TextContent,
)
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(
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) 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"})]
)

View file

@ -193,11 +193,13 @@ many requirements at once; if the assertions would be separate, write separate t
### Notifications and concurrency
The client's receive loop dispatches each incoming message to completion before reading the next,
and the in-memory transport delivers everything on one ordered stream. Together these guarantee
that every notification a server handler emits before its response reaches the client callback
before the originating request returns — so tests collect notifications into a plain list and
assert after the call, with no synchronisation. The exceptions:
The client's dispatcher starts a task per incoming notification in arrival order but does not
await it before reading the next message, so completion order is not structural. What still
holds: the in-memory transport delivers everything on one ordered stream, and a callback that
records synchronously (no `await` before the append) finishes its scheduling slice before the
awaited request's waiter — woken strictly later — resumes. So tests whose callbacks are plain
appends may still collect into a list and assert after the call. A callback that awaits before
recording loses that ordering and must synchronise. The other exceptions:
- a notification not triggered by a request the test is awaiting needs an `anyio.Event` set in
the receiving handler and awaited under `anyio.fail_after(5)`;
@ -220,9 +222,8 @@ but still inside an outer `async with`, and no restructure can avoid it.
A handful of `# pragma: lax no cover` markers in `src/` cover teardown exception handlers whose
execution is timing-dependent under the in-process HTTP bridge — the POST-stream and
stateless-session `except Exception` handlers in `server/streamable_http*.py`, the `_terminated`
check in `message_router`, and the response-stream double-close guard in
`BaseSession._receive_loop`. `strict-no-cover` does not check `lax` lines; do not promote them to
strict `no cover` without first making the teardown ordering deterministic. The suite also relies
on a one-line `src/mcp/server/sse.py` fix (`sse_stream_reader.aclose()`) that closes a stream the
SSE leg would otherwise leak.
stateless-session `except Exception` handlers in `server/streamable_http*.py` and the
`_terminated` check in `message_router`. `strict-no-cover` does not check `lax` lines; do not
promote them to strict `no cover` without first making the teardown ordering deterministic. The
suite also relies on a one-line `src/mcp/server/sse.py` fix (`sse_stream_reader.aclose()`) that
closes a stream the SSE leg would otherwise leak.

View file

@ -67,8 +67,9 @@ class _RecordingWriteStream:
self._log = log
async def send(self, item: SessionMessage, /) -> None:
self._log.append(item)
# Record only after the inner send returns: a failed or cancelled send never reached the transport.
await self._inner.send(item)
self._log.append(item)
async def aclose(self) -> None:
await self._inner.aclose()

View file

@ -268,18 +268,15 @@ REQUIREMENTS: dict[str, Requirement] = {
divergence=Divergence(
note=(
"The spec says receivers of a cancellation SHOULD NOT send a response for the cancelled "
"request; the server sends an error response (code 0, 'Request cancelled'), which is what "
"unblocks the SDK client's pending call."
"request; both seats send an error response (code 0, 'Request cancelled') instead — the "
"server for cancelled client requests, and the client for cancelled server-initiated "
"requests — which is what unblocks the sender's pending call."
),
),
),
"protocol:cancel:initialize-not-cancellable": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#behavior-requirements",
behavior="The client never sends notifications/cancelled for the initialize request.",
deferred=(
"Not implemented in the SDK: the client has no public cancellation API at all, so no pathway "
"exists that could cancel initialize; there is no distinct behaviour to pin beyond that absence."
),
),
"protocol:cancel:late-response-ignored": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#behavior-requirements",
@ -287,14 +284,6 @@ REQUIREMENTS: dict[str, Requirement] = {
"A response that arrives after the sender issued notifications/cancelled is ignored; the "
"request stays failed and no error is raised."
),
divergence=Divergence(
note=(
"A response whose id matches no in-flight request is delivered to the message handler "
"as a RuntimeError rather than being silently ignored. The post-cancellation case is the "
"same code path; tested in its unknown-id form because that is deterministic without the "
"client-side cancellation API the SDK does not yet provide."
),
),
),
"protocol:cancel:server-survives": Requirement(
source="sdk",
@ -306,19 +295,6 @@ REQUIREMENTS: dict[str, Requirement] = {
"A server that abandons an in-flight server-initiated request (sampling, elicitation, roots) "
"cancels it, and the client stops processing the cancelled request."
),
divergence=Divergence(
note=(
"Abandoning a server-side send_request emits no cancellation notification, and the client "
"could not act on one anyway: client callbacks run inline in the receive loop, so a "
"cancellation is not even read until the callback has finished."
),
),
deferred=(
"Not implemented in the SDK: abandoning a server-side send_request emits no cancellation "
"notification (the same sender-side gap recorded on protocol:timeout:sends-cancellation), and "
"the client could not act on one anyway because client callbacks run inline in the receive "
"loop, so a cancellation would not even be read until the callback had already finished."
),
),
"protocol:cancel:unknown-id-ignored": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#error-handling",
@ -363,6 +339,27 @@ REQUIREMENTS: dict[str, Requirement] = {
source=f"{SPEC_BASE_URL}/basic#responses",
behavior="A request whose method has no registered handler is answered with a METHOD_NOT_FOUND error.",
),
"protocol:error:null-id": Requirement(
source="sdk",
behavior=(
"An error response carrying a null id — the JSON-RPC shape for a peer reporting a failure it "
"could not attribute to a request, such as a parse error — is surfaced to the application "
"rather than silently discarded."
),
divergence=Divergence(
note=(
"The dispatcher drops null-id error responses with a debug log; in v1, JSONRPCError.id was "
"non-nullable, so a null-id error response failed transport validation and the resulting "
"ValidationError was surfaced to message_handler as an exception. A typed fault channel "
"restoring visibility is planned before v2 stable."
),
),
deferred=(
"Not yet covered here: the current drop is pinned at the dispatcher level by "
"tests/shared/test_jsonrpc_dispatcher.py; an interaction-level test waits on the planned "
"fault channel."
),
),
"protocol:meta:related-task": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/tasks#related-task-metadata",
behavior="Messages may carry related-task _meta associating them with a task.",
@ -466,11 +463,6 @@ REQUIREMENTS: dict[str, Requirement] = {
"When a request times out, the sender issues notifications/cancelled for that request before "
"failing the local call."
),
divergence=Divergence(
note=(
"The client only raises locally and sends nothing on timeout, so the server keeps running the handler."
),
),
),
"protocol:timeout:session-survives": Requirement(
source=f"{SPEC_BASE_URL}/basic/lifecycle#timeouts",

View file

@ -11,11 +11,12 @@ import pytest
from inline_snapshot import snapshot
from mcp import MCPError, types
from mcp.client import ClientSession
from mcp.client import ClientRequestContext, ClientSession
from mcp.server import Server, ServerRequestContext
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
from mcp.shared.message import SessionMessage
from mcp.types import (
REQUEST_TIMEOUT,
CallToolResult,
EmptyResult,
ErrorData,
@ -155,14 +156,71 @@ async def test_cancellation_for_unknown_request_is_ignored(connect: Connect) ->
assert result == snapshot(CallToolResult(content=[TextContent(text="unbothered")]))
@requirement("protocol:cancel:server-to-client")
async def test_abandoned_server_request_cancels_the_client_callback(connect: Connect) -> None:
"""A server that abandons a sampling request cancels it, interrupting the client's callback mid-await."""
callback_started = anyio.Event()
callback_cancelled = anyio.Event()
async def sampling_callback(
context: ClientRequestContext, params: types.CreateMessageRequestParams
) -> types.CreateMessageResult:
callback_started.set()
try:
await anyio.Event().wait() # blocks until the cancellation interrupts it
except anyio.get_cancelled_exc_class():
callback_cancelled.set()
raise
raise NotImplementedError # unreachable
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="impatient", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "impatient"
request = types.CreateMessageRequest(
params=types.CreateMessageRequestParams(
messages=[types.SamplingMessage(role="user", content=TextContent(text="Say hello."))],
max_tokens=8,
)
)
async with anyio.create_task_group() as abandon_scope:
async def sample() -> None:
await ctx.session.send_request(request, types.CreateMessageResult)
raise NotImplementedError # unreachable: the scope is cancelled
abandon_scope.start_soon(sample)
with anyio.fail_after(5):
await callback_started.wait()
abandon_scope.cancel_scope.cancel()
with anyio.fail_after(5):
await callback_cancelled.wait()
return CallToolResult(content=[TextContent(text="abandoned")])
server = Server("abandoner", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server, sampling_callback=sampling_callback) as client:
result = await client.call_tool("impatient", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="abandoned")]))
assert callback_cancelled.is_set()
@requirement("protocol:cancel:late-response-ignored")
async def test_a_response_for_an_unknown_request_id_surfaces_to_the_message_handler() -> None:
"""A response whose id matches no in-flight request is surfaced to the message handler as a RuntimeError.
async def test_a_response_for_an_unknown_request_id_is_ignored() -> None:
"""A response whose id matches no in-flight request is ignored, as the spec asks.
The spec says a sender SHOULD ignore a response that arrives after it issued a cancellation;
that is the same client-side code path as any response with an unknown id, and that form is
deterministic to test without depending on the cancellation API the SDK does not yet provide.
See the divergence note on the requirement.
deterministic to test without a client-side cancellation API.
"Ignored" is proved in two halves: the pong round-trip proves the read loop survived the
fabricated response (the ordered in-memory stream routed it first), and `surfaced` holding
only the control notification proves the fabricated response was never delivered to
`message_handler` (v1 surfaced it there as a RuntimeError).
A real Server cannot be made to answer with a fabricated id, so the test plays the server's
side of the wire by hand. Reserve this pattern for behaviour no real server can produce. The
@ -208,14 +266,18 @@ async def test_a_response_for_an_unknown_request_id_surfaces_to_the_message_hand
assert isinstance(ping, SessionMessage)
assert isinstance(ping.message, JSONRPCRequest)
assert ping.message.method == "ping"
# First answer with a fabricated id that matches nothing in flight, then the real id.
# First a fabricated id that matches nothing in flight, then a control notification that
# is surfaced to message_handler (proving the handler is live), then the real id.
await server_write.send(respond(9999, EmptyResult()))
await server_write.send(
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/tools/list_changed"))
)
await server_write.send(respond(ping.message.id, EmptyResult()))
incoming: list[IncomingMessage] = []
surfaced: list[IncomingMessage] = []
async def message_handler(message: IncomingMessage) -> None:
incoming.append(message)
surfaced.append(message)
async with (
create_client_server_memory_streams() as ((client_read, client_write), server_streams),
@ -228,7 +290,56 @@ async def test_a_response_for_an_unknown_request_id_surfaces_to_the_message_hand
pong = await session.send_request(PingRequest(), EmptyResult)
assert pong == snapshot(EmptyResult())
assert len(incoming) == 1
assert isinstance(incoming[0], RuntimeError)
# The full message embeds the response object's repr; only the prefix is stable.
assert str(incoming[0]).startswith("Received response with an unknown request ID:")
# The stream is ordered, so the fabricated response was routed before the control
# notification: only the control surfaced, so the unknown-id response was dropped.
assert surfaced == snapshot([types.ToolListChangedNotification()])
@requirement("protocol:cancel:initialize-not-cancellable")
async def test_timed_out_initialize_sends_no_cancellation() -> None:
"""An abandoned initialize is not followed by notifications/cancelled on the wire (spec-mandated).
A real Server always answers initialize, so the test plays a stalling server by hand.
"""
received_methods: list[str] = []
async def scripted_server(streams: MessageStream) -> None:
server_read, server_write = streams
# Hold the initialize request unanswered until the client's read timeout fires.
init = await server_read.receive()
assert isinstance(init, SessionMessage)
assert isinstance(init.message, JSONRPCRequest)
received_methods.append(init.message.method)
follow_up = await server_read.receive()
assert isinstance(follow_up, SessionMessage)
assert isinstance(follow_up.message, JSONRPCRequest)
received_methods.append(follow_up.message.method)
await server_write.send(
SessionMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=follow_up.message.id,
result=EmptyResult().model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
async with (
create_client_server_memory_streams() as ((client_read, client_write), server_streams),
anyio.create_task_group() as task_group,
# The session-level read timeout is the only public pathway that abandons initialize.
ClientSession(client_read, client_write, read_timeout_seconds=0.000001) as session,
):
task_group.start_soon(scripted_server, server_streams)
with anyio.fail_after(5):
with pytest.raises(MCPError) as exc_info:
await session.initialize()
assert exc_info.value.error.code == REQUEST_TIMEOUT
# Override the session-level timeout: this ping must round-trip normally.
pong = await session.send_request(PingRequest(), EmptyResult, request_read_timeout_seconds=5)
assert pong == snapshot(EmptyResult())
# The stream is ordered, so a courtesy cancel would have arrived ahead of the ping.
assert received_methods == snapshot(["initialize", "ping"])

View file

@ -1,12 +1,8 @@
"""Logging interactions against the low-level Server, driven through the public Client API.
Notification ordering: the in-memory transport delivers every server-to-client message on one
ordered stream, and the client's receive loop dispatches each incoming message to completion
before reading the next one. Over streamable HTTP that ordered single-stream guarantee holds
only for messages that carry a ``related_request_id`` (they ride the originating request's POST
stream); without it the message routes to the standalone GET stream and may arrive after the
response. These tests pass ``related_request_id`` so they can collect into a plain list and
assert after the request completes on every transport leg -- no events, no waiting.
Notification ordering: await-free callbacks finish in arrival order, and passing
``related_request_id`` keeps each notification on the originating request's POST stream over
streamable HTTP, so plain-list collection is deterministic on every transport leg.
"""
import pytest

View file

@ -87,8 +87,8 @@ async def test_progress_token_visible_to_handler(connect: Connect) -> None:
async with connect(server) as client:
result = await client.call_tool("inspect", {}, progress_callback=ignore)
# The token is the request id of the tools/call request itself (initialize is request 0).
assert result == snapshot(CallToolResult(content=[TextContent(text="1")]))
# The token is the request id of the tools/call request itself (initialize is request 1).
assert result == snapshot(CallToolResult(content=[TextContent(text="2")]))
@requirement("protocol:progress:no-token")

View file

@ -3,8 +3,9 @@
The handler blocks on an event that is never set, so the awaited response can never arrive and
any positive timeout fires deterministically on the next event-loop pass. Per-request timeouts are
set to an effectively-zero duration; the session-level test runs on trio's virtual clock instead
(see the comment there). Either way the tests add no wall-clock time to the suite. (Zero itself
cannot be used: a falsy read_timeout_seconds is silently treated as "no timeout".)
(see the comment there). Either way the tests add no wall-clock time to the suite. (Zero would
also time out immediately, but a tiny positive value keeps the duration visible in the
cancellation reason these tests snapshot.)
"""
import anyio
@ -13,9 +14,13 @@ from inline_snapshot import snapshot
from trio.testing import MockClock
from mcp import MCPError, types
from mcp.client import ClientRequestContext
from mcp.client._memory import InMemoryTransport
from mcp.client.client import Client
from mcp.server import Server, ServerRequestContext
from mcp.types import REQUEST_TIMEOUT, CallToolResult, ErrorData, TextContent
from mcp.shared.message import SessionMessage
from mcp.types import REQUEST_TIMEOUT, CallToolResult, ErrorData, JSONRPCNotification, TextContent
from tests.interaction._helpers import RecordingTransport
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@ -26,16 +31,19 @@ pytestmark = pytest.mark.anyio
async def test_request_timeout_fails_the_pending_call() -> None:
"""A request whose response does not arrive within its read timeout fails with a timeout error.
No cancellation is sent to the server (see the divergence note on the requirement): the handler
starts and is still running after the caller has already given up. The test waits for the
handler to have started only after the timeout has fired, so the timeout itself races nothing.
The timeout is followed by notifications/cancelled, which interrupts the server's handler.
"""
handler_started = anyio.Event()
handler_cancelled = anyio.Event()
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "block"
handler_started.set()
await anyio.Event().wait() # blocks until the session is torn down
try:
await anyio.Event().wait() # blocks until the courtesy cancellation interrupts it
except anyio.get_cancelled_exc_class():
handler_cancelled.set()
raise
raise NotImplementedError # unreachable
server = Server("blocker", on_call_tool=call_tool)
@ -44,18 +52,79 @@ async def test_request_timeout_fails_the_pending_call() -> None:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("block", {}, read_timeout_seconds=0.000001)
# The request was already on the wire: the handler still runs even though the caller gave up.
# The request was already on the wire: the handler started and was then cancelled.
with anyio.fail_after(5):
await handler_started.wait()
await handler_cancelled.wait()
assert exc_info.value.error == snapshot(
ErrorData(
code=REQUEST_TIMEOUT,
message="Timed out while waiting for response to CallToolRequest. Waited 1e-06 seconds.",
message="Request 'tools/call' timed out",
)
)
@requirement("protocol:timeout:basic")
@requirement("protocol:timeout:sends-cancellation")
async def test_server_request_timeout_sends_cancellation_to_the_client() -> None:
"""A server-initiated request that times out fails server-side and cancels the client's work.
The sampling callback answers only after the server gave up; the late response is discarded.
"""
release = anyio.Event()
callback_started = anyio.Event()
errors: list[ErrorData] = []
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="impatient", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "impatient"
request = types.CreateMessageRequest(
params=types.CreateMessageRequestParams(
messages=[types.SamplingMessage(role="user", content=TextContent(text="Say hello."))],
max_tokens=8,
)
)
with pytest.raises(MCPError) as exc_info:
await ctx.session.send_request(request, types.CreateMessageResult, request_read_timeout_seconds=0.000001)
errors.append(exc_info.value.error)
release.set()
return CallToolResult(content=[TextContent(text="gave up")])
server = Server("impatient", on_list_tools=list_tools, on_call_tool=call_tool)
recording = RecordingTransport(InMemoryTransport(server))
async def sampling_callback(
context: ClientRequestContext, params: types.CreateMessageRequestParams
) -> types.CreateMessageResult:
callback_started.set()
with anyio.fail_after(5):
await release.wait()
return types.CreateMessageResult(role="assistant", content=TextContent(text="too late"), model="test-model")
async with Client(recording, sampling_callback=sampling_callback) as client:
result = await client.call_tool("impatient", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="gave up")]))
assert callback_started.is_set()
assert errors == snapshot([ErrorData(code=REQUEST_TIMEOUT, message="Request 'sampling/createMessage' timed out")])
cancellations = [
item.message
for item in recording.received
if isinstance(item, SessionMessage)
and isinstance(item.message, JSONRPCNotification)
and item.message.method == "notifications/cancelled"
]
# requestId 1 is the sampling request, the server's first outbound request.
assert [notification.params for notification in cancellations] == snapshot(
[{"requestId": 1, "reason": "timed out after 1e-06s"}]
)
@requirement("protocol:timeout:session-survives")
async def test_session_serves_requests_after_timeout() -> None:
"""A timed-out request does not poison the session: the next request succeeds."""
@ -73,7 +142,7 @@ async def test_session_serves_requests_after_timeout() -> None:
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
if params.name == "echo":
return CallToolResult(content=[TextContent(text="still alive")])
await anyio.Event().wait() # blocks until the session is torn down
await anyio.Event().wait() # blocks until the courtesy cancellation interrupts it
raise NotImplementedError # unreachable
server = Server("blocker", on_list_tools=list_tools, on_call_tool=call_tool)
@ -105,7 +174,7 @@ async def test_session_level_timeout_applies_to_every_request() -> None:
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "block"
await anyio.Event().wait() # blocks until the session is torn down
await anyio.Event().wait() # blocks until the courtesy cancellation interrupts it
raise NotImplementedError # unreachable
server = Server("blocker", on_call_tool=call_tool)
@ -117,6 +186,6 @@ async def test_session_level_timeout_applies_to_every_request() -> None:
assert exc_info.value.error == snapshot(
ErrorData(
code=REQUEST_TIMEOUT,
message="Timed out while waiting for response to CallToolRequest. Waited 0.05 seconds.",
message="Request 'tools/call' timed out",
)
)

View file

@ -61,7 +61,7 @@ def _echo_server() -> Server:
async def test_request_ids_are_unique_and_never_null() -> None:
"""Every request the client sends carries a distinct, non-null id.
The id sequence is pinned: sequential integers from zero, in send order.
The id sequence is pinned: sequential integers from one, in send order.
"""
recording = RecordingTransport(InMemoryTransport(_echo_server()))
@ -77,7 +77,7 @@ async def test_request_ids_are_unique_and_never_null() -> None:
assert len(request_ids) == len(set(request_ids))
# initialize, tools/list, tools/call, tools/call, ping -- the client does not issue a
# schema-cache refresh here because the explicit tools/list already populated the cache.
assert request_ids == snapshot([0, 1, 2, 3, 4])
assert request_ids == snapshot([1, 2, 3, 4, 5])
@requirement("protocol:notifications:no-response")

View file

@ -12,7 +12,14 @@ from mcp.client.session import ClientSession
from mcp.server import Server, ServerRequestContext
from mcp.shared.exceptions import MCPError
from mcp.shared.message import SessionMessage
from mcp.types import CallToolRequestParams, CallToolResult, ListToolsResult, PaginatedRequestParams, TextContent
from mcp.types import (
REQUEST_TIMEOUT,
CallToolRequestParams,
CallToolResult,
ListToolsResult,
PaginatedRequestParams,
TextContent,
)
@pytest.mark.anyio
@ -55,7 +62,8 @@ async def test_notification_validation_error(tmp_path: Path):
assert params.name in ("slow", "fast"), f"Unknown tool: {params.name}"
if params.name == "slow":
await slow_request_lock.wait() # it should timeout here
# The client's timeout fires during this wait; the courtesy cancellation then interrupts it.
await slow_request_lock.wait()
text = f"slow {request_count}"
else:
text = f"fast {request_count}"
@ -95,9 +103,9 @@ async def test_notification_validation_error(tmp_path: Path):
# Use very small timeout to trigger quickly without waiting
with pytest.raises(MCPError) as exc_info:
await session.call_tool("slow", read_timeout_seconds=0.000001) # artificial timeout that always fails
assert "Timed out while waiting" in str(exc_info.value)
assert exc_info.value.error.code == REQUEST_TIMEOUT
# release the slow request not to have hanging process
# No-op if the courtesy cancellation already interrupted the handler.
slow_request_lock.set()
# Third call should work (fast operation, no timeout),

View file

@ -6,9 +6,9 @@ import pytest
from pydantic import BaseModel, Field
from mcp import Client, types
from mcp.client.session import ClientSession, ElicitationFnT
from mcp.client import ClientRequestContext
from mcp.client.session import ElicitationFnT
from mcp.server.mcpserver import Context, MCPServer
from mcp.shared._context import RequestContext
from mcp.types import ElicitRequestParams, ElicitResult, TextContent
@ -64,7 +64,7 @@ async def test_elicitation_accept_returns_the_users_answer_to_the_tool():
create_ask_user_tool(mcp)
# Create a custom handler for elicitation requests
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
if params.message == "Tool wants to ask: What is your name?":
return ElicitResult(action="accept", content={"answer": "Test User"})
else: # pragma: no cover
@ -81,7 +81,7 @@ async def test_elicitation_decline_reaches_the_tool_without_content():
mcp = MCPServer(name="ElicitationDeclineServer")
create_ask_user_tool(mcp)
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
return ElicitResult(action="decline")
await call_tool_and_assert(
@ -119,9 +119,7 @@ async def test_elicitation_schema_validation():
create_validation_tool("nested_model", InvalidNestedSchema)
# Dummy callback (won't be called due to validation failure)
async def elicitation_callback(
context: RequestContext[ClientSession], params: ElicitRequestParams
): # pragma: no cover
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams): # pragma: no cover
return ElicitResult(action="accept", content={})
async with Client(mcp, elicitation_callback=elicitation_callback) as client:
@ -176,7 +174,7 @@ async def test_elicitation_with_optional_fields():
for content, expected in test_cases:
async def callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def callback(context: ClientRequestContext, params: ElicitRequestParams):
return ElicitResult(action="accept", content=content)
await call_tool_and_assert(mcp, callback, "optional_tool", {}, expected)
@ -194,9 +192,7 @@ async def test_elicitation_with_optional_fields():
except TypeError as e:
return f"Validation failed: {str(e)}"
async def elicitation_callback(
context: RequestContext[ClientSession], params: ElicitRequestParams
): # pragma: no cover
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams): # pragma: no cover
return ElicitResult(action="accept", content={})
await call_tool_and_assert(
@ -219,7 +215,7 @@ async def test_elicitation_with_optional_fields():
return f"Name: {result.data.name}, Tags: {', '.join(result.data.tags)}"
return f"User {result.action}" # pragma: no cover
async def multiselect_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def multiselect_callback(context: ClientRequestContext, params: ElicitRequestParams):
if "Please provide tags" in params.message:
return ElicitResult(action="accept", content={"name": "Test", "tags": ["tag1", "tag2"]})
return ElicitResult(action="decline") # pragma: no cover
@ -239,7 +235,7 @@ async def test_elicitation_with_optional_fields():
return f"Name: {result.data.name}, Tags: {tags_str}"
return f"User {result.action}" # pragma: no cover
async def optional_multiselect_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def optional_multiselect_callback(context: ClientRequestContext, params: ElicitRequestParams):
if "Please provide optional tags" in params.message:
return ElicitResult(action="accept", content={"name": "Test", "tags": ["tag1", "tag2"]})
return ElicitResult(action="decline") # pragma: no cover
@ -273,7 +269,7 @@ async def test_elicitation_with_default_values():
return f"User {result.action}"
# First verify that defaults are present in the JSON schema sent to clients
async def callback_schema_verify(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def callback_schema_verify(context: ClientRequestContext, params: ElicitRequestParams):
# Verify the schema includes defaults
assert isinstance(params, types.ElicitRequestFormParams), "Expected form mode elicitation"
schema = params.requested_schema
@ -295,7 +291,7 @@ async def test_elicitation_with_default_values():
)
# Test overriding defaults
async def callback_override(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def callback_override(context: ClientRequestContext, params: ElicitRequestParams):
return ElicitResult(
action="accept", content={"email": "john@example.com", "name": "John", "age": 25, "subscribe": False}
)
@ -371,7 +367,7 @@ async def test_elicitation_with_enum_titles():
return f"User: {result.data.user_name}, Color: {result.data.color}"
return f"User {result.action}" # pragma: no cover
async def enum_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def enum_callback(context: ClientRequestContext, params: ElicitRequestParams):
if "colors" in params.message and "legacy" not in params.message:
return ElicitResult(action="accept", content={"user_name": "Bob", "favorite_colors": ["red", "green"]})
elif "color" in params.message:

View file

@ -26,9 +26,7 @@ from examples.snippets.servers import (
structured_output,
tool_progress,
)
from mcp.client import Client
from mcp.client.session import ClientSession
from mcp.shared._context import RequestContext
from mcp.client import Client, ClientRequestContext
from mcp.shared.session import RequestResponder
from mcp.types import (
ClientResult,
@ -80,9 +78,7 @@ class NotificationCollector:
self.tool_notifications.append(message.params)
async def sampling_callback(
context: RequestContext[ClientSession], params: CreateMessageRequestParams
) -> CreateMessageResult:
async def sampling_callback(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult:
"""Sampling callback for tests."""
return CreateMessageResult(
role="assistant",
@ -94,7 +90,7 @@ async def sampling_callback(
)
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
"""Elicitation callback for tests."""
# For restaurant booking test
if "No tables available" in params.message:

View file

@ -1098,10 +1098,10 @@ class TestContextInjection:
assert "Logged messages for test" in content.text
assert mock_log.call_count == 4
mock_log.assert_any_call(level="debug", data="Debug message", logger=None, related_request_id="1")
mock_log.assert_any_call(level="info", data="Info message", logger=None, related_request_id="1")
mock_log.assert_any_call(level="warning", data="Warning message", logger=None, related_request_id="1")
mock_log.assert_any_call(level="error", data="Error message", logger=None, related_request_id="1")
mock_log.assert_any_call(level="debug", data="Debug message", logger=None, related_request_id="2")
mock_log.assert_any_call(level="info", data="Info message", logger=None, related_request_id="2")
mock_log.assert_any_call(level="warning", data="Warning message", logger=None, related_request_id="2")
mock_log.assert_any_call(level="error", data="Error message", logger=None, related_request_id="2")
async def test_optional_context(self):
"""Test that context is optional."""

View file

@ -5,10 +5,9 @@ import pytest
from pydantic import BaseModel, Field
from mcp import Client, types
from mcp.client.session import ClientSession
from mcp.client import ClientRequestContext
from mcp.server.elicitation import CancelledElicitation, DeclinedElicitation, elicit_url
from mcp.server.mcpserver import Context, MCPServer
from mcp.shared._context import RequestContext
from mcp.types import ElicitRequestParams, ElicitResult, TextContent
@ -28,7 +27,7 @@ async def test_url_elicitation_accept():
return f"User {result.action}"
# Create elicitation callback that accepts URL mode
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
assert params.mode == "url"
assert params.url == "https://example.com/api_key_setup"
assert params.elicitation_id == "test-elicitation-001"
@ -57,7 +56,7 @@ async def test_url_elicitation_decline():
# Test only checks decline path
return f"User {result.action} authorization"
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
assert params.mode == "url"
return ElicitResult(action="decline")
@ -83,7 +82,7 @@ async def test_url_elicitation_cancel():
# Test only checks cancel path
return f"User {result.action} payment"
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
assert params.mode == "url"
return ElicitResult(action="cancel")
@ -110,7 +109,7 @@ async def test_url_elicitation_helper_function():
# Test only checks accept path - return the type name
return type(result).__name__
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
return ElicitResult(action="accept")
async with Client(mcp, elicitation_callback=elicitation_callback) as client:
@ -137,7 +136,7 @@ async def test_url_no_content_in_response():
assert result.content is None
return f"Action: {result.action}, Content: {result.content}"
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
# Verify that this is URL mode
assert params.mode == "url"
assert isinstance(params, types.ElicitRequestURLParams)
@ -170,7 +169,7 @@ async def test_form_mode_still_works():
assert result.data is not None
return f"Hello, {result.data.name}!"
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
# Verify form mode parameters
assert params.mode == "form"
assert isinstance(params, types.ElicitRequestFormParams)
@ -206,7 +205,7 @@ async def test_elicit_complete_notification():
return "Elicitation completed"
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
return ElicitResult(action="accept") # pragma: no cover
async with Client(mcp, elicitation_callback=elicitation_callback) as client:
@ -263,7 +262,7 @@ async def test_elicit_url_typed_results():
return "Not cancelled" # pragma: no cover
# Test declined result
async def decline_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def decline_callback(context: ClientRequestContext, params: ElicitRequestParams):
return ElicitResult(action="decline")
async with Client(mcp, elicitation_callback=decline_callback) as client:
@ -273,7 +272,7 @@ async def test_elicit_url_typed_results():
assert result.content[0].text == "Declined"
# Test cancelled result
async def cancel_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def cancel_callback(context: ClientRequestContext, params: ElicitRequestParams):
return ElicitResult(action="cancel")
async with Client(mcp, elicitation_callback=cancel_callback) as client:
@ -303,7 +302,7 @@ async def test_deprecated_elicit_method():
return f"Email: {result.content.get('email', 'none')}"
return "No email provided" # pragma: no cover
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
# Verify this is form mode
assert params.mode == "form"
assert params.requested_schema is not None
@ -331,7 +330,7 @@ async def test_ctx_elicit_url_convenience_method():
)
return f"Result: {result.action}"
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
assert params.mode == "url"
assert params.elicitation_id == "ctx-test-001"
return ElicitResult(action="accept")

View file

@ -103,13 +103,13 @@ async def test_send_request_omits_call_options_when_none_given():
@pytest.mark.anyio
async def test_send_request_timeout_zero_means_no_timeout():
"""0 falls through BaseSession's `or`-fallback, so it has always meant
"no timeout"; ClientSession still reads it that way."""
async def test_send_request_timeout_zero_is_forwarded():
"""0 is a real timeout (fail at the first checkpoint, `anyio.fail_after(0)`
semantics) and must reach the dispatcher; only `None` means "no timeout"."""
dispatcher = StubDispatcher(result={})
session = _make_session(dispatcher)
await session.send_request(types.PingRequest(), types.EmptyResult, request_read_timeout_seconds=0)
assert dispatcher.requests[0][2] is None
await session.send_request(types.PingRequest(), types.EmptyResult, request_read_timeout_seconds=0.0)
assert dispatcher.requests[0][2] == {"timeout": 0.0}
@pytest.mark.anyio

View file

@ -0,0 +1,20 @@
"""Tests for the contextvars-carrying memory-stream wrappers."""
import anyio
import pytest
from mcp.shared._context_streams import create_context_streams
pytestmark = pytest.mark.anyio
async def test_sync_close_closes_the_underlying_streams() -> None:
"""The wrappers mirror anyio's memory streams: close() is the sync form of aclose()."""
send, receive = create_context_streams[str](1)
await send.send("queued")
send.close()
receive.close()
with pytest.raises(anyio.ClosedResourceError):
await send.send("after close")
with pytest.raises(anyio.ClosedResourceError):
await receive.receive()

Some files were not shown because too many files have changed in this diff Show more