Files
modelcontextprotocol--pytho…/tests/client/test_session.py
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:10:27 +08:00

1950 lines
84 KiB
Python

from __future__ import annotations
from collections.abc import AsyncIterator, Mapping
from contextlib import AsyncExitStack, asynccontextmanager
from typing import Any, cast
import anyio
import anyio.abc
import anyio.streams.memory
import mcp_types as types
import pytest
from mcp_types import (
CONNECTION_CLOSED,
INTERNAL_ERROR,
INVALID_PARAMS,
METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY,
REQUEST_TIMEOUT,
UNSUPPORTED_PROTOCOL_VERSION,
CallToolResult,
Implementation,
InitializedNotification,
InitializeRequest,
InitializeResult,
JSONRPCError,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
RequestParamsMeta,
ServerCapabilities,
TextContent,
client_notification_adapter,
client_request_adapter,
)
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, LATEST_HANDSHAKE_VERSION
from pydantic import FileUrl, ValidationError
from mcp import MCPError
from mcp.client import ClientRequestContext
from mcp.client.client import Client
from mcp.client.session import DEFAULT_CLIENT_INFO, ClientSession
from mcp.client.subscriptions import ToolsListChanged, listen
from mcp.server import Server, ServerRequestContext
from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair
from mcp.shared.dispatcher import CallOptions, DispatchContext, OnNotify, OnNotifyIntercept, OnRequest
from mcp.shared.message import SessionMessage
from mcp.shared.session import RequestResponder
from mcp.shared.subscriptions import SUBSCRIPTION_ID_META_KEY
from mcp.shared.transport_context import TransportContext
_SendToClient = anyio.streams.memory.MemoryObjectSendStream[SessionMessage | Exception]
_RecvFromClient = anyio.streams.memory.MemoryObjectReceiveStream[SessionMessage]
@asynccontextmanager
async def raw_client_session(
**kwargs: Any,
) -> AsyncIterator[tuple[ClientSession, _SendToClient, _RecvFromClient]]:
"""Yield `(session, send_to_client, recv_from_client)` with the receive loop running.
`send_to_client` accepts `SessionMessage | Exception` so tests can inject
transport-level exceptions. No initialize handshake is performed.
"""
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage](32)
async with ClientSession(s2c_recv, c2s_send, **kwargs) as session:
try:
with anyio.fail_after(5):
yield session, s2c_send, c2s_recv
finally:
s2c_send.close()
c2s_recv.close()
@pytest.mark.anyio
async def test_client_session_initialize():
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1)
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1)
initialized_notification = None
result = None
async def mock_server():
nonlocal initialized_notification
session_message = await client_to_server_receive.receive()
jsonrpc_request = session_message.message
assert isinstance(jsonrpc_request, JSONRPCRequest)
request = client_request_adapter.validate_python(
jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True)
)
assert isinstance(request, InitializeRequest)
result = InitializeResult(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(
logging=None,
resources=None,
tools=None,
experimental=None,
prompts=None,
),
server_info=Implementation(name="mock-server", version="0.1.0"),
instructions="The server instructions.",
)
async with server_to_client_send:
await server_to_client_send.send(
SessionMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=jsonrpc_request.id,
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
session_notification = await client_to_server_receive.receive()
jsonrpc_notification = session_notification.message
assert isinstance(jsonrpc_notification, JSONRPCNotification)
initialized_notification = client_notification_adapter.validate_python(
jsonrpc_notification.model_dump(by_alias=True, mode="json", exclude_none=True)
)
# Create a message handler to catch exceptions
async def message_handler( # pragma: no cover
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
) -> None:
if isinstance(message, Exception):
raise message
async with (
ClientSession(
server_to_client_receive,
client_to_server_send,
message_handler=message_handler,
) as session,
anyio.create_task_group() as tg,
client_to_server_send,
client_to_server_receive,
server_to_client_send,
server_to_client_receive,
):
tg.start_soon(mock_server)
result = await session.initialize()
# Assert the result
assert isinstance(result, InitializeResult)
assert result.protocol_version == LATEST_HANDSHAKE_VERSION
assert isinstance(result.capabilities, ServerCapabilities)
assert result.server_info == Implementation(name="mock-server", version="0.1.0")
assert result.instructions == "The server instructions."
# Check that the client sent the initialized notification
assert initialized_notification
assert isinstance(initialized_notification, InitializedNotification)
@pytest.mark.anyio
async def test_client_session_custom_client_info():
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1)
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1)
custom_client_info = Implementation(name="test-client", version="1.2.3")
received_client_info = None
async def mock_server():
nonlocal received_client_info
session_message = await client_to_server_receive.receive()
jsonrpc_request = session_message.message
assert isinstance(jsonrpc_request, JSONRPCRequest)
request = client_request_adapter.validate_python(
jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True)
)
assert isinstance(request, InitializeRequest)
received_client_info = request.params.client_info
result = InitializeResult(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(),
server_info=Implementation(name="mock-server", version="0.1.0"),
)
async with server_to_client_send:
await server_to_client_send.send(
SessionMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=jsonrpc_request.id,
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
# Receive initialized notification
await client_to_server_receive.receive()
async with (
ClientSession(
server_to_client_receive,
client_to_server_send,
client_info=custom_client_info,
) as session,
anyio.create_task_group() as tg,
client_to_server_send,
client_to_server_receive,
server_to_client_send,
server_to_client_receive,
):
tg.start_soon(mock_server)
await session.initialize()
# Assert that the custom client info was sent
assert received_client_info == custom_client_info
@pytest.mark.anyio
async def test_client_session_default_client_info():
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1)
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1)
received_client_info = None
async def mock_server():
nonlocal received_client_info
session_message = await client_to_server_receive.receive()
jsonrpc_request = session_message.message
assert isinstance(jsonrpc_request, JSONRPCRequest)
request = client_request_adapter.validate_python(
jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True)
)
assert isinstance(request, InitializeRequest)
received_client_info = request.params.client_info
result = InitializeResult(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(),
server_info=Implementation(name="mock-server", version="0.1.0"),
)
async with server_to_client_send:
await server_to_client_send.send(
SessionMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=jsonrpc_request.id,
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
# Receive initialized notification
await client_to_server_receive.receive()
async with (
ClientSession(server_to_client_receive, client_to_server_send) as session,
anyio.create_task_group() as tg,
client_to_server_send,
client_to_server_receive,
server_to_client_send,
server_to_client_receive,
):
tg.start_soon(mock_server)
await session.initialize()
# Assert that the default client info was sent
assert received_client_info == DEFAULT_CLIENT_INFO
@pytest.mark.anyio
async def test_client_session_version_negotiation_success():
"""Test successful version negotiation with supported version"""
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1)
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1)
result = None
async def mock_server():
session_message = await client_to_server_receive.receive()
jsonrpc_request = session_message.message
assert isinstance(jsonrpc_request, JSONRPCRequest)
request = client_request_adapter.validate_python(
jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True)
)
assert isinstance(request, InitializeRequest)
# Verify client offers the newest handshake protocol version
assert request.params.protocol_version == LATEST_HANDSHAKE_VERSION
# Server responds with a supported older version
result = InitializeResult(
protocol_version="2024-11-05",
capabilities=ServerCapabilities(),
server_info=Implementation(name="mock-server", version="0.1.0"),
)
async with server_to_client_send:
await server_to_client_send.send(
SessionMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=jsonrpc_request.id,
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
# Receive initialized notification
await client_to_server_receive.receive()
async with (
ClientSession(server_to_client_receive, client_to_server_send) as session,
anyio.create_task_group() as tg,
client_to_server_send,
client_to_server_receive,
server_to_client_send,
server_to_client_receive,
):
tg.start_soon(mock_server)
result = await session.initialize()
# Assert the result with negotiated version
assert isinstance(result, InitializeResult)
assert result.protocol_version == "2024-11-05"
assert result.protocol_version in HANDSHAKE_PROTOCOL_VERSIONS
@pytest.mark.anyio
async def test_client_session_version_negotiation_failure():
"""Test version negotiation failure with unsupported version"""
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1)
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1)
async def mock_server():
session_message = await client_to_server_receive.receive()
jsonrpc_request = session_message.message
assert isinstance(jsonrpc_request, JSONRPCRequest)
request = client_request_adapter.validate_python(
jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True)
)
assert isinstance(request, InitializeRequest)
# Server responds with an unsupported version
result = InitializeResult(
protocol_version="2020-01-01", # Unsupported old version
capabilities=ServerCapabilities(),
server_info=Implementation(name="mock-server", version="0.1.0"),
)
async with server_to_client_send:
await server_to_client_send.send(
SessionMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=jsonrpc_request.id,
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
async with (
ClientSession(server_to_client_receive, client_to_server_send) as session,
anyio.create_task_group() as tg,
client_to_server_send,
client_to_server_receive,
server_to_client_send,
server_to_client_receive,
):
tg.start_soon(mock_server)
# Should raise RuntimeError for unsupported version
with pytest.raises(RuntimeError, match="Unsupported protocol version"):
await session.initialize()
@pytest.mark.anyio
async def test_client_capabilities_default():
"""Test that client capabilities are properly set with default callbacks"""
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1)
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1)
received_capabilities = None
async def mock_server():
nonlocal received_capabilities
session_message = await client_to_server_receive.receive()
jsonrpc_request = session_message.message
assert isinstance(jsonrpc_request, JSONRPCRequest)
request = client_request_adapter.validate_python(
jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True)
)
assert isinstance(request, InitializeRequest)
received_capabilities = request.params.capabilities
result = InitializeResult(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(),
server_info=Implementation(name="mock-server", version="0.1.0"),
)
async with server_to_client_send:
await server_to_client_send.send(
SessionMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=jsonrpc_request.id,
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
# Receive initialized notification
await client_to_server_receive.receive()
async with (
ClientSession(server_to_client_receive, client_to_server_send) as session,
anyio.create_task_group() as tg,
client_to_server_send,
client_to_server_receive,
server_to_client_send,
server_to_client_receive,
):
tg.start_soon(mock_server)
await session.initialize()
# Assert that capabilities are properly set with defaults
assert received_capabilities is not None
assert received_capabilities.sampling is None # No custom sampling callback
assert received_capabilities.roots is None # No custom list_roots callback
@pytest.mark.anyio
async def test_client_capabilities_with_custom_callbacks():
"""Test that client capabilities are properly set with custom callbacks"""
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1)
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1)
received_capabilities = None
async def custom_sampling_callback( # pragma: no cover
context: ClientRequestContext,
params: types.CreateMessageRequestParams,
) -> types.CreateMessageResult | types.ErrorData:
return types.CreateMessageResult(
role="assistant",
content=types.TextContent(type="text", text="test"),
model="test-model",
)
async def custom_list_roots_callback( # pragma: no cover
context: ClientRequestContext,
) -> types.ListRootsResult | types.ErrorData:
return types.ListRootsResult(roots=[])
async def mock_server():
nonlocal received_capabilities
session_message = await client_to_server_receive.receive()
jsonrpc_request = session_message.message
assert isinstance(jsonrpc_request, JSONRPCRequest)
request = client_request_adapter.validate_python(
jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True)
)
assert isinstance(request, InitializeRequest)
received_capabilities = request.params.capabilities
result = InitializeResult(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(),
server_info=Implementation(name="mock-server", version="0.1.0"),
)
async with server_to_client_send:
await server_to_client_send.send(
SessionMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=jsonrpc_request.id,
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
# Receive initialized notification
await client_to_server_receive.receive()
async with (
ClientSession(
server_to_client_receive,
client_to_server_send,
sampling_callback=custom_sampling_callback,
list_roots_callback=custom_list_roots_callback,
) as session,
anyio.create_task_group() as tg,
client_to_server_send,
client_to_server_receive,
server_to_client_send,
server_to_client_receive,
):
tg.start_soon(mock_server)
await session.initialize()
# Assert that capabilities are properly set with custom callbacks
assert received_capabilities is not None
# Custom sampling callback provided
assert received_capabilities.sampling is not None
assert isinstance(received_capabilities.sampling, types.SamplingCapability)
# Default sampling capabilities (no tools)
assert received_capabilities.sampling.tools is None
# Custom list_roots callback provided
assert received_capabilities.roots is not None
assert isinstance(received_capabilities.roots, types.RootsCapability)
# Should be True for custom callback
assert received_capabilities.roots.list_changed is True
@pytest.mark.anyio
async def test_client_capabilities_with_sampling_tools():
"""Test that sampling capabilities with tools are properly advertised"""
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1)
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1)
received_capabilities = None
async def custom_sampling_callback( # pragma: no cover
context: ClientRequestContext,
params: types.CreateMessageRequestParams,
) -> types.CreateMessageResult | types.ErrorData:
return types.CreateMessageResult(
role="assistant",
content=types.TextContent(type="text", text="test"),
model="test-model",
)
async def mock_server():
nonlocal received_capabilities
session_message = await client_to_server_receive.receive()
jsonrpc_request = session_message.message
assert isinstance(jsonrpc_request, JSONRPCRequest)
request = client_request_adapter.validate_python(
jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True)
)
assert isinstance(request, InitializeRequest)
received_capabilities = request.params.capabilities
result = InitializeResult(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(),
server_info=Implementation(name="mock-server", version="0.1.0"),
)
async with server_to_client_send:
await server_to_client_send.send(
SessionMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=jsonrpc_request.id,
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
# Receive initialized notification
await client_to_server_receive.receive()
async with (
ClientSession(
server_to_client_receive,
client_to_server_send,
sampling_callback=custom_sampling_callback,
sampling_capabilities=types.SamplingCapability(tools=types.SamplingToolsCapability()),
) as session,
anyio.create_task_group() as tg,
client_to_server_send,
client_to_server_receive,
server_to_client_send,
server_to_client_receive,
):
tg.start_soon(mock_server)
await session.initialize()
# Assert that sampling capabilities with tools are properly advertised
assert received_capabilities is not None
assert received_capabilities.sampling is not None
assert isinstance(received_capabilities.sampling, types.SamplingCapability)
# Tools capability should be present
assert received_capabilities.sampling.tools is not None
assert isinstance(received_capabilities.sampling.tools, types.SamplingToolsCapability)
@pytest.mark.anyio
async def test_initialize_result():
"""Test that initialize_result is None before init and contains the full result after."""
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1)
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1)
expected_capabilities = ServerCapabilities(
logging=types.LoggingCapability(),
prompts=types.PromptsCapability(list_changed=True),
resources=types.ResourcesCapability(subscribe=True, list_changed=True),
tools=types.ToolsCapability(list_changed=False),
)
expected_server_info = Implementation(name="mock-server", version="0.1.0")
expected_instructions = "Use the tools wisely."
async def mock_server():
session_message = await client_to_server_receive.receive()
jsonrpc_request = session_message.message
assert isinstance(jsonrpc_request, JSONRPCRequest)
request = client_request_adapter.validate_python(
jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True)
)
assert isinstance(request, InitializeRequest)
result = InitializeResult(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=expected_capabilities,
server_info=expected_server_info,
instructions=expected_instructions,
)
async with server_to_client_send:
await server_to_client_send.send(
SessionMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=jsonrpc_request.id,
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
await client_to_server_receive.receive()
async with (
ClientSession(
server_to_client_receive,
client_to_server_send,
) as session,
anyio.create_task_group() as tg,
client_to_server_send,
client_to_server_receive,
server_to_client_send,
server_to_client_receive,
):
assert session.initialize_result is None
tg.start_soon(mock_server)
await session.initialize()
result = session.initialize_result
assert result is not None
assert result.server_info == expected_server_info
assert result.capabilities == expected_capabilities
assert result.instructions == expected_instructions
assert result.protocol_version == LATEST_HANDSHAKE_VERSION
# Era-neutral accessors are populated from the InitializeResult.
assert session.server_info == expected_server_info
assert session.server_capabilities == expected_capabilities
assert session.instructions == expected_instructions
assert session.protocol_version == LATEST_HANDSHAKE_VERSION
@pytest.mark.anyio
@pytest.mark.parametrize(argnames="meta", argvalues=[None, {"toolMeta": "value"}])
async def test_client_tool_call_with_meta(meta: RequestParamsMeta | None):
"""Test that client tool call requests can include metadata"""
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1)
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1)
mocked_tool = types.Tool(name="sample_tool", input_schema={"type": "object"})
async def mock_server():
# Receive initialization request from client
session_message = await client_to_server_receive.receive()
jsonrpc_request = session_message.message
assert isinstance(jsonrpc_request, JSONRPCRequest)
request = client_request_adapter.validate_python(
jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True)
)
assert isinstance(request, InitializeRequest)
result = InitializeResult(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(),
server_info=Implementation(name="mock-server", version="0.1.0"),
)
# Answer initialization request
await server_to_client_send.send(
SessionMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=jsonrpc_request.id,
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
# Receive initialized notification
await client_to_server_receive.receive()
# Wait for the client to send a 'tools/call' request
session_message = await client_to_server_receive.receive()
jsonrpc_request = session_message.message
assert isinstance(jsonrpc_request, JSONRPCRequest)
assert jsonrpc_request.method == "tools/call"
if meta is not None:
assert jsonrpc_request.params
assert "_meta" in jsonrpc_request.params
assert jsonrpc_request.params["_meta"] == meta
result = CallToolResult(content=[TextContent(type="text", text="Called successfully")], is_error=False)
# Send the tools/call result
await server_to_client_send.send(
SessionMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=jsonrpc_request.id,
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
# Wait for the tools/list request from the client
# The client requires this step to validate the tool output schema
session_message = await client_to_server_receive.receive()
jsonrpc_request = session_message.message
assert isinstance(jsonrpc_request, JSONRPCRequest)
assert jsonrpc_request.method == "tools/list"
result = types.ListToolsResult(tools=[mocked_tool])
await server_to_client_send.send(
SessionMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=jsonrpc_request.id,
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
server_to_client_send.close()
async with (
ClientSession(server_to_client_receive, client_to_server_send) as session,
anyio.create_task_group() as tg,
client_to_server_send,
client_to_server_receive,
server_to_client_send,
server_to_client_receive,
):
tg.start_soon(mock_server)
await session.initialize()
await session.call_tool(name=mocked_tool.name, arguments={"foo": "bar"}, meta=meta)
@pytest.mark.anyio
async def test_receive_loop_answers_malformed_inbound_request_with_invalid_params():
"""A request that fails ServerRequest validation gets an INVALID_PARAMS error response."""
async with raw_client_session() as (_session, to_client, from_client):
await to_client.send(
SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=7, method="sampling/createMessage", params={"broken": 1}))
)
out = await from_client.receive()
assert isinstance(out.message, JSONRPCError)
assert out.message.id == 7
assert out.message.error.code == INVALID_PARAMS
@pytest.mark.anyio
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
def _set_negotiated_version(session: ClientSession, version: str) -> None:
"""Force `session.protocol_version` without running the handshake."""
session.adopt(
InitializeResult(
protocol_version=version,
capabilities=ServerCapabilities(),
server_info=Implementation(name="mock-server", version="0.1.0"),
)
)
@pytest.mark.anyio
async def test_on_request_rejects_a_server_request_absent_at_the_negotiated_version():
"""`elicitation/create` does not exist at 2025-03-26: the version gate answers
METHOD_NOT_FOUND instead of reaching the elicitation callback."""
async with raw_client_session() as (session, to_client, from_client):
_set_negotiated_version(session, "2025-03-26")
await to_client.send(
SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="elicitation/create", params={"message": "hi"}))
)
out = await from_client.receive()
assert isinstance(out.message, JSONRPCError)
assert out.message.error.code == METHOD_NOT_FOUND
assert out.message.error.data == "elicitation/create"
@pytest.mark.anyio
async def test_on_request_validates_the_callback_result_against_the_surface_schema():
"""A surface-valid callback result reaches the wire as the dump dict unchanged."""
async def sampling(
ctx: ClientRequestContext, params: types.CreateMessageRequestParams
) -> types.CreateMessageResult:
return types.CreateMessageResult(role="assistant", content=types.TextContent(type="text", text="hi"), model="m")
request_params = types.CreateMessageRequestParams(
messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="q"))],
max_tokens=10,
).model_dump(by_alias=True, mode="json", exclude_none=True)
async with raw_client_session(sampling_callback=sampling) as (_session, to_client, from_client):
await to_client.send(
SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=2, method="sampling/createMessage", params=request_params))
)
out = await from_client.receive()
assert isinstance(out.message, JSONRPCResponse)
assert out.message.result == {"role": "assistant", "content": {"type": "text", "text": "hi"}, "model": "m"}
@pytest.mark.anyio
async def test_on_request_callback_returning_a_surface_invalid_result_is_internal_error(
caplog: pytest.LogCaptureFixture,
):
"""A callback result the surface schema rejects is answered with INTERNAL_ERROR.
`EmptyResult` is a `ClientResult` arm so the union accepts it, but `roots/list`
requires a `roots` array."""
async def list_roots(ctx: ClientRequestContext) -> types.ListRootsResult | types.ErrorData:
return cast("types.ListRootsResult", types.EmptyResult())
async with raw_client_session(list_roots_callback=list_roots) as (_session, to_client, from_client):
await to_client.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=3, method="roots/list")))
out = await from_client.receive()
assert isinstance(out.message, JSONRPCError)
assert out.message.error.code == INTERNAL_ERROR
assert out.message.error.message == "Client callback returned an invalid result"
assert "client callback for 'roots/list' returned an invalid result" in caplog.text
@pytest.mark.anyio
async def test_on_notify_drops_a_server_notification_absent_at_the_negotiated_version(
caplog: pytest.LogCaptureFixture,
):
"""`notifications/elicitation/complete` does not exist at 2025-06-18: it is
debug-log-dropped without reaching `message_handler`."""
seen: list[object] = []
delivered = anyio.Event()
async def handler(msg: object) -> None:
seen.append(msg)
delivered.set()
with caplog.at_level("DEBUG", logger="client"):
async with raw_client_session(message_handler=handler) as (session, to_client, _):
_set_negotiated_version(session, "2025-06-18")
await to_client.send(
SessionMessage(
JSONRPCNotification(
jsonrpc="2.0", method="notifications/elicitation/complete", params={"elicitationId": "e1"}
)
)
)
await to_client.send(
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/tools/list_changed"))
)
await delivered.wait()
assert len(seen) == 1
assert isinstance(seen[0], types.ToolListChangedNotification)
assert "dropped 'notifications/elicitation/complete': not defined at 2025-06-18" in caplog.text
@pytest.mark.anyio
async def test_on_request_elicitation_with_loose_property_schema_reaches_the_callback():
"""Older python-sdk servers emit `anyOf` for `Optional` form fields; the
inbound surface gate must let that through to the elicitation callback."""
seen: list[types.ElicitRequestParams] = []
async def elicitation(ctx: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult:
seen.append(params)
return types.ElicitResult(action="accept", content={"x": 1})
request_params = {
"message": "m",
"requestedSchema": {
"type": "object",
"properties": {"x": {"anyOf": [{"type": "integer"}, {"type": "null"}]}},
},
}
async with raw_client_session(elicitation_callback=elicitation) as (_session, to_client, from_client):
await to_client.send(
SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=4, method="elicitation/create", params=request_params))
)
out = await from_client.receive()
assert isinstance(out.message, JSONRPCResponse)
assert out.message.result == {"action": "accept", "content": {"x": 1}}
assert len(seen) == 1
@pytest.mark.anyio
async def test_send_request_validates_the_server_result_against_the_surface_schema():
"""A spec-method result that fails the per-version surface schema raises
`ValidationError` even when the caller's `result_type` would accept it."""
async with raw_client_session() as (session, to_client, from_client):
async with anyio.create_task_group() as tg:
async def call() -> None:
with pytest.raises(ValidationError):
await session.send_request(types.ListToolsRequest(), types.EmptyResult)
tg.start_soon(call)
request = await from_client.receive()
assert isinstance(request.message, JSONRPCRequest)
await to_client.send(
SessionMessage(JSONRPCResponse(jsonrpc="2.0", id=request.message.id, result={"tools": "nope"}))
)
@pytest.mark.anyio
async def test_send_request_skips_the_surface_gate_when_method_absent_at_version():
"""Surface row absent for the negotiated version: gate is bypassed and only
`result_type` validates."""
async with raw_client_session() as (session, to_client, from_client):
_set_negotiated_version(session, "2026-07-28")
async with anyio.create_task_group() as tg:
async def call() -> None:
result = await session.send_request(types.PingRequest(), types.EmptyResult)
assert isinstance(result, types.EmptyResult)
tg.start_soon(call)
request = await from_client.receive()
assert isinstance(request.message, JSONRPCRequest)
await to_client.send(SessionMessage(JSONRPCResponse(jsonrpc="2.0", id=request.message.id, result={})))
@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")
params = types.CreateMessageRequestParams(
messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hi"))],
max_tokens=10,
).model_dump(by_alias=True, mode="json", exclude_none=True)
async with raw_client_session(sampling_callback=boom) as (_session, to_client, from_client):
await to_client.send(
SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=8, method="sampling/createMessage", params=params))
)
out = await from_client.receive()
assert isinstance(out.message, JSONRPCError)
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 malformed notification is warn-logged and dropped without reaching `message_handler` (SDK-defined).
Scripted peer: the typed API cannot emit malformed params for a spec method."""
seen: list[object] = []
delivered = anyio.Event()
async def handler(msg: object) -> None:
seen.append(msg)
delivered.set()
async with raw_client_session(message_handler=handler) as (_session, to_client, _):
await to_client.send(
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/progress", params={"broken": 1}))
)
# Follow with a valid notification so we know the loop is still alive.
await to_client.send(
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/tools/list_changed"))
)
await delivered.wait()
assert isinstance(seen[0], types.ToolListChangedNotification)
assert "Failed to validate notification: notifications/progress" in caplog.text
@pytest.mark.anyio
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, 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
async def test_receive_loop_consumes_server_cancelled_without_reaching_message_handler():
"""A server-sent notifications/cancelled is swallowed, matching the pre-swap contract.
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()
async def handler(msg: object) -> None:
seen.append(msg)
delivered.set()
async with raw_client_session(message_handler=handler) as (_session, to_client, _):
await to_client.send(
SessionMessage(
JSONRPCNotification(
jsonrpc="2.0", method="notifications/cancelled", params={"requestId": 1, "reason": "timed out"}
)
)
)
# Follow with a notification that does reach the handler so we can
# assert ordering deterministically.
await to_client.send(
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/tools/list_changed"))
)
await delivered.wait()
assert len(seen) == 1
assert isinstance(seen[0], types.ToolListChangedNotification)
@pytest.mark.anyio
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 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, 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
def test_adopt_raises_when_no_mutual_modern_version_is_supported() -> None:
"""SDK-defined: ``adopt(DiscoverResult)`` picks the newest version both sides support; an
empty intersection is unrecoverable and raises rather than installing a stamp."""
client_d, _ = create_direct_dispatcher_pair()
session = ClientSession(dispatcher=client_d)
with pytest.raises(RuntimeError, match="No mutually supported modern protocol version"):
session.adopt(
types.DiscoverResult(
supported_versions=["1999-01-01"],
capabilities=types.ServerCapabilities(),
server_info=types.Implementation(name="s", version="0"),
result_type="complete",
ttl_ms=0,
cache_scope="public",
)
)
assert session.protocol_version is None
class _OptsRecordingDispatcher:
"""Records `send_raw_request` opts and answers from a per-method script (default `{}`)."""
def __init__(self, answers: dict[str, dict[str, Any]] | None = None) -> None:
self.calls: list[tuple[str, CallOptions]] = []
self._answers = answers or {}
async def run(
self,
on_request: OnRequest,
on_notify: OnNotify,
on_notify_intercept: OnNotifyIntercept | None = None,
*,
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 {}))
return self._answers.get(method, {})
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
pass
@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."""
init_answer = InitializeResult(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(),
server_info=Implementation(name="mock-server", version="0.1.0"),
).model_dump(by_alias=True, mode="json", exclude_none=True)
dispatcher = _OptsRecordingDispatcher({"initialize": init_answer})
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"]
@pytest.mark.anyio
async def test_modern_stamp_leaves_cancel_on_abandon_at_the_dispatcher_default():
"""Post-adopt modern requests leave `cancel_on_abandon` unset (the dispatcher default,
True): the courtesy frame is the abandon signal — the 2026 cancellation spelling on
stream transports, and the streamable-HTTP transport's cue to abort the request's own
POST. The negotiation methods still opt out on every path: `send_discover`'s explicit
opts, and the stamp's own carve-out for a `server/discover` sent through the generic
`send_request`."""
dispatcher = _OptsRecordingDispatcher({"server/discover": _discover_result_dict()})
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
await session.discover()
await session.send_ping()
await session.send_request(types.DiscoverRequest(params=types.RequestParams()), types.DiscoverResult)
assert [method for method, _ in dispatcher.calls] == ["server/discover", "ping", "server/discover"]
negotiation_opts, ping_opts, stamped_negotiation_opts = (opts for _, opts in dispatcher.calls)
assert negotiation_opts.get("cancel_on_abandon") is False
assert "cancel_on_abandon" not in ping_opts
assert stamped_negotiation_opts.get("cancel_on_abandon") is False
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,
on_notify_intercept: OnNotifyIntercept | None = None,
*,
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, opts: CallOptions | None = 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()
# --- discover() ladder ---
class _ScriptedDispatcher:
"""Records every `send_raw_request` and plays back scripted answers in order.
A script entry that is an `Exception` is raised; a dict is returned."""
def __init__(self, *script: dict[str, Any] | Exception) -> None:
self.calls: list[tuple[str, Mapping[str, Any] | None]] = []
self.notifies: list[str] = []
self._script: list[dict[str, Any] | Exception] = list(script)
async def run(
self,
on_request: OnRequest,
on_notify: OnNotify,
on_notify_intercept: OnNotifyIntercept | None = None,
*,
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, params))
item = self._script.pop(0)
if isinstance(item, Exception):
raise item
return item
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
self.notifies.append(method)
def _discover_result_dict() -> dict[str, Any]:
return types.DiscoverResult(
supported_versions=["2026-07-28"],
capabilities=ServerCapabilities(),
server_info=Implementation(name="stub", version="0"),
).model_dump(by_alias=True, mode="json", exclude_none=True)
@pytest.mark.anyio
async def test_initialize_is_idempotent_and_returns_the_cached_result() -> None:
"""A second `initialize()` returns the first call's result by identity and sends nothing
over the wire — the early-return guard short-circuits before the dispatcher is touched."""
init_result = InitializeResult(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(),
server_info=Implementation(name="mock-server", version="0.1.0"),
).model_dump(by_alias=True, mode="json", exclude_none=True)
dispatcher = _ScriptedDispatcher(init_result)
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
first = await session.initialize()
second = await session.initialize()
assert first is second
assert [method for method, _ in dispatcher.calls] == ["initialize"]
assert dispatcher.notifies == ["notifications/initialized"]
@pytest.mark.anyio
async def test_discover_adopts_the_returned_result_and_installs_the_modern_stamp() -> None:
"""SDK-defined: a successful `server/discover` is adopted and subsequent requests
carry the modern `_meta` envelope (protocol version + client info + capabilities)."""
dispatcher = _ScriptedDispatcher(_discover_result_dict(), {})
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
result = await session.discover()
assert isinstance(result, types.DiscoverResult)
assert session.protocol_version == "2026-07-28"
await session.send_ping()
ping_method, ping_params = dispatcher.calls[-1]
assert ping_method == "ping"
assert ping_params is not None
assert ping_params["_meta"][PROTOCOL_VERSION_META_KEY] == "2026-07-28"
@pytest.mark.anyio
async def test_discover_retries_once_on_unsupported_version_then_adopts() -> None:
"""Spec SHOULD: a -32022 reply that names a mutually-supported version
triggers exactly one retry at that version, and the retry's result is adopted."""
dispatcher = _ScriptedDispatcher(
MCPError(
UNSUPPORTED_PROTOCOL_VERSION,
"unsupported",
data={"supported": ["2026-07-28"], "requested": "2026-07-28"},
),
_discover_result_dict(),
)
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
await session.discover()
assert session.protocol_version == "2026-07-28"
assert [m for m, _ in dispatcher.calls] == ["server/discover", "server/discover"]
@pytest.mark.anyio
async def test_discover_raises_when_retry_intersection_is_empty() -> None:
"""Spec SHOULD: a -32022 reply whose `supported` list shares nothing with the
client's modern versions is unrecoverable — the original error is re-raised
without a second probe."""
dispatcher = _ScriptedDispatcher(
MCPError(
UNSUPPORTED_PROTOCOL_VERSION,
"unsupported",
data={"supported": ["1999-01-01"], "requested": "2026-07-28"},
),
)
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
with pytest.raises(MCPError) as exc:
await session.discover()
assert exc.value.error.code == UNSUPPORTED_PROTOCOL_VERSION
assert [m for m, _ in dispatcher.calls] == ["server/discover"]
@pytest.mark.anyio
@pytest.mark.parametrize("code", [METHOD_NOT_FOUND, REQUEST_TIMEOUT, INTERNAL_ERROR])
async def test_discover_reraises_non_retry_errors_without_falling_back(code: int) -> None:
"""SDK-defined: any error outside the -32022 retry rung propagates verbatim
— `discover()` does not fall back to `initialize()` itself; that is the
caller's policy (`Client.__aenter__`)."""
dispatcher = _ScriptedDispatcher(MCPError(code, "nope"))
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
with pytest.raises(MCPError) as exc:
await session.discover()
assert exc.value.error.code == code
assert session.protocol_version is None
assert [m for m, _ in dispatcher.calls] == ["server/discover"]
assert dispatcher.notifies == []
@pytest.mark.anyio
async def test_discover_validates_the_response_shape_before_adopting() -> None:
"""SDK-defined: the raw response is run through `DiscoverResult` validation
before any state is installed, so a malformed reply leaves the session
un-adopted rather than half-configured."""
dispatcher = _ScriptedDispatcher({"supportedVersions": ["2026-07-28"]})
session = ClientSession(dispatcher=dispatcher)
with anyio.fail_after(5):
async with session:
with pytest.raises(ValidationError):
await session.discover()
assert session.protocol_version is None
@pytest.mark.anyio
async def test_discover_is_idempotent_and_returns_the_cached_result() -> None:
"""SDK-defined: a second `discover()` returns the already-adopted result without
re-probing — the script holds exactly one entry, so a second wire call would
`IndexError` on the empty script."""
dispatcher = _ScriptedDispatcher(_discover_result_dict())
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
first = await session.discover()
assert isinstance(first, types.DiscoverResult)
assert await session.discover() is first
assert session.discover_result is first
assert [m for m, _ in dispatcher.calls] == ["server/discover"]
def test_era_neutral_properties_are_none_before_any_handshake() -> None:
"""SDK-defined: the era-neutral accessors all read as None on a fresh session."""
client_d, _ = create_direct_dispatcher_pair()
session = ClientSession(dispatcher=client_d)
assert session.protocol_version is None
assert session.server_info is None
assert session.server_capabilities is None
assert session.instructions is None
assert session.discover_result is None
assert session.initialize_result is None
@pytest.mark.anyio
async def test_era_neutral_properties_after_discover() -> None:
"""SDK-defined: after `discover()` the era-neutral accessors read from the
DiscoverResult; `initialize_result` stays None."""
raw = types.DiscoverResult(
supported_versions=["2026-07-28"],
capabilities=ServerCapabilities(tools=types.ToolsCapability(list_changed=True)),
server_info=Implementation(name="discovered", version="2.0"),
instructions="hello",
).model_dump(by_alias=True, mode="json", exclude_none=True)
dispatcher = _ScriptedDispatcher(raw)
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
await session.discover()
assert session.protocol_version == "2026-07-28"
assert session.server_info == Implementation(name="discovered", version="2.0")
assert session.server_capabilities == ServerCapabilities(tools=types.ToolsCapability(list_changed=True))
assert session.instructions == "hello"
assert session.initialize_result is None
assert isinstance(session.discover_result, types.DiscoverResult)
@pytest.mark.anyio
async def test_discover_reraises_unsupported_version_with_malformed_error_data() -> None:
"""SDK-defined: a -32022 reply whose `data` is not a valid
`UnsupportedProtocolVersionErrorData` payload is unrecoverable — the original
error is re-raised without a retry probe."""
dispatcher = _ScriptedDispatcher(MCPError(UNSUPPORTED_PROTOCOL_VERSION, "unsupported", data="not-an-object"))
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
with pytest.raises(MCPError) as exc:
await session.discover()
assert exc.value.error.code == UNSUPPORTED_PROTOCOL_VERSION
assert [m for m, _ in dispatcher.calls] == ["server/discover"]
# --- inbound ttlMs clamp ---
@pytest.mark.anyio
async def test_a_positive_inbound_ttl_reaches_the_result_unchanged() -> None:
listing: dict[str, Any] = {"resultType": "complete", "tools": [], "ttlMs": 60_000, "cacheScope": "private"}
dispatcher = _ScriptedDispatcher(_discover_result_dict(), listing)
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
await session.discover()
result = await session.list_tools()
assert result.ttl_ms == 60_000
@pytest.mark.anyio
@pytest.mark.parametrize("wire_ttl", [True, False])
async def test_a_boolean_inbound_ttl_is_not_clamped_only_coerced_by_validation(wire_ttl: bool) -> None:
"""SDK-defined: `bool` is an `int` subclass; the clamp skips it and pydantic's lax mode coerces it instead."""
listing: dict[str, Any] = {"resultType": "complete", "tools": [], "ttlMs": wire_ttl, "cacheScope": "private"}
dispatcher = _ScriptedDispatcher(_discover_result_dict(), listing)
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
await session.discover()
result = await session.list_tools()
assert result.ttl_ms == int(wire_ttl)
@pytest.mark.anyio
async def test_session_call_tool_returns_input_required_result_when_opted_in() -> None:
"""`ClientSession.call_tool(..., allow_input_required=True)` surfaces the
raw `InputRequiredResult` so the caller can drive the loop manually."""
# `on_call_tool` is still typed `-> CallToolResult` on this branch (#2967 widens it later);
# `add_request_handler` is `HandlerResult`-typed and accepts `InputRequiredResult` cleanly.
async def handler(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.InputRequiredResult:
return types.InputRequiredResult(request_state="s")
server = Server("test")
server.add_request_handler("tools/call", types.CallToolRequestParams, handler)
with anyio.fail_after(5):
async with Client(server, mode="2026-07-28") as client:
result = await client.session.call_tool("ask", allow_input_required=True)
assert isinstance(result, types.InputRequiredResult)
assert result.request_state == "s"
@pytest.mark.anyio
async def test_call_tool_threads_input_responses_and_request_state_into_params() -> None:
captured: list[types.CallToolRequestParams] = []
async def on_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
captured.append(params)
return CallToolResult(content=[])
async def on_list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[])
server = Server("test", on_call_tool=on_call_tool, on_list_tools=on_list_tools)
with anyio.fail_after(5):
async with Client(server, mode="2026-07-28") as client:
await client.call_tool(
"ask",
input_responses={"k": types.ElicitResult(action="decline")},
request_state="s",
)
assert captured[0].input_responses == {"k": types.ElicitResult(action="decline")}
assert captured[0].request_state == "s"
@pytest.mark.anyio
async def test_session_call_tool_raises_on_input_required_without_opt_in() -> None:
"""SDK-defined: `ClientSession.call_tool` is mechanics-only; an
`InputRequiredResult` with the default `allow_input_required=False` raises
`RuntimeError` (the auto-loop policy lives on `Client`, not here)."""
async def handler(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.InputRequiredResult:
return types.InputRequiredResult(request_state="s")
server = Server("test")
server.add_request_handler("tools/call", types.CallToolRequestParams, handler)
with anyio.fail_after(5):
async with Client(server, mode="2026-07-28") as client:
with pytest.raises(RuntimeError, match="allow_input_required=True"):
await client.session.call_tool("t")
result = await client.session.call_tool("t", allow_input_required=True)
assert isinstance(result, types.InputRequiredResult)
@pytest.mark.anyio
async def test_session_get_prompt_returns_input_required_result_when_opted_in() -> None:
"""`ClientSession.get_prompt` mirrors `call_tool`: opting in returns the
raw `InputRequiredResult`; the default raises `RuntimeError`."""
async def handler(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> types.InputRequiredResult:
return types.InputRequiredResult(request_state="prompt-state")
server = Server("test")
server.add_request_handler("prompts/get", types.GetPromptRequestParams, handler)
with anyio.fail_after(5):
async with Client(server, mode="2026-07-28") as client:
with pytest.raises(RuntimeError, match="allow_input_required=True"):
await client.session.get_prompt("p")
result = await client.session.get_prompt("p", allow_input_required=True)
assert isinstance(result, types.InputRequiredResult)
assert result.request_state == "prompt-state"
@pytest.mark.anyio
async def test_session_read_resource_returns_input_required_result_when_opted_in() -> None:
"""`ClientSession.read_resource` mirrors `call_tool`: opting in returns the
raw `InputRequiredResult`; the default raises `RuntimeError`."""
async def handler(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> types.InputRequiredResult:
return types.InputRequiredResult(request_state="resource-state")
server = Server("test")
server.add_request_handler("resources/read", types.ReadResourceRequestParams, handler)
with anyio.fail_after(5):
async with Client(server, mode="2026-07-28") as client:
with pytest.raises(RuntimeError, match="allow_input_required=True"):
await client.session.read_resource("memory://r")
result = await client.session.read_resource("memory://r", allow_input_required=True)
assert isinstance(result, types.InputRequiredResult)
assert result.request_state == "resource-state"
@pytest.mark.anyio
async def test_a_late_ack_for_a_closed_driver_listen_reaches_message_handler():
"""Ack consumption is keyed on the live route registry alone: a stray ack for a
closed subscription's id surfaces through message_handler like any other unowned frame."""
seen: list[object] = []
follow_up = anyio.Event()
async def handler(msg: object) -> None:
seen.append(msg)
if len(seen) == 2:
follow_up.set()
async with raw_client_session(message_handler=handler) as (session, to_client, _):
_set_negotiated_version(session, "2026-07-28")
session._register_listen_route("listen-99") # pyright: ignore[reportPrivateUsage]
session._unregister_listen_route("listen-99") # pyright: ignore[reportPrivateUsage]
await to_client.send(
SessionMessage(
JSONRPCNotification(
jsonrpc="2.0",
method="notifications/subscriptions/acknowledged",
params={
"notifications": {"toolsListChanged": True},
"_meta": {SUBSCRIPTION_ID_META_KEY: "listen-99"},
},
)
)
)
await to_client.send(
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/tools/list_changed", params={}))
)
with anyio.fail_after(5):
await follow_up.wait()
assert [type(message).__name__ for message in seen] == [
"SubscriptionsAcknowledgedNotification",
"ToolListChangedNotification",
]
@pytest.mark.anyio
async def test_a_graceful_result_does_not_outrun_the_events_that_preceded_it():
"""[ack, event, result] written back-to-back: the event delivers and the wire ack's filter
survives a parked message_handler tee, because routes settle on the dispatcher's receive path in wire order."""
async def parked_handler(message: object) -> None:
await anyio.sleep_forever()
events: list[object] = []
honored: list[types.SubscriptionFilter] = []
async with raw_client_session(message_handler=parked_handler) as (session, to_client, from_client):
_set_negotiated_version(session, "2026-07-28")
with anyio.fail_after(5):
async with anyio.create_task_group() as tg: # pragma: no branch
async def consume() -> None:
async with listen(session, tools_list_changed=True) as sub: # pragma: no branch
honored.append(sub.honored)
events.extend([event async for event in sub])
tg.start_soon(consume)
request = await from_client.receive()
assert isinstance(request.message, JSONRPCRequest)
meta = {SUBSCRIPTION_ID_META_KEY: request.message.id}
for message in (
JSONRPCNotification(
jsonrpc="2.0",
method="notifications/subscriptions/acknowledged",
params={"notifications": {"toolsListChanged": True}, "_meta": meta},
),
JSONRPCNotification(
jsonrpc="2.0", method="notifications/tools/list_changed", params={"_meta": meta}
),
JSONRPCResponse(jsonrpc="2.0", id=request.message.id, result={"_meta": meta}),
):
await to_client.send(SessionMessage(message))
assert honored == [types.SubscriptionFilter(tools_list_changed=True)]
assert events == [ToolsListChanged()]
def _intercept_only_session() -> ClientSession:
"""A never-entered session whose intercept can be driven directly (it is synchronous)."""
dispatcher, _peer = create_direct_dispatcher_pair()
return ClientSession(dispatcher=dispatcher)
def test_intercept_settles_only_the_named_listen_route_on_cancelled():
"""SDK demux contract: a server-sent cancel settles exactly the listen route it names and is never consumed."""
session = _intercept_only_session()
route = session._register_listen_route("listen-1") # pyright: ignore[reportPrivateUsage]
intercept = session._intercept_notification # pyright: ignore[reportPrivateUsage]
assert intercept("notifications/cancelled", {"requestId": "unrelated"}) is False
assert route.end is None
assert intercept("notifications/cancelled", {"requestId": "listen-1"}) is False
assert route.end == "lost"
def test_intercept_ignores_frames_without_a_route_or_with_broken_meta():
"""SDK demux contract: frames that correlate to no live route flow through to the normal notification path."""
session = _intercept_only_session()
intercept = session._intercept_notification # pyright: ignore[reportPrivateUsage]
assert intercept("notifications/tools/list_changed", {"_meta": {SUBSCRIPTION_ID_META_KEY: "listen-1"}}) is False
route = session._register_listen_route("listen-1") # pyright: ignore[reportPrivateUsage]
route.set_acked(types.SubscriptionFilter(tools_list_changed=True))
assert intercept("notifications/tools/list_changed", None) is False
# A non-mapping `_meta` is constructible on pre-2026 wires.
assert intercept("notifications/tools/list_changed", {"_meta": "oops"}) is False
assert intercept("notifications/tools/list_changed", {"_meta": {SUBSCRIPTION_ID_META_KEY: "other"}}) is False
# A non-string uri is not an event; surface validation owns it.
meta = {"_meta": {SUBSCRIPTION_ID_META_KEY: "listen-1"}}
assert intercept("notifications/resources/updated", {"uri": 7, **meta}) is False
assert route._pending == {} # pyright: ignore[reportPrivateUsage]
def test_intercept_consumes_acks_for_live_routes_and_leaves_malformed_ones():
"""SDK demux contract: a well-formed ack for a live route is consumed as driver state; malformed acks pass on."""
session = _intercept_only_session()
route = session._register_listen_route("listen-1") # pyright: ignore[reportPrivateUsage]
intercept = session._intercept_notification # pyright: ignore[reportPrivateUsage]
meta = {"_meta": {SUBSCRIPTION_ID_META_KEY: "listen-1"}}
assert intercept("notifications/subscriptions/acknowledged", {"notifications": ["nope"], **meta}) is False
assert route.honored is None
# A missing `notifications` field must not be read as an (all-refusing) empty filter.
assert intercept("notifications/subscriptions/acknowledged", dict(meta)) is False
assert route.honored is None
assert (
intercept("notifications/subscriptions/acknowledged", {"notifications": {"toolsListChanged": True}, **meta})
is True
)
assert route.honored == types.SubscriptionFilter(tools_list_changed=True)
# Events deliver but are never consumed - they still tee to message_handler.
assert intercept("notifications/tools/list_changed", meta) is False
assert list(route._pending) == [ToolsListChanged()] # pyright: ignore[reportPrivateUsage]