feat: add paginated list decorators for prompts, resources, and tools (#1286)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Max Isbey 2025-09-23 14:58:06 +01:00 • committed by GitHub
commit 4fb975c6dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1274 additions and 19 deletions

111
README.md
View file

@ -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,116 @@ 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
<!-- snippet-source examples/snippets/servers/pagination_example.py -->
```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()
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
# 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)_
<!-- /snippet-source -->
#### Client-side Consumption
<!-- snippet-source examples/snippets/clients/pagination_client.py -->
```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)_
<!-- /snippet-source -->
#### 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
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):

View file

@ -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

View file

@ -0,0 +1,5 @@
import sys
from .server import main
sys.exit(main()) # type: ignore[call-arg]

View file

@ -0,0 +1,228 @@
"""
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()
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
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()
async def list_resources_paginated(
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
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()
async def list_prompts_paginated(
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
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

View file

@ -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"]

View file

@ -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())

View file

@ -0,0 +1,38 @@
"""
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()
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
# 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)

View file

@ -0,0 +1,54 @@
import inspect
from collections.abc import Callable
from typing import Any, TypeVar, get_type_hints
T = TypeVar("T")
R = TypeVar("R")
def create_call_wrapper(func: Callable[..., R], request_type: type[T]) -> Callable[[T], R]:
"""
Create a wrapper function that knows how to call func with the request object.
Returns a wrapper function that takes the request and calls func appropriately.
The wrapper handles three calling patterns:
1. Positional-only parameter typed as request_type (no default): func(req)
2. Positional/keyword parameter typed as request_type (no default): func(**{param_name: req})
3. No request parameter or parameter with default: func()
"""
try:
sig = inspect.signature(func)
type_hints = get_type_hints(func)
except (ValueError, TypeError, NameError):
return lambda _: func()
# Check for positional-only parameter typed as request_type
for param_name, param in sig.parameters.items():
if param.kind == inspect.Parameter.POSITIONAL_ONLY:
param_type = type_hints.get(param_name)
if param_type == request_type:
# Check if it has a default - if so, treat as old style
if param.default is not inspect.Parameter.empty:
return lambda _: func()
# Found positional-only parameter with correct type and no default
return lambda req: func(req)
# Check for any positional/keyword parameter typed as request_type
for param_name, param in sig.parameters.items():
if param.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY):
param_type = type_hints.get(param_name)
if param_type == request_type:
# Check if it has a default - if so, treat as old style
if param.default is not inspect.Parameter.empty:
return lambda _: func()
# Found keyword parameter with correct type and no default
# Need to capture param_name in closure properly
def make_keyword_wrapper(name: str) -> Callable[[Any], Any]:
return lambda req: func(**{name: req})
return make_keyword_wrapper(param_name)
# No request parameter found - use old style
return lambda _: func()

View file

