Add ergonomic Client class for testing MCP servers (#1870)

This commit is contained in:
Felix Weinberger 2026-01-16 15:49:26 +00:00 • committed by GitHub
commit df039bf97c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1298 additions and 526 deletions

View file

@ -1,10 +1,11 @@
# Testing MCP Servers
If you call yourself a developer, you will want to test your MCP server.
The Python SDK offers the `create_connected_server_and_client_session` function to create a session
using an in-memory transport. I know, I know, the name is too long... We are working on improving it.
The Python SDK provides a `Client` class for testing MCP servers with an in-memory transport.
This makes it easy to write tests without network overhead.
Anyway, let's assume you have a simple server with a single tool:
## Basic Usage
Let's assume you have a simple server with a single tool:
```python title="server.py"
from mcp.server import FastMCP
@ -40,12 +41,9 @@ To run the below test, you'll need to install the following dependencies:
server - you don't need to use it, but we are spreading the word for best practices.
```python title="test_server.py"
from collections.abc import AsyncGenerator
import pytest
from inline_snapshot import snapshot
from mcp.client.session import ClientSession
from mcp.shared.memory import create_connected_server_and_client_session
from mcp import Client
from mcp.types import CallToolResult, TextContent
from server import app
@ -57,14 +55,14 @@ def anyio_backend(): # (1)!
@pytest.fixture
async def client_session() -> AsyncGenerator[ClientSession]:
async with create_connected_server_and_client_session(app, raise_exceptions=True) as _session:
yield _session
async def client(): # (2)!
async with Client(app, raise_exceptions=True) as c:
yield c
@pytest.mark.anyio
async def test_call_add_tool(client_session: ClientSession):
result = await client_session.call_tool("add", {"a": 1, "b": 2})
async def test_call_add_tool(client: Client):
result = await client.call_tool("add", {"a": 1, "b": 2})
assert result == snapshot(
CallToolResult(
content=[TextContent(type="text", text="3")],
@ -74,5 +72,6 @@ async def test_call_add_tool(client_session: ClientSession):
```
1. If you are using `trio`, you should set `"trio"` as the `anyio_backend`. Check more information in the [anyio documentation](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on).
2. The `client` fixture creates a connected client that can be reused across multiple tests.
There you go! You can now extend your tests to cover more scenarios.

View file

@ -14,8 +14,8 @@ from typing import TypedDict
from pydantic import BaseModel, Field
from mcp.client import Client
from mcp.server.fastmcp import FastMCP
from mcp.shared.memory import create_connected_server_and_client_session as client_session
# Create server
mcp = FastMCP("Weather Service")
@ -157,7 +157,7 @@ if __name__ == "__main__":
print("Testing Weather Service Tools (via MCP protocol)\n")
print("=" * 80)
async with client_session(mcp._mcp_server) as client:
async with Client(mcp) as client:
# Test get_weather
result = await client.call_tool("get_weather", {"city": "London"})
print("\nWeather in London:")

View file

@ -1,3 +1,4 @@
from .client.client import Client
from .client.session import ClientSession
from .client.session_group import ClientSessionGroup
from .client.stdio import StdioServerParameters, stdio_client
@ -66,6 +67,7 @@ from .types import Role as SamplingRole
__all__ = [
"CallToolRequest",
"Client",
"ClientCapabilities",
"ClientNotification",
"ClientRequest",

View file

@ -0,0 +1,9 @@
"""MCP Client module."""
from mcp.client.client import Client
from mcp.client.session import ClientSession
__all__ = [
"Client",
"ClientSession",
]

97
src/mcp/client/_memory.py Normal file
View file

@ -0,0 +1,97 @@
"""In-memory transport for testing MCP servers without network overhead."""
from __future__ import annotations
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from typing import Any
import anyio
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
from mcp.server import Server
from mcp.server.fastmcp import FastMCP
from mcp.shared.memory import create_client_server_memory_streams
from mcp.shared.message import SessionMessage
class InMemoryTransport:
"""
In-memory transport for testing MCP servers without network overhead.
This transport starts the server in a background task and provides
streams for client-side communication. The server is automatically
stopped when the context manager exits.
Example:
server = FastMCP("test")
transport = InMemoryTransport(server)
async with transport.connect() as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
# Use the session...
Or more commonly, use with Client:
async with Client(server) as client:
result = await client.call_tool("my_tool", {...})
"""
def __init__(
self,
server: Server[Any] | FastMCP,
*,
raise_exceptions: bool = False,
) -> None:
"""
Initialize the in-memory transport.
Args:
server: The MCP server to connect to (Server or FastMCP instance)
raise_exceptions: Whether to raise exceptions from the server
"""
self._server = server
self._raise_exceptions = raise_exceptions
@asynccontextmanager
async def connect(
self,
) -> AsyncGenerator[
tuple[
MemoryObjectReceiveStream[SessionMessage | Exception],
MemoryObjectSendStream[SessionMessage],
],
None,
]:
"""
Connect to the server and return streams for communication.
Yields:
A tuple of (read_stream, write_stream) for bidirectional communication
"""
# Unwrap FastMCP to get underlying Server
actual_server: Server[Any]
if isinstance(self._server, FastMCP):
actual_server = self._server._mcp_server # type: ignore[reportPrivateUsage]
else:
actual_server = self._server
async with create_client_server_memory_streams() as (client_streams, server_streams):
client_read, client_write = client_streams
server_read, server_write = server_streams
async with anyio.create_task_group() as tg:
# Start server in background
tg.start_soon(
lambda: actual_server.run(
server_read,
server_write,
actual_server.create_initialization_options(),
raise_exceptions=self._raise_exceptions,
)
)
try:
yield client_read, client_write
finally:
tg.cancel_scope.cancel()

302
src/mcp/client/client.py Normal file
View file

@ -0,0 +1,302 @@
"""Unified MCP Client that wraps ClientSession with transport management."""
from __future__ import annotations
import logging
from contextlib import AsyncExitStack
from typing import Any
from pydantic import AnyUrl
import mcp.types as types
from mcp.client._memory import InMemoryTransport
from mcp.client.session import (
ClientSession,
ElicitationFnT,
ListRootsFnT,
LoggingFnT,
MessageHandlerFnT,
SamplingFnT,
)
from mcp.server import Server
from mcp.server.fastmcp import FastMCP
from mcp.shared.session import ProgressFnT
logger = logging.getLogger(__name__)
class Client:
"""A high-level MCP client for connecting to MCP servers.
Currently supports in-memory transport for testing. Pass a Server or
FastMCP instance directly to the constructor.
Example:
```python
from mcp.client import Client
from mcp.server.fastmcp import FastMCP
server = FastMCP("test")
@server.tool()
def add(a: int, b: int) -> int:
return a + b
async with Client(server) as client:
result = await client.call_tool("add", {"a": 1, "b": 2})
```
"""
# TODO(felixweinberger): Expand to support all transport types (like FastMCP 2):
# - Add ClientTransport base class with connect_session() method
# - Add StreamableHttpTransport, SSETransport, StdioTransport
# - Add infer_transport() to auto-detect transport from input type
# - Accept URL strings, Path objects, config dicts in constructor
# - Add auth support (OAuth, bearer tokens)
def __init__(
self,
server: Server[Any] | FastMCP,
*,
raise_exceptions: bool = False,
read_timeout_seconds: float | None = None,
sampling_callback: SamplingFnT | None = None,
list_roots_callback: ListRootsFnT | None = None,
logging_callback: LoggingFnT | None = None,
message_handler: MessageHandlerFnT | None = None,
client_info: types.Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None,
) -> None:
"""
Initialize the client with a server.
Args:
server: The MCP server to connect to (Server or FastMCP instance)
raise_exceptions: Whether to raise exceptions from the server
read_timeout_seconds: Timeout for read operations
sampling_callback: Callback for handling sampling requests
list_roots_callback: Callback for handling list roots requests
logging_callback: Callback for handling logging notifications
message_handler: Callback for handling raw messages
client_info: Client implementation info to send to server
elicitation_callback: Callback for handling elicitation requests
"""
self._server = server
self._raise_exceptions = raise_exceptions
self._read_timeout_seconds = read_timeout_seconds
self._sampling_callback = sampling_callback
self._list_roots_callback = list_roots_callback
self._logging_callback = logging_callback
self._message_handler = message_handler
self._client_info = client_info
self._elicitation_callback = elicitation_callback
self._session: ClientSession | None = None
self._exit_stack: AsyncExitStack | None = None
async def __aenter__(self) -> Client:
"""Enter the async context manager."""
if self._session is not None:
raise RuntimeError("Client is already entered; cannot reenter")
async with AsyncExitStack() as exit_stack:
# Create transport and connect
transport = InMemoryTransport(self._server, raise_exceptions=self._raise_exceptions)
read_stream, write_stream = await exit_stack.enter_async_context(transport.connect())
# Create session
self._session = await exit_stack.enter_async_context(
ClientSession(
read_stream=read_stream,
write_stream=write_stream,
read_timeout_seconds=self._read_timeout_seconds,
sampling_callback=self._sampling_callback,
list_roots_callback=self._list_roots_callback,
logging_callback=self._logging_callback,
message_handler=self._message_handler,
client_info=self._client_info,
elicitation_callback=self._elicitation_callback,
)
)
# Initialize the session
await self._session.initialize()
# Transfer ownership to self for __aexit__ to handle
self._exit_stack = exit_stack.pop_all()
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: Any,
) -> None:
"""Exit the async context manager."""
if self._exit_stack:
await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb)
self._session = None
@property
def session(self) -> ClientSession:
"""
Get the underlying ClientSession.
This provides access to the full ClientSession API for advanced use cases.
Raises:
RuntimeError: If accessed before entering the context manager.
"""
if self._session is None:
raise RuntimeError("Client must be used within an async context manager")
return self._session
@property
def server_capabilities(self) -> types.ServerCapabilities | None:
"""The server capabilities received during initialization, or None if not yet initialized."""
return self.session.get_server_capabilities()
async def send_ping(self) -> types.EmptyResult:
"""Send a ping request to the server."""
return await self.session.send_ping()
async def send_progress_notification(
self,
progress_token: str | int,
progress: float,
total: float | None = None,
message: str | None = None,
) -> None:
"""Send a progress notification to the server."""
await self.session.send_progress_notification(
progress_token=progress_token,
progress=progress,
total=total,
message=message,
)
async def set_logging_level(self, level: types.LoggingLevel) -> types.EmptyResult:
"""Set the logging level on the server."""
return await self.session.set_logging_level(level)
async def list_resources(
self,
params: types.PaginatedRequestParams | None = None,
) -> types.ListResourcesResult:
"""List available resources from the server."""
return await self.session.list_resources(params=params)
async def list_resource_templates(
self,
params: types.PaginatedRequestParams | None = None,
) -> types.ListResourceTemplatesResult:
"""List available resource templates from the server."""
return await self.session.list_resource_templates(params=params)
async def read_resource(self, uri: str | AnyUrl) -> types.ReadResourceResult:
"""
Read a resource from the server.
Args:
uri: The URI of the resource to read
Returns:
The resource content
"""
return await self.session.read_resource(uri)
async def subscribe_resource(self, uri: str | AnyUrl) -> types.EmptyResult:
"""Subscribe to resource updates."""
return await self.session.subscribe_resource(uri)
async def unsubscribe_resource(self, uri: str | AnyUrl) -> types.EmptyResult:
"""Unsubscribe from resource updates."""
return await self.session.unsubscribe_resource(uri)
async def call_tool(
self,
name: str,
arguments: dict[str, Any] | None = None,
read_timeout_seconds: float | None = None,
progress_callback: ProgressFnT | None = None,
*,
meta: dict[str, Any] | None = None,
) -> types.CallToolResult:
"""
Call a tool on the server.
Args:
name: The name of the tool to call
arguments: Arguments to pass to the tool
read_timeout_seconds: Timeout for the tool call
progress_callback: Callback for progress updates
meta: Additional metadata for the request
Returns:
The tool result
"""
return await self.session.call_tool(
name=name,
arguments=arguments,
read_timeout_seconds=read_timeout_seconds,
progress_callback=progress_callback,
meta=meta,
)
async def list_prompts(
self,
params: types.PaginatedRequestParams | None = None,
) -> types.ListPromptsResult:
"""List available prompts from the server."""
return await self.session.list_prompts(params=params)
async def get_prompt(
self,
name: str,
arguments: dict[str, str] | None = None,
) -> types.GetPromptResult:
"""
Get a prompt from the server.
Args:
name: The name of the prompt
arguments: Arguments to pass to the prompt
Returns:
The prompt content
"""
return await self.session.get_prompt(name=name, arguments=arguments)
async def complete(
self,
ref: types.ResourceTemplateReference | types.PromptReference,
argument: dict[str, str],
context_arguments: dict[str, str] | None = None,
) -> types.CompleteResult:
"""
Get completions for a prompt or resource template argument.
Args:
ref: Reference to the prompt or resource template
argument: The argument to complete
context_arguments: Additional context arguments
Returns:
Completion suggestions
"""
return await self.session.complete(
ref=ref,
argument=argument,
context_arguments=context_arguments,
)
async def list_tools(
self,
params: types.PaginatedRequestParams | None = None,
) -> types.ListToolsResult:
"""List available tools from the server."""
return await self.session.list_tools(params=params)
async def send_roots_list_changed(self) -> None:
"""Send a notification that the roots list has changed."""
await self.session.send_roots_list_changed()

View file

@ -407,7 +407,6 @@ class ClientSession(
"""Send a tools/list request.
Args:
cursor: Simple cursor string for pagination (deprecated, use params instead)
params: Full pagination parameters including cursor and any future fields
"""
result = await self.send_request(

View file

@ -6,15 +6,10 @@ from __future__ import annotations
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from typing import Any
import anyio
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
import mcp.types as types
from mcp.client.session import ClientSession, ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT
from mcp.server import Server
from mcp.server.fastmcp import FastMCP
from mcp.shared.message import SessionMessage
MessageStream = tuple[MemoryObjectReceiveStream[SessionMessage | Exception], MemoryObjectSendStream[SessionMessage]]
@ -43,55 +38,3 @@ async def create_client_server_memory_streams() -> AsyncGenerator[tuple[MessageS
server_to_client_send,
):
yield client_streams, server_streams
@asynccontextmanager
async def create_connected_server_and_client_session(
server: Server[Any] | FastMCP,
read_timeout_seconds: float | None = None,
sampling_callback: SamplingFnT | None = None,
list_roots_callback: ListRootsFnT | None = None,
logging_callback: LoggingFnT | None = None,
message_handler: MessageHandlerFnT | None = None,
client_info: types.Implementation | None = None,
raise_exceptions: bool = False,
elicitation_callback: ElicitationFnT | None = None,
) -> AsyncGenerator[ClientSession, None]:
"""Creates a ClientSession that is connected to a running MCP server."""
# TODO(Marcelo): we should have a proper `Client` that can use this "in-memory transport",
# and we should expose a method in the `FastMCP` so we don't access a private attribute.
if isinstance(server, FastMCP): # pragma: no cover
server = server._mcp_server # type: ignore[reportPrivateUsage]
async with create_client_server_memory_streams() as (client_streams, server_streams):
client_read, client_write = client_streams
server_read, server_write = server_streams
# Create a cancel scope for the server task
async with anyio.create_task_group() as tg:
tg.start_soon(
lambda: server.run(
server_read,
server_write,
server.create_initialization_options(),
raise_exceptions=raise_exceptions,
)
)
try:
async with ClientSession(
read_stream=client_read,
write_stream=client_write,
read_timeout_seconds=read_timeout_seconds,
sampling_callback=sampling_callback,
list_roots_callback=list_roots_callback,
logging_callback=logging_callback,
message_handler=message_handler,
client_info=client_info,
elicitation_callback=elicitation_callback,
) as client_session:
await client_session.initialize()
yield client_session
finally: # pragma: no cover
tg.cancel_scope.cancel()

View file

@ -123,11 +123,13 @@ def stream_spy() -> Generator[Callable[[], StreamSpyCollection], None, None]:
yield (client_read, spy_client_write), (server_read, spy_server_write)
# Apply the patch for the duration of the test
# Patch both locations since InMemoryTransport imports it directly
with patch("mcp.shared.memory.create_client_server_memory_streams", patched_create_streams):
# Return a collection with helper methods
def get_spy_collection() -> StreamSpyCollection:
assert client_spy is not None, "client_spy was not initialized"
assert server_spy is not None, "server_spy was not initialized"
return StreamSpyCollection(client_spy, server_spy)
with patch("mcp.client._memory.create_client_server_memory_streams", patched_create_streams):
# Return a collection with helper methods
def get_spy_collection() -> StreamSpyCollection:
assert client_spy is not None, "client_spy was not initialized"
assert server_spy is not None, "server_spy was not initialized"
return StreamSpyCollection(client_spy, server_spy)
yield get_spy_collection
yield get_spy_collection

336
tests/client/test_client.py Normal file
View file

@ -0,0 +1,336 @@
"""Tests for the unified Client class."""
from unittest.mock import AsyncMock, patch
import pytest
import mcp.types as types
from mcp.client.client import Client
from mcp.server import Server
from mcp.server.fastmcp import FastMCP
from mcp.types import EmptyResult, Resource
pytestmark = pytest.mark.anyio
@pytest.fixture
def simple_server() -> Server:
"""Create a simple MCP server for testing."""
server = Server(name="test_server")
@server.list_resources()
async def handle_list_resources():
return [
Resource(
uri="memory://test",
name="Test Resource",
description="A test resource",
)
]
return server
@pytest.fixture
def app() -> FastMCP:
"""Create a FastMCP server for testing."""
server = FastMCP("test")
@server.tool()
def greet(name: str) -> str:
"""Greet someone by name."""
return f"Hello, {name}!"
@server.tool()
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
@server.resource("test://resource")
def test_resource() -> str:
"""A test resource."""
return "Test content"
@server.prompt()
def greeting_prompt(name: str) -> str:
"""A greeting prompt."""
return f"Please greet {name} warmly."
return server
async def test_creates_client(app: FastMCP):
"""Test that from_server creates a connected client."""
async with Client(app) as client:
assert client is not None
async def test_client_is_initialized(app: FastMCP):
"""Test that the client is initialized after entering context."""
async with Client(app) as client:
caps = client.server_capabilities
assert caps is not None
assert caps.tools is not None
async def test_with_simple_server(simple_server: Server):
"""Test that from_server works with a basic Server instance."""
async with Client(simple_server) as client:
assert client is not None
caps = client.server_capabilities
assert caps is not None
# Verify list_resources works and returns expected resource
resources = await client.list_resources()
assert len(resources.resources) == 1
assert resources.resources[0].uri == "memory://test"
async def test_ping_returns_empty_result(app: FastMCP):
"""Test that ping returns an EmptyResult."""
async with Client(app) as client:
result = await client.send_ping()
assert isinstance(result, EmptyResult)
async def test_list_tools(app: FastMCP):
"""Test listing tools."""
async with Client(app) as client:
result = await client.list_tools()
assert result.tools is not None
tool_names = [t.name for t in result.tools]
assert "greet" in tool_names
assert "add" in tool_names
async def test_list_tools_with_pagination(app: FastMCP):
"""Test listing tools with pagination params."""
from mcp.types import PaginatedRequestParams
async with Client(app) as client:
result = await client.list_tools(params=PaginatedRequestParams())
assert result.tools is not None
async def test_call_tool(app: FastMCP):
"""Test calling a tool."""
async with Client(app) as client:
result = await client.call_tool("greet", {"name": "World"})
assert result.content is not None
assert len(result.content) > 0
content_str = str(result.content[0])
assert "Hello, World!" in content_str
async def test_call_tool_with_multiple_args(app: FastMCP):
"""Test calling a tool with multiple arguments."""
async with Client(app) as client:
result = await client.call_tool("add", {"a": 5, "b": 3})
assert result.content is not None
content_str = str(result.content[0])
assert "8" in content_str
async def test_list_resources(app: FastMCP):
"""Test listing resources."""
async with Client(app) as client:
result = await client.list_resources()
# FastMCP may have different resource listing behavior
assert result is not None
async def test_read_resource(app: FastMCP):
"""Test reading a resource."""
async with Client(app) as client:
result = await client.read_resource("test://resource")
assert result.contents is not None
assert len(result.contents) > 0
async def test_list_prompts(app: FastMCP):
"""Test listing prompts."""
async with Client(app) as client:
result = await client.list_prompts()
prompt_names = [p.name for p in result.prompts]
assert "greeting_prompt" in prompt_names
async def test_get_prompt(app: FastMCP):
"""Test getting a prompt."""
async with Client(app) as client:
result = await client.get_prompt("greeting_prompt", {"name": "Alice"})
assert result.messages is not None
assert len(result.messages) > 0
async def test_session_property(app: FastMCP):
"""Test that the session property returns the ClientSession."""
from mcp.client.session import ClientSession
async with Client(app) as client:
session = client.session
assert isinstance(session, ClientSession)
async def test_session_is_same_as_internal(app: FastMCP):
"""Test that session property returns consistent instance."""
async with Client(app) as client:
session1 = client.session
session2 = client.session
assert session1 is session2
async def test_enters_and_exits_cleanly(app: FastMCP):
"""Test that the client enters and exits cleanly."""
async with Client(app) as client:
# Should be able to use client
await client.send_ping()
# After exiting, resources should be cleaned up
async def test_exception_during_use(app: FastMCP):
"""Test that exceptions during use don't prevent cleanup."""
with pytest.raises(Exception): # May be wrapped in ExceptionGroup by anyio
async with Client(app) as client:
await client.send_ping()
raise ValueError("Test exception")
# Should exit cleanly despite exception
async def test_aexit_without_aenter(app: FastMCP):
"""Test that calling __aexit__ without __aenter__ doesn't raise."""
client = Client(app)
# This should not raise even though __aenter__ was never called
await client.__aexit__(None, None, None)
assert client._session is None
async def test_server_capabilities_after_init(app: FastMCP):
"""Test server_capabilities property after initialization."""
async with Client(app) as client:
caps = client.server_capabilities
assert caps is not None
# FastMCP should advertise tools capability
assert caps.tools is not None
def test_session_property_before_enter(app: FastMCP):
"""Test that accessing session before context manager raises RuntimeError."""
client = Client(app)
with pytest.raises(RuntimeError, match="Client must be used within an async context manager"):
_ = client.session
async def test_reentry_raises_runtime_error(app: FastMCP):
"""Test that reentering a client raises RuntimeError."""
async with Client(app) as client:
with pytest.raises(RuntimeError, match="Client is already entered"):
await client.__aenter__()
async def test_cleanup_on_init_failure(app: FastMCP):
"""Test that resources are cleaned up if initialization fails."""
with patch("mcp.client.client.ClientSession") as mock_session_class:
# Create a mock context manager that fails on __aenter__
mock_session = AsyncMock()
mock_session.__aenter__.side_effect = RuntimeError("Session init failed")
mock_session.__aexit__ = AsyncMock(return_value=None)
mock_session_class.return_value = mock_session
client = Client(app)
with pytest.raises(BaseException) as exc_info:
await client.__aenter__()
# The error should contain our message (may be wrapped in ExceptionGroup)
# Use repr() to see nested exceptions in ExceptionGroup
assert "Session init failed" in repr(exc_info.value)
# Verify the client is in a clean state (session should be None)
assert client._session is None
async def test_send_progress_notification(app: FastMCP):
"""Test sending progress notification."""
async with Client(app) as client:
# Send a progress notification - this should not raise
await client.send_progress_notification(
progress_token="test-token",
progress=50.0,
total=100.0,
message="Half done",
)
async def test_subscribe_resource(app: FastMCP):
"""Test subscribing to a resource."""
async with Client(app) as client:
# Mock the session's subscribe_resource since FastMCP doesn't support it
with patch.object(client.session, "subscribe_resource", return_value=EmptyResult()):
result = await client.subscribe_resource("test://resource")
assert isinstance(result, EmptyResult)
async def test_unsubscribe_resource(app: FastMCP):
"""Test unsubscribing from a resource."""
async with Client(app) as client:
# Mock the session's unsubscribe_resource since FastMCP doesn't support it
with patch.object(client.session, "unsubscribe_resource", return_value=EmptyResult()):
result = await client.unsubscribe_resource("test://resource")
assert isinstance(result, EmptyResult)
async def test_send_roots_list_changed(app: FastMCP):
"""Test sending roots list changed notification."""
async with Client(app) as client:
# Send roots list changed notification - should not raise
await client.send_roots_list_changed()
async def test_set_logging_level(app: FastMCP):
"""Test setting logging level."""
async with Client(app) as client:
# Mock the session's set_logging_level since FastMCP doesn't support it
with patch.object(client.session, "set_logging_level", return_value=EmptyResult()):
result = await client.set_logging_level("debug")
assert isinstance(result, EmptyResult)
async def test_list_resources_with_params(app: FastMCP):
"""Test listing resources with params parameter."""
async with Client(app) as client:
result = await client.list_resources(params=types.PaginatedRequestParams())
assert result is not None
async def test_list_resource_templates_with_params(app: FastMCP):
"""Test listing resource templates with params parameter."""
async with Client(app) as client:
result = await client.list_resource_templates(params=types.PaginatedRequestParams())
assert result is not None
async def test_list_resource_templates_default(app: FastMCP):
"""Test listing resource templates with no params or cursor."""
async with Client(app) as client:
result = await client.list_resource_templates()
assert result is not None
async def test_list_prompts_with_params(app: FastMCP):
"""Test listing prompts with params parameter."""
async with Client(app) as client:
result = await client.list_prompts(params=types.PaginatedRequestParams())
assert result is not None
async def test_complete_with_prompt_reference(app: FastMCP):
"""Test getting completions for a prompt argument."""
async with Client(app) as client:
ref = types.PromptReference(type="ref/prompt", name="greeting_prompt")
# Mock the session's complete method since FastMCP may not support it
with patch.object(
client.session,
"complete",
return_value=types.CompleteResult(completion=types.Completion(values=[])),
):
result = await client.complete(ref=ref, argument={"name": "test"})
assert result is not None

View file

@ -3,9 +3,10 @@ from collections.abc import Callable
import pytest
import mcp.types as types
from mcp.client._memory import InMemoryTransport
from mcp.client.session import ClientSession
from mcp.server import Server
from mcp.server.fastmcp import FastMCP
from mcp.shared.memory import create_connected_server_and_client_session as create_session
from mcp.types import ListToolsRequest, ListToolsResult
from .conftest import StreamSpyCollection
@ -18,30 +19,28 @@ async def full_featured_server():
"""Create a server with tools, resources, prompts, and templates."""
server = FastMCP("test")
@server.tool(name="test_tool_1")
async def test_tool_1() -> str: # pragma: no cover
"""First test tool"""
return "Result 1"
@server.tool(name="test_tool_2")
async def test_tool_2() -> str: # pragma: no cover
"""Second test tool"""
return "Result 2"
@server.resource("resource://test/data")
async def test_resource() -> str: # pragma: no cover
"""Test resource"""
return "Test data"
@server.prompt()
async def test_prompt(name: str) -> str: # pragma: no cover
"""Test prompt"""
# pragma: no cover on handlers below - these exist only to register items with the
# server so list_* methods return results. The handlers themselves are never called
# because these tests only verify pagination/cursor behavior, not tool/resource invocation.
@server.tool()
def greet(name: str) -> str: # pragma: no cover
"""Greet someone by name."""
return f"Hello, {name}!"
@server.resource("resource://test/{name}")
async def test_template(name: str) -> str: # pragma: no cover
"""Test resource template"""
return f"Data for {name}"
@server.resource("test://resource")
def test_resource() -> str: # pragma: no cover
"""A test resource."""
return "Test content"
@server.resource("test://template/{id}")
def test_template(id: str) -> str: # pragma: no cover
"""A test resource template."""
return f"Template content for {id}"
@server.prompt()
def greeting_prompt(name: str) -> str: # pragma: no cover
"""A greeting prompt."""
return f"Please greet {name}."
return server
@ -61,78 +60,82 @@ async def test_list_methods_params_parameter(
method_name: str,
request_method: str,
):
"""Test that the params parameter works correctly for list methods.
"""Test that the params parameter is accepted and correctly passed to the server.
Covers: list_tools, list_resources, list_prompts, list_resource_templates
This tests the new params parameter API (non-deprecated) to ensure
it correctly handles all parameter combinations.
See: https://modelcontextprotocol.io/specification/2025-03-26/server/utilities/pagination#request-format
"""
async with create_session(full_featured_server._mcp_server) as client_session:
spies = stream_spy()
method = getattr(client_session, method_name)
transport = InMemoryTransport(full_featured_server)
async with transport.connect() as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
spies = stream_spy()
# Test without params parameter (omitted)
_ = await method()
requests = spies.get_client_requests(method=request_method)
assert len(requests) == 1
assert requests[0].params is None
# Test without params (omitted)
method = getattr(session, method_name)
_ = await method()
requests = spies.get_client_requests(method=request_method)
assert len(requests) == 1
assert requests[0].params is None
spies.clear()
spies.clear()
# Test with params=None
_ = await method(params=None)
requests = spies.get_client_requests(method=request_method)
assert len(requests) == 1
assert requests[0].params is None
# Test with params containing cursor
_ = await method(params=types.PaginatedRequestParams(cursor="from_params"))
requests = spies.get_client_requests(method=request_method)
assert len(requests) == 1
assert requests[0].params is not None
assert requests[0].params["cursor"] == "from_params"
spies.clear()
spies.clear()
# Test with empty params (for strict servers)
_ = await method(params=types.PaginatedRequestParams())
requests = spies.get_client_requests(method=request_method)
assert len(requests) == 1
assert requests[0].params is not None
assert requests[0].params.get("cursor") is None
spies.clear()
# Test with params containing cursor
_ = await method(params=types.PaginatedRequestParams(cursor="some_cursor_value"))
requests = spies.get_client_requests(method=request_method)
assert len(requests) == 1
assert requests[0].params is not None
assert requests[0].params["cursor"] == "some_cursor_value"
# Test with empty params
_ = await method(params=types.PaginatedRequestParams())
requests = spies.get_client_requests(method=request_method)
assert len(requests) == 1
# Empty params means no cursor
assert requests[0].params is None or "cursor" not in requests[0].params
async def test_list_tools_with_strict_server_validation():
"""Test that list_tools works with strict servers require a params field,
even if it is empty.
async def test_list_tools_with_strict_server_validation(
full_featured_server: FastMCP,
):
"""Test pagination with a server that validates request format strictly."""
transport = InMemoryTransport(full_featured_server)
async with transport.connect() as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
result = await session.list_tools(params=types.PaginatedRequestParams())
assert isinstance(result, ListToolsResult)
assert len(result.tools) > 0
Some MCP servers may implement strict JSON-RPC validation that requires
the params field to always be present in requests, even if empty {}.
This test ensures such servers are supported by the client SDK for list_resources
requests without a cursor.
"""
server = Server("strict_server")
async def test_list_tools_with_lowlevel_server():
"""Test that list_tools works with a lowlevel Server using params."""
server = Server("test-lowlevel")
@server.list_tools()
async def handle_list_tools(request: ListToolsRequest) -> ListToolsResult: # pragma: no cover
"""Strict handler that validates params field exists"""
async def handle_list_tools(request: ListToolsRequest) -> ListToolsResult:
# Echo back what cursor we received in the tool description
cursor = request.params.cursor if request.params else None
return ListToolsResult(
tools=[
types.Tool(
name="test_tool",
description=f"cursor={cursor}",
input_schema={},
)
]
)
# Simulate strict server validation
if request.params is None:
raise ValueError(
"Strict server validation failed: params field must be present. "
"Expected params: {} for requests without cursor."
)
transport = InMemoryTransport(server)
async with transport.connect() as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
# Return empty tools list
return ListToolsResult(tools=[])
result = await session.list_tools(params=types.PaginatedRequestParams())
assert result.tools[0].description == "cursor=None"
async with create_session(server) as client_session:
# Use params to explicitly send params: {} for strict server compatibility
result = await client_session.list_tools(params=types.PaginatedRequestParams())
assert result is not None
result = await session.list_tools(params=types.PaginatedRequestParams(cursor="page2"))
assert result.tools[0].description == "cursor=page2"

View file

@ -1,14 +1,12 @@
import pytest
from pydantic import FileUrl
from mcp import Client
from mcp.client.session import ClientSession
from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.server import Context
from mcp.server.session import ServerSession
from mcp.shared.context import RequestContext
from mcp.shared.memory import (
create_connected_server_and_client_session as create_session,
)
from mcp.types import ListRootsResult, Root, TextContent
@ -41,17 +39,17 @@ async def test_list_roots_callback():
return True
# Test with list_roots callback
async with create_session(server._mcp_server, list_roots_callback=list_roots_callback) as client_session:
async with Client(server, list_roots_callback=list_roots_callback) as client:
# Make a request to trigger sampling callback
result = await client_session.call_tool("test_list_roots", {"message": "test message"})
result = await client.call_tool("test_list_roots", {"message": "test message"})
assert result.is_error is False
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "true"
# Test without list_roots callback
async with create_session(server._mcp_server) as client_session:
async with Client(server) as client:
# Make a request to trigger sampling callback
result = await client_session.call_tool("test_list_roots", {"message": "test message"})
result = await client.call_tool("test_list_roots", {"message": "test message"})
assert result.is_error is True
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Error executing tool test_list_roots: List roots not supported"

View file

@ -3,10 +3,8 @@ from typing import Any, Literal
import pytest
import mcp.types as types
from mcp import Client
from mcp.server.fastmcp import FastMCP
from mcp.shared.memory import (
create_connected_server_and_client_session as create_session,
)
from mcp.shared.session import RequestResponder
from mcp.types import (
LoggingMessageNotificationParams,
@ -70,19 +68,19 @@ async def test_logging_callback():
if isinstance(message, Exception): # pragma: no cover
raise message
async with create_session(
server._mcp_server,
async with Client(
server,
logging_callback=logging_collector,
message_handler=message_handler,
) as client_session:
) as client:
# First verify our test tool works
result = await client_session.call_tool("test_tool", {})
result = await client.call_tool("test_tool", {})
assert result.is_error is False
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "true"
# Now send a log message via our tool
log_result = await client_session.call_tool(
log_result = await client.call_tool(
"test_tool_with_log",
{
"message": "Test log message",
@ -90,7 +88,7 @@ async def test_logging_callback():
"logger": "test_logger",
},
)
log_result_with_extra = await client_session.call_tool(
log_result_with_extra = await client.call_tool(
"test_tool_with_log_extra",
{
"message": "Test log message",

View file

@ -7,10 +7,8 @@ from unittest.mock import patch
import jsonschema
import pytest
from mcp import Client
from mcp.server.lowlevel import Server
from mcp.shared.memory import (
create_connected_server_and_client_session as client_session,
)
from mcp.types import Tool
@ -77,7 +75,7 @@ class TestClientOutputSchemaValidation:
# Test that client validates the structured content
with bypass_server_output_validation():
async with client_session(server) as client:
async with Client(server) as client:
# The client validates structured content and should raise an error
with pytest.raises(RuntimeError) as exc_info:
await client.call_tool("get_user", {})
@ -114,7 +112,7 @@ class TestClientOutputSchemaValidation:
return {"result": "not_a_number"} # Invalid: should be int
with bypass_server_output_validation():
async with client_session(server) as client:
async with Client(server) as client:
# The client validates structured content and should raise an error
with pytest.raises(RuntimeError) as exc_info:
await client.call_tool("calculate", {})
@ -145,7 +143,7 @@ class TestClientOutputSchemaValidation:
return {"alice": "100", "bob": "85"} # Invalid: values should be int
with bypass_server_output_validation():
async with client_session(server) as client:
async with Client(server) as client:
# The client validates structured content and should raise an error
with pytest.raises(RuntimeError) as exc_info:
await client.call_tool("get_scores", {})
@ -180,7 +178,7 @@ class TestClientOutputSchemaValidation:
return {"name": "John", "age": 30} # Missing required 'email'
with bypass_server_output_validation():
async with client_session(server) as client:
async with Client(server) as client:
# The client validates structured content and should raise an error
with pytest.raises(RuntimeError) as exc_info:
await client.call_tool("get_person", {})
@ -205,7 +203,7 @@ class TestClientOutputSchemaValidation:
caplog.set_level(logging.WARNING)
with bypass_server_output_validation():
async with client_session(server) as client:
async with Client(server) as client:
# Call a tool that wasn't listed
result = await client.call_tool("mystery_tool", {})
assert result.structured_content == {"result": 42}

View file

@ -1,11 +1,9 @@
import pytest
from mcp import Client
from mcp.client.session import ClientSession
from mcp.server.fastmcp import FastMCP
from mcp.shared.context import RequestContext
from mcp.shared.memory import (
create_connected_server_and_client_session as create_session,
)
from mcp.types import (
CreateMessageRequestParams,
CreateMessageResult,
@ -43,17 +41,17 @@ async def test_sampling_callback():
return True
# Test with sampling callback
async with create_session(server._mcp_server, sampling_callback=sampling_callback) as client_session:
async with Client(server, sampling_callback=sampling_callback) as client:
# Make a request to trigger sampling callback
result = await client_session.call_tool("test_sampling", {"message": "Test message for sampling"})
result = await client.call_tool("test_sampling", {"message": "Test message for sampling"})
assert result.is_error is False
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "true"
# Test without sampling callback
async with create_session(server._mcp_server) as client_session:
async with Client(server) as client:
# Make a request to trigger sampling callback
result = await client_session.call_tool("test_sampling", {"message": "Test message for sampling"})
result = await client.call_tool("test_sampling", {"message": "Test message for sampling"})
assert result.is_error is True
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Error executing tool test_sampling: Sampling not supported"
@ -94,8 +92,8 @@ async def test_create_message_backwards_compat_single_content():
assert not hasattr(result, "content_as_list") or not callable(getattr(result, "content_as_list", None))
return True
async with create_session(server._mcp_server, sampling_callback=sampling_callback) as client_session:
result = await client_session.call_tool("test_backwards_compat", {"message": "Test"})
async with Client(server, sampling_callback=sampling_callback) as client:
result = await client.call_tool("test_backwards_compat", {"message": "Test"})
assert result.is_error is False
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "true"

View file

View file

@ -0,0 +1,114 @@
"""Tests for InMemoryTransport."""
import pytest
from mcp.client._memory import InMemoryTransport
from mcp.server import Server
from mcp.server.fastmcp import FastMCP
from mcp.types import Resource
@pytest.fixture
def simple_server() -> Server:
"""Create a simple MCP server for testing."""
server = Server(name="test_server")
# pragma: no cover - handler exists only to register a resource capability.
# Transport tests verify stream creation, not handler invocation.
@server.list_resources()
async def handle_list_resources(): # pragma: no cover
return [
Resource(
uri="memory://test",
name="Test Resource",
description="A test resource",
)
]
return server
@pytest.fixture
def fastmcp_server() -> FastMCP:
"""Create a FastMCP server for testing."""
server = FastMCP("test")
# pragma: no cover on handlers below - they exist only to register capabilities.
# Transport tests verify stream creation and basic protocol, not handler invocation.
@server.tool()
def greet(name: str) -> str: # pragma: no cover
"""Greet someone by name."""
return f"Hello, {name}!"
@server.resource("test://resource")
def test_resource() -> str: # pragma: no cover
"""A test resource."""
return "Test content"
return server
pytestmark = pytest.mark.anyio
async def test_with_server(simple_server: Server):
"""Test creating transport with a Server instance."""
transport = InMemoryTransport(simple_server)
async with transport.connect() as (read_stream, write_stream):
assert read_stream is not None
assert write_stream is not None
async def test_with_fastmcp(fastmcp_server: FastMCP):
"""Test creating transport with a FastMCP instance."""
transport = InMemoryTransport(fastmcp_server)
async with transport.connect() as (read_stream, write_stream):
assert read_stream is not None
assert write_stream is not None
async def test_server_is_running(fastmcp_server: FastMCP):
"""Test that the server is running and responding to requests."""
from mcp.client.session import ClientSession
transport = InMemoryTransport(fastmcp_server)
async with transport.connect() as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
result = await session.initialize()
assert result is not None
assert result.server_info.name == "test"
async def test_list_tools(fastmcp_server: FastMCP):
"""Test listing tools through the transport."""
from mcp.client.session import ClientSession
transport = InMemoryTransport(fastmcp_server)
async with transport.connect() as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
tools_result = await session.list_tools()
assert len(tools_result.tools) > 0
tool_names = [t.name for t in tools_result.tools]
assert "greet" in tool_names
async def test_call_tool(fastmcp_server: FastMCP):
"""Test calling a tool through the transport."""
from mcp.client.session import ClientSession
transport = InMemoryTransport(fastmcp_server)
async with transport.connect() as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
result = await session.call_tool("greet", {"name": "World"})
assert result is not None
assert len(result.content) > 0
assert "Hello, World!" in str(result.content[0])
async def test_raise_exceptions(fastmcp_server: FastMCP):
"""Test that raise_exceptions parameter is passed through."""
transport = InMemoryTransport(fastmcp_server, raise_exceptions=True)
async with transport.connect() as (read_stream, _write_stream):
assert read_stream is not None

View file

@ -1,10 +1,8 @@
import pytest
from pydantic import AnyUrl
from mcp import Client
from mcp.server.fastmcp import FastMCP
from mcp.shared.memory import (
create_connected_server_and_client_session as client_session,
)
from mcp.types import (
ListResourceTemplatesResult,
TextResourceContents,
@ -78,10 +76,7 @@ async def test_resource_template_client_interaction():
def get_user_profile(user_id: str) -> str:
return f"Profile for user {user_id}"
async with client_session(mcp._mcp_server) as session:
# Initialize the session
await session.initialize()
async with Client(mcp) as session:
# List available resources
resources = await session.list_resource_templates()
assert isinstance(resources, ListResourceTemplatesResult)

View file

@ -3,13 +3,10 @@ import base64
import pytest
from pydantic import AnyUrl
from mcp import types
from mcp import Client, types
from mcp.server.fastmcp import FastMCP
from mcp.server.lowlevel import Server
from mcp.server.lowlevel.helper_types import ReadResourceContents
from mcp.shared.memory import (
create_connected_server_and_client_session as client_session,
)
pytestmark = pytest.mark.anyio
@ -33,7 +30,7 @@ async def test_fastmcp_resource_mime_type():
return image_bytes
# Test that resources are listed with correct mime type
async with client_session(mcp._mcp_server) as client:
async with Client(mcp) as client:
# List resources and verify mime types
resources = await client.list_resources()
assert resources.resources is not None
@ -91,7 +88,7 @@ async def test_lowlevel_resource_mime_type():
raise Exception(f"Resource not found: {uri}") # pragma: no cover
# Test that resources are listed with correct mime type
async with client_session(server) as client:
async with Client(server) as client:
# List resources and verify mime types
resources = await client.list_resources()
assert resources.resources is not None

View file

@ -12,12 +12,9 @@ These tests verify the fix works end-to-end through the JSON-RPC protocol.
import pytest
from mcp import types
from mcp import Client, types
from mcp.server.lowlevel import Server
from mcp.server.lowlevel.helper_types import ReadResourceContents
from mcp.shared.memory import (
create_connected_server_and_client_session as client_session,
)
pytestmark = pytest.mark.anyio
@ -48,7 +45,7 @@ async def test_relative_uri_roundtrip():
)
]
async with client_session(server) as client:
async with Client(server) as client:
# List should return the exact URIs we specified
resources = await client.list_resources()
uri_map = {r.uri: r for r in resources.resources}
@ -83,7 +80,7 @@ async def test_custom_scheme_uri_roundtrip():
async def read_resource(uri: str):
return [ReadResourceContents(content="data", mime_type="text/plain")]
async with client_session(server) as client:
async with Client(server) as client:
resources = await client.list_resources()
uri_map = {r.uri: r for r in resources.resources}

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