Pre-submission checklist
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:
-
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.
-
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
- 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).
- Start the gateway:
1mcp serve --transport http --port 3051 --config <config>.
- Walk
tools/list to exhaustion, following nextCursor each time, and collect the set of returned tool names.
- 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.
Pre-submission checklist
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/listpage (>20 tools).Bug Description
What happened
tools/listpagination 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:
The array is rebuilt per request.
CapabilityCatalog.listVisibleTools(src/core/capabilities/capabilityCatalog.ts:98-106) callsvisibleToolRegistry(...)(:264-276), which constructs a newToolRegistryviaToolRegistry.fromToolsWithServer(...)on every inboundListToolsRequest. Each page request gets a freshly materialized array, assembled from the current registry contents and re-filtered throughfilterByServersandisToolDisabled.Nothing imposes a total order before the slice.
ToolRegistry.listTools(src/core/capabilities/toolRegistry.ts:143-200) copiesthis.tools, applies theserver/pattern/tagfilters, and then doesfiltered.slice(offset, offset + limit)withoffsetdecoded from the cursor andDEFAULT_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
listToolsonly 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
1mcp serve --transport http --port 3051 --config <config>.tools/listto exhaustion, followingnextCursoreach time, and collect the set of returned tool names.Suggested fix
Establish a total order before paginating. The minimal change in
listTools, immediately beforeconst totalCount = filtered.length;:(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.