From 68055612c069d17b0bfa6564fe728cd1a14b0458 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 20 Aug 2025 14:11:15 +0100 Subject: [PATCH 1/8] feat: add paginated list decorators for prompts, resources, and tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add list_prompts_paginated, list_resources_paginated, and list_tools_paginated decorators to support cursor-based pagination for listing endpoints. These decorators: - Accept a cursor parameter (can be None for first page) - Return the respective ListResult type directly - Maintain backward compatibility with existing non-paginated decorators - Update tool cache for list_tools_paginated Also includes simplified unit tests that verify cursor passthrough. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/mcp/server/lowlevel/server.py | 45 +++++++ tests/server/lowlevel/__init__.py | 0 .../server/lowlevel/test_server_pagination.py | 110 ++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 tests/server/lowlevel/__init__.py create mode 100644 tests/server/lowlevel/test_server_pagination.py diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 8c459383c..a3562875f 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -242,6 +242,19 @@ async def handler(_: Any): return decorator + def list_prompts_paginated(self): + def decorator(func: Callable[[types.Cursor | None], Awaitable[types.ListPromptsResult]]): + logger.debug("Registering handler for PromptListRequest with pagination") + + async def handler(req: types.ListPromptsRequest): + result = await func(req.params.cursor if req.params else None) + return types.ServerResult(result) + + self.request_handlers[types.ListPromptsRequest] = handler + return func + + return decorator + def get_prompt(self): def decorator( func: Callable[[str, dict[str, str] | None], Awaitable[types.GetPromptResult]], @@ -270,6 +283,19 @@ async def handler(_: Any): return decorator + def list_resources_paginated(self): + def decorator(func: Callable[[types.Cursor | None], Awaitable[types.ListResourcesResult]]): + logger.debug("Registering handler for ListResourcesRequest with pagination") + + async def handler(req: types.ListResourcesRequest): + result = await func(req.params.cursor if req.params else None) + return types.ServerResult(result) + + self.request_handlers[types.ListResourcesRequest] = handler + return func + + return decorator + def list_resource_templates(self): def decorator(func: Callable[[], Awaitable[list[types.ResourceTemplate]]]): logger.debug("Registering handler for ListResourceTemplatesRequest") @@ -397,6 +423,25 @@ async def handler(_: Any): return decorator + def list_tools_paginated(self): + def decorator( + func: Callable[[types.Cursor | None], Awaitable[types.ListToolsResult]] + ): + logger.debug("Registering paginated handler for ListToolsRequest") + + async def handler(request: types.ListToolsRequest): + cursor = request.params.cursor if request.params else None + result = await func(cursor) + # Refresh the tool cache with returned tools + for tool in result.tools: + self._tool_cache[tool.name] = tool + return types.ServerResult(result) + + self.request_handlers[types.ListToolsRequest] = handler + return func + + return decorator + def _make_error_result(self, error_message: str) -> types.ServerResult: """Create a ServerResult with an error CallToolResult.""" return types.ServerResult( diff --git a/tests/server/lowlevel/__init__.py b/tests/server/lowlevel/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/server/lowlevel/test_server_pagination.py b/tests/server/lowlevel/test_server_pagination.py new file mode 100644 index 000000000..d094fd432 --- /dev/null +++ b/tests/server/lowlevel/test_server_pagination.py @@ -0,0 +1,110 @@ +import pytest + +from mcp.server import Server +from mcp.types import ( + Cursor, + ListPromptsRequest, + ListPromptsResult, + ListResourcesRequest, + ListResourcesResult, + ListToolsRequest, + ListToolsResult, + PaginatedRequestParams, + ServerResult, +) + + +@pytest.mark.anyio +async def test_list_prompts_pagination() -> None: + server = Server("test") + test_cursor = "test-cursor-123" + + # Track what cursor was received + received_cursor: Cursor | None = None + + @server.list_prompts_paginated() + async def handle_list_prompts(cursor: Cursor | None) -> ListPromptsResult: + nonlocal received_cursor + received_cursor = cursor + return ListPromptsResult(prompts=[], nextCursor="next") + + handler = server.request_handlers[ListPromptsRequest] + + # Test: No cursor provided -> handler receives None + request = ListPromptsRequest(method="prompts/list", params=None) + result = await handler(request) + assert received_cursor is None + assert isinstance(result, ServerResult) + + # Test: Cursor provided -> handler receives exact cursor value + request_with_cursor = ListPromptsRequest( + method="prompts/list", + params=PaginatedRequestParams(cursor=test_cursor) + ) + result2 = await handler(request_with_cursor) + assert received_cursor == test_cursor + assert isinstance(result2, ServerResult) + + +@pytest.mark.anyio +async def test_list_resources_pagination() -> None: + server = Server("test") + test_cursor = "resource-cursor-456" + + # Track what cursor was received + received_cursor: Cursor | None = None + + @server.list_resources_paginated() + async def handle_list_resources(cursor: Cursor | None) -> ListResourcesResult: + nonlocal received_cursor + received_cursor = cursor + return ListResourcesResult(resources=[], nextCursor="next") + + handler = server.request_handlers[ListResourcesRequest] + + # Test: No cursor provided -> handler receives None + request = ListResourcesRequest(method="resources/list", params=None) + result = await handler(request) + assert received_cursor is None + assert isinstance(result, ServerResult) + + # Test: Cursor provided -> handler receives exact cursor value + request_with_cursor = ListResourcesRequest( + method="resources/list", + params=PaginatedRequestParams(cursor=test_cursor) + ) + result2 = await handler(request_with_cursor) + assert received_cursor == test_cursor + assert isinstance(result2, ServerResult) + + +@pytest.mark.anyio +async def test_list_tools_pagination() -> None: + server = Server("test") + test_cursor = "tools-cursor-789" + + # Track what cursor was received + received_cursor: Cursor | None = None + + @server.list_tools_paginated() + async def handle_list_tools(cursor: Cursor | None) -> ListToolsResult: + nonlocal received_cursor + received_cursor = cursor + return ListToolsResult(tools=[], nextCursor="next") + + handler = server.request_handlers[ListToolsRequest] + + # Test: No cursor provided -> handler receives None + request = ListToolsRequest(method="tools/list", params=None) + result = await handler(request) + assert received_cursor is None + assert isinstance(result, ServerResult) + + # Test: Cursor provided -> handler receives exact cursor value + request_with_cursor = ListToolsRequest( + method="tools/list", + params=PaginatedRequestParams(cursor=test_cursor) + ) + result2 = await handler(request_with_cursor) + assert received_cursor == test_cursor + assert isinstance(result2, ServerResult) From 48836d219e4084fff6b794d2bc7b8253a6e9ec99 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 20 Aug 2025 14:42:41 +0100 Subject: [PATCH 2/8] style: apply ruff formatting to pass pre-commit checks Apply automatic formatting from ruff to ensure code meets project standards: - Remove trailing whitespace - Adjust line breaks for consistency - Format function arguments according to line length limits --- src/mcp/server/lowlevel/server.py | 4 +- .../server/lowlevel/test_server_pagination.py | 43 ++++++++----------- 2 files changed, 19 insertions(+), 28 deletions(-) diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index a3562875f..f17aa1ac0 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -424,9 +424,7 @@ async def handler(_: Any): return decorator def list_tools_paginated(self): - def decorator( - func: Callable[[types.Cursor | None], Awaitable[types.ListToolsResult]] - ): + def decorator(func: Callable[[types.Cursor | None], Awaitable[types.ListToolsResult]]): logger.debug("Registering paginated handler for ListToolsRequest") async def handler(request: types.ListToolsRequest): diff --git a/tests/server/lowlevel/test_server_pagination.py b/tests/server/lowlevel/test_server_pagination.py index d094fd432..f2c786e45 100644 --- a/tests/server/lowlevel/test_server_pagination.py +++ b/tests/server/lowlevel/test_server_pagination.py @@ -18,29 +18,26 @@ async def test_list_prompts_pagination() -> None: server = Server("test") test_cursor = "test-cursor-123" - + # Track what cursor was received received_cursor: Cursor | None = None - + @server.list_prompts_paginated() async def handle_list_prompts(cursor: Cursor | None) -> ListPromptsResult: nonlocal received_cursor received_cursor = cursor return ListPromptsResult(prompts=[], nextCursor="next") - + handler = server.request_handlers[ListPromptsRequest] - + # Test: No cursor provided -> handler receives None request = ListPromptsRequest(method="prompts/list", params=None) result = await handler(request) assert received_cursor is None assert isinstance(result, ServerResult) - + # Test: Cursor provided -> handler receives exact cursor value - request_with_cursor = ListPromptsRequest( - method="prompts/list", - params=PaginatedRequestParams(cursor=test_cursor) - ) + request_with_cursor = ListPromptsRequest(method="prompts/list", params=PaginatedRequestParams(cursor=test_cursor)) result2 = await handler(request_with_cursor) assert received_cursor == test_cursor assert isinstance(result2, ServerResult) @@ -50,28 +47,27 @@ async def handle_list_prompts(cursor: Cursor | None) -> ListPromptsResult: async def test_list_resources_pagination() -> None: server = Server("test") test_cursor = "resource-cursor-456" - + # Track what cursor was received received_cursor: Cursor | None = None - + @server.list_resources_paginated() async def handle_list_resources(cursor: Cursor | None) -> ListResourcesResult: nonlocal received_cursor received_cursor = cursor return ListResourcesResult(resources=[], nextCursor="next") - + handler = server.request_handlers[ListResourcesRequest] - + # Test: No cursor provided -> handler receives None request = ListResourcesRequest(method="resources/list", params=None) result = await handler(request) assert received_cursor is None assert isinstance(result, ServerResult) - + # Test: Cursor provided -> handler receives exact cursor value request_with_cursor = ListResourcesRequest( - method="resources/list", - params=PaginatedRequestParams(cursor=test_cursor) + method="resources/list", params=PaginatedRequestParams(cursor=test_cursor) ) result2 = await handler(request_with_cursor) assert received_cursor == test_cursor @@ -82,29 +78,26 @@ async def handle_list_resources(cursor: Cursor | None) -> ListResourcesResult: async def test_list_tools_pagination() -> None: server = Server("test") test_cursor = "tools-cursor-789" - + # Track what cursor was received received_cursor: Cursor | None = None - + @server.list_tools_paginated() async def handle_list_tools(cursor: Cursor | None) -> ListToolsResult: nonlocal received_cursor received_cursor = cursor return ListToolsResult(tools=[], nextCursor="next") - + handler = server.request_handlers[ListToolsRequest] - + # Test: No cursor provided -> handler receives None request = ListToolsRequest(method="tools/list", params=None) result = await handler(request) assert received_cursor is None assert isinstance(result, ServerResult) - + # Test: Cursor provided -> handler receives exact cursor value - request_with_cursor = ListToolsRequest( - method="tools/list", - params=PaginatedRequestParams(cursor=test_cursor) - ) + request_with_cursor = ListToolsRequest(method="tools/list", params=PaginatedRequestParams(cursor=test_cursor)) result2 = await handler(request_with_cursor) assert received_cursor == test_cursor assert isinstance(result2, ServerResult) From 541b0a859f8ec11af5c06c57397955f8c0639ce8 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 20 Aug 2025 16:25:24 +0100 Subject: [PATCH 3/8] feat: add pagination examples and documentation - Create mcp_simple_pagination example server demonstrating all three paginated endpoints - Add pagination snippets for both server and client implementations - Update README to use snippet-source pattern for pagination examples - Move mutually exclusive note to blockquote format for better visibility - Complete example shows tools, resources, and prompts pagination with different page sizes --- .pre-commit-config.yaml | 8 +- README.md | 116 +++++++++ examples/servers/simple-pagination/README.md | 77 ++++++ .../mcp_simple_pagination/__init__.py | 0 .../mcp_simple_pagination/__main__.py | 5 + .../mcp_simple_pagination/server.py | 225 ++++++++++++++++++ .../servers/simple-pagination/pyproject.toml | 47 ++++ .../snippets/clients/pagination_client.py | 41 ++++ .../snippets/servers/pagination_example.py | 35 +++ uv.lock | 36 ++- 10 files changed, 585 insertions(+), 5 deletions(-) create mode 100644 examples/servers/simple-pagination/README.md create mode 100644 examples/servers/simple-pagination/mcp_simple_pagination/__init__.py create mode 100644 examples/servers/simple-pagination/mcp_simple_pagination/__main__.py create mode 100644 examples/servers/simple-pagination/mcp_simple_pagination/server.py create mode 100644 examples/servers/simple-pagination/pyproject.toml create mode 100644 examples/snippets/clients/pagination_client.py create mode 100644 examples/snippets/servers/pagination_example.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 553c52d62..73f024d9a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,14 +25,14 @@ repos: hooks: - id: ruff-format name: Ruff Format - entry: uv run ruff + entry: uv run --frozen ruff args: [format] language: system types: [python] pass_filenames: false - id: ruff name: Ruff - entry: uv run ruff + entry: uv run --frozen ruff args: ["check", "--fix", "--exit-non-zero-on-fix"] types: [python] language: system @@ -40,7 +40,7 @@ repos: exclude: ^README\.md$ - id: pyright name: pyright - entry: uv run pyright + entry: uv run --frozen pyright language: system types: [python] pass_filenames: false @@ -52,7 +52,7 @@ repos: pass_filenames: false - id: readme-snippets name: Check README snippets are up to date - entry: uv run scripts/update_readme_snippets.py --check + entry: uv run --frozen scripts/update_readme_snippets.py --check language: system files: ^(README\.md|examples/.*\.py|scripts/update_readme_snippets\.py)$ pass_filenames: false diff --git a/README.md b/README.md index d2fb9194a..fda988898 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ - [Advanced Usage](#advanced-usage) - [Low-Level Server](#low-level-server) - [Structured Output Support](#structured-output-support) + - [Pagination (Advanced)](#pagination-advanced) - [Writing MCP Clients](#writing-mcp-clients) - [Client Display Utilities](#client-display-utilities) - [OAuth Authentication for Clients](#oauth-authentication-for-clients) @@ -1737,6 +1738,121 @@ Tools can return data in three ways: When an `outputSchema` is defined, the server automatically validates the structured output against the schema. This ensures type safety and helps catch errors early. +### Pagination (Advanced) + +For servers that need to handle large datasets, the low-level server provides paginated versions of list operations. This is an optional optimization - most servers won't need pagination unless they're dealing with hundreds or thousands of items. + +#### Server-side Implementation + + +```python +""" +Example of implementing pagination with MCP server decorators. +""" + +from pydantic import AnyUrl + +import mcp.types as types +from mcp.server.lowlevel import Server + +# Initialize the server +server = Server("paginated-server") + +# Sample data to paginate +ITEMS = [f"Item {i}" for i in range(1, 101)] # 100 items + + +@server.list_resources_paginated() +async def list_resources_paginated(cursor: types.Cursor | None) -> types.ListResourcesResult: + """List resources with pagination support.""" + page_size = 10 + + # Parse cursor to get offset + start = 0 if cursor is None else int(cursor) + end = start + page_size + + # Get page of resources + page_items = [ + types.Resource(uri=AnyUrl(f"resource://items/{item}"), name=item, description=f"Description for {item}") + for item in ITEMS[start:end] + ] + + # Determine next cursor + next_cursor = str(end) if end < len(ITEMS) else None + + return types.ListResourcesResult(resources=page_items, nextCursor=next_cursor) +``` + +_Full example: [examples/snippets/servers/pagination_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/pagination_example.py)_ + + +Similar decorators are available for all list operations: + +- `@server.list_tools_paginated()` - for paginating tools +- `@server.list_resources_paginated()` - for paginating resources +- `@server.list_prompts_paginated()` - for paginating prompts + +#### Client-side Consumption + + +```python +""" +Example of consuming paginated MCP endpoints from a client. +""" + +import asyncio + +from mcp.client.session import ClientSession +from mcp.client.stdio import StdioServerParameters, stdio_client +from mcp.types import Resource + + +async def list_all_resources() -> None: + """Fetch all resources using pagination.""" + async with stdio_client(StdioServerParameters(command="uv", args=["run", "mcp-simple-pagination"])) as ( + read, + write, + ): + async with ClientSession(read, write) as session: + await session.initialize() + + all_resources: list[Resource] = [] + cursor = None + + while True: + # Fetch a page of resources + result = await session.list_resources(cursor=cursor) + all_resources.extend(result.resources) + + print(f"Fetched {len(result.resources)} resources") + + # Check if there are more pages + if result.nextCursor: + cursor = result.nextCursor + else: + break + + print(f"Total resources: {len(all_resources)}") + + +if __name__ == "__main__": + asyncio.run(list_all_resources()) +``` + +_Full example: [examples/snippets/clients/pagination_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/pagination_client.py)_ + + +#### Key Points + +- **Cursors are opaque strings** - the server defines the format (numeric offsets, timestamps, etc.) +- **Return `nextCursor=None`** when there are no more pages +- **Backward compatible** - clients that don't support pagination will still work (they'll just get the first page) +- **Flexible page sizes** - Each endpoint can define its own page size based on data characteristics + +> **NOTE**: The paginated decorators (`list_tools_paginated()`, `list_resources_paginated()`, `list_prompts_paginated()`) are mutually exclusive with their non-paginated counterparts and cannot be used together on the same server instance. + +See the [simple-pagination example](examples/servers/simple-pagination) for a complete implementation. + ### Writing MCP Clients The SDK provides a high-level client interface for connecting to MCP servers using various [transports](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports): diff --git a/examples/servers/simple-pagination/README.md b/examples/servers/simple-pagination/README.md new file mode 100644 index 000000000..e732b8efb --- /dev/null +++ b/examples/servers/simple-pagination/README.md @@ -0,0 +1,77 @@ +# MCP Simple Pagination + +A simple MCP server demonstrating pagination for tools, resources, and prompts using cursor-based pagination. + +## Usage + +Start the server using either stdio (default) or SSE transport: + +```bash +# Using stdio transport (default) +uv run mcp-simple-pagination + +# Using SSE transport on custom port +uv run mcp-simple-pagination --transport sse --port 8000 +``` + +The server exposes: + +- 25 tools (paginated, 5 per page) +- 30 resources (paginated, 10 per page) +- 20 prompts (paginated, 7 per page) + +Each paginated list returns a `nextCursor` when more pages are available. Use this cursor in subsequent requests to retrieve the next page. + +## Example + +Using the MCP client, you can retrieve paginated items like this using the STDIO transport: + +```python +import asyncio +from mcp.client.session import ClientSession +from mcp.client.stdio import StdioServerParameters, stdio_client + + +async def main(): + async with stdio_client( + StdioServerParameters(command="uv", args=["run", "mcp-simple-pagination"]) + ) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + # Get first page of tools + tools_page1 = await session.list_tools() + print(f"First page: {len(tools_page1.tools)} tools") + print(f"Next cursor: {tools_page1.nextCursor}") + + # Get second page using cursor + if tools_page1.nextCursor: + tools_page2 = await session.list_tools(cursor=tools_page1.nextCursor) + print(f"Second page: {len(tools_page2.tools)} tools") + + # Similarly for resources + resources_page1 = await session.list_resources() + print(f"First page: {len(resources_page1.resources)} resources") + + # And for prompts + prompts_page1 = await session.list_prompts() + print(f"First page: {len(prompts_page1.prompts)} prompts") + + +asyncio.run(main()) +``` + +## Pagination Details + +The server uses simple numeric indices as cursors for demonstration purposes. In production scenarios, you might use: + +- Database offsets or row IDs +- Timestamps for time-based pagination +- Opaque tokens encoding pagination state + +The pagination implementation demonstrates: + +- Handling `None` cursor for the first page +- Returning `nextCursor` when more data exists +- Gracefully handling invalid cursors +- Different page sizes for different resource types diff --git a/examples/servers/simple-pagination/mcp_simple_pagination/__init__.py b/examples/servers/simple-pagination/mcp_simple_pagination/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/servers/simple-pagination/mcp_simple_pagination/__main__.py b/examples/servers/simple-pagination/mcp_simple_pagination/__main__.py new file mode 100644 index 000000000..e7ef16530 --- /dev/null +++ b/examples/servers/simple-pagination/mcp_simple_pagination/__main__.py @@ -0,0 +1,5 @@ +import sys + +from .server import main + +sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-pagination/mcp_simple_pagination/server.py b/examples/servers/simple-pagination/mcp_simple_pagination/server.py new file mode 100644 index 000000000..8a7ebdb9c --- /dev/null +++ b/examples/servers/simple-pagination/mcp_simple_pagination/server.py @@ -0,0 +1,225 @@ +""" +Simple MCP server demonstrating pagination for tools, resources, and prompts. + +This example shows how to use the paginated decorators to handle large lists +of items that need to be split across multiple pages. +""" + +from typing import Any + +import anyio +import click +import mcp.types as types +from mcp.server.lowlevel import Server +from pydantic import AnyUrl +from starlette.requests import Request + +# Sample data - in real scenarios, this might come from a database +SAMPLE_TOOLS = [ + types.Tool( + name=f"tool_{i}", + title=f"Tool {i}", + description=f"This is sample tool number {i}", + inputSchema={"type": "object", "properties": {"input": {"type": "string"}}}, + ) + for i in range(1, 26) # 25 tools total +] + +SAMPLE_RESOURCES = [ + types.Resource( + uri=AnyUrl(f"file:///path/to/resource_{i}.txt"), + name=f"resource_{i}", + description=f"This is sample resource number {i}", + ) + for i in range(1, 31) # 30 resources total +] + +SAMPLE_PROMPTS = [ + types.Prompt( + name=f"prompt_{i}", + description=f"This is sample prompt number {i}", + arguments=[ + types.PromptArgument(name="arg1", description="First argument", required=True), + ], + ) + for i in range(1, 21) # 20 prompts total +] + + +@click.command() +@click.option("--port", default=8000, help="Port to listen on for SSE") +@click.option( + "--transport", + type=click.Choice(["stdio", "sse"]), + default="stdio", + help="Transport type", +) +def main(port: int, transport: str) -> int: + app = Server("mcp-simple-pagination") + + # Paginated list_tools - returns 5 tools per page + @app.list_tools_paginated() + async def list_tools_paginated(cursor: types.Cursor | None) -> types.ListToolsResult: + page_size = 5 + + if cursor is None: + # First page + start_idx = 0 + else: + # Parse cursor to get the start index + try: + start_idx = int(cursor) + except (ValueError, TypeError): + # Invalid cursor, return empty + return types.ListToolsResult(tools=[], nextCursor=None) + + # Get the page of tools + page_tools = SAMPLE_TOOLS[start_idx : start_idx + page_size] + + # Determine if there are more pages + next_cursor = None + if start_idx + page_size < len(SAMPLE_TOOLS): + next_cursor = str(start_idx + page_size) + + return types.ListToolsResult(tools=page_tools, nextCursor=next_cursor) + + # Paginated list_resources - returns 10 resources per page + @app.list_resources_paginated() + async def list_resources_paginated( + cursor: types.Cursor | None, + ) -> types.ListResourcesResult: + page_size = 10 + + if cursor is None: + # First page + start_idx = 0 + else: + # Parse cursor to get the start index + try: + start_idx = int(cursor) + except (ValueError, TypeError): + # Invalid cursor, return empty + return types.ListResourcesResult(resources=[], nextCursor=None) + + # Get the page of resources + page_resources = SAMPLE_RESOURCES[start_idx : start_idx + page_size] + + # Determine if there are more pages + next_cursor = None + if start_idx + page_size < len(SAMPLE_RESOURCES): + next_cursor = str(start_idx + page_size) + + return types.ListResourcesResult(resources=page_resources, nextCursor=next_cursor) + + # Paginated list_prompts - returns 7 prompts per page + @app.list_prompts_paginated() + async def list_prompts_paginated( + cursor: types.Cursor | None, + ) -> types.ListPromptsResult: + page_size = 7 + + if cursor is None: + # First page + start_idx = 0 + else: + # Parse cursor to get the start index + try: + start_idx = int(cursor) + except (ValueError, TypeError): + # Invalid cursor, return empty + return types.ListPromptsResult(prompts=[], nextCursor=None) + + # Get the page of prompts + page_prompts = SAMPLE_PROMPTS[start_idx : start_idx + page_size] + + # Determine if there are more pages + next_cursor = None + if start_idx + page_size < len(SAMPLE_PROMPTS): + next_cursor = str(start_idx + page_size) + + return types.ListPromptsResult(prompts=page_prompts, nextCursor=next_cursor) + + # Implement call_tool handler + @app.call_tool() + async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: + # Find the tool in our sample data + tool = next((t for t in SAMPLE_TOOLS if t.name == name), None) + if not tool: + raise ValueError(f"Unknown tool: {name}") + + # Simple mock response + return [ + types.TextContent( + type="text", + text=f"Called tool '{name}' with arguments: {arguments}", + ) + ] + + # Implement read_resource handler + @app.read_resource() + async def read_resource(uri: AnyUrl) -> str: + # Find the resource in our sample data + resource = next((r for r in SAMPLE_RESOURCES if r.uri == uri), None) + if not resource: + raise ValueError(f"Unknown resource: {uri}") + + # Return a simple string - the decorator will convert it to TextResourceContents + return f"Content of {resource.name}: This is sample content for the resource." + + # Implement get_prompt handler + @app.get_prompt() + async def get_prompt(name: str, arguments: dict[str, str] | None) -> types.GetPromptResult: + # Find the prompt in our sample data + prompt = next((p for p in SAMPLE_PROMPTS if p.name == name), None) + if not prompt: + raise ValueError(f"Unknown prompt: {name}") + + # Simple mock response + message_text = f"This is the prompt '{name}'" + if arguments: + message_text += f" with arguments: {arguments}" + + return types.GetPromptResult( + description=prompt.description, + messages=[ + types.PromptMessage( + role="user", + content=types.TextContent(type="text", text=message_text), + ) + ], + ) + + if transport == "sse": + from mcp.server.sse import SseServerTransport + from starlette.applications import Starlette + from starlette.responses import Response + from starlette.routing import Mount, Route + + sse = SseServerTransport("/messages/") + + async def handle_sse(request: Request): + async with sse.connect_sse(request.scope, request.receive, request._send) as streams: # type: ignore[reportPrivateUsage] + await app.run(streams[0], streams[1], app.create_initialization_options()) + return Response() + + starlette_app = Starlette( + debug=True, + routes=[ + Route("/sse", endpoint=handle_sse, methods=["GET"]), + Mount("/messages/", app=sse.handle_post_message), + ], + ) + + import uvicorn + + uvicorn.run(starlette_app, host="127.0.0.1", port=port) + else: + from mcp.server.stdio import stdio_server + + async def arun(): + async with stdio_server() as streams: + await app.run(streams[0], streams[1], app.create_initialization_options()) + + anyio.run(arun) + + return 0 diff --git a/examples/servers/simple-pagination/pyproject.toml b/examples/servers/simple-pagination/pyproject.toml new file mode 100644 index 000000000..0c60cf73c --- /dev/null +++ b/examples/servers/simple-pagination/pyproject.toml @@ -0,0 +1,47 @@ +[project] +name = "mcp-simple-pagination" +version = "0.1.0" +description = "A simple MCP server demonstrating pagination for tools, resources, and prompts" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Anthropic, PBC." }] +maintainers = [ + { name = "David Soria Parra", email = "davidsp@anthropic.com" }, + { name = "Justin Spahr-Summers", email = "justin@anthropic.com" }, +] +keywords = ["mcp", "llm", "automation", "pagination", "cursor"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"] + +[project.scripts] +mcp-simple-pagination = "mcp_simple_pagination.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_pagination"] + +[tool.pyright] +include = ["mcp_simple_pagination"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[tool.uv] +dev-dependencies = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] \ No newline at end of file diff --git a/examples/snippets/clients/pagination_client.py b/examples/snippets/clients/pagination_client.py new file mode 100644 index 000000000..4df1aec60 --- /dev/null +++ b/examples/snippets/clients/pagination_client.py @@ -0,0 +1,41 @@ +""" +Example of consuming paginated MCP endpoints from a client. +""" + +import asyncio + +from mcp.client.session import ClientSession +from mcp.client.stdio import StdioServerParameters, stdio_client +from mcp.types import Resource + + +async def list_all_resources() -> None: + """Fetch all resources using pagination.""" + async with stdio_client(StdioServerParameters(command="uv", args=["run", "mcp-simple-pagination"])) as ( + read, + write, + ): + async with ClientSession(read, write) as session: + await session.initialize() + + all_resources: list[Resource] = [] + cursor = None + + while True: + # Fetch a page of resources + result = await session.list_resources(cursor=cursor) + all_resources.extend(result.resources) + + print(f"Fetched {len(result.resources)} resources") + + # Check if there are more pages + if result.nextCursor: + cursor = result.nextCursor + else: + break + + print(f"Total resources: {len(all_resources)}") + + +if __name__ == "__main__": + asyncio.run(list_all_resources()) diff --git a/examples/snippets/servers/pagination_example.py b/examples/snippets/servers/pagination_example.py new file mode 100644 index 000000000..3852a209c --- /dev/null +++ b/examples/snippets/servers/pagination_example.py @@ -0,0 +1,35 @@ +""" +Example of implementing pagination with MCP server decorators. +""" + +from pydantic import AnyUrl + +import mcp.types as types +from mcp.server.lowlevel import Server + +# Initialize the server +server = Server("paginated-server") + +# Sample data to paginate +ITEMS = [f"Item {i}" for i in range(1, 101)] # 100 items + + +@server.list_resources_paginated() +async def list_resources_paginated(cursor: types.Cursor | None) -> types.ListResourcesResult: + """List resources with pagination support.""" + page_size = 10 + + # Parse cursor to get offset + start = 0 if cursor is None else int(cursor) + end = start + page_size + + # Get page of resources + page_items = [ + types.Resource(uri=AnyUrl(f"resource://items/{item}"), name=item, description=f"Description for {item}") + for item in ITEMS[start:end] + ] + + # Determine next cursor + next_cursor = str(end) if end < len(ITEMS) else None + + return types.ListResourcesResult(resources=page_items, nextCursor=next_cursor) diff --git a/uv.lock b/uv.lock index 59192bee0..eedc19757 100644 --- a/uv.lock +++ b/uv.lock @@ -1,11 +1,12 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10" [manifest] members = [ "mcp", "mcp-simple-auth", + "mcp-simple-pagination", "mcp-simple-prompt", "mcp-simple-resource", "mcp-simple-streamablehttp", @@ -705,6 +706,39 @@ dev = [ { name = "ruff", specifier = ">=0.8.5" }, ] +[[package]] +name = "mcp-simple-pagination" +version = "0.1.0" +source = { editable = "examples/servers/simple-pagination" } +dependencies = [ + { name = "anyio" }, + { name = "click" }, + { name = "httpx" }, + { name = "mcp" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = ">=4.5" }, + { name = "click", specifier = ">=8.2.0" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "mcp", editable = "." }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.378" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "ruff", specifier = ">=0.6.9" }, +] + [[package]] name = "mcp-simple-prompt" version = "0.1.0" From a9eca029c906f84dd0c47b4e2c6fbbad99f01221 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 21 Aug 2025 17:33:31 +0100 Subject: [PATCH 4/8] chore: clean up pre-commit to use tools directly over uv with --frozen --- .pre-commit-config.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 73f024d9a..51321979f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,14 +25,14 @@ repos: hooks: - id: ruff-format name: Ruff Format - entry: uv run --frozen ruff + entry: ruff args: [format] language: system types: [python] pass_filenames: false - id: ruff name: Ruff - entry: uv run --frozen ruff + entry: ruff args: ["check", "--fix", "--exit-non-zero-on-fix"] types: [python] language: system @@ -40,7 +40,7 @@ repos: exclude: ^README\.md$ - id: pyright name: pyright - entry: uv run --frozen pyright + entry: pyright language: system types: [python] pass_filenames: false @@ -52,7 +52,7 @@ repos: pass_filenames: false - id: readme-snippets name: Check README snippets are up to date - entry: uv run --frozen scripts/update_readme_snippets.py --check + entry: python scripts/update_readme_snippets.py --check language: system files: ^(README\.md|examples/.*\.py|scripts/update_readme_snippets\.py)$ pass_filenames: false From 37d3723ed0f7d2f8bdaf8fb4b78385c3ddd01541 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 22 Aug 2025 17:24:57 +0100 Subject: [PATCH 5/8] switch pagination to single decorator with callback inspection --- README.md | 10 +- .../mcp_simple_pagination/server.py | 6 +- .../snippets/servers/pagination_example.py | 2 +- src/mcp/server/lowlevel/func_inspection.py | 54 ++++++ src/mcp/server/lowlevel/server.py | 116 +++++++------ tests/server/lowlevel/test_func_inspection.py | 141 +++++++++++++++ tests/server/lowlevel/test_server_listing.py | 162 ++++++++++++++++++ .../server/lowlevel/test_server_pagination.py | 6 +- 8 files changed, 429 insertions(+), 68 deletions(-) create mode 100644 src/mcp/server/lowlevel/func_inspection.py create mode 100644 tests/server/lowlevel/test_func_inspection.py create mode 100644 tests/server/lowlevel/test_server_listing.py diff --git a/README.md b/README.md index fda988898..95c871a4e 100644 --- a/README.md +++ b/README.md @@ -1762,7 +1762,7 @@ server = Server("paginated-server") ITEMS = [f"Item {i}" for i in range(1, 101)] # 100 items -@server.list_resources_paginated() +@server.list_resources() async def list_resources_paginated(cursor: types.Cursor | None) -> types.ListResourcesResult: """List resources with pagination support.""" page_size = 10 @@ -1786,12 +1786,6 @@ async def list_resources_paginated(cursor: types.Cursor | None) -> types.ListRes _Full example: [examples/snippets/servers/pagination_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/pagination_example.py)_ -Similar decorators are available for all list operations: - -- `@server.list_tools_paginated()` - for paginating tools -- `@server.list_resources_paginated()` - for paginating resources -- `@server.list_prompts_paginated()` - for paginating prompts - #### Client-side Consumption @@ -1849,8 +1843,6 @@ _Full example: [examples/snippets/clients/pagination_client.py](https://github.c - **Backward compatible** - clients that don't support pagination will still work (they'll just get the first page) - **Flexible page sizes** - Each endpoint can define its own page size based on data characteristics -> **NOTE**: The paginated decorators (`list_tools_paginated()`, `list_resources_paginated()`, `list_prompts_paginated()`) are mutually exclusive with their non-paginated counterparts and cannot be used together on the same server instance. - See the [simple-pagination example](examples/servers/simple-pagination) for a complete implementation. ### Writing MCP Clients diff --git a/examples/servers/simple-pagination/mcp_simple_pagination/server.py b/examples/servers/simple-pagination/mcp_simple_pagination/server.py index 8a7ebdb9c..97f545718 100644 --- a/examples/servers/simple-pagination/mcp_simple_pagination/server.py +++ b/examples/servers/simple-pagination/mcp_simple_pagination/server.py @@ -58,7 +58,7 @@ def main(port: int, transport: str) -> int: app = Server("mcp-simple-pagination") # Paginated list_tools - returns 5 tools per page - @app.list_tools_paginated() + @app.list_tools() async def list_tools_paginated(cursor: types.Cursor | None) -> types.ListToolsResult: page_size = 5 @@ -84,7 +84,7 @@ async def list_tools_paginated(cursor: types.Cursor | None) -> types.ListToolsRe return types.ListToolsResult(tools=page_tools, nextCursor=next_cursor) # Paginated list_resources - returns 10 resources per page - @app.list_resources_paginated() + @app.list_resources() async def list_resources_paginated( cursor: types.Cursor | None, ) -> types.ListResourcesResult: @@ -112,7 +112,7 @@ async def list_resources_paginated( return types.ListResourcesResult(resources=page_resources, nextCursor=next_cursor) # Paginated list_prompts - returns 7 prompts per page - @app.list_prompts_paginated() + @app.list_prompts() async def list_prompts_paginated( cursor: types.Cursor | None, ) -> types.ListPromptsResult: diff --git a/examples/snippets/servers/pagination_example.py b/examples/snippets/servers/pagination_example.py index 3852a209c..c8c99323c 100644 --- a/examples/snippets/servers/pagination_example.py +++ b/examples/snippets/servers/pagination_example.py @@ -14,7 +14,7 @@ ITEMS = [f"Item {i}" for i in range(1, 101)] # 100 items -@server.list_resources_paginated() +@server.list_resources() async def list_resources_paginated(cursor: types.Cursor | None) -> types.ListResourcesResult: """List resources with pagination support.""" page_size = 10 diff --git a/src/mcp/server/lowlevel/func_inspection.py b/src/mcp/server/lowlevel/func_inspection.py new file mode 100644 index 000000000..9573ee0ae --- /dev/null +++ b/src/mcp/server/lowlevel/func_inspection.py @@ -0,0 +1,54 @@ +import inspect +from collections.abc import Callable +from typing import Any + + +def accepts_cursor(func: Callable[..., Any]) -> bool: + """ + True if the function accepts a cursor parameter call, otherwise false. + + `accepts_cursor` does not validate that the function will work. For + example, if `func` contains keyword-only arguments with no defaults, + then it will not work when used in the `lowlevel/server.py` code, but + this function will not raise an exception. + """ + try: + sig = inspect.signature(func) + except (ValueError, TypeError): + return False + + params = dict(sig.parameters.items()) + + method = inspect.ismethod(func) + + if method: + params.pop("self", None) + params.pop("cls", None) + + if len(params) == 0: + # No parameters at all - can't accept cursor + return False + + # Check if ALL remaining parameters are keyword-only + all_keyword_only = all(param.kind == inspect.Parameter.KEYWORD_ONLY for param in params.values()) + + if all_keyword_only: + # If all params are keyword-only, check if they ALL have defaults + # If they do, the function can be called with no arguments -> no cursor + all_have_defaults = all(param.default is not inspect.Parameter.empty for param in params.values()) + return not all_have_defaults # False if all have defaults (no cursor), True otherwise + + # Check if the ONLY parameter is **kwargs (VAR_KEYWORD) + # A function with only **kwargs can't accept a positional cursor argument + if len(params) == 1: + only_param = next(iter(params.values())) + if only_param.kind == inspect.Parameter.VAR_KEYWORD: + return False # Can't pass positional cursor to **kwargs + + # Has at least one positional or variadic parameter - can accept cursor + # Important note: this is designed to _not_ handle the situation where + # there are multiple keyword only arguments with no defaults. In those + # situations it's an invalid handler function, and will error. But it's + # not the responsibility of this function to check the validity of a + # callback. + return True diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index f17aa1ac0..9c4b5c9c2 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -82,6 +82,7 @@ async def main(): from typing_extensions import TypeVar import mcp.types as types +from mcp.server.lowlevel.func_inspection import accepts_cursor from mcp.server.lowlevel.helper_types import ReadResourceContents from mcp.server.models import InitializationOptions from mcp.server.session import ServerSession @@ -230,25 +231,29 @@ def request_context( return request_ctx.get() def list_prompts(self): - def decorator(func: Callable[[], Awaitable[list[types.Prompt]]]): + def decorator( + func: Callable[[], Awaitable[list[types.Prompt]]] + | Callable[[types.Cursor | None], Awaitable[types.ListPromptsResult]], + ): logger.debug("Registering handler for PromptListRequest") + pass_cursor = accepts_cursor(func) - async def handler(_: Any): - prompts = await func() - return types.ServerResult(types.ListPromptsResult(prompts=prompts)) + if pass_cursor: + cursor_func = cast(Callable[[types.Cursor | None], Awaitable[types.ListPromptsResult]], func) - self.request_handlers[types.ListPromptsRequest] = handler - return func + async def cursor_handler(req: types.ListPromptsRequest): + result = await cursor_func(req.params.cursor if req.params is not None else None) + return types.ServerResult(result) - return decorator + handler = cursor_handler + else: + list_func = cast(Callable[[], Awaitable[list[types.Prompt]]], func) - def list_prompts_paginated(self): - def decorator(func: Callable[[types.Cursor | None], Awaitable[types.ListPromptsResult]]): - logger.debug("Registering handler for PromptListRequest with pagination") + async def list_handler(_: types.ListPromptsRequest): + result = await list_func() + return types.ServerResult(types.ListPromptsResult(prompts=result)) - async def handler(req: types.ListPromptsRequest): - result = await func(req.params.cursor if req.params else None) - return types.ServerResult(result) + handler = list_handler self.request_handlers[types.ListPromptsRequest] = handler return func @@ -271,25 +276,29 @@ async def handler(req: types.GetPromptRequest): return decorator def list_resources(self): - def decorator(func: Callable[[], Awaitable[list[types.Resource]]]): + def decorator( + func: Callable[[], Awaitable[list[types.Resource]]] + | Callable[[types.Cursor | None], Awaitable[types.ListResourcesResult]], + ): logger.debug("Registering handler for ListResourcesRequest") + pass_cursor = accepts_cursor(func) - async def handler(_: Any): - resources = await func() - return types.ServerResult(types.ListResourcesResult(resources=resources)) + if pass_cursor: + cursor_func = cast(Callable[[types.Cursor | None], Awaitable[types.ListResourcesResult]], func) - self.request_handlers[types.ListResourcesRequest] = handler - return func + async def cursor_handler(req: types.ListResourcesRequest): + result = await cursor_func(req.params.cursor if req.params is not None else None) + return types.ServerResult(result) - return decorator + handler = cursor_handler + else: + list_func = cast(Callable[[], Awaitable[list[types.Resource]]], func) - def list_resources_paginated(self): - def decorator(func: Callable[[types.Cursor | None], Awaitable[types.ListResourcesResult]]): - logger.debug("Registering handler for ListResourcesRequest with pagination") + async def list_handler(_: types.ListResourcesRequest): + result = await list_func() + return types.ServerResult(types.ListResourcesResult(resources=result)) - async def handler(req: types.ListResourcesRequest): - result = await func(req.params.cursor if req.params else None) - return types.ServerResult(result) + handler = list_handler self.request_handlers[types.ListResourcesRequest] = handler return func @@ -407,33 +416,36 @@ async def handler(req: types.UnsubscribeRequest): return decorator def list_tools(self): - def decorator(func: Callable[[], Awaitable[list[types.Tool]]]): + def decorator( + func: Callable[[], Awaitable[list[types.Tool]]] + | Callable[[types.Cursor | None], Awaitable[types.ListToolsResult]], + ): logger.debug("Registering handler for ListToolsRequest") - - async def handler(_: Any): - tools = await func() - # Refresh the tool cache - self._tool_cache.clear() - for tool in tools: - self._tool_cache[tool.name] = tool - return types.ServerResult(types.ListToolsResult(tools=tools)) - - self.request_handlers[types.ListToolsRequest] = handler - return func - - return decorator - - def list_tools_paginated(self): - def decorator(func: Callable[[types.Cursor | None], Awaitable[types.ListToolsResult]]): - logger.debug("Registering paginated handler for ListToolsRequest") - - async def handler(request: types.ListToolsRequest): - cursor = request.params.cursor if request.params else None - result = await func(cursor) - # Refresh the tool cache with returned tools - for tool in result.tools: - self._tool_cache[tool.name] = tool - return types.ServerResult(result) + pass_cursor = accepts_cursor(func) + + if pass_cursor: + cursor_func = cast(Callable[[types.Cursor | None], Awaitable[types.ListToolsResult]], func) + + async def cursor_handler(req: types.ListToolsRequest): + result = await cursor_func(req.params.cursor if req.params is not None else None) + # Refresh the tool cache with returned tools + for tool in result.tools: + self._tool_cache[tool.name] = tool + return types.ServerResult(result) + + handler = cursor_handler + else: + list_func = cast(Callable[[], Awaitable[list[types.Tool]]], func) + + async def list_handler(req: types.ListToolsRequest): + result = await list_func() + # Clear and refresh the entire tool cache + self._tool_cache.clear() + for tool in result: + self._tool_cache[tool.name] = tool + return types.ServerResult(types.ListToolsResult(tools=result)) + + handler = list_handler self.request_handlers[types.ListToolsRequest] = handler return func diff --git a/tests/server/lowlevel/test_func_inspection.py b/tests/server/lowlevel/test_func_inspection.py new file mode 100644 index 000000000..4114b329d --- /dev/null +++ b/tests/server/lowlevel/test_func_inspection.py @@ -0,0 +1,141 @@ +from collections.abc import Callable +from typing import Any + +import pytest + +from mcp import types +from mcp.server.lowlevel.func_inspection import accepts_cursor + + +# Test fixtures - functions and methods with various signatures +class MyClass: + async def no_cursor_method(self): + """Instance method without cursor parameter""" + pass + + async def cursor_method(self, cursor: types.Cursor | None): + """Instance method with cursor parameter""" + pass + + @classmethod + async def no_cursor_class_method(cls): + """Class method without cursor parameter""" + pass + + @classmethod + async def cursor_class_method(cls, cursor: types.Cursor | None): + """Class method with cursor parameter""" + pass + + @staticmethod + async def no_cursor_static_method(): + """Static method without cursor parameter""" + pass + + @staticmethod + async def cursor_static_method(cursor: types.Cursor | None): + """Static method with cursor parameter""" + pass + + +async def no_cursor_func(): + """Function without cursor parameter""" + pass + + +async def cursor_func(cursor: types.Cursor | None): + """Function with cursor parameter""" + pass + + +async def cursor_func_different_name(c: types.Cursor | None): + """Function with cursor parameter but different arg name""" + pass + + +async def cursor_func_with_self(self: types.Cursor | None): + """Function with parameter named 'self' (edge case)""" + pass + + +async def var_positional_func(*args: Any): + """Function with *args""" + pass + + +async def positional_with_var_positional_func(cursor: types.Cursor | None, *args: Any): + """Function with cursor and *args""" + pass + + +async def var_keyword_func(**kwargs: Any): + """Function with **kwargs""" + pass + + +async def cursor_with_var_keyword_func(cursor: types.Cursor | None, **kwargs: Any): + """Function with cursor and **kwargs""" + pass + + +async def cursor_with_default(cursor: types.Cursor | None = None): + """Function with cursor parameter having default value""" + pass + + +async def keyword_only_with_defaults(*, cursor: types.Cursor | None = None): + """Function with keyword-only cursor with default""" + pass + + +async def keyword_only_multiple_all_defaults(*, a: str = "test", b: int = 42): + """Function with multiple keyword-only params all with defaults""" + pass + + +async def mixed_positional_and_keyword(cursor: types.Cursor | None, *, extra: str = "test"): + """Function with positional and keyword-only params""" + pass + + +@pytest.mark.parametrize( + "callable_obj,expected,description", + [ + # Regular functions + (no_cursor_func, False, "function without parameters"), + (cursor_func, True, "function with cursor parameter"), + (cursor_func_different_name, True, "function with cursor (different param name)"), + (cursor_func_with_self, True, "function with param named 'self'"), + # Instance methods + (MyClass().no_cursor_method, False, "instance method without cursor"), + (MyClass().cursor_method, True, "instance method with cursor"), + # Class methods + (MyClass.no_cursor_class_method, False, "class method without cursor"), + (MyClass.cursor_class_method, True, "class method with cursor"), + # Static methods + (MyClass.no_cursor_static_method, False, "static method without cursor"), + (MyClass.cursor_static_method, True, "static method with cursor"), + # Variadic parameters + (var_positional_func, True, "function with *args"), + (positional_with_var_positional_func, True, "function with cursor and *args"), + (var_keyword_func, False, "function with **kwargs"), + (cursor_with_var_keyword_func, True, "function with cursor and **kwargs"), + # Edge cases + (cursor_with_default, True, "function with cursor having default value"), + # Keyword-only parameters + (keyword_only_with_defaults, False, "keyword-only with default (can call with no args)"), + (keyword_only_multiple_all_defaults, False, "multiple keyword-only all with defaults"), + (mixed_positional_and_keyword, True, "mixed positional and keyword-only params"), + ], + ids=lambda x: x if isinstance(x, str) else "", +) +def test_accepts_cursor(callable_obj: Callable[..., Any], expected: bool, description: str): + """Test that accepts_cursor correctly identifies functions that accept a cursor parameter. + + The function should return True if the callable can potentially accept a positional + cursor argument. Returns False if: + - No parameters at all + - Only keyword-only parameters that ALL have defaults (can call with no args) + - Only **kwargs parameter (can't accept positional arguments) + """ + assert accepts_cursor(callable_obj) == expected, f"Failed for {description}" diff --git a/tests/server/lowlevel/test_server_listing.py b/tests/server/lowlevel/test_server_listing.py new file mode 100644 index 000000000..9474edb3f --- /dev/null +++ b/tests/server/lowlevel/test_server_listing.py @@ -0,0 +1,162 @@ +"""Basic tests for list_prompts, list_resources, and list_tools decorators without pagination.""" + +import pytest +from pydantic import AnyUrl + +from mcp.server import Server +from mcp.types import ( + ListPromptsRequest, + ListPromptsResult, + ListResourcesRequest, + ListResourcesResult, + ListToolsRequest, + ListToolsResult, + Prompt, + Resource, + ServerResult, + Tool, +) + + +@pytest.mark.anyio +async def test_list_prompts_basic() -> None: + """Test basic prompt listing without pagination.""" + server = Server("test") + + test_prompts = [ + Prompt(name="prompt1", description="First prompt"), + Prompt(name="prompt2", description="Second prompt"), + ] + + @server.list_prompts() + async def handle_list_prompts() -> list[Prompt]: + return test_prompts + + handler = server.request_handlers[ListPromptsRequest] + request = ListPromptsRequest(method="prompts/list", params=None) + result = await handler(request) + + assert isinstance(result, ServerResult) + assert isinstance(result.root, ListPromptsResult) + assert result.root.prompts == test_prompts + + +@pytest.mark.anyio +async def test_list_resources_basic() -> None: + """Test basic resource listing without pagination.""" + server = Server("test") + + test_resources = [ + Resource(uri=AnyUrl("file:///test1.txt"), name="Test 1"), + Resource(uri=AnyUrl("file:///test2.txt"), name="Test 2"), + ] + + @server.list_resources() + async def handle_list_resources() -> list[Resource]: + return test_resources + + handler = server.request_handlers[ListResourcesRequest] + request = ListResourcesRequest(method="resources/list", params=None) + result = await handler(request) + + assert isinstance(result, ServerResult) + assert isinstance(result.root, ListResourcesResult) + assert result.root.resources == test_resources + + +@pytest.mark.anyio +async def test_list_tools_basic() -> None: + """Test basic tool listing without pagination.""" + server = Server("test") + + test_tools = [ + Tool( + name="tool1", + description="First tool", + inputSchema={ + "type": "object", + "properties": { + "message": {"type": "string"}, + }, + "required": ["message"], + }, + ), + Tool( + name="tool2", + description="Second tool", + inputSchema={ + "type": "object", + "properties": { + "count": {"type": "number"}, + "enabled": {"type": "boolean"}, + }, + "required": ["count"], + }, + ), + ] + + @server.list_tools() + async def handle_list_tools() -> list[Tool]: + return test_tools + + handler = server.request_handlers[ListToolsRequest] + request = ListToolsRequest(method="tools/list", params=None) + result = await handler(request) + + assert isinstance(result, ServerResult) + assert isinstance(result.root, ListToolsResult) + assert result.root.tools == test_tools + + +@pytest.mark.anyio +async def test_list_prompts_empty() -> None: + """Test listing with empty results.""" + server = Server("test") + + @server.list_prompts() + async def handle_list_prompts() -> list[Prompt]: + return [] + + handler = server.request_handlers[ListPromptsRequest] + request = ListPromptsRequest(method="prompts/list", params=None) + result = await handler(request) + + assert isinstance(result, ServerResult) + assert isinstance(result.root, ListPromptsResult) + assert result.root.prompts == [] + + +@pytest.mark.anyio +async def test_list_resources_empty() -> None: + """Test listing with empty results.""" + server = Server("test") + + @server.list_resources() + async def handle_list_resources() -> list[Resource]: + return [] + + handler = server.request_handlers[ListResourcesRequest] + request = ListResourcesRequest(method="resources/list", params=None) + result = await handler(request) + + assert isinstance(result, ServerResult) + assert isinstance(result.root, ListResourcesResult) + assert result.root.resources == [] + + +@pytest.mark.anyio +async def test_list_tools_empty() -> None: + """Test listing with empty results.""" + server = Server("test") + + @server.list_tools() + async def handle_list_tools() -> list[Tool]: + return [] + + handler = server.request_handlers[ListToolsRequest] + request = ListToolsRequest(method="tools/list", params=None) + result = await handler(request) + + assert isinstance(result, ServerResult) + assert isinstance(result.root, ListToolsResult) + assert result.root.tools == [] diff --git a/tests/server/lowlevel/test_server_pagination.py b/tests/server/lowlevel/test_server_pagination.py index f2c786e45..3a6b9f8ba 100644 --- a/tests/server/lowlevel/test_server_pagination.py +++ b/tests/server/lowlevel/test_server_pagination.py @@ -22,7 +22,7 @@ async def test_list_prompts_pagination() -> None: # Track what cursor was received received_cursor: Cursor | None = None - @server.list_prompts_paginated() + @server.list_prompts() async def handle_list_prompts(cursor: Cursor | None) -> ListPromptsResult: nonlocal received_cursor received_cursor = cursor @@ -51,7 +51,7 @@ async def test_list_resources_pagination() -> None: # Track what cursor was received received_cursor: Cursor | None = None - @server.list_resources_paginated() + @server.list_resources() async def handle_list_resources(cursor: Cursor | None) -> ListResourcesResult: nonlocal received_cursor received_cursor = cursor @@ -82,7 +82,7 @@ async def test_list_tools_pagination() -> None: # Track what cursor was received received_cursor: Cursor | None = None - @server.list_tools_paginated() + @server.list_tools() async def handle_list_tools(cursor: Cursor | None) -> ListToolsResult: nonlocal received_cursor received_cursor = cursor From 503c6f318d8b3c0684d272a8b7f76246030dc03a Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 27 Aug 2025 10:47:27 -0400 Subject: [PATCH 6/8] fix: revert pre-commit config to use uv run for CI compatibility The previous change to run tools directly (without uv run) broke the CI pipeline because the tools are installed in uv's virtual environment which isn't in PATH during pre-commit execution. Reverting to use `uv run --frozen` ensures the tools are found both locally and in CI environments. --- .pre-commit-config.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 51321979f..28da65c60 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,14 +25,14 @@ repos: hooks: - id: ruff-format name: Ruff Format - entry: ruff + entry: uv run --frozen ruff args: [format] language: system types: [python] pass_filenames: false - id: ruff name: Ruff - entry: ruff + entry: uv run --frozen ruff args: ["check", "--fix", "--exit-non-zero-on-fix"] types: [python] language: system @@ -40,7 +40,7 @@ repos: exclude: ^README\.md$ - id: pyright name: pyright - entry: pyright + entry: uv run --frozen pyright language: system types: [python] pass_filenames: false @@ -52,7 +52,7 @@ repos: pass_filenames: false - id: readme-snippets name: Check README snippets are up to date - entry: python scripts/update_readme_snippets.py --check + entry: uv run --frozen python scripts/update_readme_snippets.py --check language: system files: ^(README\.md|examples/.*\.py|scripts/update_readme_snippets\.py)$ pass_filenames: false From 02a2c306aff110f635b1879401c15fccb7276a8e Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 1 Sep 2025 13:50:54 +0100 Subject: [PATCH 7/8] chore: clean up inspection code to remove redundant param inspection --- src/mcp/server/lowlevel/func_inspection.py | 6 ---- tests/server/lowlevel/test_func_inspection.py | 32 +++++++++++++++++++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/mcp/server/lowlevel/func_inspection.py b/src/mcp/server/lowlevel/func_inspection.py index 9573ee0ae..f69db4b95 100644 --- a/src/mcp/server/lowlevel/func_inspection.py +++ b/src/mcp/server/lowlevel/func_inspection.py @@ -19,12 +19,6 @@ def accepts_cursor(func: Callable[..., Any]) -> bool: params = dict(sig.parameters.items()) - method = inspect.ismethod(func) - - if method: - params.pop("self", None) - params.pop("cls", None) - if len(params) == 0: # No parameters at all - can't accept cursor return False diff --git a/tests/server/lowlevel/test_func_inspection.py b/tests/server/lowlevel/test_func_inspection.py index 4114b329d..cb00d9d78 100644 --- a/tests/server/lowlevel/test_func_inspection.py +++ b/tests/server/lowlevel/test_func_inspection.py @@ -13,20 +13,42 @@ async def no_cursor_method(self): """Instance method without cursor parameter""" pass + # noinspection PyMethodParameters + async def no_cursor_method_bad_self_name(bad): # pyright: ignore[reportSelfClsParameterName] + """Instance method with cursor parameter, but with bad self name""" + pass + async def cursor_method(self, cursor: types.Cursor | None): """Instance method with cursor parameter""" pass + # noinspection PyMethodParameters + async def cursor_method_bad_self_name(bad, cursor: types.Cursor | None): # pyright: ignore[reportSelfClsParameterName] + """Instance method with cursor parameter, but with bad self name""" + pass + @classmethod async def no_cursor_class_method(cls): """Class method without cursor parameter""" pass + # noinspection PyMethodParameters + @classmethod + async def no_cursor_class_method_bad_cls_name(bad): # pyright: ignore[reportSelfClsParameterName] + """Class method without cursor parameter, but with bad cls name""" + pass + @classmethod async def cursor_class_method(cls, cursor: types.Cursor | None): """Class method with cursor parameter""" pass + # noinspection PyMethodParameters + @classmethod + async def cursor_class_method_bad_cls_name(bad, cursor: types.Cursor | None): # pyright: ignore[reportSelfClsParameterName] + """Class method with cursor parameter, but with bad cls name""" + pass + @staticmethod async def no_cursor_static_method(): """Static method without cursor parameter""" @@ -37,6 +59,11 @@ async def cursor_static_method(cursor: types.Cursor | None): """Static method with cursor parameter""" pass + @staticmethod + async def cursor_static_method_bad_arg_name(self: types.Cursor | None): # pyright: ignore[reportSelfClsParameterName] + """Static method with cursor parameter, but the cursor argument is named self""" + pass + async def no_cursor_func(): """Function without cursor parameter""" @@ -108,13 +135,18 @@ async def mixed_positional_and_keyword(cursor: types.Cursor | None, *, extra: st (cursor_func_with_self, True, "function with param named 'self'"), # Instance methods (MyClass().no_cursor_method, False, "instance method without cursor"), + (MyClass().no_cursor_method_bad_self_name, False, "instance method without cursor (bad self name)"), (MyClass().cursor_method, True, "instance method with cursor"), + (MyClass().cursor_method_bad_self_name, True, "instance method with cursor (bad self name)"), # Class methods (MyClass.no_cursor_class_method, False, "class method without cursor"), + (MyClass.no_cursor_class_method_bad_cls_name, False, "class method without cursor (bad cls name)"), (MyClass.cursor_class_method, True, "class method with cursor"), + (MyClass.cursor_class_method_bad_cls_name, True, "class method with cursor (bad cls name)"), # Static methods (MyClass.no_cursor_static_method, False, "static method without cursor"), (MyClass.cursor_static_method, True, "static method with cursor"), + (MyClass.cursor_static_method_bad_arg_name, True, "static method with cursor (bad arg name)"), # Variadic parameters (var_positional_func, True, "function with *args"), (positional_with_var_positional_func, True, "function with cursor and *args"), From 7a394eea63d6327b154d68797934b881652a01c8 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 1 Sep 2025 14:58:22 +0100 Subject: [PATCH 8/8] feat: change to passing requests instead of cursors for pagination --- README.md | 5 +- .../mcp_simple_pagination/server.py | 9 +- .../snippets/servers/pagination_example.py | 5 +- src/mcp/server/lowlevel/func_inspection.py | 2 +- src/mcp/server/lowlevel/server.py | 44 +++---- tests/server/lowlevel/test_func_inspection.py | 122 +++++++++--------- .../server/lowlevel/test_server_pagination.py | 64 +++++---- 7 files changed, 134 insertions(+), 117 deletions(-) diff --git a/README.md b/README.md index 95c871a4e..e2ef5a7ca 100644 --- a/README.md +++ b/README.md @@ -1763,10 +1763,13 @@ ITEMS = [f"Item {i}" for i in range(1, 101)] # 100 items @server.list_resources() -async def list_resources_paginated(cursor: types.Cursor | None) -> types.ListResourcesResult: +async def list_resources_paginated(request: types.ListResourcesRequest) -> types.ListResourcesResult: """List resources with pagination support.""" page_size = 10 + # Extract cursor from request params + cursor = request.params.cursor if request.params is not None else None + # Parse cursor to get offset start = 0 if cursor is None else int(cursor) end = start + page_size diff --git a/examples/servers/simple-pagination/mcp_simple_pagination/server.py b/examples/servers/simple-pagination/mcp_simple_pagination/server.py index 97f545718..360cbc3cf 100644 --- a/examples/servers/simple-pagination/mcp_simple_pagination/server.py +++ b/examples/servers/simple-pagination/mcp_simple_pagination/server.py @@ -59,9 +59,10 @@ def main(port: int, transport: str) -> int: # Paginated list_tools - returns 5 tools per page @app.list_tools() - async def list_tools_paginated(cursor: types.Cursor | None) -> types.ListToolsResult: + async def list_tools_paginated(request: types.ListToolsRequest) -> types.ListToolsResult: page_size = 5 + cursor = request.params.cursor if request.params is not None else None if cursor is None: # First page start_idx = 0 @@ -86,10 +87,11 @@ async def list_tools_paginated(cursor: types.Cursor | None) -> types.ListToolsRe # Paginated list_resources - returns 10 resources per page @app.list_resources() async def list_resources_paginated( - cursor: types.Cursor | None, + request: types.ListResourcesRequest, ) -> types.ListResourcesResult: page_size = 10 + cursor = request.params.cursor if request.params is not None else None if cursor is None: # First page start_idx = 0 @@ -114,10 +116,11 @@ async def list_resources_paginated( # Paginated list_prompts - returns 7 prompts per page @app.list_prompts() async def list_prompts_paginated( - cursor: types.Cursor | None, + request: types.ListPromptsRequest, ) -> types.ListPromptsResult: page_size = 7 + cursor = request.params.cursor if request.params is not None else None if cursor is None: # First page start_idx = 0 diff --git a/examples/snippets/servers/pagination_example.py b/examples/snippets/servers/pagination_example.py index c8c99323c..70c3b3492 100644 --- a/examples/snippets/servers/pagination_example.py +++ b/examples/snippets/servers/pagination_example.py @@ -15,10 +15,13 @@ @server.list_resources() -async def list_resources_paginated(cursor: types.Cursor | None) -> types.ListResourcesResult: +async def list_resources_paginated(request: types.ListResourcesRequest) -> types.ListResourcesResult: """List resources with pagination support.""" page_size = 10 + # Extract cursor from request params + cursor = request.params.cursor if request.params is not None else None + # Parse cursor to get offset start = 0 if cursor is None else int(cursor) end = start + page_size diff --git a/src/mcp/server/lowlevel/func_inspection.py b/src/mcp/server/lowlevel/func_inspection.py index f69db4b95..7f6e18860 100644 --- a/src/mcp/server/lowlevel/func_inspection.py +++ b/src/mcp/server/lowlevel/func_inspection.py @@ -3,7 +3,7 @@ from typing import Any -def accepts_cursor(func: Callable[..., Any]) -> bool: +def accepts_request(func: Callable[..., Any]) -> bool: """ True if the function accepts a cursor parameter call, otherwise false. diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 9c4b5c9c2..abc650083 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -82,7 +82,7 @@ async def main(): from typing_extensions import TypeVar import mcp.types as types -from mcp.server.lowlevel.func_inspection import accepts_cursor +from mcp.server.lowlevel.func_inspection import accepts_request from mcp.server.lowlevel.helper_types import ReadResourceContents from mcp.server.models import InitializationOptions from mcp.server.session import ServerSession @@ -233,19 +233,19 @@ def request_context( def list_prompts(self): def decorator( func: Callable[[], Awaitable[list[types.Prompt]]] - | Callable[[types.Cursor | None], Awaitable[types.ListPromptsResult]], + | Callable[[types.ListPromptsRequest], Awaitable[types.ListPromptsResult]], ): logger.debug("Registering handler for PromptListRequest") - pass_cursor = accepts_cursor(func) + pass_request = accepts_request(func) - if pass_cursor: - cursor_func = cast(Callable[[types.Cursor | None], Awaitable[types.ListPromptsResult]], func) + if pass_request: + request_func = cast(Callable[[types.ListPromptsRequest], Awaitable[types.ListPromptsResult]], func) - async def cursor_handler(req: types.ListPromptsRequest): - result = await cursor_func(req.params.cursor if req.params is not None else None) + async def request_handler(req: types.ListPromptsRequest): + result = await request_func(req) return types.ServerResult(result) - handler = cursor_handler + handler = request_handler else: list_func = cast(Callable[[], Awaitable[list[types.Prompt]]], func) @@ -278,19 +278,19 @@ async def handler(req: types.GetPromptRequest): def list_resources(self): def decorator( func: Callable[[], Awaitable[list[types.Resource]]] - | Callable[[types.Cursor | None], Awaitable[types.ListResourcesResult]], + | Callable[[types.ListResourcesRequest], Awaitable[types.ListResourcesResult]], ): logger.debug("Registering handler for ListResourcesRequest") - pass_cursor = accepts_cursor(func) + pass_request = accepts_request(func) - if pass_cursor: - cursor_func = cast(Callable[[types.Cursor | None], Awaitable[types.ListResourcesResult]], func) + if pass_request: + request_func = cast(Callable[[types.ListResourcesRequest], Awaitable[types.ListResourcesResult]], func) - async def cursor_handler(req: types.ListResourcesRequest): - result = await cursor_func(req.params.cursor if req.params is not None else None) + async def request_handler(req: types.ListResourcesRequest): + result = await request_func(req) return types.ServerResult(result) - handler = cursor_handler + handler = request_handler else: list_func = cast(Callable[[], Awaitable[list[types.Resource]]], func) @@ -418,22 +418,22 @@ async def handler(req: types.UnsubscribeRequest): def list_tools(self): def decorator( func: Callable[[], Awaitable[list[types.Tool]]] - | Callable[[types.Cursor | None], Awaitable[types.ListToolsResult]], + | Callable[[types.ListToolsRequest], Awaitable[types.ListToolsResult]], ): logger.debug("Registering handler for ListToolsRequest") - pass_cursor = accepts_cursor(func) + pass_request = accepts_request(func) - if pass_cursor: - cursor_func = cast(Callable[[types.Cursor | None], Awaitable[types.ListToolsResult]], func) + if pass_request: + request_func = cast(Callable[[types.ListToolsRequest], Awaitable[types.ListToolsResult]], func) - async def cursor_handler(req: types.ListToolsRequest): - result = await cursor_func(req.params.cursor if req.params is not None else None) + async def request_handler(req: types.ListToolsRequest): + result = await request_func(req) # Refresh the tool cache with returned tools for tool in result.tools: self._tool_cache[tool.name] = tool return types.ServerResult(result) - handler = cursor_handler + handler = request_handler else: list_func = cast(Callable[[], Awaitable[list[types.Tool]]], func) diff --git a/tests/server/lowlevel/test_func_inspection.py b/tests/server/lowlevel/test_func_inspection.py index cb00d9d78..674675d8d 100644 --- a/tests/server/lowlevel/test_func_inspection.py +++ b/tests/server/lowlevel/test_func_inspection.py @@ -4,83 +4,83 @@ import pytest from mcp import types -from mcp.server.lowlevel.func_inspection import accepts_cursor +from mcp.server.lowlevel.func_inspection import accepts_request # Test fixtures - functions and methods with various signatures class MyClass: - async def no_cursor_method(self): - """Instance method without cursor parameter""" + async def no_request_method(self): + """Instance method without request parameter""" pass # noinspection PyMethodParameters - async def no_cursor_method_bad_self_name(bad): # pyright: ignore[reportSelfClsParameterName] - """Instance method with cursor parameter, but with bad self name""" + async def no_request_method_bad_self_name(bad): # pyright: ignore[reportSelfClsParameterName] + """Instance method without request parameter, but with bad self name""" pass - async def cursor_method(self, cursor: types.Cursor | None): - """Instance method with cursor parameter""" + async def request_method(self, request: types.ListPromptsRequest): + """Instance method with request parameter""" pass # noinspection PyMethodParameters - async def cursor_method_bad_self_name(bad, cursor: types.Cursor | None): # pyright: ignore[reportSelfClsParameterName] - """Instance method with cursor parameter, but with bad self name""" + async def request_method_bad_self_name(bad, request: types.ListPromptsRequest): # pyright: ignore[reportSelfClsParameterName] + """Instance method with request parameter, but with bad self name""" pass @classmethod - async def no_cursor_class_method(cls): - """Class method without cursor parameter""" + async def no_request_class_method(cls): + """Class method without request parameter""" pass # noinspection PyMethodParameters @classmethod - async def no_cursor_class_method_bad_cls_name(bad): # pyright: ignore[reportSelfClsParameterName] - """Class method without cursor parameter, but with bad cls name""" + async def no_request_class_method_bad_cls_name(bad): # pyright: ignore[reportSelfClsParameterName] + """Class method without request parameter, but with bad cls name""" pass @classmethod - async def cursor_class_method(cls, cursor: types.Cursor | None): - """Class method with cursor parameter""" + async def request_class_method(cls, request: types.ListPromptsRequest): + """Class method with request parameter""" pass # noinspection PyMethodParameters @classmethod - async def cursor_class_method_bad_cls_name(bad, cursor: types.Cursor | None): # pyright: ignore[reportSelfClsParameterName] - """Class method with cursor parameter, but with bad cls name""" + async def request_class_method_bad_cls_name(bad, request: types.ListPromptsRequest): # pyright: ignore[reportSelfClsParameterName] + """Class method with request parameter, but with bad cls name""" pass @staticmethod - async def no_cursor_static_method(): - """Static method without cursor parameter""" + async def no_request_static_method(): + """Static method without request parameter""" pass @staticmethod - async def cursor_static_method(cursor: types.Cursor | None): - """Static method with cursor parameter""" + async def request_static_method(request: types.ListPromptsRequest): + """Static method with request parameter""" pass @staticmethod - async def cursor_static_method_bad_arg_name(self: types.Cursor | None): # pyright: ignore[reportSelfClsParameterName] - """Static method with cursor parameter, but the cursor argument is named self""" + async def request_static_method_bad_arg_name(self: types.ListPromptsRequest): # pyright: ignore[reportSelfClsParameterName] + """Static method with request parameter, but the request argument is named self""" pass -async def no_cursor_func(): - """Function without cursor parameter""" +async def no_request_func(): + """Function without request parameter""" pass -async def cursor_func(cursor: types.Cursor | None): - """Function with cursor parameter""" +async def request_func(request: types.ListPromptsRequest): + """Function with request parameter""" pass -async def cursor_func_different_name(c: types.Cursor | None): - """Function with cursor parameter but different arg name""" +async def request_func_different_name(req: types.ListPromptsRequest): + """Function with request parameter but different arg name""" pass -async def cursor_func_with_self(self: types.Cursor | None): +async def request_func_with_self(self: types.ListPromptsRequest): """Function with parameter named 'self' (edge case)""" pass @@ -90,8 +90,8 @@ async def var_positional_func(*args: Any): pass -async def positional_with_var_positional_func(cursor: types.Cursor | None, *args: Any): - """Function with cursor and *args""" +async def positional_with_var_positional_func(request: types.ListPromptsRequest, *args: Any): + """Function with request and *args""" pass @@ -100,18 +100,18 @@ async def var_keyword_func(**kwargs: Any): pass -async def cursor_with_var_keyword_func(cursor: types.Cursor | None, **kwargs: Any): - """Function with cursor and **kwargs""" +async def request_with_var_keyword_func(request: types.ListPromptsRequest, **kwargs: Any): + """Function with request and **kwargs""" pass -async def cursor_with_default(cursor: types.Cursor | None = None): - """Function with cursor parameter having default value""" +async def request_with_default(request: types.ListPromptsRequest | None = None): + """Function with request parameter having default value""" pass -async def keyword_only_with_defaults(*, cursor: types.Cursor | None = None): - """Function with keyword-only cursor with default""" +async def keyword_only_with_defaults(*, request: types.ListPromptsRequest | None = None): + """Function with keyword-only request with default""" pass @@ -120,7 +120,7 @@ async def keyword_only_multiple_all_defaults(*, a: str = "test", b: int = 42): pass -async def mixed_positional_and_keyword(cursor: types.Cursor | None, *, extra: str = "test"): +async def mixed_positional_and_keyword(request: types.ListPromptsRequest, *, extra: str = "test"): """Function with positional and keyword-only params""" pass @@ -129,31 +129,31 @@ async def mixed_positional_and_keyword(cursor: types.Cursor | None, *, extra: st "callable_obj,expected,description", [ # Regular functions - (no_cursor_func, False, "function without parameters"), - (cursor_func, True, "function with cursor parameter"), - (cursor_func_different_name, True, "function with cursor (different param name)"), - (cursor_func_with_self, True, "function with param named 'self'"), + (no_request_func, False, "function without parameters"), + (request_func, True, "function with request parameter"), + (request_func_different_name, True, "function with request (different param name)"), + (request_func_with_self, True, "function with param named 'self'"), # Instance methods - (MyClass().no_cursor_method, False, "instance method without cursor"), - (MyClass().no_cursor_method_bad_self_name, False, "instance method without cursor (bad self name)"), - (MyClass().cursor_method, True, "instance method with cursor"), - (MyClass().cursor_method_bad_self_name, True, "instance method with cursor (bad self name)"), + (MyClass().no_request_method, False, "instance method without request"), + (MyClass().no_request_method_bad_self_name, False, "instance method without request (bad self name)"), + (MyClass().request_method, True, "instance method with request"), + (MyClass().request_method_bad_self_name, True, "instance method with request (bad self name)"), # Class methods - (MyClass.no_cursor_class_method, False, "class method without cursor"), - (MyClass.no_cursor_class_method_bad_cls_name, False, "class method without cursor (bad cls name)"), - (MyClass.cursor_class_method, True, "class method with cursor"), - (MyClass.cursor_class_method_bad_cls_name, True, "class method with cursor (bad cls name)"), + (MyClass.no_request_class_method, False, "class method without request"), + (MyClass.no_request_class_method_bad_cls_name, False, "class method without request (bad cls name)"), + (MyClass.request_class_method, True, "class method with request"), + (MyClass.request_class_method_bad_cls_name, True, "class method with request (bad cls name)"), # Static methods - (MyClass.no_cursor_static_method, False, "static method without cursor"), - (MyClass.cursor_static_method, True, "static method with cursor"), - (MyClass.cursor_static_method_bad_arg_name, True, "static method with cursor (bad arg name)"), + (MyClass.no_request_static_method, False, "static method without request"), + (MyClass.request_static_method, True, "static method with request"), + (MyClass.request_static_method_bad_arg_name, True, "static method with request (bad arg name)"), # Variadic parameters (var_positional_func, True, "function with *args"), - (positional_with_var_positional_func, True, "function with cursor and *args"), + (positional_with_var_positional_func, True, "function with request and *args"), (var_keyword_func, False, "function with **kwargs"), - (cursor_with_var_keyword_func, True, "function with cursor and **kwargs"), + (request_with_var_keyword_func, True, "function with request and **kwargs"), # Edge cases - (cursor_with_default, True, "function with cursor having default value"), + (request_with_default, True, "function with request having default value"), # Keyword-only parameters (keyword_only_with_defaults, False, "keyword-only with default (can call with no args)"), (keyword_only_multiple_all_defaults, False, "multiple keyword-only all with defaults"), @@ -161,13 +161,13 @@ async def mixed_positional_and_keyword(cursor: types.Cursor | None, *, extra: st ], ids=lambda x: x if isinstance(x, str) else "", ) -def test_accepts_cursor(callable_obj: Callable[..., Any], expected: bool, description: str): - """Test that accepts_cursor correctly identifies functions that accept a cursor parameter. +def test_accepts_request(callable_obj: Callable[..., Any], expected: bool, description: str): + """Test that accepts_request correctly identifies functions that accept a request parameter. The function should return True if the callable can potentially accept a positional - cursor argument. Returns False if: + request argument. Returns False if: - No parameters at all - Only keyword-only parameters that ALL have defaults (can call with no args) - Only **kwargs parameter (can't accept positional arguments) """ - assert accepts_cursor(callable_obj) == expected, f"Failed for {description}" + assert accepts_request(callable_obj) == expected, f"Failed for {description}" diff --git a/tests/server/lowlevel/test_server_pagination.py b/tests/server/lowlevel/test_server_pagination.py index 3a6b9f8ba..8d64dd525 100644 --- a/tests/server/lowlevel/test_server_pagination.py +++ b/tests/server/lowlevel/test_server_pagination.py @@ -2,7 +2,6 @@ from mcp.server import Server from mcp.types import ( - Cursor, ListPromptsRequest, ListPromptsResult, ListResourcesRequest, @@ -19,27 +18,30 @@ async def test_list_prompts_pagination() -> None: server = Server("test") test_cursor = "test-cursor-123" - # Track what cursor was received - received_cursor: Cursor | None = None + # Track what request was received + received_request: ListPromptsRequest | None = None @server.list_prompts() - async def handle_list_prompts(cursor: Cursor | None) -> ListPromptsResult: - nonlocal received_cursor - received_cursor = cursor + async def handle_list_prompts(request: ListPromptsRequest) -> ListPromptsResult: + nonlocal received_request + received_request = request return ListPromptsResult(prompts=[], nextCursor="next") handler = server.request_handlers[ListPromptsRequest] - # Test: No cursor provided -> handler receives None + # Test: No cursor provided -> handler receives request with None params request = ListPromptsRequest(method="prompts/list", params=None) result = await handler(request) - assert received_cursor is None + assert received_request is not None + assert received_request.params is None assert isinstance(result, ServerResult) - # Test: Cursor provided -> handler receives exact cursor value + # Test: Cursor provided -> handler receives request with cursor in params request_with_cursor = ListPromptsRequest(method="prompts/list", params=PaginatedRequestParams(cursor=test_cursor)) result2 = await handler(request_with_cursor) - assert received_cursor == test_cursor + assert received_request is not None + assert received_request.params is not None + assert received_request.params.cursor == test_cursor assert isinstance(result2, ServerResult) @@ -48,29 +50,32 @@ async def test_list_resources_pagination() -> None: server = Server("test") test_cursor = "resource-cursor-456" - # Track what cursor was received - received_cursor: Cursor | None = None + # Track what request was received + received_request: ListResourcesRequest | None = None @server.list_resources() - async def handle_list_resources(cursor: Cursor | None) -> ListResourcesResult: - nonlocal received_cursor - received_cursor = cursor + async def handle_list_resources(request: ListResourcesRequest) -> ListResourcesResult: + nonlocal received_request + received_request = request return ListResourcesResult(resources=[], nextCursor="next") handler = server.request_handlers[ListResourcesRequest] - # Test: No cursor provided -> handler receives None + # Test: No cursor provided -> handler receives request with None params request = ListResourcesRequest(method="resources/list", params=None) result = await handler(request) - assert received_cursor is None + assert received_request is not None + assert received_request.params is None assert isinstance(result, ServerResult) - # Test: Cursor provided -> handler receives exact cursor value + # Test: Cursor provided -> handler receives request with cursor in params request_with_cursor = ListResourcesRequest( method="resources/list", params=PaginatedRequestParams(cursor=test_cursor) ) result2 = await handler(request_with_cursor) - assert received_cursor == test_cursor + assert received_request is not None + assert received_request.params is not None + assert received_request.params.cursor == test_cursor assert isinstance(result2, ServerResult) @@ -79,25 +84,28 @@ async def test_list_tools_pagination() -> None: server = Server("test") test_cursor = "tools-cursor-789" - # Track what cursor was received - received_cursor: Cursor | None = None + # Track what request was received + received_request: ListToolsRequest | None = None @server.list_tools() - async def handle_list_tools(cursor: Cursor | None) -> ListToolsResult: - nonlocal received_cursor - received_cursor = cursor + async def handle_list_tools(request: ListToolsRequest) -> ListToolsResult: + nonlocal received_request + received_request = request return ListToolsResult(tools=[], nextCursor="next") handler = server.request_handlers[ListToolsRequest] - # Test: No cursor provided -> handler receives None + # Test: No cursor provided -> handler receives request with None params request = ListToolsRequest(method="tools/list", params=None) result = await handler(request) - assert received_cursor is None + assert received_request is not None + assert received_request.params is None assert isinstance(result, ServerResult) - # Test: Cursor provided -> handler receives exact cursor value + # Test: Cursor provided -> handler receives request with cursor in params request_with_cursor = ListToolsRequest(method="tools/list", params=PaginatedRequestParams(cursor=test_cursor)) result2 = await handler(request_with_cursor) - assert received_cursor == test_cursor + assert received_request is not None + assert received_request.params is not None + assert received_request.params.cursor == test_cursor assert isinstance(result2, ServerResult)