refactor: flatten the methods in Client (#1914)

This commit is contained in:
Marcelo Trylesinski 2026-01-20 13:12:07 +01:00 • committed by GitHub
commit bcb07c2b25
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 213 additions and 315 deletions

View file

@ -58,7 +58,7 @@ jobs:
- name: Run pytest with coverage - name: Run pytest with coverage
shell: bash shell: bash
run: | run: |
uv run --frozen --no-sync coverage run -m pytest uv run --frozen --no-sync coverage run -m pytest -n auto
uv run --frozen --no-sync coverage combine uv run --frozen --no-sync coverage combine
uv run --frozen --no-sync coverage report uv run --frozen --no-sync coverage report

View file

@ -17,6 +17,7 @@ This document contains critical information about working with this codebase. Fo
- Functions must be focused and small - Functions must be focused and small
- Follow existing patterns exactly - Follow existing patterns exactly
- Line length: 120 chars maximum - Line length: 120 chars maximum
- FORBIDDEN: imports inside functions
3. Testing Requirements 3. Testing Requirements
- Framework: `uv run --frozen pytest` - Framework: `uv run --frozen pytest`
@ -25,6 +26,8 @@ This document contains critical information about working with this codebase. Fo
- Coverage: test edge cases and errors - Coverage: test edge cases and errors
- New features require tests - New features require tests
- Bug fixes require regression tests - Bug fixes require regression tests
- IMPORTANT: The `tests/client/test_client.py` is the most well designed test file. Follow its patterns.
- IMPORTANT: Be minimal, and focus on E2E tests: Use the `mcp.client.Client` whenever possible.
- For commits fixing bugs or adding features based on user reports add: - For commits fixing bugs or adding features based on user reports add:

View file

@ -171,7 +171,6 @@ xfail_strict = true
addopts = """ addopts = """
--color=yes --color=yes
--capture=fd --capture=fd
--numprocesses auto
""" """
filterwarnings = [ filterwarnings = [
"error", "error",

View file

@ -36,12 +36,7 @@ class InMemoryTransport:
result = await client.call_tool("my_tool", {...}) result = await client.call_tool("my_tool", {...})
""" """
def __init__( def __init__(self, server: Server[Any] | FastMCP, *, raise_exceptions: bool = False) -> None:
self,
server: Server[Any] | FastMCP,
*,
raise_exceptions: bool = False,
) -> None:
"""Initialize the in-memory transport. """Initialize the in-memory transport.
Args: Args:

View file

@ -58,6 +58,7 @@ class Client:
self, self,
server: Server[Any] | FastMCP, server: Server[Any] | FastMCP,
*, *,
# TODO(Marcelo): When do `raise_exceptions=True` actually raises?
raise_exceptions: bool = False, raise_exceptions: bool = False,
read_timeout_seconds: float | None = None, read_timeout_seconds: float | None = None,
sampling_callback: SamplingFnT | None = None, sampling_callback: SamplingFnT | None = None,
@ -125,14 +126,9 @@ class Client:
self._exit_stack = exit_stack.pop_all() self._exit_stack = exit_stack.pop_all()
return self return self
async def __aexit__( async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None:
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: Any,
) -> None:
"""Exit the async context manager.""" """Exit the async context manager."""
if self._exit_stack: if self._exit_stack: # pragma: no branch
await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb) await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb)
self._session = None self._session = None
@ -177,28 +173,22 @@ class Client:
"""Set the logging level on the server.""" """Set the logging level on the server."""
return await self.session.set_logging_level(level) return await self.session.set_logging_level(level)
async def list_resources( async def list_resources(self, *, cursor: str | None = None) -> types.ListResourcesResult:
self,
params: types.PaginatedRequestParams | None = None,
) -> types.ListResourcesResult:
"""List available resources from the server.""" """List available resources from the server."""
return await self.session.list_resources(params=params) return await self.session.list_resources(params=types.PaginatedRequestParams(cursor=cursor))
async def list_resource_templates( async def list_resource_templates(self, *, cursor: str | None = None) -> types.ListResourceTemplatesResult:
self,
params: types.PaginatedRequestParams | None = None,
) -> types.ListResourceTemplatesResult:
"""List available resource templates from the server.""" """List available resource templates from the server."""
return await self.session.list_resource_templates(params=params) return await self.session.list_resource_templates(params=types.PaginatedRequestParams(cursor=cursor))
async def read_resource(self, uri: str | AnyUrl) -> types.ReadResourceResult: async def read_resource(self, uri: str | AnyUrl) -> types.ReadResourceResult:
"""Read a resource from the server. """Read a resource from the server.
Args: Args:
uri: The URI of the resource to read uri: The URI of the resource to read.
Returns: Returns:
The resource content The resource content.
""" """
return await self.session.read_resource(uri) return await self.session.read_resource(uri)
@ -239,18 +229,11 @@ class Client:
meta=meta, meta=meta,
) )
async def list_prompts( async def list_prompts(self, *, cursor: str | None = None) -> types.ListPromptsResult:
self,
params: types.PaginatedRequestParams | None = None,
) -> types.ListPromptsResult:
"""List available prompts from the server.""" """List available prompts from the server."""
return await self.session.list_prompts(params=params) return await self.session.list_prompts(params=types.PaginatedRequestParams(cursor=cursor))
async def get_prompt( async def get_prompt(self, name: str, arguments: dict[str, str] | None = None) -> types.GetPromptResult:
self,
name: str,
arguments: dict[str, str] | None = None,
) -> types.GetPromptResult:
"""Get a prompt from the server. """Get a prompt from the server.
Args: Args:
@ -258,7 +241,7 @@ class Client:
arguments: Arguments to pass to the prompt arguments: Arguments to pass to the prompt
Returns: Returns:
The prompt content The prompt content.
""" """
return await self.session.get_prompt(name=name, arguments=arguments) return await self.session.get_prompt(name=name, arguments=arguments)
@ -276,21 +259,15 @@ class Client:
context_arguments: Additional context arguments context_arguments: Additional context arguments
Returns: Returns:
Completion suggestions Completion suggestions.
""" """
return await self.session.complete( return await self.session.complete(ref=ref, argument=argument, context_arguments=context_arguments)
ref=ref,
argument=argument,
context_arguments=context_arguments,
)
async def list_tools( async def list_tools(self, *, cursor: str | None = None) -> types.ListToolsResult:
self,
params: types.PaginatedRequestParams | None = None,
) -> types.ListToolsResult:
"""List available tools from the server.""" """List available tools from the server."""
return await self.session.list_tools(params=params) return await self.session.list_tools(params=types.PaginatedRequestParams(cursor=cursor))
async def send_roots_list_changed(self) -> None: async def send_roots_list_changed(self) -> None:
"""Send a notification that the roots list has changed.""" """Send a notification that the roots list has changed."""
await self.session.send_roots_list_changed() # TODO(Marcelo): Currently, there is no way for the server to handle this. We should add support.
await self.session.send_roots_list_changed() # pragma: no cover

View file

@ -349,10 +349,7 @@ class ClientSession(
Args: Args:
params: Full pagination parameters including cursor and any future fields params: Full pagination parameters including cursor and any future fields
""" """
return await self.send_request( return await self.send_request(types.ListPromptsRequest(params=params), types.ListPromptsResult)
types.ListPromptsRequest(params=params),
types.ListPromptsResult,
)
async def get_prompt(self, name: str, arguments: dict[str, str] | None = None) -> types.GetPromptResult: async def get_prompt(self, name: str, arguments: dict[str, str] | None = None) -> types.GetPromptResult:
"""Send a prompts/get request.""" """Send a prompts/get request."""

View file

@ -234,12 +234,12 @@ class BaseSession(
metadata: MessageMetadata = None, metadata: MessageMetadata = None,
progress_callback: ProgressFnT | None = None, progress_callback: ProgressFnT | None = None,
) -> ReceiveResultT: ) -> ReceiveResultT:
"""Sends a request and wait for a response. Raises an McpError if the """Sends a request and wait for a response.
response contains an error. If a request read timeout is provided, it
will take precedence over the session read timeout.
Do not use this method to emit notifications! Use send_notification() Raises an McpError if the response contains an error. If a request read timeout is provided, it will take
instead. precedence over the session read timeout.
Do not use this method to emit notifications! Use send_notification() instead.
""" """
request_id = self._request_id request_id = self._request_id
self._request_id = request_id + 1 self._request_id = request_id + 1
@ -261,15 +261,10 @@ class BaseSession(
try: try:
jsonrpc_request = JSONRPCRequest(jsonrpc="2.0", id=request_id, **request_data) jsonrpc_request = JSONRPCRequest(jsonrpc="2.0", id=request_id, **request_data)
await self._write_stream.send(SessionMessage(message=jsonrpc_request, metadata=metadata)) await self._write_stream.send(SessionMessage(message=jsonrpc_request, metadata=metadata))
# request read timeout takes precedence over session read timeout # request read timeout takes precedence over session read timeout
timeout = None timeout = request_read_timeout_seconds or self._session_read_timeout_seconds
if request_read_timeout_seconds is not None: # pragma: no cover
timeout = request_read_timeout_seconds
elif self._session_read_timeout_seconds is not None: # pragma: no cover
timeout = self._session_read_timeout_seconds
try: try:
with anyio.fail_after(timeout): with anyio.fail_after(timeout):
@ -279,9 +274,8 @@ class BaseSession(
ErrorData( ErrorData(
code=httpx.codes.REQUEST_TIMEOUT, code=httpx.codes.REQUEST_TIMEOUT,
message=( message=(
f"Timed out while waiting for response to " f"Timed out while waiting for response to {request.__class__.__name__}. "
f"{request.__class__.__name__}. Waited " f"Waited {timeout} seconds."
f"{timeout} seconds."
), ),
) )
) )
@ -302,9 +296,7 @@ class BaseSession(
notification: SendNotificationT, notification: SendNotificationT,
related_request_id: RequestId | None = None, related_request_id: RequestId | None = None,
) -> None: ) -> None:
"""Emits a notification, which is a one-way message that does not expect """Emits a notification, which is a one-way message that does not expect a response."""
a response.
"""
# Some transport implementations may need to set the related_request_id # Some transport implementations may need to set the related_request_id
# to attribute to the notifications to the request that triggered them. # to attribute to the notifications to the request that triggered them.
jsonrpc_notification = JSONRPCNotification( jsonrpc_notification = JSONRPCNotification(
@ -373,11 +365,7 @@ class BaseSession(
error_response = JSONRPCError( error_response = JSONRPCError(
jsonrpc="2.0", jsonrpc="2.0",
id=message.message.id, id=message.message.id,
error=ErrorData( error=ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data=""),
code=INVALID_PARAMS,
message="Invalid request parameters",
data="",
),
) )
session_message = SessionMessage(message=error_response) session_message = SessionMessage(message=error_response)
await self._write_stream.send(session_message) await self._write_stream.send(session_message)
@ -518,13 +506,9 @@ class BaseSession(
total: float | None = None, total: float | None = None,
message: str | None = None, message: str | None = None,
) -> None: ) -> None:
"""Sends a progress notification for a request that is currently being """Sends a progress notification for a request that is currently being processed."""
processed.
"""
async def _handle_incoming( async def _handle_incoming(
self, self, req: RequestResponder[ReceiveRequestT, SendResultT] | ReceiveNotificationT | Exception
req: RequestResponder[ReceiveRequestT, SendResultT] | ReceiveNotificationT | Exception,
) -> None: ) -> None:
"""A generic handler for incoming messages. Overwritten by subclasses.""" """A generic handler for incoming messages. Overwritten by subclasses."""
pass # pragma: no cover

View file

@ -1,14 +1,36 @@
"""Tests for the unified Client class.""" """Tests for the unified Client class."""
from unittest.mock import AsyncMock, patch from __future__ import annotations
import anyio
import pytest import pytest
from inline_snapshot import snapshot
import mcp.types as types import mcp.types as types
from mcp.client.client import Client from mcp.client.client import Client
from mcp.server import Server from mcp.server import Server
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from mcp.types import EmptyResult, Resource from mcp.types import (
CallToolResult,
EmptyResult,
GetPromptResult,
ListPromptsResult,
ListResourcesResult,
ListResourceTemplatesResult,
ListToolsResult,
Prompt,
PromptArgument,
PromptMessage,
PromptsCapability,
ReadResourceResult,
Resource,
ResourcesCapability,
ServerCapabilities,
TextContent,
TextResourceContents,
Tool,
ToolsCapability,
)
pytestmark = pytest.mark.anyio pytestmark = pytest.mark.anyio
@ -20,13 +42,27 @@ def simple_server() -> Server:
@server.list_resources() @server.list_resources()
async def handle_list_resources(): async def handle_list_resources():
return [ return [Resource(uri="memory://test", name="Test Resource", description="A test resource")]
Resource(
uri="memory://test", @server.subscribe_resource()
name="Test Resource", async def handle_subscribe_resource(uri: str):
description="A test resource", pass
)
] @server.unsubscribe_resource()
async def handle_unsubscribe_resource(uri: str):
pass
@server.set_logging_level()
async def handle_set_logging_level(level: str):
pass
@server.completion()
async def handle_completion(
ref: types.PromptReference | types.ResourceTemplateReference,
argument: types.CompletionArgument,
context: types.CompletionContext | None,
) -> types.Completion | None:
return types.Completion(values=[])
return server return server
@ -41,11 +77,6 @@ def app() -> FastMCP:
"""Greet someone by name.""" """Greet someone by name."""
return f"Hello, {name}!" return f"Hello, {name}!"
@server.tool()
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
@server.resource("test://resource") @server.resource("test://resource")
def test_resource() -> str: def test_resource() -> str:
"""A test resource.""" """A test resource."""
@ -59,278 +90,198 @@ def app() -> FastMCP:
return server 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): async def test_client_is_initialized(app: FastMCP):
"""Test that the client is initialized after entering context.""" """Test that the client is initialized after entering context."""
async with Client(app) as client: async with Client(app) as client:
caps = client.server_capabilities assert client.server_capabilities == snapshot(
assert caps is not None ServerCapabilities(
assert caps.tools is not None experimental={},
prompts=PromptsCapability(list_changed=False),
resources=ResourcesCapability(subscribe=False, list_changed=False),
tools=ToolsCapability(list_changed=False),
)
)
async def test_with_simple_server(simple_server: Server): async def test_client_with_simple_server(simple_server: Server):
"""Test that from_server works with a basic Server instance.""" """Test that from_server works with a basic Server instance."""
async with Client(simple_server) as client: 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() resources = await client.list_resources()
assert len(resources.resources) == 1 assert resources == snapshot(
assert resources.resources[0].uri == "memory://test" ListResourcesResult(
resources=[Resource(name="Test Resource", uri="memory://test", description="A test resource")]
)
)
async def test_ping_returns_empty_result(app: FastMCP): async def test_client_send_ping(app: FastMCP):
"""Test that ping returns an EmptyResult."""
async with Client(app) as client: async with Client(app) as client:
result = await client.send_ping() result = await client.send_ping()
assert isinstance(result, EmptyResult) assert result == snapshot(EmptyResult())
async def test_list_tools(app: FastMCP): async def test_client_list_tools(app: FastMCP):
"""Test listing tools."""
async with Client(app) as client: async with Client(app) as client:
result = await client.list_tools() result = await client.list_tools()
assert result.tools is not None assert result == snapshot(
tool_names = [t.name for t in result.tools] ListToolsResult(
assert "greet" in tool_names tools=[
assert "add" in tool_names Tool(
name="greet",
description="Greet someone by name.",
input_schema={
"properties": {"name": {"title": "Name", "type": "string"}},
"required": ["name"],
"title": "greetArguments",
"type": "object",
},
output_schema={
"properties": {"result": {"title": "Result", "type": "string"}},
"required": ["result"],
"title": "greetOutput",
"type": "object",
},
)
]
)
)
async def test_list_tools_with_pagination(app: FastMCP): async def test_client_call_tool(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: async with Client(app) as client:
result = await client.call_tool("greet", {"name": "World"}) result = await client.call_tool("greet", {"name": "World"})
assert result.content is not None assert result == snapshot(
assert len(result.content) > 0 CallToolResult(
content_str = str(result.content[0]) content=[TextContent(text="Hello, World!")],
assert "Hello, World!" in content_str structured_content={"result": "Hello, World!"},
)
)
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): async def test_read_resource(app: FastMCP):
"""Test reading a resource.""" """Test reading a resource."""
async with Client(app) as client: async with Client(app) as client:
result = await client.read_resource("test://resource") result = await client.read_resource("test://resource")
assert result.contents is not None assert result == snapshot(
assert len(result.contents) > 0 ReadResourceResult(
contents=[TextResourceContents(uri="test://resource", mime_type="text/plain", text="Test content")]
)
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): async def test_get_prompt(app: FastMCP):
"""Test getting a prompt.""" """Test getting a prompt."""
async with Client(app) as client: async with Client(app) as client:
result = await client.get_prompt("greeting_prompt", {"name": "Alice"}) result = await client.get_prompt("greeting_prompt", {"name": "Alice"})
assert result.messages is not None assert result == snapshot(
assert len(result.messages) > 0 GetPromptResult(
description="A greeting prompt.",
messages=[PromptMessage(role="user", content=TextContent(text="Please greet Alice warmly."))],
)
)
async def test_session_property(app: FastMCP): def test_client_session_property_before_enter(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.""" """Test that accessing session before context manager raises RuntimeError."""
client = Client(app) client = Client(app)
with pytest.raises(RuntimeError, match="Client must be used within an async context manager"): with pytest.raises(RuntimeError, match="Client must be used within an async context manager"):
_ = client.session client.session
async def test_reentry_raises_runtime_error(app: FastMCP): async def test_client_reentry_raises_runtime_error(app: FastMCP):
"""Test that reentering a client raises RuntimeError.""" """Test that reentering a client raises RuntimeError."""
async with Client(app) as client: async with Client(app) as client:
with pytest.raises(RuntimeError, match="Client is already entered"): with pytest.raises(RuntimeError, match="Client is already entered"):
await client.__aenter__() await client.__aenter__()
async def test_cleanup_on_init_failure(app: FastMCP): async def test_client_send_progress_notification():
"""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.""" """Test sending progress notification."""
received_from_client = None
event = anyio.Event()
server = Server(name="test_server")
@server.progress_notification()
async def handle_progress_notification(
progress_token: str | int,
progress: float = 0.0,
total: float | None = None,
message: str | None = None,
) -> None:
nonlocal received_from_client
received_from_client = {"progress_token": progress_token, "progress": progress}
event.set()
async with Client(server) as client:
await client.send_progress_notification(progress_token="token123", progress=50.0)
await event.wait()
assert received_from_client == snapshot({"progress_token": "token123", "progress": 50.0})
async def test_client_subscribe_resource(simple_server: Server):
async with Client(simple_server) as client:
result = await client.subscribe_resource("memory://test")
assert result == snapshot(EmptyResult())
async def test_client_unsubscribe_resource(simple_server: Server):
async with Client(simple_server) as client:
result = await client.unsubscribe_resource("memory://test")
assert result == snapshot(EmptyResult())
async def test_client_set_logging_level(simple_server: Server):
"""Test setting logging level."""
async with Client(simple_server) as client:
result = await client.set_logging_level("debug")
assert result == snapshot(EmptyResult())
async def test_client_list_resources_with_params(app: FastMCP):
"""Test listing resources with params parameter."""
async with Client(app) as client: async with Client(app) as client:
# Send a progress notification - this should not raise result = await client.list_resources()
await client.send_progress_notification( assert result == snapshot(
progress_token="test-token", ListResourcesResult(
progress=50.0, resources=[
total=100.0, Resource(
message="Half done", name="test_resource",
uri="test://resource",
description="A test resource.",
mime_type="text/plain",
)
]
)
) )
async def test_subscribe_resource(app: FastMCP): async def test_client_list_resource_templates(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.""" """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: async with Client(app) as client:
result = await client.list_resource_templates() result = await client.list_resource_templates()
assert result is not None assert result == snapshot(ListResourceTemplatesResult(resource_templates=[]))
async def test_list_prompts_with_params(app: FastMCP): async def test_list_prompts(app: FastMCP):
"""Test listing prompts with params parameter.""" """Test listing prompts with params parameter."""
async with Client(app) as client: async with Client(app) as client:
result = await client.list_prompts(params=types.PaginatedRequestParams()) result = await client.list_prompts()
assert result is not None assert result == snapshot(
ListPromptsResult(
prompts=[
Prompt(
name="greeting_prompt",
description="A greeting prompt.",
arguments=[PromptArgument(name="name", required=True)],
)
]
)
)
async def test_complete_with_prompt_reference(app: FastMCP): async def test_complete_with_prompt_reference(simple_server: Server):
"""Test getting completions for a prompt argument.""" """Test getting completions for a prompt argument."""
async with Client(app) as client: async with Client(simple_server) as client:
ref = types.PromptReference(type="ref/prompt", name="greeting_prompt") ref = types.PromptReference(type="ref/prompt", name="test_prompt")
# Mock the session's complete method since FastMCP may not support it result = await client.complete(ref=ref, argument={"name": "arg", "value": "test"})
with patch.object( assert result == snapshot(types.CompleteResult(completion=types.Completion(values=[])))
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

@ -73,12 +73,12 @@ async def test_list_methods_params_parameter(
_ = await method() _ = await method()
requests = spies.get_client_requests(method=request_method) requests = spies.get_client_requests(method=request_method)
assert len(requests) == 1 assert len(requests) == 1
assert requests[0].params is None assert requests[0].params is None or "cursor" not in requests[0].params
spies.clear() spies.clear()
# Test with params containing cursor # Test with params containing cursor
_ = await method(params=types.PaginatedRequestParams(cursor="from_params")) _ = await method(cursor="from_params")
requests = spies.get_client_requests(method=request_method) requests = spies.get_client_requests(method=request_method)
assert len(requests) == 1 assert len(requests) == 1
assert requests[0].params is not None assert requests[0].params is not None
@ -87,7 +87,7 @@ async def test_list_methods_params_parameter(
spies.clear() spies.clear()
# Test with empty params # Test with empty params
_ = await method(params=types.PaginatedRequestParams()) _ = await method()
requests = spies.get_client_requests(method=request_method) requests = spies.get_client_requests(method=request_method)
assert len(requests) == 1 assert len(requests) == 1
# Empty params means no cursor # Empty params means no cursor
@ -99,7 +99,7 @@ async def test_list_tools_with_strict_server_validation(
): ):
"""Test pagination with a server that validates request format strictly.""" """Test pagination with a server that validates request format strictly."""
async with Client(full_featured_server) as client: async with Client(full_featured_server) as client:
result = await client.list_tools(params=types.PaginatedRequestParams()) result = await client.list_tools()
assert isinstance(result, ListToolsResult) assert isinstance(result, ListToolsResult)
assert len(result.tools) > 0 assert len(result.tools) > 0
@ -112,19 +112,11 @@ async def test_list_tools_with_lowlevel_server():
async def handle_list_tools(request: ListToolsRequest) -> ListToolsResult: async def handle_list_tools(request: ListToolsRequest) -> ListToolsResult:
# Echo back what cursor we received in the tool description # Echo back what cursor we received in the tool description
cursor = request.params.cursor if request.params else None cursor = request.params.cursor if request.params else None
return ListToolsResult( return ListToolsResult(tools=[types.Tool(name="test_tool", description=f"cursor={cursor}", input_schema={})])
tools=[
types.Tool(
name="test_tool",
description=f"cursor={cursor}",
input_schema={},
)
]
)
async with Client(server) as client: async with Client(server) as client:
result = await client.list_tools(params=types.PaginatedRequestParams()) result = await client.list_tools()
assert result.tools[0].description == "cursor=None" assert result.tools[0].description == "cursor=None"
result = await client.list_tools(params=types.PaginatedRequestParams(cursor="page2")) result = await client.list_tools(cursor="page2")
assert result.tools[0].description == "cursor=page2" assert result.tools[0].description == "cursor=page2"