diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 553c52d62..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: 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 python 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..e2ef5a7ca 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,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 + + +```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)_ + + +#### 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 + +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..360cbc3cf --- /dev/null +++ b/examples/servers/simple-pagination/mcp_simple_pagination/server.py @@ -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 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..70c3b3492 --- /dev/null +++ b/examples/snippets/servers/pagination_example.py @@ -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) diff --git a/src/mcp/server/lowlevel/func_inspection.py b/src/mcp/server/lowlevel/func_inspection.py new file mode 100644 index 000000000..7f6e18860 --- /dev/null +++ b/src/mcp/server/lowlevel/func_inspection.py @@ -0,0 +1,48 @@ +import inspect +from collections.abc import Callable +from typing import Any + + +def accepts_request(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()) + + 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 8c459383c..abc650083 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_request from mcp.server.lowlevel.helper_types import ReadResourceContents from mcp.server.models import InitializationOptions from mcp.server.session import ServerSession @@ -230,12 +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.ListPromptsRequest], Awaitable[types.ListPromptsResult]], + ): logger.debug("Registering handler for PromptListRequest") + pass_request = accepts_request(func) - async def handler(_: Any): - prompts = await func() - return types.ServerResult(types.ListPromptsResult(prompts=prompts)) + if pass_request: + request_func = cast(Callable[[types.ListPromptsRequest], Awaitable[types.ListPromptsResult]], func) + + async def request_handler(req: types.ListPromptsRequest): + result = await request_func(req) + return types.ServerResult(result) + + handler = request_handler + else: + list_func = cast(Callable[[], Awaitable[list[types.Prompt]]], func) + + async def list_handler(_: types.ListPromptsRequest): + result = await list_func() + return types.ServerResult(types.ListPromptsResult(prompts=result)) + + handler = list_handler self.request_handlers[types.ListPromptsRequest] = handler return func @@ -258,12 +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.ListResourcesRequest], Awaitable[types.ListResourcesResult]], + ): logger.debug("Registering handler for ListResourcesRequest") + pass_request = accepts_request(func) - async def handler(_: Any): - resources = await func() - return types.ServerResult(types.ListResourcesResult(resources=resources)) + if pass_request: + request_func = cast(Callable[[types.ListResourcesRequest], Awaitable[types.ListResourcesResult]], func) + + async def request_handler(req: types.ListResourcesRequest): + result = await request_func(req) + return types.ServerResult(result) + + handler = request_handler + else: + list_func = cast(Callable[[], Awaitable[list[types.Resource]]], func) + + async def list_handler(_: types.ListResourcesRequest): + result = await list_func() + return types.ServerResult(types.ListResourcesResult(resources=result)) + + handler = list_handler self.request_handlers[types.ListResourcesRequest] = handler return func @@ -381,16 +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.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)) + pass_request = accepts_request(func) + + if pass_request: + request_func = cast(Callable[[types.ListToolsRequest], Awaitable[types.ListToolsResult]], func) + + 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 = request_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/__init__.py b/tests/server/lowlevel/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/server/lowlevel/test_func_inspection.py b/tests/server/lowlevel/test_func_inspection.py new file mode 100644 index 000000000..674675d8d --- /dev/null +++ b/tests/server/lowlevel/test_func_inspection.py @@ -0,0 +1,173 @@ +from collections.abc import Callable +from typing import Any + +import pytest + +from mcp import types +from mcp.server.lowlevel.func_inspection import accepts_request + + +# Test fixtures - functions and methods with various signatures +class MyClass: + async def no_request_method(self): + """Instance method without request parameter""" + pass + + # noinspection PyMethodParameters + 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 request_method(self, request: types.ListPromptsRequest): + """Instance method with request parameter""" + pass + + # noinspection PyMethodParameters + 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_request_class_method(cls): + """Class method without request parameter""" + pass + + # noinspection PyMethodParameters + @classmethod + 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 request_class_method(cls, request: types.ListPromptsRequest): + """Class method with request parameter""" + pass + + # noinspection PyMethodParameters + @classmethod + 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_request_static_method(): + """Static method without request parameter""" + pass + + @staticmethod + async def request_static_method(request: types.ListPromptsRequest): + """Static method with request parameter""" + pass + + @staticmethod + 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_request_func(): + """Function without request parameter""" + pass + + +async def request_func(request: types.ListPromptsRequest): + """Function with request parameter""" + pass + + +async def request_func_different_name(req: types.ListPromptsRequest): + """Function with request parameter but different arg name""" + pass + + +async def request_func_with_self(self: types.ListPromptsRequest): + """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(request: types.ListPromptsRequest, *args: Any): + """Function with request and *args""" + pass + + +async def var_keyword_func(**kwargs: Any): + """Function with **kwargs""" + pass + + +async def request_with_var_keyword_func(request: types.ListPromptsRequest, **kwargs: Any): + """Function with request and **kwargs""" + pass + + +async def request_with_default(request: types.ListPromptsRequest | None = None): + """Function with request parameter having default value""" + pass + + +async def keyword_only_with_defaults(*, request: types.ListPromptsRequest | None = None): + """Function with keyword-only request 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(request: types.ListPromptsRequest, *, extra: str = "test"): + """Function with positional and keyword-only params""" + pass + + +@pytest.mark.parametrize( + "callable_obj,expected,description", + [ + # Regular functions + (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_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_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_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 request and *args"), + (var_keyword_func, False, "function with **kwargs"), + (request_with_var_keyword_func, True, "function with request and **kwargs"), + # Edge cases + (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"), + (mixed_positional_and_keyword, True, "mixed positional and keyword-only params"), + ], + ids=lambda x: x if isinstance(x, str) else "", +) +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 + 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_request(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 new file mode 100644 index 000000000..8d64dd525 --- /dev/null +++ b/tests/server/lowlevel/test_server_pagination.py @@ -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) 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"