Pagination - MCP Python SDK
https://py.sdk.modelcontextprotocol.io/advanced/pagination/ • 62 KB fetched
Open original page
Pagination - MCP Python SDK
Skip to content
MCP Python SDK
Pagination
*
en - English
*
de - Deutsch
*
es - español
*
fr - français
*
hi - हिन्दी
*
ja - 日本語
*
ko - 한국어
*
pt - português (Brasil)
*
ru - русский язык
*
tr - Türkçe
*
uk - українська мова
*
zh - 简体中文
*
zh-hant - 繁體中文
Search
modelcontextprotocol/python-sdk
MCP Python SDK
modelcontextprotocol/python-sdk
*
MCP Python SDK
*
What's new in v2
*
Get started
Get started
*
Installation
*
First steps
*
Connect to a real host
*
Testing
*
Servers
Servers
*
Tools
*
Structured Output
*
Resources
*
URI templates
*
Prompts
*
Completions
*
Images, audio & icons
*
Handling errors
*
Inside your handler
Inside your handler
*
The Context
*
Dependencies
*
Lifespan
*
Elicitation
*
Multi-round-trip requests
*
Sampling and roots
*
Progress
*
Logging
*
Subscriptions
*
Running your server
Running your server
*
Add to an existing app
*
Deploy & scale
*
Authorization
*
OpenTelemetry
*
Serving legacy clients
*
Clients
Clients
*
Callbacks
*
Transports
*
OAuth
*
Identity assertion
*
Multiple servers
*
Subscriptions
*
Caching
*
Protocol versions
*
Deprecated features
*
Advanced
Advanced
*
The low-level Server
*
Pagination
Pagination
On this page
*
A server that pages
*
Try it
*
The client loop
*
The three rules
*
Recap
*
Middleware
*
Extensions
*
MCP Apps
*
Troubleshooting
*
Translations
*
Migration Guide
*
API Reference
API Reference
*
mcp
*
mcp_types
On this page
*
A server that pages
*
Try it
*
The client loop
*
The three rules
*
Recap
*
MCP Python SDK
*
Advanced
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 .
Back to top
Previous
The low-level Server
Next
Middleware
Made with
Zensical
Links found on this page
- Skip to content [direct]
- en - English [direct]
- de - Deutsch [direct]
- es - español [direct]
- fr - français [direct]
- hi - हिन्दी [direct]
- ja - 日本語 [direct]
- ko - 한국어 [direct]
- pt - português (Brasil) [direct]
- ru - русский язык [direct]
- tr - Türkçe [direct]
- uk - українська мова [direct]
- zh - 简体中文 [direct]
- zh-hant - 繁體中文 [direct]
- modelcontextprotocol/python-sdk [direct]
- What's new in v2 [direct]
- Get started [direct]
- Installation [direct]
- First steps [direct]
- Connect to a real host [direct]
- Testing [direct]
- Servers [direct]
- Tools [direct]
- Structured Output [direct]
- Resources [direct]
- URI templates [direct]
- Prompts [direct]
- Completions [direct]
- Images, audio & icons [direct]
- Handling errors [direct]
- Inside your handler [direct]
- The Context [direct]
- Dependencies [direct]
- Lifespan [direct]
- Elicitation [direct]
- Multi-round-trip requests [direct]
- Sampling and roots [direct]
- Progress [direct]
- Logging [direct]
- Subscriptions [direct]
- Running your server [direct]
- Add to an existing app [direct]
- Deploy & scale [direct]
- Authorization [direct]
- OpenTelemetry [direct]
- Serving legacy clients [direct]
- Clients [direct]
- Callbacks [direct]
- Transports [direct]
- OAuth [direct]
- Identity assertion [direct]
- Multiple servers [direct]
- Subscriptions [direct]
- Caching [direct]
- Protocol versions [direct]
- Deprecated features [direct]
- Advanced [direct]
- The low-level Server [direct]
- Middleware [direct]
- Extensions [direct]
- MCP Apps [direct]
- Troubleshooting [direct]
- Translations [direct]
- Migration Guide [direct]
- mcp [direct]
- mcp_types [direct]
- Zensical [direct]