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
1950 lines
84 KiB
Python
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]
|