Missing MCP tools may be on a page the client never requested
A tool that a connected MCP client cannot see may sit on a second tools/list page that nobody asked for. A two-page probe server, the evidence to capture at each hop and a table that separates pagination from registration, policy and host exposure.
Written by Worksi's AI marketing agents and checked by a separate AI fact-checking agent.
How this blog is made
In this article
- The spec makes following cursors a SHOULD, not a MUST.
- The symptom is already documented.
- Build a probe server with one inert tool on page two.
- Capture evidence at every hop, without the secrets.
- Read the chain boundary by boundary.
- A raw probe client that keeps its evidence on failure.
- Compare three readers against the same server.
- Workarounds have a cost.
- Where Worksi sits in this chain.
- Keep it as a regression test.
An MCP client connects, lists tools, and one tool you registered is not there. Before widening a permission or rewriting a schema, check whether pagination completed. When MCP tools are missing, the first question is whether tools/list pagination ran to the end: whether every page the server offered was requested, returned, forwarded and exposed. A chain can stop at the server, at a gateway in between, in the client library, or in the host that builds the model's tool catalog. Each stop looks the same from the chat window: the tool is simply absent.
This article gives a safe test that tells those stops apart, and a table of what each observation shows and what it leaves open. The table is our own synthesis for debugging, not a fix verified in production.
The spec makes following cursors a SHOULD, not a MUST.#
The 2025-06-18 pagination page lists tools/list among the paginated operations. Its rules, with their exact strength:
- Page size is determined by the server, and clients MUST NOT assume a fixed page size.
- A response carries an optional
nextCursorif more results exist. - Clients SHOULD treat a missing
nextCursoras the end of results, and SHOULD support both paginated and non-paginated flows. - Clients MUST treat cursors as opaque: no assumptions about format, no parsing or modifying, and no persisting across sessions.
- Servers SHOULD provide stable cursors and handle invalid ones gracefully. Invalid cursors SHOULD result in error
-32602(Invalid params).
So a client that reads only page one is not following a SHOULD, and a server that answers a bad cursor with something other than -32602 is not following a SHOULD either. Neither is a MUST violation. The rule also runs one way only: an invalid cursor should produce -32602, but a -32602 means invalid params, not necessarily a bad cursor.
The 2026-07-28 page, as read on 10 November 2026, differs in three ways that matter here. Every request MUST include required _meta fields, including the protocol version. Responses carry resultType, ttlMs and cacheScope. And the cursor rules change: the bullet about persisting across sessions is gone, and a client must make no determination from the cursor value other than whether a non-null value was provided, so an empty string is a valid cursor and MUST NOT be treated as the end of results. Record which revision each connection negotiated before you judge any behavior.
One more rule, from the 2026-07-28 tools page, shapes every comparison below. The set of tools a server returns MAY change over time and MAY vary by the authorization presented on the request. A catalog that differs between two runs, or two identities, is allowed. On its own it is not evidence of a pagination bug.
The symptom is already documented.#
Codex issue 28858 is the clearest public report. The reporter ran Codex CLI 0.140.0 against a minimal stdio server returning 30 tools and nextCursor: "page-2", with one more tool on page two. The server log shows one tools/list request and no second request carrying the cursor; only the first 30 tools were callable. Later comments report the same pattern with a Laravel MCP server whose default page size is 15, an Arcade gateway with a page size of 100, and Codex CLI 0.155.0 against a server with 138 tools in three pages. Commenters also report that MCP Inspector and other clients loaded the later pages from the same servers.
These are issue reports for specific versions, not our reproduction and not a statement about current releases.
Build a probe server with one inert tool on page two.#
Use synthetic data only: a local server, no real credentials, no tool that changes state. Page one holds filler tools. Page two holds exactly one tool with a name that cannot collide with anything else, such as probe_page2_echo, which returns fixed text. Keep the catalog fixed for the whole of each comparison: no tools added, removed or reordered while a test runs. That is what lets a gap between pages mean something.
The exchange, adapted for tools from the spec's resource examples (2025-06-18 framing; under 2026-07-28 add the required _meta fields and expect the extra result fields):
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"probe_page1_01","description":"Filler. No side effects.","inputSchema":{"type":"object","properties":{},"additionalProperties":false}}],"nextCursor":"c1-opaque"}}
{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{"cursor":"c1-opaque"}}
{"jsonrpc":"2.0","id":3,"result":{"tools":[{"name":"probe_page2_echo","description":"Returns the fixed text page2-ok. No side effects.","inputSchema":{"type":"object","properties":{},"additionalProperties":false}}]}}
{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"probe_page2_echo","arguments":{}}}
{"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":"page2-ok"}],"isError":false}}
Give the server a mode switch so one binary covers the edge cases. Each mode has a pass condition, so the same table becomes your regression suite:
| Mode | Server behavior | Pass condition |
|---|---|---|
single |
All tools on one page, no nextCursor |
One request; all tools exposed |
two-page |
Page one returns nextCursor; the tool is on page two |
Second request carries the cursor; probe_page2_echo exposed and returns page2-ok |
empty-middle |
Page two is empty but carries a cursor; the tool is on page three | Page three requested; tool exposed. An empty page is not the end |
empty-string |
Page one returns nextCursor: "" (2026-07-28 sessions) |
Next page requested with "" |
invalid |
Answers any cursor with -32602 and a message naming the cursor |
Error recorded; client reports a partial catalog and stops |
loop |
Returns the same cursor forever | Client stops on a repeated cursor, a page limit or a timeout |
Page length tells you nothing. A short page with a cursor is not the end, and a full page without one is.
Capture evidence at every hop, without the secrets.#
Record at each boundary:
- Server: every
tools/listrequest with its JSON-RPC id, whetherparams.cursorwas present, a short hash of the cursor (not the value), the number of tools returned, whether anextCursorwas sent, and any error with its code and message. Everytools/callwith name and result status. - Intermediary, if any: downstream and upstream request ids, tool names received upstream and sent downstream, hashed cursors it emitted downstream, the effective principal, the policy version and decisions, and the environment.
- Client: client and SDK version, negotiated protocol version, transport and session identifier, effective identity, the raw
tools/listresponses received, any warnings logged, and the final tool names the host exposes to the model.
Hash cursors: they are opaque and may encode server state. Never log authorization headers, including inside stored exception text.
Read the chain boundary by boundary.#
| Observation | What it shows | What stays unknown | Next step |
|---|---|---|---|
| Direct probe, all pages followed, union lacks the tool | Absent from the catalog served to this identity | Whether that is registration, per-identity filtering or a catalog change between runs | Check registration and authorization rules, then repeat with a second test identity on a test system |
Server saw one tools/list, its response had nextCursor, no follow-up arrived |
No continuation reached the server | Which component stopped, if an intermediary exists | Read the intermediary's upstream log |
| Follow-up arrived and the server returned an error | The follow-up request failed | Whether the cause was the cursor, authentication, missing request metadata or a server failure | Record the actual error; check authentication, _meta and server logs. Call it cursor rejection only when the error or server log names the cursor |
| Intermediary received every upstream page; some of those tools are missing downstream | Results were dropped inside the intermediary | Whether policy accounts for the difference | Compare upstream union, downstream union and policy decisions |
Intermediary re-paginated and emitted a downstream nextCursor; the client sent no follow-up |
The client-facing continuation stopped after that cursor | Whether the same client follows a server's cursor when connected directly | Check the client log for a request carrying that cursor; compare with the direct run |
| Client received every page on the wire; host's catalog lacks the tool | Pagination completed on the wire; the tool was not exposed | Why the host dropped it | Compare received and exposed lists and read client warnings. Under 2026-07-28, Streamable HTTP clients MUST exclude a tool whose x-mcp-header values break the spec's constraints |
| Complete chains under two identities; tool present for one only | An identity-dependent catalog difference | Which rule caused it; see testing MCP tool authorization | Call it policy filtering only with policy-decision evidence |
Tool listed, then tools/call failed |
Invocation failed after discovery | Whether it was authorization, approval, input validation, a catalog change or an execution failure | Inspect the protocol error, isError, approval state and server logs; route to authorization only if the refusal names authorization or approval |
Every page fetched, union contains the tool, host exposes it, harmless call returns page2-ok |
Verified complete for this client version, identity, server version and protocol revision | Whether other versions behave alike; whether a model would pick this tool unprompted | Add it to regression |
The probe's fixed catalog makes the cross-page rows meaningful. In production you rarely control that: a tool added or removed between page one and page two can leave a gap with no pagination fault at all. Where you cannot show the catalog was stable during the enumeration, write the result as inconclusive rather than blame a hop.
When an intermediary is present, compare like with like: same principal, policy, environment and server version, and the same session where the transport has one. A complete chain under an admin identity and an incomplete one under the agent's identity says little about pagination. Do not bypass a gateway's security on a production system to get a cleaner comparison; reproduce on a test deployment.
A successful call shows the tool was exposed and callable, not that a model would choose it unprompted.
A raw probe client that keeps its evidence on failure.#
The control for every comparison is a client you can read in full. This sketch takes a request(method, params) function for your transport, which adds any required _meta, enforces a per-request timeout, and raises on a JSON-RPC error, a timeout or a transport failure. The probe catches that, keeps the pages it already has and returns a partial result with the error:
import hashlib
def short_hash(value):
return hashlib.sha256(value.encode()).hexdigest()[:12]
def list_all_tools(request, max_pages=50):
tools, seen, pages = [], set(), []
params = {}
for page_no in range(1, max_pages + 1):
sent = short_hash(params["cursor"]) if "cursor" in params else None
try:
result = request("tools/list", params)
except Exception as exc: # JSON-RPC error, timeout or transport failure
pages.append({"page": page_no, "cursor_sent": sent,
"error": f"{type(exc).__name__}: {exc}"})
return tools, pages, f"partial: request for page {page_no} failed"
page = result.get("tools", [])
cursor = result.get("nextCursor")
pages.append({"page": page_no, "cursor_sent": sent, "count": len(page),
"next": None if cursor is None else short_hash(cursor)})
tools.extend(page)
if cursor is None:
return tools, pages, "chain complete"
if cursor in seen:
return tools, pages, "partial: repeated cursor"
seen.add(cursor)
params = {"cursor": cursor}
return tools, pages, "partial: page limit"
The end test is cursor is None, not if not cursor, because the 2026-07-28 revision makes an empty string a valid cursor. The repeated-cursor guard, page limit and timeout are our defensive suggestions, not spec requirements, and a client that hits one should report "partial" rather than hand the model a silently shortened list.
"Chain complete" means the server said there was nothing more. It does not mean your tool is in the union. Check that separately.
Compare three readers against the same server.#
Run the raw probe, the affected client and MCP Inspector against the same probe server, with comparable credentials. The Inspector ships web, CLI and TUI clients on one core, needs Node 22.19.0 or newer per its docs, and handles both the legacy and the 2026-07-28 protocol eras. Record its version with your results.
Inspector is a control. If it shows probe_page2_echo and the affected client does not, the server's pagination works for at least one client at that version pair. It does not show that every version behaves the same. Its docs also note that on a machine without a keychain it saves OAuth tokens and stdio env: values to ~/.mcp-inspector/secrets.json, unencrypted unless you supply a key: one more reason to use synthetic credentials.
Workarounds have a cost.#
Raising the server's page size above the tool count puts everything on page one. Commenters on the issue report doing this with Laravel MCP and with an Arcade gateway. It is a compatibility workaround, not a fix: it hides the client's fault, makes the first response larger, breaks again when the tool count passes the new size, and does nothing for intermediaries that paginate on their own. If you do it, keep the probe in your test suite.
Commenters also report that Codex's mcp_2026_07_28 feature makes it follow nextCursor but switches the protocol revision for every configured server, so check every server before changing it globally. And turning off an allowlist to "see whether the tool appears" changes two variables at once and removes a control; use a second, scoped test identity instead.
Where Worksi sits in this chain.#
If your servers sit behind Worksi's MCP gateway, that gateway is one boundary to inspect. Worksi's MCP integration page says tools/list shows only the tools your policies and the agent's allowed tools permit, tools/call is checked first, and every call is recorded as a span. So a tool absent through the gateway may be a policy result rather than a pagination one: run the same comparison under the same API key and agent. Our product materials do not describe missing-tool diagnosis or how the gateway handles upstream pagination, and this article claims neither. Test the gateway with the probe as another boundary.
Keep it as a regression test.#
Keep the probe server and its six modes in your test suite, and run them directly, under a second identity and through every intermediary. Through an intermediary, pass means the exposed union equals the expected policy-adjusted catalog; page counts and cursors may differ from the direct run. Across identities, pass means the differences match written policy. A failed pass condition starts an investigation, read through the boundary table; it names no cause until the logs do.
Write every result as one of three verdicts. Verified complete: every page fetched, the tool exposed and a harmless call returned the expected text, for named versions, identity and protocol revision. Partial: the chain stopped, with the log line that shows where. Inconclusive: a hop had no logs, the comparison mixed identities or versions, or the catalog may have changed during the run. An inconclusive result is a finding: it tells you which log to add before the next test.
Sources
How this blog is made
Worksi's AI marketing agents write the articles here, and a separate AI fact-checking agent checks each one before it is published. The sources an article cites are listed with it. Its illustration was made with AI. If you find a mistake, write to info@worksi.ai.