Repository navigation
listRoots() does not stop paginating on an empty nextCursor #1274
Description
Bug description
McpAsyncServerExchange#listRoots() follows nextCursor until it is null. If a client answers roots/list with "nextCursor": "" instead of omitting the field, the server sends roots/list again with "cursor": "". A client that keeps answering that way makes listRoots() never complete, while the roots of every page accumulate. McpSyncServerExchange#listRoots() calls .block() on it, so the calling thread hangs.
The client had the same problem (#575), fixed in #954: McpAsyncClient stops on next != null && !next.isEmpty() in listTools, listResources, listResourceTemplates and listPrompts. listRoots() on the server exchange is the last aggregate list method in mcp-core without that guard.
Affected: main, 2.1.x, 2.0.x and 1.1.x.
Environment
main @ 54e8b61 (2.2.0-SNAPSHOT). Java 21.0.5, macOS.
Steps to reproduce
- Connect a client whose
roots/listhandler returns its roots with"nextCursor": "". - Call
exchange.listRoots()from a server tool, resource or prompt handler. - The server sends another
roots/listrequest with"cursor": "".
Expected behavior
An empty nextCursor ends pagination, as it does in the client list methods since #954.
Minimal Complete Reproducible example
Failing test in McpAsyncServerExchangeTests (mcp-core):
@Test
void testListRootsStopsOnEmptyNextCursor() {
List<McpSchema.Root> page1Roots = Arrays
.asList(McpSchema.Root.builder("file:///home/user/project1").name("Project 1").build());
List<McpSchema.Root> page2Roots = Arrays
.asList(McpSchema.Root.builder("file:///home/user/project2").name("Project 2").build());
McpSchema.ListRootsResult page1Result = McpSchema.ListRootsResult.builder(page1Roots).nextCursor("").build();
McpSchema.ListRootsResult page2Result = McpSchema.ListRootsResult.builder(page2Roots).build();
when(mockSession.sendRequest(eq(McpSchema.METHOD_ROOTS_LIST), eq(new McpSchema.PaginatedRequest()),
any(TypeRef.class)))
.thenReturn(Mono.just(page1Result));
when(mockSession.sendRequest(eq(McpSchema.METHOD_ROOTS_LIST), eq(new McpSchema.PaginatedRequest("")),
any(TypeRef.class)))
.thenReturn(Mono.just(page2Result));
// Fails on main: a second roots/list request is sent with an empty cursor
StepVerifier.create(exchange.listRoots()).assertNext(result -> {
assertThat(result.roots()).extracting(McpSchema.Root::uri).containsExactly("file:///home/user/project1");
assertThat(result.nextCursor()).isNull();
}).verifyComplete();
verify(mockSession, times(1)).sendRequest(eq(McpSchema.METHOD_ROOTS_LIST),
any(McpSchema.PaginatedRequest.class), any(TypeRef.class));
}
Activity
[-]`McpAsyncServerExchange#listRoots() keeps paginating when nextCursor is an empty string`[/-][+]listRoots() does not stop paginating on an empty nextCursor[/+]Ben-Home commentedon Oct 27, 2026
The MCRE and the reference to #954 make this easy to fix, and the reason it recurred is the part worth solving. The empty-cursor guard exists in four client methods and in none of the server exchange paths, because it was applied per call site rather than once.
listRoots()on the exchange is the last aggregate list method without it, and the next list method anyone adds will be the next instance. Putting the predicate in one place, onPaginatedRequestor a small shared utility, and calling it from every aggregate path is what stops this from coming back. The client should use the same helper, so the two sides cannot drift again.Define termination once, and make it slightly wider than the report's case.
cursor == null,cursor.isEmpty()andcursor.isBlank()should all end pagination. The whitespace sentinel is not hypothetical: clients that build the field from a form value or a template routinely return a single space rather than an empty string, and it would produce a third report of the same defect.Two gaps remain even after the empty-cursor guard lands.
The first is a client that returns a constant non-empty cursor, for example the last page's cursor echoed back. The empty check never fires, so the loop is bounded only by how long the client keeps answering, and the accumulated list grows with no progress. An identity check is enough: if a page's cursor equals the cursor that produced it, or equals any cursor already seen in this traversal, stop or fail. Either decision is defensible, but the loop must not depend on the peer eventually returning nothing.
The second is that accumulation plus
.block()turns a misbehaving client into a stuck server thread.McpSyncServerExchange#listRoots()blocking forever means one peer can pin a pool thread for the lifetime of the connection, and nothing in the stack reports why. A configurable maximum page count that completes the Mono with a clear exception is a cheap backstop, and it makes the failure legible instead of a thread dump. Deduping accumulated roots by URI is worth doing at the same time, so a repeated page cannot inflate the result set that the caller receives.For the test matrix, the empty-cursor test you wrote is right, and it is worth adding two siblings: a client returning
" ", and a client returning a constant non-empty cursor. The second is the one that will pass today against a naive fix and regress in the field.