Why architecture matters here
The reason pagination belongs in the protocol, standardized, rather than as each server's private convention is interoperability. An MCP client talks to many servers written by many authors; if every server invented its own way to chunk large lists — one using page numbers, another using offsets, a third streaming — the client would need bespoke handling per server, defeating the point of a common protocol. By fixing the shape (a page plus an opaque nextCursor, echoed back to continue), MCP lets a single client loop work against every conformant server, and lets a server evolve its internal strategy without a client ever noticing.
The core problem pagination solves is that unbounded responses are hostile to every layer. A message carrying fifty thousand resource descriptors is slow to serialize, slow to transmit, slow to parse, and forces both peers to hold the entire list in memory at once. It also makes the first useful result arrive only after the last one is ready — a client that just wants to show the first screen of resources waits for the whole catalog. Bounded pages fix all of this: the first page arrives quickly, memory stays flat regardless of total size, and a client that only needs a prefix can stop after one page.
The reason cursors beat offsets is stability under mutation. Consider listing resources while files are being added and removed. With an offset, 'skip 100, take 100' assumes the list has not shifted; if ten items were deleted before your second page, offset 100 now points somewhere different and you skip ten items you never saw. A cursor that means 'continue after item X' does not care how many items before X changed — it resumes from a concrete anchor. This does not make pagination perfectly consistent under concurrent mutation (that requires a snapshot), but it eliminates the systematic skip-and-duplicate errors that make offset pagination quietly wrong.
Opacity is the property that makes the contract durable. Because the client treats the cursor as an inscrutable token, the server is free to encode whatever it needs — a primary-key value, a keyset tuple, a snapshot id plus offset, an encrypted blob — and to change that encoding across versions, without any client caring. The moment a client parses or fabricates cursors, the server's internal format becomes a de facto public API that cannot change. Opacity keeps the coupling minimal: the only thing the client is allowed to know is that a non-null cursor means 'more exists, send this back to continue.'
There is a security dimension to opacity as well. A cursor often encodes server-side position state, and if the client could read or construct it, an attacker could forge a cursor to seek into data they should not reach, or to trigger expensive queries. Because the cursor is opaque, a server can sign or encrypt it and validate it on return, treating any cursor it did not issue as invalid. So opacity is not merely an engineering convenience for versioning — it is also what lets the server retain authority over what a cursor means and refuse tampered ones, which matters as soon as the list operation touches anything sensitive or costly.
The architecture: every piece explained
Trace the pieces. The MCP client initiates by sending a list request (for example resources/list, tools/list, or prompts/list) with no cursor parameter, signaling 'start from the beginning.' The MCP server's list handler receives it and queries its data source — a database, an in-memory catalog, a filesystem — for a bounded slice of results. The server, not the client, chooses the page size, which lets it protect itself from oversized responses regardless of what the client asks.
The page slice is the bounded set of items returned in this response. Alongside it, when more results remain, the server produces a nextCursor via the cursor encoder: an opaque string encoding the position just after the last item on this page, so a subsequent request can resume exactly there. The encoding is the server's private business — commonly a keyset value (the sort key of the last item) rather than an offset, because keyset resumption is what gives the stability-under-mutation property. If this page is the last, the server omits nextCursor entirely, which is the signal that terminates the client's loop.
On the return trip, the client loop takes the nextCursor and reissues the same list request with that cursor set. The server's cursor decode and validate step is where correctness and security meet: it decodes the token, verifies it is one the server legitimately issued (via a signature or by looking it up), rejects tampered or expired cursors with a protocol error, and extracts the resume position. It then fetches the next slice starting after that position and, again, returns a page plus possibly another cursor.
The consistency policy is the design decision that colors everything. A server can offer snapshot pagination — the cursor pins a point-in-time view so the client walks a consistent list even as data changes, at the cost of holding or reconstructing that snapshot — or live pagination, where each page reflects current data and the client may see items added or removed mid-walk. Most MCP servers use live keyset pagination because it is cheap and the stability property is usually good enough; a server whose list must be transactionally consistent (say, a billing catalog) may invest in snapshots. The policy also sets a page-size limit and a cursor TTL, bounding both response size and how long a resume token stays valid.
The client-side aggregated view is the last piece: the client concatenates pages into the full list it needs, or — better — consumes them incrementally, rendering or processing each page as it arrives rather than waiting for the whole set. This is the payoff of pagination on the client side: memory stays bounded and the first results are actionable immediately. A well-behaved client also caps the total number of pages it will follow, so a buggy or malicious server that keeps returning a non-null cursor forever cannot trap the client in an unbounded loop.
End-to-end flow
Walk a full multi-page list. A client wants every resource a file-server exposes, and the server holds 2,500 files with a page size of 500. The client sends resources/list with no cursor. The server sorts by a stable key (say, path), returns the first 500 descriptors, and — because more remain — encodes the path of the 500th item into an opaque nextCursor and includes it.
The client processes the 500 resources (perhaps rendering them, perhaps indexing them) and, seeing a non-null cursor, resends resources/list with cursor set to the token. The server decodes it, validates it was legitimately issued, extracts 'resume after path = /docs/f0500,' queries the data source for the next 500 items whose path sorts after that anchor, and returns them with a fresh cursor pointing after item 1,000. The client repeats. On the fifth request the server returns the final 500 items (2,001–2,500) and omits nextCursor, signaling the end. The client's loop terminates, having assembled all 2,500 without ever holding more than one page in flight.
Now introduce mutation. Between the client's second and third pages, someone deletes fifty files that sorted early in the list. Because the cursor is keyset-based ('resume after path X'), the deletion of items before X does not shift the client's position — it still resumes exactly after X and sees the correct next items. Had the server used offset pagination, the deleted fifty would have shifted every later item forward, and the client would have skipped fifty files it never listed. The cursor's anchor is what preserves correctness across the concurrent change.
Consider a tampering attempt. A malicious client, guessing the cursor might encode a database key, alters the token and resends it hoping to seek past an access boundary or trigger a costly scan. The server's validate step catches it: the cursor fails its signature check (or is not found in the issued-cursor store), so the server rejects the request with an invalid-params error rather than acting on attacker-controlled position state. Because the cursor was opaque and authenticated, the client never had the ability to construct a meaningful one, and forgery is detected rather than obeyed.
Finally, the expiry path. A client fetches page one, then pauses for an hour before requesting page two, by which time the cursor's TTL has lapsed — perhaps the server-side snapshot it referenced has been reclaimed. The server decodes the cursor, sees it is expired, and returns an error indicating the pagination session must be restarted. A robust client handles this by restarting the list from the beginning rather than assuming the cursor is permanently valid. The TTL is what lets the server reclaim pagination state instead of holding it forever for clients that may never return, trading a rare restart for bounded server memory — a trade that is almost always correct at scale.