ページネーション
ほとんどのサーバーには必要ありません。
MCPServer はすべての list_* リクエストに対して、持っているものを全部 1 ページにまとめ、next_cursor=None で返します。ツールやリソース、プロンプトが数十個程度なら、それが正しい答えであり、設定することは何もありません。
ページネーションは、リソース一覧が実質的にデータベースであるようなサーバーのためのものです。数千行もあり、1 つのレスポンスにシリアライズするわけにはいかない場合です。プロトコルの答えはカーソルです。サーバーはページと不透明なトークンを返し、クライアントはそのトークンを送り返して次のページを取得します。
@mcp.resource() にはそのためのフックがありません。ページングするには、低レベルの Server の上で、リストハンドラーを自分で書きます。
ページングするサーバー
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)
- 低レベルの
Serverでは、ハンドラーはデコレーターではなくコンストラクターの引数です。on_list_resourcesがすべてのresources/listリクエストに応答します。接続の仕組みはこれだけです。 - ページングするハンドラーはすべて
params: PaginatedRequestParams | Noneという型で、この例は両方を受け付けます。ただし接続越しでは、SDK がNoneを渡すことはありません(paramsメンバーのないリクエストは、デフォルト値を持つモデルとしてハンドラーに届きます)。したがって重要なシグナルはparams.cursor is None、つまり先頭から始めるということです。 - カーソルが「何であるか」は自分で決めます。ここでは文字列として表現したオフセットです。タイムスタンプでも主キーでも base64 のかたまりでもよく、返すときに発行でき、戻ってきたときに認識できるものなら何でもかまいません。
next_cursor=Noneが「これが最後のページでした」と伝える方法です。件数も、合計も、has_moreもありません。Noneがシグナルのすべてです。
Tip
PAGE_SIZE を 10 にしているのは、例を読みやすくするためです。実際の値はエンドポイントごとに選んでください。1 行のリソースが並ぶ一覧なら 1 ページ 500 件でも問題ありませんが、大きなプロンプトテンプレートの一覧ではそうはいきません。クライアントに選択の余地はなく、それは意図された設計です。
試してみる
Client(server) は、MCPServer に接続するのとまったく同じように、低レベルの Server にメモリ内で接続します。
引数なしで list_resources() を呼び出してください。book-1 から book-10 までの 10 個のリソースが返り、next_cursor は文字列 "10" です。
それを list_resources(cursor="10") として返すと、最初のリソースは book-11 になり、新しい next_cursor は "20" です。
10 ページ目は next_cursor が None に設定されて返ってきます。これで完了です。
クライアントのループ
Client のすべての list_* メソッド(list_tools、list_resources、list_resource_templates、list_prompts)は cursor= キーワードを受け取ります。ページングされた一覧をすべて取り出すには、while True を 1 つ書くだけです。
from typing import Any
from mcp import Client
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)
async def main() -> None:
async with Client(server) as client:
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
print(f"{len(resources)} resources")
cursorはNoneから始まるので、最初のリクエストにはカーソルがありません。next_cursorを見る前に extend してください。最後のページにもリソースはあります。next_cursor is Noneが出口です。それ以外はそのまま、手を加えずにcursor=に戻します。
その main() を実行すると 100 resources と表示されます。10 件ずつの 10 ページが、10 ページあることなど知らないループによってつなぎ合わされた結果です。
これは クライアント がすべての list_* メソッドについて示しているのと同じループで、ページングしないサーバーに対してもコストはかかりません。最初のレスポンスで next_cursor が None になり、ループは 1 回だけ回ります。
3 つのルール
カーソルは不透明です。 クライアントはカーソルを解析したり、組み立てたり、推測したりしてはいけません。カーソルの正当な出どころは、前のページの next_cursor をそのまま使うことだけです。
ページサイズはサーバーが決めます。 プロトコルに limit= はありません。別のページサイズが必要なら、サーバーを変更します。
ページングを無視するクライアントもそのまま動きます。 list_resources() を 1 回呼び、最初の 10 件を受け取り、捨ててしまった next_cursor に気づくことはありません。何も壊れません。見えるものが少ないだけです。
Check
不透明とは本当に不透明ということです。カーソルをでっち上げても(list_resources(cursor="page-2"))、プロトコルにできることは何もありません。このサーバーは int("page-2") を試み、ハンドラーが例外を送出し、クライアントに返ってくるのは次のとおりです。
MCPError(-32603, 'Internal server error', None)
サーバーから受け取ったものではないカーソルはバグであり、機能要望ではありません。
まとめ
MCPServerはすべてを 1 ページで返します。ページネーションはオプトインであり、低レベルのServerでオプトインします。on_list_resources(およびon_list_tools、on_list_prompts、on_list_resource_templates)はPaginatedRequestParams | Noneを受け取ります。最初のページではparams.cursorがNoneです。- ページと
next_cursorを返します。後で認識できる任意の文字列か、残りが何もないときはNoneです。 - クライアントのループは、
cursor=を渡し、蓄積し、next_cursor is Noneになるまで繰り返します。 - カーソルは不透明で、ページサイズはサーバーが決め、ページングしないクライアントも 1 ページ目は受け取れます。
手書きの Server API の残り(on_call_tool、input_schema の dict、_meta)は 低レベルの Server にあります。