diff --git a/README.md b/README.md index 48d1c1742..a0daf5c6d 100644 --- a/README.md +++ b/README.md @@ -1840,7 +1840,7 @@ import asyncio from mcp.client.session import ClientSession from mcp.client.stdio import StdioServerParameters, stdio_client -from mcp.types import Resource +from mcp.types import PaginatedRequestParams, Resource async def list_all_resources() -> None: @@ -1857,7 +1857,7 @@ async def list_all_resources() -> None: while True: # Fetch a page of resources - result = await session.list_resources(cursor=cursor) + result = await session.list_resources(params=PaginatedRequestParams(cursor=cursor)) all_resources.extend(result.resources) print(f"Fetched {len(result.resources)} resources") diff --git a/examples/snippets/clients/pagination_client.py b/examples/snippets/clients/pagination_client.py index 4df1aec60..1805d2d31 100644 --- a/examples/snippets/clients/pagination_client.py +++ b/examples/snippets/clients/pagination_client.py @@ -6,7 +6,7 @@ from mcp.client.session import ClientSession from mcp.client.stdio import StdioServerParameters, stdio_client -from mcp.types import Resource +from mcp.types import PaginatedRequestParams, Resource async def list_all_resources() -> None: @@ -23,7 +23,7 @@ async def list_all_resources() -> None: while True: # Fetch a page of resources - result = await session.list_resources(cursor=cursor) + result = await session.list_resources(params=PaginatedRequestParams(cursor=cursor)) all_resources.extend(result.resources) print(f"Fetched {len(result.resources)} resources") diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index bcf80d62a..45c57089c 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -1,11 +1,12 @@ import logging from datetime import timedelta -from typing import Any, Protocol +from typing import Any, Protocol, overload import anyio.lowlevel from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from jsonschema import SchemaError, ValidationError, validate from pydantic import AnyUrl, TypeAdapter +from typing_extensions import deprecated import mcp.types as types from mcp.shared.context import RequestContext @@ -212,25 +213,79 @@ async def set_logging_level(self, level: types.LoggingLevel) -> types.EmptyResul types.EmptyResult, ) - async def list_resources(self, cursor: str | None = None) -> types.ListResourcesResult: - """Send a resources/list request.""" + @overload + @deprecated("Use list_resources(params=PaginatedRequestParams(...)) instead") + async def list_resources(self, cursor: str | None) -> types.ListResourcesResult: ... + + @overload + async def list_resources(self, *, params: types.PaginatedRequestParams | None) -> types.ListResourcesResult: ... + + @overload + async def list_resources(self) -> types.ListResourcesResult: ... + + async def list_resources( + self, + cursor: str | None = None, + *, + params: types.PaginatedRequestParams | None = None, + ) -> types.ListResourcesResult: + """Send a resources/list request. + + Args: + cursor: Simple cursor string for pagination (deprecated, use params instead) + params: Full pagination parameters including cursor and any future fields + """ + if params is not None and cursor is not None: + raise ValueError("Cannot specify both cursor and params") + + if params is not None: + request_params = params + elif cursor is not None: + request_params = types.PaginatedRequestParams(cursor=cursor) + else: + request_params = None + return await self.send_request( - types.ClientRequest( - types.ListResourcesRequest( - params=types.PaginatedRequestParams(cursor=cursor) if cursor is not None else None, - ) - ), + types.ClientRequest(types.ListResourcesRequest(params=request_params)), types.ListResourcesResult, ) - async def list_resource_templates(self, cursor: str | None = None) -> types.ListResourceTemplatesResult: - """Send a resources/templates/list request.""" + @overload + @deprecated("Use list_resource_templates(params=PaginatedRequestParams(...)) instead") + async def list_resource_templates(self, cursor: str | None) -> types.ListResourceTemplatesResult: ... + + @overload + async def list_resource_templates( + self, *, params: types.PaginatedRequestParams | None + ) -> types.ListResourceTemplatesResult: ... + + @overload + async def list_resource_templates(self) -> types.ListResourceTemplatesResult: ... + + async def list_resource_templates( + self, + cursor: str | None = None, + *, + params: types.PaginatedRequestParams | None = None, + ) -> types.ListResourceTemplatesResult: + """Send a resources/templates/list request. + + Args: + cursor: Simple cursor string for pagination (deprecated, use params instead) + params: Full pagination parameters including cursor and any future fields + """ + if params is not None and cursor is not None: + raise ValueError("Cannot specify both cursor and params") + + if params is not None: + request_params = params + elif cursor is not None: + request_params = types.PaginatedRequestParams(cursor=cursor) + else: + request_params = None + return await self.send_request( - types.ClientRequest( - types.ListResourceTemplatesRequest( - params=types.PaginatedRequestParams(cursor=cursor) if cursor is not None else None, - ) - ), + types.ClientRequest(types.ListResourceTemplatesRequest(params=request_params)), types.ListResourceTemplatesResult, ) @@ -317,14 +372,40 @@ async def _validate_tool_result(self, name: str, result: types.CallToolResult) - except SchemaError as e: raise RuntimeError(f"Invalid schema for tool {name}: {e}") - async def list_prompts(self, cursor: str | None = None) -> types.ListPromptsResult: - """Send a prompts/list request.""" + @overload + @deprecated("Use list_prompts(params=PaginatedRequestParams(...)) instead") + async def list_prompts(self, cursor: str | None) -> types.ListPromptsResult: ... + + @overload + async def list_prompts(self, *, params: types.PaginatedRequestParams | None) -> types.ListPromptsResult: ... + + @overload + async def list_prompts(self) -> types.ListPromptsResult: ... + + async def list_prompts( + self, + cursor: str | None = None, + *, + params: types.PaginatedRequestParams | None = None, + ) -> types.ListPromptsResult: + """Send a prompts/list request. + + Args: + cursor: Simple cursor string for pagination (deprecated, use params instead) + params: Full pagination parameters including cursor and any future fields + """ + if params is not None and cursor is not None: + raise ValueError("Cannot specify both cursor and params") + + if params is not None: + request_params = params + elif cursor is not None: + request_params = types.PaginatedRequestParams(cursor=cursor) + else: + request_params = None + return await self.send_request( - types.ClientRequest( - types.ListPromptsRequest( - params=types.PaginatedRequestParams(cursor=cursor) if cursor is not None else None, - ) - ), + types.ClientRequest(types.ListPromptsRequest(params=request_params)), types.ListPromptsResult, ) @@ -363,14 +444,40 @@ async def complete( types.CompleteResult, ) - async def list_tools(self, cursor: str | None = None) -> types.ListToolsResult: - """Send a tools/list request.""" + @overload + @deprecated("Use list_tools(params=PaginatedRequestParams(...)) instead") + async def list_tools(self, cursor: str | None) -> types.ListToolsResult: ... + + @overload + async def list_tools(self, *, params: types.PaginatedRequestParams | None) -> types.ListToolsResult: ... + + @overload + async def list_tools(self) -> types.ListToolsResult: ... + + async def list_tools( + self, + cursor: str | None = None, + *, + params: types.PaginatedRequestParams | None = None, + ) -> types.ListToolsResult: + """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 + """ + if params is not None and cursor is not None: + raise ValueError("Cannot specify both cursor and params") + + if params is not None: + request_params = params + elif cursor is not None: + request_params = types.PaginatedRequestParams(cursor=cursor) + else: + request_params = None + result = await self.send_request( - types.ClientRequest( - types.ListToolsRequest( - params=types.PaginatedRequestParams(cursor=cursor) if cursor is not None else None, - ) - ), + types.ClientRequest(types.ListToolsRequest(params=request_params)), types.ListToolsResult, ) diff --git a/tests/client/test_list_methods_cursor.py b/tests/client/test_list_methods_cursor.py index b31b704a4..e99f622f4 100644 --- a/tests/client/test_list_methods_cursor.py +++ b/tests/client/test_list_methods_cursor.py @@ -2,23 +2,22 @@ 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_tool_2() -> str: """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() + @server.resource("resource://test/data") + async def test_resource() -> str: + """Test resource""" + return "Test data" - # 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"] == "" + @server.prompt() + async def test_prompt(name: str) -> str: + """Test prompt""" + return f"Hello, {name}!" + @server.resource("resource://test/{name}") + async def test_template(name: str) -> str: + """Test resource template""" + return f"Data for {name}" -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. + 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 """ - 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: + async with create_session(full_featured_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 + 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_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 + _ = 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_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" + _ = 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_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 + _ = 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. """ - 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: + async with create_session(full_featured_server._mcp_server) as client_session: spies = stream_spy() + method = getattr(client_session, method_name) - # 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 + # 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 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 + # 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 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" + # 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 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 + # 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. """ - server = FastMCP("test") + async with create_session(full_featured_server._mcp_server) as client_session: + method = getattr(client_session, method_name) - # 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}" + # 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 with create_session(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 +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. - spies.clear() + Some MCP servers may implement strict JSON-RPC validation that requires + the params field to always be present in requests, even if empty {}. - # 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 + This test ensures such servers are supported by the client SDK for list_resources + requests without a cursor. + """ - spies.clear() + server = Server("strict_server") - # 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" + @server.list_tools() + async def handle_list_tools(request: ListToolsRequest) -> ListToolsResult: + """Strict handler that validates params field exists""" - spies.clear() + # 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." + ) - # 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"] == "" + # 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