{"slug": "mcp-pagination-empty-nextcursor-don-t-stop-after-page-one", "title": "MCP Pagination Empty `nextCursor`: Don't Stop After Page One", "summary": "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.", "body_md": "MCP pagination empty `nextCursor`\n\nhandling 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`\n\nends traversal. In a nullable C# response model, that absence is represented by `null`\n\n. 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.\n\nMCP uses cursor pagination for `tools/list`\n\n, `prompts/list`\n\n, `resources/list`\n\n, and `resources/templates/list`\n\n. The server chooses each page size, so a client cannot infer completion from the number of returned items.\n\nThe [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`\n\n. The client must pass that token back without parsing or changing it. The empty string is still a supplied token.\n\nStripped to the fields relevant to this bug, an intermediate response can contain:\n\n```\n{\n  \"result\": {\n    \"tools\": [{ \"name\": \"catalog.search\" }],\n    \"nextCursor\": \"\"\n  }\n}\n```\n\nThat 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`\n\n. None of those fields changes the cursor termination rule.\n\nA common C# loop accidentally treats that response as the last page:\n\n``` js\nstring? cursor = null;\n\ndo\n{\n    var page = await pager.ListToolsAsync(cursor, cancellationToken);\n    tools.AddRange(page.Tools);\n    cursor = page.NextCursor;\n}\nwhile (!string.IsNullOrEmpty(cursor));\n```\n\n`IsNullOrEmpty`\n\ncombines two states that have different protocol meanings. A missing field, represented by `null`\n\nin this DTO, means finished. Empty means make another request with `cursor: \"\"`\n\n.\n\nThe 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.\n\nMy fake `tools/list`\n\nserver has three deterministic pages. The first request omits the cursor. Page one returns `\"\"`\n\n; page two returns `opaque:/+==`\n\n; page three omits `nextCursor`\n\n, which the C# page model represents as `null`\n\n.\n\n``` js\nvar page = cursor switch\n{\n    null => new ToolPage([\"catalog.search\"], string.Empty),\n    \"\" => new ToolPage([\"catalog.lookup\"], \"opaque:/+==\"),\n    \"opaque:/+==\" => new ToolPage([\"catalog.health\"], null),\n    _ => throw new McpProtocolException(-32602, \"Invalid cursor\")\n};\n```\n\nThe broken loop returns only `catalog.search`\n\n. That is more dangerous than a loud exception because a user may simply believe the server exposes one tool.\n\nThe 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`\n\n, the specification's recommended Invalid params response for a bad cursor.\n\nThe 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\n\nThe corrected loop makes the protocol distinction explicit:\n\n``` js\nfor (var pageNumber = 1; pageNumber <= maxPages; pageNumber++)\n{\n    var page = await pager.ListToolsAsync(cursor, cancellationToken);\n    tools.AddRange(page.Tools);\n\n    if (page.NextCursor is null)\n    {\n        return tools;\n    }\n\n    cursor = page.NextCursor;\n}\n\nthrow new InvalidOperationException(\"Pagination exceeded its page limit.\");\n```\n\nThis version forwards both `\"\"`\n\nand `opaque:/+==`\n\nwithout 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.\n\nIn 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`\n\n, while `\"\"`\n\nremains non-null. Explicit JSON `null`\n\nis 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`\n\ncan 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`\n\n, an empty string, and a punctuation-heavy token.\n\nThe [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.\n\nThis verifier isolates cursor control. It does not implement JSON-RPC framing, transports, authentication, required request `_meta`\n\n, 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.\n\nThe 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.\n\nI 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.\n\nDoes your MCP client test a non-null empty cursor, or would its catalog stop after page one?\n\nHappy coding!", "url": "https://wpnews.pro/news/mcp-pagination-empty-nextcursor-don-t-stop-after-page-one", "canonical_source": "https://dev.to/ssukhpinder/mcp-pagination-empty-nextcursor-dont-stop-after-page-one-1006", "published_at": "2026-08-24 19:51:37+00:00", "updated_at": "2026-08-24 20:14:14.090600+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools"], "entities": ["MCP", ".NET 10", "GitHub"], "alternates": {"html": "https://wpnews.pro/news/mcp-pagination-empty-nextcursor-don-t-stop-after-page-one", "markdown": "https://wpnews.pro/news/mcp-pagination-empty-nextcursor-don-t-stop-after-page-one.md", "text": "https://wpnews.pro/news/mcp-pagination-empty-nextcursor-don-t-stop-after-page-one.txt", "jsonld": "https://wpnews.pro/news/mcp-pagination-empty-nextcursor-don-t-stop-after-page-one.jsonld"}}