Add ergonomic Client class for testing MCP servers (#1870)
This commit is contained in:
parent
8adb5bdce8
commit
df039bf97c
9 changed files with 1298 additions and 526 deletions
|
|
@ -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
336
tests/client/test_client.py
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
0
tests/client/transports/__init__.py
Normal file
0
tests/client/transports/__init__.py
Normal file
114
tests/client/transports/test_memory.py
Normal file
114
tests/client/transports/test_memory.py
Normal 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
|
||||
Loading…
Reference in a new issue