Skip to content

[Bug]: tools/list pagination cursor is a bare offset into a per-request-rebuilt, unordered array — full walks return incomplete and non-reproducible tool sets #411

Description

@Ja178411

Pre-submission checklist

  • I have searched existing issues to ensure this bug hasn't been reported before
  • I am using the latest version of 1MCP

1MCP Version

v0.34.4

Environment

macOS

Node.js Version

v24.18.0

MCP Server(s) Involved

Not server-specific — reproduces on any configuration large enough to require more than one tools/list page (>20 tools).

Bug Description

What happened

tools/list pagination uses a bare integer offset as its cursor, but the array that offset indexes into is rebuilt on every request and is never ordered. A client that walks all pages therefore receives a tool set that is neither complete nor reproducible: some tools are returned twice, others never appear, and two consecutive full walks disagree with each other.

The problem is structural rather than conditional, and both halves are visible in the source:

  1. The array is rebuilt per request. CapabilityCatalog.listVisibleTools (src/core/capabilities/capabilityCatalog.ts:98-106) calls visibleToolRegistry(...) (:264-276), which constructs a new ToolRegistry via ToolRegistry.fromToolsWithServer(...) on every inbound ListToolsRequest. Each page request gets a freshly materialized array, assembled from the current registry contents and re-filtered through filterByServers and isToolDisabled.

  2. Nothing imposes a total order before the slice. ToolRegistry.listTools (src/core/capabilities/toolRegistry.ts:143-200) copies this.tools, applies the server / pattern / tag filters, and then does filtered.slice(offset, offset + limit) with offset decoded from the cursor and DEFAULT_PAGE_SIZE = 20. There is no sort anywhere on that path.

An integer offset is only a valid cursor if the sequence it indexes is stably ordered across the whole pagination sequence. That invariant is never established here, so the cursor is unsound by construction — it does not depend on any particular backend misbehaving. Any event that changes the underlying tool set or its iteration order between two page fetches (a backend reconnecting, a registry snapshot being republished, a config reload) silently shifts every subsequent page.

The existing cursor validation in listTools only checks that the filters encoded in the cursor still match; it has no way to detect that the underlying sequence has been reordered.

What I expected

A full pagination walk should return each tool exactly once, and two walks over an unchanged configuration should return identical sets.

Observed

On 0.34.4 over HTTP with ~35 configured stdio/HTTP backends and ~420 aggregated tools, three consecutive full paginated walks at the default page size returned three different tool sets, with a majority of tool names appearing in some walks and not others. Adding a single sort before the slice made three consecutive walks byte-identical and complete. Lazy loading (--enable-lazy-loading) was not enabled in any of these runs, so this is distinct from the per-session filter issue in #392.

Steps to Reproduce

  1. Configure enough backends that the aggregate tool count exceeds the default page size of 20 (the effect is easier to see with a few hundred tools).
  2. Start the gateway: 1mcp serve --transport http --port 3051 --config <config>.
  3. Walk tools/list to exhaustion, following nextCursor each time, and collect the set of returned tool names.
  4. Repeat step 3 twice more, then diff the three sets. They disagree, and each is missing tools that the other walks returned.

Suggested fix

Establish a total order before paginating. The minimal change in listTools, immediately before const totalCount = filtered.length;:

filtered.sort((a, b) => {
  if (a.server !== b.server) return a.server < b.server ? -1 : 1;
  return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
});

(Deliberately not localeCompare — the ordering needs to be locale-independent so it does not vary with the process locale.) (server, name) is a total order because tool names are unique within a server. I have been running this one-line patch against the installed 0.34.4 build; it fixed the instability completely and I have not seen a downside.

That makes the offset meaningful across rebuilds, but note it only guarantees stability of ordering, not of membership — a tool that appears or disappears mid-walk still shifts later pages by one. A fuller fix would make the cursor a keyset (encode the last (server, name) seen and resume after it, rather than an offset), which is robust to insertions and deletions as well. Sorting is the cheap correctness floor; keyset is the complete answer.

Bug Severity

High - Breaks important functionality

Additional Context

Related but distinct: #380 (a single misbehaving backend stalling the global registry build) and #392 (per-session server filter poisoned mid-load). Those describe conditions that make the registry churn more; this issue is about the pagination cursor being unsound even without churn, and I could not find an existing issue covering it.

Happy to open a PR with either the sort or the keyset cursor, whichever you prefer.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions