Claude Media
MCPのページネーション仕様 — カーソルで一覧を取得する設計

MCPのページネーション仕様 — カーソルで一覧を取得する設計

MCPのtools/list等が返す大量データを、不透明なカーソルで分割取得する仕様を解説します。ページサイズの扱いと実装時の注意点を仕様原文から示します。

MCPはtools/listやresources/listのような一覧取得の応答が大きくなりすぎないよう、カーソルベースのページネーションを仕様として定義しています。ページ番号ではなく、サーバーが発行する不透明な(opaque)文字列トークンで「どこまで読んだか」を管理する方式です。MCPサーバーを自作するなら、ツール数やリソース数が増えた将来を見越してこの仕様に沿った実装が必要です。

MCPのページネーションはなぜページ番号でないのか

仕様がページネーションの前提として置いているのは2点だけです。カーソルは結果集合の中の位置を表す不透明な文字列であること。そしてページサイズはサーバーが決め、クライアントは固定のページサイズを仮定してはならない(MUST NOT)こと。ページ番号方式を採らない理由そのものは書かれていません。

ただ、オフセット(何件目から)をカーソルに使った場合に何が起きるかは、数行のコードで再現できます。6件の一覧を2件ずつ読み、1ページ目を返した直後に先頭へ1件挿入する、という最小の実験です。

items = ["a", "b", "c", "d", "e", "f"]
 
def page_offset(cur):          # カーソル = 何件目から
    o = int(cur or 0)
    nxt = str(o + 2) if o + 2 < len(items) else None
    return items[o:o + 2], nxt
 
def page_key(cur):             # カーソル = 最後に返したキー
    rest = [x for x in items if cur is None or x > cur][:2]
    last = rest[-1] if rest and rest[-1] != items[-1] else None
    return rest, last

どちらの関数も、1ページ読んだところでitems.insert(0, "0")を実行してから最後まで読み切ります。結果は次のとおりです。

offset ['a', 'b', 'b', 'c', 'd', 'e', 'f']
key    ['a', 'b', 'c', 'd', 'e', 'f']

オフセット方式はbを2回返しました。先頭への挿入で全項目が1つ後ろにずれたのに、カーソルは古い位置を指したままだったからです。キー方式は、挿入があっても重複も欠落も出ていません。仕様が安定したカーソルを求める(SHOULD)のも、この差を避けるためと読めます。

くらべる

カーソルの中身を何にするか

ずれやすい

オフセット(件数)

先頭への挿入・削除で、発行済みカーソルの指す位置が1つずつずれます。上の実験では同じ項目が2回返りました。

ずれにくい

ソートキー(ID・作成日時)

「このキーより後ろ」という指定なので、前方の増減に影響されません。中身は不透明トークンなので、クライアントには見えません。

もう1つの背景が、2026-07-28リビジョンのステートレス化です。このリビジョンはStreamable HTTPトランスポートからMcp-Session-Idヘッダーとプロトコルレベルのセッションを削除し、tools/list・resources/list・prompts/listの結果が接続ごとに変わらないものになりました。接続をまたぐ状態が必要なサーバーは、明示的なハンドルを通常のツール引数として渡す設計へ移ります。位置情報をトークンに閉じ込めるカーソルは、リクエストに載って運ばれるだけなので、この方針と矛盾しません。

対象となる操作は次の4つです。

  • resources/list — 利用可能なリソースの一覧
  • resources/templates/list — リソーステンプレートの一覧
  • prompts/list — 利用可能なプロンプトの一覧
  • tools/list — 利用可能なツールの一覧

tools/listが対象に含まれているので、Claude Codeに接続するMCPサーバーのうち、ツール数の多いものでは避けて通れない仕様です。クライアントがnextCursorを無視すると、得られるのは先頭ページ分のツールだけになります。大量の行を返すサーバーの例にはClickHouse MCPサーバーがあります。

リクエストとレスポンスの仕様

ページネーションは、サーバーが返すレスポンスにnextCursorフィールドが含まれることで始まります。

