MCP Pagination Empty `nextCursor`: Don't Stop After Page One A developer built a .NET 10 verifier to expose a subtle MCP pagination bug where treating an empty nextCursor as a termination signal can hide most of a server's catalog. The official 2026-07-28 spec says only a missing nextCursor ends traversal; an empty string is a valid token that must be passed back. The developer's fake tools/list server demonstrates that a common IsNullOrEmpty check stops after the first page, and the corrected loop checks for null explicitly. MCP pagination empty nextCursor handling looks like a tiny null check, but the wrong predicate can hide most of a server's catalog. In the final 2026-07-28 specification, cursors are opaque strings. An empty string is valid; only a missing nextCursor ends traversal. In a nullable C response model, that absence is represented by null . I built a small .NET 10 verifier because this failure is unusually quiet: the request succeeds, the first page looks reasonable, and no error says that later pages never arrived. MCP uses cursor pagination for tools/list , prompts/list , resources/list , and resources/templates/list . The server chooses each page size, so a client cannot infer completion from the number of returned items. The official pagination specification https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/pagination gives the reliable rule: continue whenever the response supplies a non-null nextCursor . The client must pass that token back without parsing or changing it. The empty string is still a supplied token. Stripped to the fields relevant to this bug, an intermediate response can contain: { "result": { "tools": { "name": "catalog.search" } , "nextCursor": "" } } That JSON is a pagination excerpt, not a complete MCP wire message. A conforming response still needs its JSON-RPC envelope and the other required result metadata, while every 2026-07-28 request needs the required meta . None of those fields changes the cursor termination rule. A common C loop accidentally treats that response as the last page: js string? cursor = null; do { var page = await pager.ListToolsAsync cursor, cancellationToken ; tools.AddRange page.Tools ; cursor = page.NextCursor; } while string.IsNullOrEmpty cursor ; IsNullOrEmpty combines two states that have different protocol meanings. A missing field, represented by null in this DTO, means finished. Empty means make another request with cursor: "" . The symptom can outlive the original request. If the client caches the partial catalog, later tool selection operates on the same incomplete view until that cache expires. Counting returned tools is not a safe fallback because the server controls page size and a one-item page may be perfectly legitimate. I prefer tests that assert the sequence of requested cursor values, not just the final item count. My fake tools/list server has three deterministic pages. The first request omits the cursor. Page one returns "" ; page two returns opaque:/+== ; page three omits nextCursor , which the C page model represents as null . js var page = cursor switch { null = new ToolPage "catalog.search" , string.Empty , "" = new ToolPage "catalog.lookup" , "opaque:/+==" , "opaque:/+==" = new ToolPage "catalog.health" , null , = throw new McpProtocolException -32602, "Invalid cursor" }; The broken loop returns only catalog.search . That is more dangerous than a loud exception because a user may simply believe the server exposes one tool. The opaque second token contains punctuation on purpose. A client that decodes, trims, normalizes, or reconstructs it is also violating the contract. An unknown token triggers -32602 , the specification's recommended Invalid params response for a bad cursor. The complete runnable sample on main https://github.com/ssukhpinder/dev-to-code-samples/tree/main/080-mcp-empty-cursor-pagination performs eight checks without an MCP server, credential, model call, clock, or random input. The The corrected loop makes the protocol distinction explicit: js for var pageNumber = 1; pageNumber <= maxPages; pageNumber++ { var page = await pager.ListToolsAsync cursor, cancellationToken ; tools.AddRange page.Tools ; if page.NextCursor is null { return tools; } cursor = page.NextCursor; } throw new InvalidOperationException "Pagination exceeded its page limit." ; This version forwards both "" and opaque:/+== without changing their string values. It also includes cancellation and a page cap so a faulty server cannot keep the client in an unbounded loop. The cap is an application guard, not a conclusion derived from a cursor's contents. In production, I would also check the mapping layer, not only the loop. A nullable string can represent the optional field correctly because absence maps to null , while "" remains non-null. Explicit JSON null is outside the 2026-07-28 cursor schema, so a strict mapper should reject it rather than treat it as a normal terminator. A custom converter, DTO mapper, or helper such as NullIfEmpty can erase the valid empty-string distinction before pagination code sees it. A transport-level regression matrix should therefore cover an absent field, malformed explicit null , an empty string, and a punctuation-heavy token. The 2026-07-28 release announcement https://blog.modelcontextprotocol.io/posts/2026-07-28/ confirms that this is the released specification and that the Tier 1 SDKs were updated for it. Even when an SDK offers a pagination helper, I still want a boundary test with an empty token. It catches wrappers that apply a language's truthiness rules after the SDK returns a page. This verifier isolates cursor control. It does not implement JSON-RPC framing, transports, authentication, required request meta , cache metadata, retries, or a dataset changing between calls. A production client must define whether an error discards partial results, how retries interact with cursors, and what telemetry is safe to record. The page cap also needs an explicit failure path. Reaching it should not quietly return an incomplete catalog that looks successful. I would surface a distinct error, retain enough page-count context for diagnosis, and avoid recording the opaque cursor value unless the application's data policy permits it. A retry should resend the exact cursor associated with the failed page rather than restart and combine two potentially different catalog snapshots. I would not replace a well-tested SDK paginator just to own more loop code. I would keep the empty-cursor fixture as a regression test around the abstraction my application actually calls. I also would not copy this termination rule into an unrelated REST or GraphQL API without reading that API's contract; some protocols explicitly use empty values as sentinels. Does your MCP client test a non-null empty cursor, or would its catalog stop after page one? Happy coding