Guide · MCP Protocol · Tool Design
MCP server pagination
MCP tools return a CallToolResult — a single response, not a stream or an iterator. When a tool needs to return hundreds of records, two things have to happen: the tool must accept a cursor and return a next-cursor in its output, and the tool description must explain this so the LLM knows to call the tool again to get the next page. The MCP protocol has built-in cursor pagination for list operations (resources/list, tools/list), but tools/call has no built-in pagination — cursor handling is part of the tool's own contract, encoded in the JSON returned as tool content. Getting this right requires opaque cursor design, a hasMore flag the LLM can parse, a description written for LLM consumption, and tests that verify no rows are skipped or duplicated between pages.
TL;DR
Accept cursor?: string and limit?: number in your tool's inputSchema. Return a JSON object in the text content that includes items, nextCursor (omit or set null when there are no more pages), and hasMore. Encode the cursor position as a base64-encoded JSON object so it's opaque to the LLM — never expose raw SQL offsets. Write the tool description to explicitly instruct the LLM that it must call the tool again with the returned nextCursor to fetch more results. Use cursor-based pagination (keyset pagination) rather than OFFSET queries — offset pagination on mutable data skips or duplicates rows when records are inserted or deleted between pages.
Why MCP tools need their own pagination contract
The MCP protocol paginates list operations natively. When you call resources/list, the response includes an optional nextCursor field; you pass it to the next resources/list call. tools/list works the same way.
Tool calls (tools/call) are different. A single call produces a single CallToolResult. There is no protocol-level continuation — pagination must be implemented as part of the tool's own interface. The tool's inputSchema must accept a cursor, and the tool's response content must include a next cursor, just like a REST endpoint would include a Link: <…>; rel="next" header or a JSON next_page_token field.
The additional challenge: the consumer is an LLM, not code. The LLM reads the tool description to understand what the tool does and how to call it. If the description doesn't explain that nextCursor exists and must be passed back to get more results, the LLM won't paginate — it'll assume the first page is the complete result.
Cursor vs offset pagination
Two common approaches to pagination. Only one is correct for mutable data.
| Property | Offset pagination (LIMIT N OFFSET k) | Cursor pagination (keyset) |
|---|---|---|
| Implementation complexity | Low — trivial SQL | Medium — requires indexed anchor column |
| Performance at high offsets | Degrades — DB scans all skipped rows | Constant — seeks directly to cursor position |
| Correctness on insert during paging | Rows shift — subsequent pages skip rows | Stable — cursor anchors to the last-seen row |
| Correctness on delete during paging | Rows shift — subsequent pages duplicate rows | Stable — deleted rows simply not seen |
| Arbitrary page jumps | Supported (client can request page N) | Not supported — forward-only |
| Best for | Static datasets; UI with page number links | Live data; LLM-driven sequential consumption |
For MCP tools, cursor pagination is almost always the right choice. LLMs traverse results sequentially; they don't need to jump to page 47. And most datasets that need pagination are mutable — skipped or duplicated rows produce incorrect tool results that the LLM cannot detect.
Cursor design: opaque base64-encoded position
The cursor encodes the position in the result set. Keep it opaque to the caller: base64-encode a JSON object containing the anchor column value and sort direction. This lets you change the internal implementation later without breaking existing cursors.
// lib/cursor.ts
interface CursorData {
lastId: string;
direction: 'asc' | 'desc';
}
export function encodeCursor(data: CursorData): string {
return Buffer.from(JSON.stringify(data)).toString('base64url');
}
export function decodeCursor(cursor: string): CursorData {
try {
return JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
} catch {
throw new Error('Invalid cursor — do not modify cursor values between pages');
}
}
// The cursor is opaque from the LLM's perspective:
//
// {
// "items": [...],
// "nextCursor": "eyJsYXN0SWQiOiJ1c3ItNDIiLCJkaXJlY3Rpb24iOiJhc2MifQ",
// "hasMore": true,
// "total": 847
// }
//
// The LLM passes the nextCursor value verbatim in the next call.
// It never inspects or modifies the cursor contents.
Implementing a paginated list tool
// tools/list-records.ts
import { encodeCursor, decodeCursor } from '../lib/cursor.js';
import { db } from '../lib/db.js';
server.tool(
'list_records',
{
cursor: {
type: 'string',
description: 'Pagination cursor from the previous call. Omit for the first page.',
optional: true,
},
limit: {
type: 'number',
description: 'Maximum records to return (1–100). Default 25.',
minimum: 1,
maximum: 100,
optional: true,
},
status: {
type: 'string',
enum: ['active', 'archived', 'all'],
description: 'Filter by record status. Default "active".',
optional: true,
},
},
async (args) => {
const limit = Math.min(args.limit ?? 25, 100);
const status = args.status ?? 'active';
// Decode cursor (anchor point for keyset query)
let lastId: string | null = null;
if (args.cursor) {
const decoded = decodeCursor(args.cursor);
lastId = decoded.lastId;
}
// Keyset query: WHERE id > lastId ORDER BY id ASC LIMIT limit+1
// Fetch limit+1 to detect whether there's a next page
const rows = await db.records.findPage({
status,
afterId: lastId,
limit: limit + 1,
});
const hasMore = rows.length > limit;
const items = hasMore ? rows.slice(0, limit) : rows;
const nextCursor = hasMore
? encodeCursor({ lastId: items[items.length - 1].id, direction: 'asc' })
: null;
return {
content: [{
type: 'text',
text: JSON.stringify({
items: items.map(r => ({ id: r.id, name: r.name, status: r.status, created_at: r.created_at })),
nextCursor,
hasMore,
pageSize: items.length,
}),
}],
};
}
);
Writing tool descriptions that teach LLMs to paginate
The most common pagination bug isn't in the server — it's that the LLM stops after the first page because the tool description didn't explain there are more pages. The description must explicitly state the pagination contract.
// Good description — explains the pagination contract to the LLM
const GOOD_DESCRIPTION = `
List records with cursor-based pagination.
Returns up to \`limit\` records (default 25, max 100). If \`hasMore\` is true in
the response, there are additional records available. Call this tool again with
\`cursor\` set to the \`nextCursor\` value from the previous response to retrieve
the next page. Continue until \`hasMore\` is false.
Example multi-page flow:
1. Call list_records({}) → returns items[0..24], nextCursor="abc", hasMore=true
2. Call list_records({cursor: "abc"}) → returns items[25..49], nextCursor="def", hasMore=true
3. Call list_records({cursor: "def"}) → returns items[50..52], nextCursor=null, hasMore=false
`.trim();
// Bad description — LLM won't know to paginate
const BAD_DESCRIPTION = 'List records. Use cursor and limit for pagination.';
The explicit example in the good description is intentional. LLMs reason better with concrete examples than with abstract protocol descriptions. Spelling out the three-step flow gives the model a pattern to follow.
Including totals and page metadata
Decide up front whether to include a total count in the response. Totals require a separate COUNT(*) query and are expensive on large tables. They're also frequently inaccurate for cursored results because the count can change between pages.
| Metadata field | Cost | When to include |
|---|---|---|
hasMore | Free (fetch limit+1) | Always — required for LLM pagination |
pageSize | Free | Always — useful for LLM to know how many items it got |
nextCursor | Free | Always — null when no more pages |
total | Expensive (COUNT query) | Only if users explicitly need "N of M results" |
estimatedTotal | Medium (table statistics) | Acceptable for large tables where an approximation is enough |
Testing pagination correctness
Two bugs are easy to miss: skipped rows (if a record is inserted between pages) and duplicate rows (if a record's sort key changes). Write targeted tests for both.
// test/pagination.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { db } from '../lib/db.js';
import { callTool } from './helpers.js';
describe('list_records pagination', () => {
beforeEach(async () => {
await db.records.deleteAll();
// Insert 55 records with known IDs
for (let i = 1; i <= 55; i++) {
await db.records.insert({ id: `rec-${i.toString().padStart(3, '0')}`, name: `Record ${i}` });
}
});
it('returns all 55 records across pages of 20', async () => {
const allIds: string[] = [];
let cursor: string | null = null;
do {
const result = await callTool('list_records', { limit: 20, cursor: cursor ?? undefined });
const page = JSON.parse(result.content[0].text);
allIds.push(...page.items.map((r: { id: string }) => r.id));
cursor = page.nextCursor;
} while (cursor !== null);
expect(allIds).toHaveLength(55);
expect(new Set(allIds).size).toBe(55); // no duplicates
});
it('does not skip records inserted after page 1', async () => {
const page1 = JSON.parse(
(await callTool('list_records', { limit: 20 })).content[0].text
);
// Insert a new record between pages
await db.records.insert({ id: 'rec-inserted', name: 'Inserted mid-page' });
const page2 = JSON.parse(
(await callTool('list_records', { limit: 20, cursor: page1.nextCursor })).content[0].text
);
const allIds = [...page1.items.map((r: { id: string }) => r.id), ...page2.items.map((r: { id: string }) => r.id)];
expect(new Set(allIds).size).toBe(allIds.length); // no duplicates
});
});
Frequently asked questions
Should I use the same pagination pattern for the MCP Resources API?
The MCP Resources API (resources/list) has built-in cursor pagination at the protocol level — return nextCursor in the ListResourcesResult and clients will use it automatically. You don't need to embed cursors in the resource content. This page covers pagination for tools/call results only, where there's no protocol-level pagination and the cursor must be part of the tool's own JSON contract.
What's the right default page size?
25 is a reasonable default for most tools. It's small enough to keep response size manageable (MCP tool results go into the LLM's context window — large pages waste tokens on records the LLM won't use) and large enough to reduce round trips for typical use cases. Allow the caller to override with a limit parameter, cap it at 100. For tools that return large objects (full document text, binary data), default to 10 and cap lower.
Can the LLM handle multi-page pagination autonomously?
Yes, if the tool description explicitly explains the pagination contract. Modern frontier models (Claude 3+, GPT-4o) can execute multi-step tool call sequences autonomously: call page 1, extract the cursor, call page 2 with the cursor, and so on. The key is writing the description precisely enough that the LLM knows (a) there may be more pages, (b) how to check (the hasMore field), and (c) how to request the next page (pass nextCursor as the cursor argument). Without explicit description, the LLM may assume the first page is complete.
How do I handle invalid or expired cursors?
Cursors can become invalid if the underlying data schema changes significantly, or if you explicitly expire them (e.g. cursors older than 24 hours). Catch decoding errors and return a clear error message telling the caller to start over from page 1: { "error": "cursor_expired", "message": "This cursor is no longer valid. Start a new query without a cursor." }. Don't return a generic 500-style error — the LLM needs to know whether to retry with the same cursor or start fresh.
How does pagination affect AliveMCP monitoring probes?
AliveMCP probes call your tool with a single request and verify the response structure. For paginated tools, the probe should call the tool without a cursor (page 1 only) and verify that the response includes items, hasMore, and the correct format. It does not need to traverse all pages — the goal is verifying that the tool is reachable and returning the expected structure, not that all data is present. Configure the probe with a representative non-cursor call that exercises the tool's core query path.
Further reading
- MCP server tool design — naming, schemas, and output formats for LLMs
- MCP server context propagation — threading user identity through tool calls
- MCP server Resources API — file-like resources with built-in cursor pagination
- MCP server database tools — query builders, ORMs, and safe SQL patterns
- MCP server rate limiting — per-tenant quotas and token bucket strategies
- AliveMCP — continuous protocol monitoring for MCP servers