@ -11,7 +11,7 @@ Usage:
2. Define request handlers using decorators:
@server.list_prompts()
async def handle_list_prompts() -> list[types.Prompt]:
async def handle_list_prompts(request: types.ListPromptsRequest) -> types.ListPromptsResult:
# Implementation
@server.get_prompt()
@ -21,7 +21,7 @@ Usage:
# Implementation
@server.list_tools()
async def handle_list_tools() -> list[types.Tool]:
async def handle_list_tools(request: types.ListToolsRequest) -> types.ListToolsResult:
# Implementation
@server.call_tool()
@ -82,6 +82,7 @@ from pydantic import AnyUrl
from typing_extensions import TypeVar
import mcp.types as types
from mcp.server.lowlevel.func_inspection import create_call_wrapper
from mcp.server.lowlevel.helper_types import ReadResourceContents
from mcp.server.models import InitializationOptions
from mcp.server.session import ServerSession
@ -229,12 +230,22 @@ class Server(Generic[LifespanResultT, RequestT]):
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.ListPromptsRequest], Awaitable[types.ListPromptsResult]],
):
logger.debug("Registering handler for PromptListRequest")
async def handler(_: Any):
prompts = await func()
return types.ServerResult(types.ListPromptsResult(prompts=prompts))
wrapper = create_call_wrapper(func, types.ListPromptsRequest)
async def handler(req: types.ListPromptsRequest):
result = await wrapper(req)
# Handle both old style (list[Prompt]) and new style (ListPromptsResult)
if isinstance(result, types.ListPromptsResult):
return types.ServerResult(result)
else:
# Old style returns list[Prompt]
return types.ServerResult(types.ListPromptsResult(prompts=result))
self.request_handlers[types.ListPromptsRequest] = handler
return func
@ -257,12 +268,22 @@ class Server(Generic[LifespanResultT, RequestT]):
return decorator
def list_resources(self):
def decorator(func: Callable[[], Awaitable[list[types.Resource]]]):
def decorator(
func: Callable[[], Awaitable[list[types.Resource]]]
| Callable[[types.ListResourcesRequest], Awaitable[types.ListResourcesResult]],
):
logger.debug("Registering handler for ListResourcesRequest")
async def handler(_: Any):
resources = await func()
return types.ServerResult(types.ListResourcesResult(resources=resources))
wrapper = create_call_wrapper(func, types.ListResourcesRequest)
async def handler(req: types.ListResourcesRequest):
result = await wrapper(req)
# Handle both old style (list[Resource]) and new style (ListResourcesResult)
if isinstance(result, types.ListResourcesResult):
return types.ServerResult(result)
else:
# Old style returns list[Resource]
return types.ServerResult(types.ListResourcesResult(resources=result))
self.request_handlers[types.ListResourcesRequest] = handler
return func
@ -380,16 +401,30 @@ class Server(Generic[LifespanResultT, RequestT]):
return decorator
def list_tools(self):
def decorator(func: Callable[[], Awaitable[list[types.Tool]]]):
def decorator(
func: Callable[[], Awaitable[list[types.Tool]]]
| Callable[[types.ListToolsRequest], 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))
wrapper = create_call_wrapper(func, types.ListToolsRequest)
async def handler(req: types.ListToolsRequest):
result = await wrapper(req)
# Handle both old style (list[Tool]) and new style (ListToolsResult)
if isinstance(result, types.ListToolsResult):
# Refresh the tool cache with returned tools
for tool in result.tools:
self._tool_cache[tool.name] = tool
return types.ServerResult(result)
else:
# Old style returns list[Tool]
# 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))
self.request_handlers[types.ListToolsRequest] = handler
return func

View file

View file

