Skip to content

Commit e18d0fa

Browse files
committed
fix: stop pagination on empty cursors
Signed-off-by: Nanook <nanookclaw@users.noreply.github.com>
1 parent 87e2c7d commit e18d0fa

2 files changed

Lines changed: 144 additions & 22 deletions

File tree

mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -717,13 +717,13 @@ private NotificationHandler asyncToolsChangeNotificationHandler(
717717
* @see #readResource(McpSchema.Resource)
718718
*/
719719
public Mono<McpSchema.ListResourcesResult> listResources() {
720-
return this.listResources(McpSchema.FIRST_PAGE)
721-
.expand(result -> (result.nextCursor() != null) ? this.listResources(result.nextCursor()) : Mono.empty())
722-
.reduce(new ArrayList<McpSchema.Resource>(), (accumulated, result) -> {
723-
accumulated.addAll(result.resources());
724-
return accumulated;
725-
})
726-
.map(all -> McpSchema.ListResourcesResult.builder(Collections.unmodifiableList(all)).build());
720+
return this.listResources(McpSchema.FIRST_PAGE).expand(result -> {
721+
String next = result.nextCursor();
722+
return (next != null && !next.isEmpty()) ? this.listResources(next) : Mono.empty();
723+
}).reduce(new ArrayList<McpSchema.Resource>(), (accumulated, result) -> {
724+
accumulated.addAll(result.resources());
725+
return accumulated;
726+
}).map(all -> McpSchema.ListResourcesResult.builder(Collections.unmodifiableList(all)).build());
727727
}
728728

729729
/**
@@ -803,14 +803,13 @@ public Mono<McpSchema.ReadResourceResult> readResource(McpSchema.ReadResourceReq
803803
* @see McpSchema.ListResourceTemplatesResult
804804
*/
805805
public Mono<McpSchema.ListResourceTemplatesResult> listResourceTemplates() {
806-
return this.listResourceTemplates(McpSchema.FIRST_PAGE)
807-
.expand(result -> (result.nextCursor() != null) ? this.listResourceTemplates(result.nextCursor())
808-
: Mono.empty())
809-
.reduce(new ArrayList<McpSchema.ResourceTemplate>(), (accumulated, result) -> {
810-
accumulated.addAll(result.resourceTemplates());
811-
return accumulated;
812-
})
813-
.map(all -> McpSchema.ListResourceTemplatesResult.builder(Collections.unmodifiableList(all)).build());
806+
return this.listResourceTemplates(McpSchema.FIRST_PAGE).expand(result -> {
807+
String next = result.nextCursor();
808+
return (next != null && !next.isEmpty()) ? this.listResourceTemplates(next) : Mono.empty();
809+
}).reduce(new ArrayList<McpSchema.ResourceTemplate>(), (accumulated, result) -> {
810+
accumulated.addAll(result.resourceTemplates());
811+
return accumulated;
812+
}).map(all -> McpSchema.ListResourceTemplatesResult.builder(Collections.unmodifiableList(all)).build());
814813
}
815814

816815
/**
@@ -923,13 +922,13 @@ private NotificationHandler asyncResourcesUpdatedNotificationHandler(
923922
* @see #getPrompt(GetPromptRequest)
924923
*/
925924
public Mono<ListPromptsResult> listPrompts() {
926-
return this.listPrompts(McpSchema.FIRST_PAGE)
927-
.expand(result -> (result.nextCursor() != null) ? this.listPrompts(result.nextCursor()) : Mono.empty())
928-
.reduce(new ArrayList<McpSchema.Prompt>(), (accumulated, result) -> {
929-
accumulated.addAll(result.prompts());
930-
return accumulated;
931-
})
932-
.map(all -> McpSchema.ListPromptsResult.builder(Collections.unmodifiableList(all)).build());
925+
return this.listPrompts(McpSchema.FIRST_PAGE).expand(result -> {
926+
String next = result.nextCursor();
927+
return (next != null && !next.isEmpty()) ? this.listPrompts(next) : Mono.empty();
928+
}).reduce(new ArrayList<McpSchema.Prompt>(), (accumulated, result) -> {
929+
accumulated.addAll(result.prompts());
930+
return accumulated;
931+
}).map(all -> McpSchema.ListPromptsResult.builder(Collections.unmodifiableList(all)).build());
933932
}
934933

935934
/**

mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import java.util.Map;
99
import java.util.Objects;
1010
import java.util.Set;
11+
import java.util.concurrent.atomic.AtomicInteger;
1112
import java.util.concurrent.atomic.AtomicReference;
1213
import java.util.function.Function;
1314
import java.util.stream.Collectors;
@@ -298,6 +299,42 @@ void testListPromptsWithCursorAndMeta() {
298299

299300
}
300301

302+
@Test
303+
void listResourcesStopsOnEmptyNextCursor() {
304+
var transport = new EmptyCursorTestMcpClientTransport(McpSchema.METHOD_RESOURCES_LIST);
305+
McpAsyncClient client = McpClient.async(transport).build();
306+
307+
McpSchema.ListResourcesResult result = client.listResources().block();
308+
309+
assertThat(result).isNotNull();
310+
assertThat(result.resources()).extracting(McpSchema.Resource::name).containsExactly("test.txt");
311+
assertThat(transport.getRequestCount()).isEqualTo(1);
312+
}
313+
314+
@Test
315+
void listResourceTemplatesStopsOnEmptyNextCursor() {
316+
var transport = new EmptyCursorTestMcpClientTransport(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST);
317+
McpAsyncClient client = McpClient.async(transport).build();
318+
319+
McpSchema.ListResourceTemplatesResult result = client.listResourceTemplates().block();
320+
321+
assertThat(result).isNotNull();
322+
assertThat(result.resourceTemplates()).extracting(McpSchema.ResourceTemplate::name).containsExactly("template");
323+
assertThat(transport.getRequestCount()).isEqualTo(1);
324+
}
325+
326+
@Test
327+
void listPromptsStopsOnEmptyNextCursor() {
328+
var transport = new EmptyCursorTestMcpClientTransport(McpSchema.METHOD_PROMPT_LIST);
329+
McpAsyncClient client = McpClient.async(transport).build();
330+
331+
McpSchema.ListPromptsResult result = client.listPrompts().block();
332+
333+
assertThat(result).isNotNull();
334+
assertThat(result.prompts()).extracting(McpSchema.Prompt::name).containsExactly("test-prompt");
335+
assertThat(transport.getRequestCount()).isEqualTo(1);
336+
}
337+
301338
static class TestMcpClientTransport implements McpClientTransport {
302339

303340
private Function<Mono<McpSchema.JSONRPCMessage>, Mono<McpSchema.JSONRPCMessage>> handler;
@@ -397,4 +434,90 @@ public McpSchema.PaginatedRequest getCapturedRequest() {
397434

398435
}
399436

437+
static class EmptyCursorTestMcpClientTransport implements McpClientTransport {
438+
439+
private final String listMethod;
440+
441+
private final AtomicInteger requestCount = new AtomicInteger();
442+
443+
private Function<Mono<McpSchema.JSONRPCMessage>, Mono<McpSchema.JSONRPCMessage>> handler;
444+
445+
EmptyCursorTestMcpClientTransport(String listMethod) {
446+
this.listMethod = listMethod;
447+
}
448+
449+
@Override
450+
public Mono<Void> connect(Function<Mono<McpSchema.JSONRPCMessage>, Mono<McpSchema.JSONRPCMessage>> handler) {
451+
this.handler = handler;
452+
return Mono.empty();
453+
}
454+
455+
@Override
456+
public Mono<Void> closeGracefully() {
457+
return Mono.empty();
458+
}
459+
460+
@Override
461+
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) {
462+
if (!(message instanceof McpSchema.JSONRPCRequest request)) {
463+
return Mono.empty();
464+
}
465+
466+
McpSchema.JSONRPCResponse response;
467+
if (McpSchema.METHOD_INITIALIZE.equals(request.method())) {
468+
McpSchema.ServerCapabilities caps = McpSchema.ServerCapabilities.builder()
469+
.prompts(false)
470+
.resources(false, false)
471+
.tools(false)
472+
.build();
473+
474+
McpSchema.InitializeResult initResult = McpSchema.InitializeResult
475+
.builder(ProtocolVersions.MCP_2024_11_05, caps, MOCK_SERVER_INFO)
476+
.build();
477+
response = McpSchema.JSONRPCResponse.result(request.id(), initResult);
478+
}
479+
else if (this.listMethod.equals(request.method())) {
480+
this.requestCount.incrementAndGet();
481+
response = McpSchema.JSONRPCResponse.result(request.id(), resultForMethod(request.method()));
482+
}
483+
else {
484+
return Mono.empty();
485+
}
486+
487+
return this.handler.apply(Mono.just(response)).then();
488+
}
489+
490+
private Object resultForMethod(String method) {
491+
if (McpSchema.METHOD_RESOURCES_LIST.equals(method)) {
492+
McpSchema.Resource resource = McpSchema.Resource.builder("file:///test.txt", "test.txt").build();
493+
return McpSchema.ListResourcesResult.builder(List.of(resource)).nextCursor("").build();
494+
}
495+
if (McpSchema.METHOD_RESOURCES_TEMPLATES_LIST.equals(method)) {
496+
McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate.builder("file:///{name}", "template")
497+
.build();
498+
return McpSchema.ListResourceTemplatesResult.builder(List.of(template)).nextCursor("").build();
499+
}
500+
if (McpSchema.METHOD_PROMPT_LIST.equals(method)) {
501+
McpSchema.Prompt prompt = McpSchema.Prompt.builder("test-prompt").build();
502+
return McpSchema.ListPromptsResult.builder(List.of(prompt)).nextCursor("").build();
503+
}
504+
throw new IllegalArgumentException("Unsupported method: " + method);
505+
}
506+
507+
@Override
508+
public <T> T unmarshalFrom(Object data, TypeRef<T> typeRef) {
509+
return JSON_MAPPER.convertValue(data, new TypeRef<>() {
510+
@Override
511+
public java.lang.reflect.Type getType() {
512+
return typeRef.getType();
513+
}
514+
});
515+
}
516+
517+
int getRequestCount() {
518+
return this.requestCount.get();
519+
}
520+
521+
}
522+
400523
}

0 commit comments

Comments
 (0)