{
  "jsonrpc": "2.0",
  "id": "123",
  "result": {
    "resultType": "complete",
    "resources": [ "..." ],
    "nextCursor": "eyJwYWdlIjogM30=",
    "ttlMs": 300000,
    "cacheScope": "public"
  }
}

resultTypeはページネーション固有ではなく、2026-07-28リビジョンですべての結果に付く必須フィールドです。通常の結果はcomplete、Multi Round-Trip Requestsの途中結果はinput_requiredになり、フィールドを持たない旧リビジョンのサーバーの結果はcompleteとして扱います。

続きを取得するクライアントは、受け取ったカーソルをそのまま次のリクエストのparams.cursorに載せます。

{
  "jsonrpc": "2.0",
  "id": "124",
  "method": "resources/list",
  "params": {
    "cursor": "eyJwYWdlIjogMn0="
  }
}

実際のリクエストには、仕様のページが省略している_meta(プロトコルバージョンとクライアント機能。クライアント情報は推奨)も付きます。仕様が定める要件は次の3点です。

  1. カーソルは不透明なトークンとして扱う。形式の推測、解析、改変をしてはならない(MUST)
  2. 「非null値が渡されたか」以外を、カーソルの値から判断してはならない。空文字列も有効なカーソルで、終端として扱ってはならない(MUST)
  3. nextCursorが無ければ終端とみなす(SHOULD)

2点目は見落としやすく、「カーソルが空文字なら終わり」と書くと、続きのページをすべて取りこぼします。

2025-11-25から変わった点

同じ「Pagination」ページを2025-11-25版と2026-07-28版で並べると、差分は次のとおりです。

くらべる

ページネーション仕様の差分

1つ前のリビジョン

2025-11-25

応答はresourcesとnextCursorだけです。クライアントの要件には「カーソルをセッションをまたいで保存しない」が入っています。

現行

2026-07-28

応答にresultType、ttlMs、cacheScopeが加わります。「セッションをまたいで保存しない」は消え、代わりに「空文字列も有効なカーソル」が入りました。

セッションの概念ごと無くなったので、前者の条項が消えたのは筋が通ります。変わっていないものもあります。不正なカーソルに対するエラーコード-32602(Invalid params)は、2025-11-25版のページにも同じ形で書かれています。

changelogには別件で-32002から-32602への変更もありますが、これは「リソースが見つからない」エラーの話です。カーソルのエラーコードが今回変わったわけではありません。ttlMsとcacheScopeの必須化はSEP-2549で、tools/list・prompts/list・resources/list・resources/read・resources/templates/listが対象です。仕様のキャッシュのページは、これにserver/discoverを加えた6操作を挙げています。

サーバーとクライアントが守ること

仕様は役割ごとに推奨事項を分けています。

役割求められる挙動
サーバー求められる挙動安定したカーソルを発行する / 無効なカーソルでクラッシュしない(SHOULD)
クライアント求められる挙動nextCursorが無ければ終端とみなす / ページネーションありとなしの両方に対応する(SHOULD)
クライアント求められる挙動カーソルを不透明なトークンとして扱い、形式を推測・改変しない(MUST)

クライアントの取得ループは、この要件をそのまま書くと次の形になります。仕様から組み立てた骨子で、requestは任意のJSON-RPC送信関数です。

async function listAll(method: string, key: string) {
  const items: unknown[] = [];
  let cursor: string | undefined = undefined;
  do {
    const res: any = await request(method, cursor === undefined ? {} : { cursor });
    items.push(...res[key]);
    cursor = res.nextCursor ?? undefined; // 空文字列は有効なカーソルなので truthy 判定にしない
  } while (cursor !== undefined);
  return items;
}

while (cursor)と書くと、空文字列のカーソルで止まってしまいます。仕様の2点目の要件が、そのままバグの形になる箇所です。

ページ単位のキャッシュとカーソル失効

2026-07-28リビジョンの応答にはttlMsとcacheScopeが同居します。キャッシュ仕様のページには、ページネーションとの関係が箇条書きで定められています。

手順