@ -0,0 +1,292 @@
"""Unit tests for func_inspection module.
Tests the create_call_wrapper function which determines how to call handler functions
with different parameter signatures and type hints.
"""
from typing import Any, Generic, TypeVar
import pytest
from mcp.server.lowlevel.func_inspection import create_call_wrapper
from mcp.types import ListPromptsRequest, ListResourcesRequest, ListToolsRequest, PaginatedRequestParams
T = TypeVar("T")
@pytest.mark.anyio
async def test_no_params_returns_deprecated_wrapper() -> None:
"""Test: def foo() - should call without request."""
called_without_request = False
async def handler() -> list[str]:
nonlocal called_without_request
called_without_request = True
return ["test"]
wrapper = create_call_wrapper(handler, ListPromptsRequest)
# Wrapper should call handler without passing request
request = ListPromptsRequest(method="prompts/list", params=None)
result = await wrapper(request)
assert called_without_request is True
assert result == ["test"]
@pytest.mark.anyio
async def test_param_with_default_returns_deprecated_wrapper() -> None:
"""Test: def foo(thing: int = 1) - should call without request."""
called_without_request = False
async def handler(thing: int = 1) -> list[str]:
nonlocal called_without_request
called_without_request = True
return [f"test-{thing}"]
wrapper = create_call_wrapper(handler, ListPromptsRequest)
# Wrapper should call handler without passing request (uses default value)
request = ListPromptsRequest(method="prompts/list", params=None)
result = await wrapper(request)
assert called_without_request is True
assert result == ["test-1"]
@pytest.mark.anyio
async def test_typed_request_param_passes_request() -> None:
"""Test: def foo(req: ListPromptsRequest) - should pass request through."""
received_request = None
async def handler(req: ListPromptsRequest) -> list[str]:
nonlocal received_request
received_request = req
return ["test"]
wrapper = create_call_wrapper(handler, ListPromptsRequest)
# Wrapper should pass request to handler
request = ListPromptsRequest(method="prompts/list", params=PaginatedRequestParams(cursor="test-cursor"))
await wrapper(request)
assert received_request is not None
assert received_request is request
params = getattr(received_request, "params", None)
assert params is not None
assert params.cursor == "test-cursor"
@pytest.mark.anyio
async def test_typed_request_with_default_param_passes_request() -> None:
"""Test: def foo(req: ListPromptsRequest, thing: int = 1) - should pass request through."""
received_request = None
received_thing = None
async def handler(req: ListPromptsRequest, thing: int = 1) -> list[str]:
nonlocal received_request, received_thing
received_request = req
received_thing = thing
return ["test"]
wrapper = create_call_wrapper(handler, ListPromptsRequest)
# Wrapper should pass request to handler
request = ListPromptsRequest(method="prompts/list", params=None)
await wrapper(request)
assert received_request is request
assert received_thing == 1 # default value
@pytest.mark.anyio
async def test_optional_typed_request_with_default_none_is_deprecated() -> None:
"""Test: def foo(thing: int = 1, req: ListPromptsRequest | None = None) - old style."""
called_without_request = False
async def handler(thing: int = 1, req: ListPromptsRequest | None = None) -> list[str]:
nonlocal called_without_request
called_without_request = True
return ["test"]
wrapper = create_call_wrapper(handler, ListPromptsRequest)
# Wrapper should call handler without passing request
request = ListPromptsRequest(method="prompts/list", params=None)
result = await wrapper(request)
assert called_without_request is True
assert result == ["test"]
@pytest.mark.anyio
async def test_untyped_request_param_is_deprecated() -> None:
"""Test: def foo(req) - should call without request."""
called = False
async def handler(req): # type: ignore[no-untyped-def] # pyright: ignore[reportMissingParameterType]
nonlocal called
called = True
return ["test"]
wrapper = create_call_wrapper(handler, ListPromptsRequest) # pyright: ignore[reportUnknownArgumentType]
# Wrapper should call handler without passing request, which will fail because req is required
request = ListPromptsRequest(method="prompts/list", params=None)
# This will raise TypeError because handler expects 'req' but wrapper doesn't provide it
with pytest.raises(TypeError, match="missing 1 required positional argument"):
await wrapper(request)
@pytest.mark.anyio
async def test_any_typed_request_param_is_deprecated() -> None:
"""Test: def foo(req: Any) - should call without request."""
async def handler(req: Any) -> list[str]:
return ["test"]
wrapper = create_call_wrapper(handler, ListPromptsRequest)
# Wrapper should call handler without passing request, which will fail because req is required
request = ListPromptsRequest(method="prompts/list", params=None)
# This will raise TypeError because handler expects 'req' but wrapper doesn't provide it
with pytest.raises(TypeError, match="missing 1 required positional argument"):
await wrapper(request)
@pytest.mark.anyio
async def test_generic_typed_request_param_is_deprecated() -> None:
"""Test: def foo(req: Generic[T]) - should call without request."""
async def handler(req: Generic[T]) -> list[str]: # pyright: ignore[reportGeneralTypeIssues]
return ["test"]
wrapper = create_call_wrapper(handler, ListPromptsRequest)
# Wrapper should call handler without passing request, which will fail because req is required
request = ListPromptsRequest(method="prompts/list", params=None)
# This will raise TypeError because handler expects 'req' but wrapper doesn't provide it
with pytest.raises(TypeError, match="missing 1 required positional argument"):
await wrapper(request)
@pytest.mark.anyio
async def test_wrong_typed_request_param_is_deprecated() -> None:
"""Test: def foo(req: str) - should call without request."""
async def handler(req: str) -> list[str]:
return ["test"]
wrapper = create_call_wrapper(handler, ListPromptsRequest)
# Wrapper should call handler without passing request, which will fail because req is required
request = ListPromptsRequest(method="prompts/list", params=None)
# This will raise TypeError because handler expects 'req' but wrapper doesn't provide it
with pytest.raises(TypeError, match="missing 1 required positional argument"):
await wrapper(request)
@pytest.mark.anyio
async def test_required_param_before_typed_request_attempts_to_pass() -> None:
"""Test: def foo(thing: int, req: ListPromptsRequest) - attempts to pass request (will fail at runtime)."""
received_request = None
async def handler(thing: int, req: ListPromptsRequest) -> list[str]:
nonlocal received_request
received_request = req
return ["test"]
wrapper = create_call_wrapper(handler, ListPromptsRequest)
# Wrapper will attempt to pass request, but it will fail at runtime
# because 'thing' is required and has no default
request = ListPromptsRequest(method="prompts/list", params=None)
# This will raise TypeError because 'thing' is missing
with pytest.raises(TypeError, match="missing 1 required positional argument: 'thing'"):
await wrapper(request)
@pytest.mark.anyio
async def test_positional_only_param_with_correct_type() -> None:
"""Test: def foo(req: ListPromptsRequest, /) - should pass request through."""
received_request = None
async def handler(req: ListPromptsRequest, /) -> list[str]:
nonlocal received_request
received_request = req
return ["test"]
wrapper = create_call_wrapper(handler, ListPromptsRequest)
# Wrapper should pass request to handler
request = ListPromptsRequest(method="prompts/list", params=None)
await wrapper(request)
assert received_request is request
@pytest.mark.anyio
async def test_keyword_only_param_with_correct_type() -> None:
"""Test: def foo(*, req: ListPromptsRequest) - should pass request through."""
received_request = None
async def handler(*, req: ListPromptsRequest) -> list[str]:
nonlocal received_request
received_request = req
return ["test"]
wrapper = create_call_wrapper(handler, ListPromptsRequest)
# Wrapper should pass request to handler with keyword argument
request = ListPromptsRequest(method="prompts/list", params=None)
await wrapper(request)
assert received_request is request
@pytest.mark.anyio
async def test_different_request_types() -> None:
"""Test that wrapper works with different request types."""
# Test with ListResourcesRequest
received_request = None
async def handler(req: ListResourcesRequest) -> list[str]:
nonlocal received_request
received_request = req
return ["test"]
wrapper = create_call_wrapper(handler, ListResourcesRequest)
request = ListResourcesRequest(method="resources/list", params=None)
await wrapper(request)
assert received_request is request
# Test with ListToolsRequest
received_request = None
async def handler2(req: ListToolsRequest) -> list[str]:
nonlocal received_request
received_request = req
return ["test"]
wrapper2 = create_call_wrapper(handler2, ListToolsRequest)
request2 = ListToolsRequest(method="tools/list", params=None)
await wrapper2(request2)
assert received_request is request2
@pytest.mark.anyio
async def test_mixed_params_with_typed_request() -> None:
"""Test: def foo(a: str, req: ListPromptsRequest, b: int = 5) - attempts to pass request."""
async def handler(a: str, req: ListPromptsRequest, b: int = 5) -> list[str]:
return ["test"]
wrapper = create_call_wrapper(handler, ListPromptsRequest)
# Will fail at runtime due to missing 'a'
request = ListPromptsRequest(method="prompts/list", params=None)
with pytest.raises(TypeError, match="missing 1 required positional argument: 'a'"):
await wrapper(request)

