cd /news/developer-tools/mcp-pagination-empty-nextcursor-don-… · home topics developer-tools article
[ARTICLE · art-109206] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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.

read5 min views3 publishedAug 24, 2026

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 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:

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

.

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 performs eight checks without an MCP server, credential, model call, clock, or random input. The

The corrected loop makes the protocol distinction explicit:

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 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!

── more in #developer-tools 4 stories · sorted by recency
── more on @mcp 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/mcp-pagination-empty…] indexed:0 read:5min 2026-08-24 ·