fix: stop pagination on empty cursors (#954)

Signed-off-by: Nanook <nanookclaw@users.noreply.github.com>
Co-authored-by: Nanook <nanookclaw@users.noreply.github.com>
This commit is contained in:
nanookclaw 2026-08-07 16:25:39 +00:00 • committed by GitHub
commit 27b91521c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 144 additions and 22 deletions

View file

@ -818,13 +818,13 @@ public class McpAsyncClient {
* @see #readResource(McpSchema.Resource)
*/
public Mono<McpSchema.ListResourcesResult> listResources() {
return this.listResources(McpSchema.FIRST_PAGE)
.expand(result -> (result.nextCursor() != null) ? this.listResources(result.nextCursor()) : Mono.empty())
.reduce(new ArrayList<McpSchema.Resource>(), (accumulated, result) -> {
accumulated.addAll(result.resources());
return accumulated;
})
.map(all -> McpSchema.ListResourcesResult.builder(Collections.unmodifiableList(all)).build());
return this.listResources(McpSchema.FIRST_PAGE).expand(result -> {
String next = result.nextCursor();
return (next != null && !next.isEmpty()) ? this.listResources(next) : Mono.empty();
}).reduce(new ArrayList<McpSchema.Resource>(), (accumulated, result) -> {
accumulated.addAll(result.resources());
return accumulated;
}).map(all -> McpSchema.ListResourcesResult.builder(Collections.unmodifiableList(all)).build());
}
/**
@ -904,14 +904,13 @@ public class McpAsyncClient {
* @see McpSchema.ListResourceTemplatesResult
*/
public Mono<McpSchema.ListResourceTemplatesResult> listResourceTemplates() {
return this.listResourceTemplates(McpSchema.FIRST_PAGE)
.expand(result -> (result.nextCursor() != null) ? this.listResourceTemplates(result.nextCursor())
: Mono.empty())
.reduce(new ArrayList<McpSchema.ResourceTemplate>(), (accumulated, result) -> {
accumulated.addAll(result.resourceTemplates());
return accumulated;
})
.map(all -> McpSchema.ListResourceTemplatesResult.builder(Collections.unmodifiableList(all)).build());
return this.listResourceTemplates(McpSchema.FIRST_PAGE).expand(result -> {
String next = result.nextCursor();
return (next != null && !next.isEmpty()) ? this.listResourceTemplates(next) : Mono.empty();
}).reduce(new ArrayList<McpSchema.ResourceTemplate>(), (accumulated, result) -> {
accumulated.addAll(result.resourceTemplates());
return accumulated;
}).map(all -> McpSchema.ListResourceTemplatesResult.builder(Collections.unmodifiableList(all)).build());
}
/**
@ -1024,13 +1023,13 @@ public class McpAsyncClient {
* @see #getPrompt(GetPromptRequest)
*/
public Mono<ListPromptsResult> listPrompts() {
return this.listPrompts(McpSchema.FIRST_PAGE)
.expand(result -> (result.nextCursor() != null) ? this.listPrompts(result.nextCursor()) : Mono.empty())
.reduce(new ArrayList<McpSchema.Prompt>(), (accumulated, result) -> {
accumulated.addAll(result.prompts());
return accumulated;
})
.map(all -> McpSchema.ListPromptsResult.builder(Collections.unmodifiableList(all)).build());
return this.listPrompts(McpSchema.FIRST_PAGE).expand(result -> {
String next = result.nextCursor();
return (next != null && !next.isEmpty()) ? this.listPrompts(next) : Mono.empty();
}).reduce(new ArrayList<McpSchema.Prompt>(), (accumulated, result) -> {
accumulated.addAll(result.prompts());
return accumulated;
}).map(all -> McpSchema.ListPromptsResult.builder(Collections.unmodifiableList(all)).build());
}
/**

View file

@ -8,6 +8,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import java.util.stream.Collectors;
@ -298,6 +299,42 @@ class McpAsyncClientTests {
}
@Test
void listResourcesStopsOnEmptyNextCursor() {
var transport = new EmptyCursorTestMcpClientTransport(McpSchema.METHOD_RESOURCES_LIST);
McpAsyncClient client = McpClient.async(transport).build();
McpSchema.ListResourcesResult result = client.listResources().block();
assertThat(result).isNotNull();
assertThat(result.resources()).extracting(McpSchema.Resource::name).containsExactly("test.txt");
assertThat(transport.getRequestCount()).isEqualTo(1);
}
@Test
void listResourceTemplatesStopsOnEmptyNextCursor() {
var transport = new EmptyCursorTestMcpClientTransport(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST);
McpAsyncClient client = McpClient.async(transport).build();
McpSchema.ListResourceTemplatesResult result = client.listResourceTemplates().block();
assertThat(result).isNotNull();
assertThat(result.resourceTemplates()).extracting(McpSchema.ResourceTemplate::name).containsExactly("template");
assertThat(transport.getRequestCount()).isEqualTo(1);
}
@Test
void listPromptsStopsOnEmptyNextCursor() {
var transport = new EmptyCursorTestMcpClientTransport(McpSchema.METHOD_PROMPT_LIST);
McpAsyncClient client = McpClient.async(transport).build();
McpSchema.ListPromptsResult result = client.listPrompts().block();
assertThat(result).isNotNull();
assertThat(result.prompts()).extracting(McpSchema.Prompt::name).containsExactly("test-prompt");
assertThat(transport.getRequestCount()).isEqualTo(1);
}
static class TestMcpClientTransport implements McpClientTransport {
private Function<Mono<McpSchema.JSONRPCMessage>, Mono<McpSchema.JSONRPCMessage>> handler;
@ -397,4 +434,90 @@ class McpAsyncClientTests {
}
static class EmptyCursorTestMcpClientTransport implements McpClientTransport {
private final String listMethod;
private final AtomicInteger requestCount = new AtomicInteger();
private Function<Mono<McpSchema.JSONRPCMessage>, Mono<McpSchema.JSONRPCMessage>> handler;
EmptyCursorTestMcpClientTransport(String listMethod) {
this.listMethod = listMethod;
}
@Override
public Mono<Void> connect(Function<Mono<McpSchema.JSONRPCMessage>, Mono<McpSchema.JSONRPCMessage>> handler) {
this.handler = handler;
return Mono.empty();
}
@Override
public Mono<Void> closeGracefully() {
return Mono.empty();
}
@Override
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) {
if (!(message instanceof McpSchema.JSONRPCRequest request)) {
return Mono.empty();
}
McpSchema.JSONRPCResponse response;
if (McpSchema.METHOD_INITIALIZE.equals(request.method())) {
McpSchema.ServerCapabilities caps = McpSchema.ServerCapabilities.builder()
.prompts(false)
.resources(false, false)
.tools(false)
.build();
McpSchema.InitializeResult initResult = McpSchema.InitializeResult
.builder(ProtocolVersions.MCP_2024_11_05, caps, MOCK_SERVER_INFO)
.build();
response = McpSchema.JSONRPCResponse.result(request.id(), initResult);
}
else if (this.listMethod.equals(request.method())) {
this.requestCount.incrementAndGet();
response = McpSchema.JSONRPCResponse.result(request.id(), resultForMethod(request.method()));
}
else {
return Mono.empty();
}
return this.handler.apply(Mono.just(response)).then();
}
private Object resultForMethod(String method) {
if (McpSchema.METHOD_RESOURCES_LIST.equals(method)) {
McpSchema.Resource resource = McpSchema.Resource.builder("file:///test.txt", "test.txt").build();
return McpSchema.ListResourcesResult.builder(List.of(resource)).nextCursor("").build();
}
if (McpSchema.METHOD_RESOURCES_TEMPLATES_LIST.equals(method)) {
McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate.builder("file:///{name}", "template")
.build();
return McpSchema.ListResourceTemplatesResult.builder(List.of(template)).nextCursor("").build();
}
if (McpSchema.METHOD_PROMPT_LIST.equals(method)) {
McpSchema.Prompt prompt = McpSchema.Prompt.builder("test-prompt").build();
return McpSchema.ListPromptsResult.builder(List.of(prompt)).nextCursor("").build();
}
throw new IllegalArgumentException("Unsupported method: " + method);
}
@Override
public <T> T unmarshalFrom(Object data, TypeRef<T> typeRef) {
return JSON_MAPPER.convertValue(data, new TypeRef<>() {
@Override
public java.lang.reflect.Type getType() {
return typeRef.getType();
}
});
}
int getRequestCount() {
return this.requestCount.get();
}
}
}