View file

@ -0,0 +1,182 @@
"""Basic tests for list_prompts, list_resources, and list_tools decorators without pagination."""
import warnings
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"),
]
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
@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"),
]
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
@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"],
},
),
]
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
@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")
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
@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")
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
@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")
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
@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 == []

View file

@ -0,0 +1,111 @@
import pytest
from mcp.server import Server
from mcp.types import (
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 request was received
received_request: ListPromptsRequest | None = None
@server.list_prompts()
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 request with None params
request = ListPromptsRequest(method="prompts/list", params=None)
result = await handler(request)
assert received_request is not None
assert received_request.params is None
assert isinstance(result, ServerResult)
# 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_request is not None
assert received_request.params is not None
assert received_request.params.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 request was received
received_request: ListResourcesRequest | None = None
@server.list_resources()
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 request with None params
request = ListResourcesRequest(method="resources/list", params=None)
result = await handler(request)
assert received_request is not None
assert received_request.params is None
assert isinstance(result, ServerResult)
# 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_request is not None
assert received_request.params is not None
assert received_request.params.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 request was received
received_request: ListToolsRequest | None = None
@server.list_tools()
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 request with None params
request = ListToolsRequest(method="tools/list", params=None)
result = await handler(request)
assert received_request is not None
assert received_request.params is None
assert isinstance(result, ServerResult)
# 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_request is not None
assert received_request.params is not None
assert received_request.params.cursor == test_cursor
assert isinstance(result2, ServerResult)

36
uv.lock generated
View file

@ -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",
@ -730,6 +731,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"