カーソルが失効するまでの動き

  1. 1

    ページごとに独立してキャッシュする

    キャッシュのキーは、メソッドと結果に影響するパラメータ(ページ付きの一覧ではcursor)の組です。各ページが自分のttlMsを持ち、鮮度の時計はそのページを受け取った時点から進みます。

  2. 2

    期限切れのページはカーソルで取り直す

    キャッシュが古くなったページは、そのカーソルで再取得します。サーバーはページごとに別のttlMsを返してよく、安定した一覧の前方のページほど長くできます。

  3. 3

    カーソルが無効になったら最初から

    以前は有効だったカーソルにエラーが返ったら、保持しているページのキャッシュをすべて捨て、カーソルなしで取り直します。

一覧の途中でデータが変わった場合の扱いも明記されています。ページ間の一貫性は保証されず、重複や欠落が見えることがあります。全体の一貫したスナップショットが必要なクライアントは、カーソルなしの最初から取り直します。冒頭の実験で出た重複は、この「保証されない」範囲に入る現象です。

サーバー側の義務もあります。同じ一覧リクエストの全ページには同じcacheScopeを付けなければなりません(MUST)。1ページ目がprivateなら、以降のページもprivateです。クライアント側は、list_changed通知を受けると、期限内でもキャッシュを失効させます。

ttlMsとcacheScopeの値の意味

レスポンス例のttlMs: 300000は5分です。サーバーはttlMsに0以上の値を付けなければなりません(MUST)。値の扱いは次のとおりで、意味はHTTPのCache-Control: max-ageに近い設計です。

値クライアントの扱い
正の数クライアントの扱い受信からttlMsミリ秒は新鮮とみなす(SHOULD)
0クライアントの扱いすぐ古くなる。使うたびに再取得してよい
欠落クライアントの扱い0として扱う。旧バージョンのサーバーで起きうる
負の数クライアントの扱い無視して0として扱う

ttlMsは鮮度のヒントで、保証ではありません。期限内でもサーバー側のデータは変わりえます。TTLは「必要になったときに古いかを調べる」ための値で、バックグラウンドで定期的に取り直す間隔にはしないよう求められています。ポーリングする実装は、ジッタとバックオフを入れなければなりません(MUST)。再取得に失敗したときは、古い応答を使い続けてもかまいません(MAY)。

cacheScopeはpublicかprivateです。publicはユーザー固有のデータを含まない応答で、共有ゲートウェイやプロキシも保存して他のユーザーに返せます。privateは同じ認可コンテキストの中でだけ再利用でき、別のアクセストークンとはキャッシュを共有できません。仕様は、publicの例にツールの一覧のように全ユーザーで同一の応答を、privateの例にユーザーごとに変わる絞り込み済みの一覧結果を挙げています。ユーザーごとにツールが絞り込まれるサーバーがpublicを付けると、認証付きの一覧が別のトークンの利用者にも返りうるため、仕様はアクセス制御をcacheScopeだけに頼らないよう求めています。

ページ番号方式の直感を持ち込むと起きること

つまずきの典型は、カーソルの中身を覗いてロジックを書くことです。仕様の例に出るeyJwYWdlIjogM30=はBase64のJSON({"page": 3})に見えますが、あくまで例で、構造に依存してよいわけではありません。サーバーが中身を変えた瞬間にクライアントは壊れます。

もう1つは、tools/listのページネーションを「ツールが少ないから関係ない」と後回しにすることです。接続先を増やすたびにツール数は積み上がります。ページネーション未対応のクライアントはnextCursorを無視するため、先頭ページ分のツールしか得られません。MCP Inspectorでツール一覧の応答を見るときは、nextCursorの有無も一緒に見ておくと、手戻りを避けられます。同じ仕様の一覧系でも、prompts/listは対象の1つです。

まとめ

カーソルの中身はサーバーの自由で、クライアントはnextCursorの有無と「空文字列も有効」の2点だけを守れば足ります。サーバー側は、オフセットではなくソートキーに基づくカーソルと、全ページで揃えたcacheScopeが実装の分かれ目です。Claude Code接続前提でMCPサーバーを設計するなら、ページネーションとキャッシュヒントを最初から一緒に組み込むと、あとの手戻りが小さく済みます。

この記事を共有:XはてブLinkedIn
MCP をもっと見る →