Add ergonomic Client class for testing MCP servers (#1870)
This commit is contained in:
parent
8adb5bdce8
commit
df039bf97c
1 changed files with 1298 additions and 526 deletions
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue