fix: send params as empty object for list methods without cursor (#1453)

This commit is contained in:
Felix Weinberger 2025-10-14 11:11:38 +01:00 • committed by GitHub
commit de89457683
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -2,23 +2,22 @@ from collections.abc import Callable
import pytest
import mcp.types as types
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
pytestmark = pytest.mark.anyio
async def test_list_tools_cursor_parameter(stream_spy: Callable[[], StreamSpyCollection]):
"""Test that the cursor parameter is accepted for list_tools
and that it is correctly passed to the server.
See: https://modelcontextprotocol.io/specification/2025-03-26/server/utilities/pagination#request-format
"""
@pytest.fixture
async def full_featured_server():
"""Create a server with tools, resources, prompts, and templates."""
server = FastMCP("test")
# Create a couple of test tools
@server.tool(name="test_tool_1")
async def test_tool_1() -> str:
"""First test tool"""
@ -29,186 +28,201 @@ async def test_list_tools_cursor_parameter(stream_spy: Callable[[], StreamSpyCol
"""Second test tool"""
return "Result 2"
async with create_session(server._mcp_server) as client_session:
spies = stream_spy()
# Test without cursor parameter (omitted)
_ = await client_session.list_tools()
list_tools_requests = spies.get_client_requests(method="tools/list")
assert len(list_tools_requests) == 1
assert list_tools_requests[0].params is None
spies.clear()
# Test with cursor=None
_ = await client_session.list_tools(cursor=None)
list_tools_requests = spies.get_client_requests(method="tools/list")
assert len(list_tools_requests) == 1
assert list_tools_requests[0].params is None
spies.clear()
# Test with cursor as string
_ = await client_session.list_tools(cursor="some_cursor_value")
list_tools_requests = spies.get_client_requests(method="tools/list")
assert len(list_tools_requests) == 1
assert list_tools_requests[0].params is not None
assert list_tools_requests[0].params["cursor"] == "some_cursor_value"
spies.clear()
# Test with empty string cursor
_ = await client_session.list_tools(cursor="")
list_tools_requests = spies.get_client_requests(method="tools/list")
assert len(list_tools_requests) == 1
assert list_tools_requests[0].params is not None
assert list_tools_requests[0].params["cursor"] == ""
async def test_list_resources_cursor_parameter(stream_spy: Callable[[], StreamSpyCollection]):
"""Test that the cursor parameter is accepted for list_resources
and that it is correctly passed to the server.
See: https://modelcontextprotocol.io/specification/2025-03-26/server/utilities/pagination#request-format
"""
server = FastMCP("test")
# Create a test resource
@server.resource("resource://test/data")
async def test_resource() -> str:
"""Test resource"""
return "Test data"
async with create_session(server._mcp_server) as client_session:
spies = stream_spy()
# Test without cursor parameter (omitted)
_ = await client_session.list_resources()
list_resources_requests = spies.get_client_requests(method="resources/list")
assert len(list_resources_requests) == 1
assert list_resources_requests[0].params is None
spies.clear()
# Test with cursor=None
_ = await client_session.list_resources(cursor=None)
list_resources_requests = spies.get_client_requests(method="resources/list")
assert len(list_resources_requests) == 1
assert list_resources_requests[0].params is None
spies.clear()
# Test with cursor as string
_ = await client_session.list_resources(cursor="some_cursor")
list_resources_requests = spies.get_client_requests(method="resources/list")
assert len(list_resources_requests) == 1
assert list_resources_requests[0].params is not None
assert list_resources_requests[0].params["cursor"] == "some_cursor"
spies.clear()
# Test with empty string cursor
_ = await client_session.list_resources(cursor="")
list_resources_requests = spies.get_client_requests(method="resources/list")
assert len(list_resources_requests) == 1
assert list_resources_requests[0].params is not None
assert list_resources_requests[0].params["cursor"] == ""
async def test_list_prompts_cursor_parameter(stream_spy: Callable[[], StreamSpyCollection]):
"""Test that the cursor parameter is accepted for list_prompts
and that it is correctly passed to the server.
See: https://modelcontextprotocol.io/specification/2025-03-26/server/utilities/pagination#request-format
"""
server = FastMCP("test")
# Create a test prompt
@server.prompt()
async def test_prompt(name: str) -> str:
"""Test prompt"""
return f"Hello, {name}!"
async with create_session(server._mcp_server) as client_session:
spies = stream_spy()
# Test without cursor parameter (omitted)
_ = await client_session.list_prompts()
list_prompts_requests = spies.get_client_requests(method="prompts/list")
assert len(list_prompts_requests) == 1
assert list_prompts_requests[0].params is None
spies.clear()
# Test with cursor=None
_ = await client_session.list_prompts(cursor=None)
list_prompts_requests = spies.get_client_requests(method="prompts/list")
assert len(list_prompts_requests) == 1
assert list_prompts_requests[0].params is None
spies.clear()
# Test with cursor as string
_ = await client_session.list_prompts(cursor="some_cursor")
list_prompts_requests = spies.get_client_requests(method="prompts/list")
assert len(list_prompts_requests) == 1
assert list_prompts_requests[0].params is not None
assert list_prompts_requests[0].params["cursor"] == "some_cursor"
spies.clear()
# Test with empty string cursor
_ = await client_session.list_prompts(cursor="")
list_prompts_requests = spies.get_client_requests(method="prompts/list")
assert len(list_prompts_requests) == 1
assert list_prompts_requests[0].params is not None
assert list_prompts_requests[0].params["cursor"] == ""
async def test_list_resource_templates_cursor_parameter(stream_spy: Callable[[], StreamSpyCollection]):
"""Test that the cursor parameter is accepted for list_resource_templates
and that it is correctly passed to the server.
See: https://modelcontextprotocol.io/specification/2025-03-26/server/utilities/pagination#request-format
"""
server = FastMCP("test")
# Create a test resource template
@server.resource("resource://test/{name}")
async def test_template(name: str) -> str:
"""Test resource template"""
return f"Data for {name}"
async with create_session(server._mcp_server) as client_session:
return server
@pytest.mark.parametrize(
"method_name,request_method",
[
("list_tools", "tools/list"),
("list_resources", "resources/list"),
("list_prompts", "prompts/list"),
("list_resource_templates", "resources/templates/list"),
],
)
@pytest.mark.filterwarnings("ignore::DeprecationWarning")
async def test_list_methods_cursor_parameter(
stream_spy: Callable[[], StreamSpyCollection],
full_featured_server: FastMCP,
method_name: str,
request_method: str,
):
"""Test that the cursor parameter is accepted and correctly passed to the server.
Covers: list_tools, list_resources, list_prompts, list_resource_templates
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()
# Test without cursor parameter (omitted)
_ = await client_session.list_resource_templates()
list_templates_requests = spies.get_client_requests(method="resources/templates/list")
assert len(list_templates_requests) == 1
assert list_templates_requests[0].params is None
method = getattr(client_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()
# Test with cursor=None
_ = await client_session.list_resource_templates(cursor=None)
list_templates_requests = spies.get_client_requests(method="resources/templates/list")
assert len(list_templates_requests) == 1
assert list_templates_requests[0].params is None
_ = await method(cursor=None)
requests = spies.get_client_requests(method=request_method)
assert len(requests) == 1
assert requests[0].params is None
spies.clear()
# Test with cursor as string
_ = await client_session.list_resource_templates(cursor="some_cursor")
list_templates_requests = spies.get_client_requests(method="resources/templates/list")
assert len(list_templates_requests) == 1
assert list_templates_requests[0].params is not None
assert list_templates_requests[0].params["cursor"] == "some_cursor"
_ = await method(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"
spies.clear()
# Test with empty string cursor
_ = await client_session.list_resource_templates(cursor="")
list_templates_requests = spies.get_client_requests(method="resources/templates/list")
assert len(list_templates_requests) == 1
assert list_templates_requests[0].params is not None
assert list_templates_requests[0].params["cursor"] == ""
_ = await method(cursor="")
requests = spies.get_client_requests(method=request_method)
assert len(requests) == 1
assert requests[0].params is not None
assert requests[0].params["cursor"] == ""
@pytest.mark.parametrize(
"method_name,request_method",
[
("list_tools", "tools/list"),
("list_resources", "resources/list"),
("list_prompts", "prompts/list"),
("list_resource_templates", "resources/templates/list"),
],
)
async def test_list_methods_params_parameter(
stream_spy: Callable[[], StreamSpyCollection],
full_featured_server: FastMCP,
method_name: str,
request_method: str,
):
"""Test that the params parameter works correctly for list methods.
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.
"""
async with create_session(full_featured_server._mcp_server) as client_session:
spies = stream_spy()
method = getattr(client_session, method_name)
# 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
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
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"
@pytest.mark.parametrize(
"method_name",
[
"list_tools",
"list_resources",
"list_prompts",
"list_resource_templates",
],
)
async def test_list_methods_raises_error_when_both_cursor_and_params_provided(
full_featured_server: FastMCP,
method_name: str,
):
"""Test that providing both cursor and params raises ValueError.
Covers: list_tools, list_resources, list_prompts, list_resource_templates
When both cursor and params are provided, a ValueError should be raised
to prevent ambiguity.
"""
async with create_session(full_featured_server._mcp_server) as client_session:
method = getattr(client_session, method_name)
# Call with both cursor and params - should raise ValueError
with pytest.raises(ValueError, match="Cannot specify both cursor and params"):
await method(
cursor="old_cursor",
params=types.PaginatedRequestParams(cursor="new_cursor"),
)
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.
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")
@server.list_tools()
async def handle_list_tools(request: ListToolsRequest) -> ListToolsResult:
"""Strict handler that validates params field exists"""
# 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."
)
# Return empty tools list
return ListToolsResult(tools=[])
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