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
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_resourcesanswers everyresources/listrequest; 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 youNone(a request with noparamsmember reaches the handler as the model with its defaults), so the signal that matters isparams.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=Noneis how you say "that was the last page". There is no count, no total, nohas_more.Noneis 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:
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)
cursorstarts asNone, so the first request carries no cursor.- Extend before you look at
next_cursor: the last page has resources too. next_cursor is Noneis the exit. Anything else goes straight back intocursor=, 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
MCPServerreturns everything in one page. Pagination is opt-in, and you opt in on the low-levelServer.on_list_resources(andon_list_tools,on_list_prompts,on_list_resource_templates) receivesPaginatedRequestParams | None;params.cursorisNonefor the first page.- You return a page plus
next_cursor: any string you'll recognise later, orNonewhen there is nothing left. - The client loop: pass
cursor=, accumulate, repeat untilnext_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.