Skip to content

Paginate MCP tools/list and prompts/list - #1336

Open
sugmanue wants to merge 2 commits into
smithy-lang:mainfrom
sugmanue:sugmanue/mcp-tools-pagination
Open

Paginate MCP tools/list and prompts/list#1336
sugmanue wants to merge 2 commits into
smithy-lang:mainfrom
sugmanue:sugmanue/mcp-tools-pagination

Conversation

@sugmanue

Copy link
Copy Markdown
Contributor

What behavior changes?

McpServerProxy.listTools() / listPrompts() now follow MCP cursor pagination. Before, they issued a single JSON-RPC call and ignored nextCursor, so a server that paged its results (commonly ~30 items per page) was silently truncated to its first page everywhere McpServerProxy is used. After, they return the union of all pages. Single-page servers are unchanged and still make exactly one round-trip.

Two related fixes ship alongside it:

  • The tools/list_changed refresh in McpService no longer runs inline on the transport reader thread. Previously that path could deadlock: the refresh calls listTools() whose response is read by the same reader thread. It now runs on a dedicated single-thread executor.
  • McpService's tool/prompt/proxy registries are now thread-safe. They were mutated by compound (multi-step) operations from several threads with no mutual exclusion.

Two intentional, minor observable differences reviewers should be aware of:

  1. tools/list and prompts/list now return a stable, deterministic order (insertion order) instead of an unspecified hash-map order.
  2. McpService.getProxies() now returns an immutable snapshot of the proxies at the time of the call. It previously returned the live internal map; mutating the returned map now throws, and the snapshot does not reflect proxies added afterward.

Why is this change needed?

Bug. MCP tools/list and prompts/list are cursor-paginated, but the client only ever read the first page, so any server or gateway federating more than one page of tools had its tool set truncated with no error and no log, which made it hard to notice.

This also re-lands the pagination fix from #1308 (reverted in #1312) together with a proper fix for the thread-safety concern that caused that revert: moving the tools/list_changed refresh off the reader thread requires the registries it mutates to be safe for concurrent access, which they were not.

How was this validated?

./gradlew :mcp:mcp-server:build (unit + integration tests, Spotless, SpotBugs)
green: 200 tests, 0 failures.

Tests added or updated:

  • McpServerProxyTest (new, 12 cases): multi-page union with exact nextCursor -> cursor threading, single-page regression, blank-cursor termination, repeated-cursor abort, cycling (A -> B -> A) abort, page-cap boundary, error on the first page, error on a later page, null-result handling, listPrompts guard path, and immutable return.
  • HttpMcpProxyTest (+1): a real localhost HttpServer pages tools/list with a nextCursor and asserts listTools() follows it, reading the cursor off the actually-deserialized response.
  • StdioProxyTest (new, 1): a silent server times out via the per-request timeout instead of hanging.
  • McpServiceTest (new, 3): the tools/list_changed refresh runs off the notifying thread (deadlock regression), add-then-prune adds new tools while pruning stale ones, and a concurrency stress test (4 adders + 4 refreshers + 4 readers x 200 iterations) asserts no lost updates and no exceptions.
  • McpServerTest: the cache-invalidation test was updated for the now-async refresh (it polls for the refresh instead of assuming inline completion).
  • McpServerIntegrationTest: helpers that assumed the first listed tool was McpEcho now look it up by name, since tool order is deterministic rather than hash-based.

What should reviewers focus on?

  • McpServerProxy#listPaginated(...) (mcp/mcp-server/.../McpServerProxy.java): the pagination loop and its three termination guards (blank/absent cursor ends, repeated or cycling cursor aborts, maxListPages() cap of 1000), the null-result guard, and the immutable return.
  • mcp/mcp-schemas/model/main.smithy: the additive optional nextCursor on ListToolsResult / ListPromptsResult.
  • McpService (mcp/mcp-server/.../McpService.java): the copy-on-write registries (tools / prompts / proxies / services held as volatile immutable snapshots mutated under a single registryLock, with network I/O kept outside the lock), the off-reader-thread tools/list_changed refresh with add-then-prune, and getProxies() returning a snapshot.
  • StdioProxy#rpc (mcp/mcp-server/.../StdioProxy.java): the builder-configurable per-request timeout (default 5m, symmetric with HttpMcpProxy) and pending-request cleanup.

Additional Links


By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

listTools()/listPrompts() only read the first page, so any server that
paged (commonly ~30 items) was silently truncated. Follow nextCursor to
the end, threading each page's cursor back into the next request.

Guards for misbehaving servers: a blank or absent cursor ends paging, a
repeated or cycling cursor aborts, and a 1000-page cap backstops a runaway
server. Returns an immutable list; single-page servers still make one call.

Also add a builder-configurable per-request timeout to StdioProxy (default
5m, symmetric with HttpMcpProxy) so a silent server can't block forever.
The registries were mutated by compound (multi-step) operations from
several threads with no mutual exclusion, and the tools/list_changed
refresh ran inline on the transport reader thread, which deadlocks: the
refresh calls listTools() whose response is read by that same thread.

Hold each registry as a volatile immutable snapshot (copy-on-write).
Readers (tools/list, prompts/list, tools/call dispatch, shutdown) read the
current snapshot with no locking; every mutation rebuilds the affected
snapshot under a single lock and publishes it atomically, with network I/O
kept outside the lock. Run the list_changed refresh on a dedicated thread
so it can't deadlock the reader, using add-then-prune so a concurrent
tools/list never sees a gap.

Snapshots are insertion-ordered (LinkedHashMap) so tools/list and
prompts/list return a stable order across refreshes and dynamic additions.
getProxies() now returns an immutable snapshot. Integration-test helpers
that assumed the first listed tool was McpEcho now look it up by name,
since order is deterministic rather than hash-based.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant