Skip to content

Pagination

Most servers never need this.

MCPServer answers every list_* request with everything it has, in one page, next_cursor=None. For a few dozen tools, resources or prompts that is the right answer and there is nothing to configure.

Pagination is for the server whose resource list is really a database: thousands of rows it refuses to serialize in one response. The protocol's answer is a cursor: the server returns a page plus an opaque token, and the client sends that token back to get the next page.

@mcp.resource() has no hook for any of that. To page, you write the list handler yourself, on the low-level Server.

A server that pages

server.py
from typing import Any

from mcp.server import Server, ServerRequestContext
from mcp.types import ListResourcesResult, PaginatedRequestParams, Resource

BOOKS = [f"book-{n}" for n in range(1, 101)]

PAGE_SIZE = 10


async def list_books(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListResourcesResult:
    start = 0 if params is None or params.cursor is None else int(params.cursor)
    end = start + PAGE_SIZE
    page = [Resource(uri=f"books://catalog/{name}", name=name) for name in BOOKS[start:end]]
    next_cursor = str(end) if end < len(BOOKS) else None
    return ListResourcesResult(resources=page, next_cursor=next_cursor)


server = Server("Bookshop", on_list_resources=list_books)
app = server.streamable_http_app()
  • On a low-level Server, handlers are constructor arguments, not decorators. on_list_resources answers every resources/list request; that's the whole hookup.
  • Every paged handler is typed params: PaginatedRequestParams | None, and the example accepts both. Over a connection, though, the SDK never hands you None (a request with no params member reaches the handler as the model with its defaults), so the signal that matters is params.cursor is None: start from the top.
  • You decide what a cursor is. Here it's an offset rendered as a string. A timestamp, a primary key, a base64 blob: anything you can mint on the way out and recognise on the way back in.
  • next_cursor=None is how you say "that was the last page". There is no count, no total, no has_more. None is the entire signal.

Tip

A PAGE_SIZE of 10 makes the example readable. Pick yours per endpoint: a list of one-line resources can afford a page of 500; a list of fat prompt templates cannot. The client has no say in it, and that is by design.

Try it

mcp run only accepts an MCPServer, so you serve this one yourself. The last line of server.py builds an ordinary ASGI app from the Server, and uvicorn runs that:

uvicorn server:app --port 8000

Point any client (The Client, or the Inspector) at http://localhost:8000/mcp and call list_resources() with no arguments. You get ten resources, book-1 through book-10, and next_cursor is the string "10".

Hand it back with list_resources(cursor="10") and the first resource is book-11, the new next_cursor is "20".

The tenth page comes back with next_cursor set to None. Done.

The client loop

Every list_* method on Client (list_tools, list_resources, list_resource_templates, list_prompts) takes a cursor= keyword. Draining a paged list is one while True:

client.py
import anyio

from mcp import Client
from mcp.types import Resource


async def list_all_resources(client: Client) -> list[Resource]:
    resources: list[Resource] = []
    cursor: str | None = None
    while True:
        page = await client.list_resources(cursor=cursor)
        resources.extend(page.resources)
        if page.next_cursor is None:
            break
        cursor = page.next_cursor
    return resources


async def main() -> None:
    async with Client("http://localhost:8000/mcp") as client:
        resources = await list_all_resources(client)
        print(f"{len(resources)} resources")


if __name__ == "__main__":
    anyio.run(main)
  • cursor starts as None, so the first request carries no cursor.
  • Extend before you look at next_cursor: the last page has resources too.
  • next_cursor is None is the exit. Anything else goes straight back into cursor=, untouched.

With uvicorn still serving server.py, run python client.py in a second terminal. It prints 100 resources: ten pages of ten, stitched together by a loop that never knew there were ten pages.

This is the same loop The Client shows for every list_* verb, and it costs nothing against a server that doesn't page: next_cursor is None on the first response and the loop runs once.

The three rules

Cursors are opaque. A client must never parse, build, or guess one. The only legal source of a cursor is the previous page's next_cursor, verbatim.

The server picks the page size. There is no limit= in the protocol. If you need a different page size, you change the server.

A client that ignores paging still works. It calls list_resources() once, gets the first ten, and never notices the next_cursor it threw away. Nothing breaks; it sees less.

Check

Opaque means opaque. Invent a cursor (list_resources(cursor="page-2")) and there is nothing the protocol can do for you. This server tries int("page-2"), the handler raises, and what comes back to the client is:

MCPError(-32603, 'Internal server error', None)

A cursor you didn't get from the server is a bug, not a feature request.

Recap

  • MCPServer returns everything in one page. Pagination is opt-in, and you opt in on the low-level Server.
  • on_list_resources (and on_list_tools, on_list_prompts, on_list_resource_templates) receives PaginatedRequestParams | None; params.cursor is None for the first page.
  • You return a page plus next_cursor: any string you'll recognise later, or None when there is nothing left.
  • The client loop: pass cursor=, accumulate, repeat until next_cursor is None.
  • Cursors are opaque, the server owns the page size, and a non-paging client still gets page one.

The rest of the hand-written Server API (on_call_tool, input_schema dicts, _meta) is The low-level Server.