Skip to content

Commit ec08a8e

Browse files
committed
Bound no-arg list operations to prevent unbounded pagination
The no-arg listTools(), listResources(), listResourceTemplates() and listPrompts() follow the server-provided nextCursor chain via Mono.expand with no page limit, no duplicate-cursor detection and no total deadline. A server that returns an endless stream of non-empty cursors makes the client issue an unbounded number of requests, accumulate unbounded memory, and block synchronous callers forever. Add a client-side pagination guard: each no-arg list operation now tracks the cursors it has already seen and the number of pages fetched, and aborts with a new McpPaginationException when the configured maxPaginationPages (default 100) or paginationTimeout (disabled by default) is exceeded, or when a cursor is returned more than once. The bounds are configurable on both McpClient.SyncSpec and McpClient.AsyncSpec (maxPaginationPages(int), paginationTimeout(Duration)); existing behavior and APIs are unchanged for servers that terminate pagination normally. Adds McpAsyncClientPaginationTests covering normal multi-page aggregation, duplicate-cursor loops, ever-changing cursor loops (page limit and total timeout) and the synchronous client path. Fixes #1084 Signed-off-by: zhaoyuzhe <1991039819@qq.com>
1 parent 8ee8ccb commit ec08a8e

5 files changed

Lines changed: 514 additions & 33 deletions

File tree

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

Lines changed: 107 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,14 @@
1010
import java.util.ArrayList;
1111
import java.util.Collections;
1212
import java.util.HashMap;
13+
import java.util.HashSet;
1314
import java.util.List;
1415
import java.util.Map;
16+
import java.util.Set;
1517
import java.util.concurrent.ConcurrentHashMap;
18+
import java.util.function.BiFunction;
1619
import java.util.function.Function;
20+
import java.util.function.Supplier;
1721

1822
import io.modelcontextprotocol.client.LifecycleInitializer.Initialization;
1923
import io.modelcontextprotocol.json.TypeRef;
@@ -185,6 +189,12 @@ public class McpAsyncClient {
185189

186190
private final boolean applyElicitationDefaults;
187191

192+
/**
193+
* Bounds applied to the no-arg list operations to protect against unbounded
194+
* pagination from misbehaving servers.
195+
*/
196+
private final PaginationConfig paginationConfig;
197+
188198
/**
189199
* Create a new McpAsyncClient with the given transport and session request-response
190200
* timeout.
@@ -196,7 +206,8 @@ public class McpAsyncClient {
196206
* schemas.
197207
*/
198208
McpAsyncClient(McpClientTransport transport, Duration requestTimeout, Duration initializationTimeout,
199-
JsonSchemaValidator jsonSchemaValidator, McpClientFeatures.Async features) {
209+
JsonSchemaValidator jsonSchemaValidator, McpClientFeatures.Async features,
210+
PaginationConfig paginationConfig) {
200211

201212
Assert.notNull(transport, "Transport must not be null");
202213
Assert.notNull(requestTimeout, "Request timeout must not be null");
@@ -210,6 +221,7 @@ public class McpAsyncClient {
210221
this.toolsOutputSchemaCache = new ConcurrentHashMap<>();
211222
this.enableCallToolSchemaCaching = features.enableCallToolSchemaCaching();
212223
this.applyElicitationDefaults = features.applyElicitationDefaults();
224+
this.paginationConfig = paginationConfig != null ? paginationConfig : PaginationConfig.DEFAULT;
213225

214226
// Request Handlers
215227
Map<String, RequestHandler<?>> requestHandlers = new HashMap<>();
@@ -731,13 +743,11 @@ private McpSchema.CallToolResult validateToolResult(String toolName, McpSchema.C
731743
* @return A Mono that emits the list of all tools result
732744
*/
733745
public Mono<McpSchema.ListToolsResult> listTools() {
734-
return this.listTools(McpSchema.FIRST_PAGE).expand(result -> {
735-
String next = result.nextCursor();
736-
return (next != null && !next.isEmpty()) ? this.listTools(next) : Mono.empty();
737-
}).reduce(new ArrayList<McpSchema.Tool>(), (accumulated, result) -> {
738-
accumulated.addAll(result.tools());
739-
return accumulated;
740-
}).map(all -> McpSchema.ListToolsResult.builder(Collections.unmodifiableList(all)).build());
746+
return paginate(this::listTools, McpSchema.ListToolsResult::nextCursor, ArrayList<McpSchema.Tool>::new,
747+
(all, result) -> {
748+
all.addAll(result.tools());
749+
return all;
750+
}, all -> McpSchema.ListToolsResult.builder(Collections.unmodifiableList(all)).build());
741751
}
742752

743753
/**
@@ -818,13 +828,11 @@ private NotificationHandler asyncToolsChangeNotificationHandler(
818828
* @see #readResource(McpSchema.Resource)
819829
*/
820830
public Mono<McpSchema.ListResourcesResult> listResources() {
821-
return this.listResources(McpSchema.FIRST_PAGE).expand(result -> {
822-
String next = result.nextCursor();
823-
return (next != null && !next.isEmpty()) ? this.listResources(next) : Mono.empty();
824-
}).reduce(new ArrayList<McpSchema.Resource>(), (accumulated, result) -> {
825-
accumulated.addAll(result.resources());
826-
return accumulated;
827-
}).map(all -> McpSchema.ListResourcesResult.builder(Collections.unmodifiableList(all)).build());
831+
return paginate(this::listResources, McpSchema.ListResourcesResult::nextCursor,
832+
ArrayList<McpSchema.Resource>::new, (all, result) -> {
833+
all.addAll(result.resources());
834+
return all;
835+
}, all -> McpSchema.ListResourcesResult.builder(Collections.unmodifiableList(all)).build());
828836
}
829837

830838
/**
@@ -904,13 +912,11 @@ public Mono<McpSchema.ReadResourceResult> readResource(McpSchema.ReadResourceReq
904912
* @see McpSchema.ListResourceTemplatesResult
905913
*/
906914
public Mono<McpSchema.ListResourceTemplatesResult> listResourceTemplates() {
907-
return this.listResourceTemplates(McpSchema.FIRST_PAGE).expand(result -> {
908-
String next = result.nextCursor();
909-
return (next != null && !next.isEmpty()) ? this.listResourceTemplates(next) : Mono.empty();
910-
}).reduce(new ArrayList<McpSchema.ResourceTemplate>(), (accumulated, result) -> {
911-
accumulated.addAll(result.resourceTemplates());
912-
return accumulated;
913-
}).map(all -> McpSchema.ListResourceTemplatesResult.builder(Collections.unmodifiableList(all)).build());
915+
return paginate(this::listResourceTemplates, McpSchema.ListResourceTemplatesResult::nextCursor,
916+
ArrayList<McpSchema.ResourceTemplate>::new, (all, result) -> {
917+
all.addAll(result.resourceTemplates());
918+
return all;
919+
}, all -> McpSchema.ListResourceTemplatesResult.builder(Collections.unmodifiableList(all)).build());
914920
}
915921

916922
/**
@@ -1023,13 +1029,85 @@ private NotificationHandler asyncResourcesUpdatedNotificationHandler(
10231029
* @see #getPrompt(GetPromptRequest)
10241030
*/
10251031
public Mono<ListPromptsResult> listPrompts() {
1026-
return this.listPrompts(McpSchema.FIRST_PAGE).expand(result -> {
1027-
String next = result.nextCursor();
1028-
return (next != null && !next.isEmpty()) ? this.listPrompts(next) : Mono.empty();
1029-
}).reduce(new ArrayList<McpSchema.Prompt>(), (accumulated, result) -> {
1030-
accumulated.addAll(result.prompts());
1031-
return accumulated;
1032-
}).map(all -> McpSchema.ListPromptsResult.builder(Collections.unmodifiableList(all)).build());
1032+
return paginate(this::listPrompts, ListPromptsResult::nextCursor, ArrayList<McpSchema.Prompt>::new,
1033+
(all, result) -> {
1034+
all.addAll(result.prompts());
1035+
return all;
1036+
}, all -> McpSchema.ListPromptsResult.builder(Collections.unmodifiableList(all)).build());
1037+
}
1038+
1039+
/**
1040+
* Fetches every page of a paginated list operation, accumulating the pages into a
1041+
* single result, while enforcing the client's pagination bounds. A server that
1042+
* returns an endless stream of non-empty cursors is stopped with an
1043+
* {@link McpPaginationException} once the configured page limit, cursor-repetition
1044+
* guard or total timeout is hit.
1045+
* @param pageFetcher fetches a single page for a given cursor
1046+
* @param nextCursorOf extracts the next cursor from a page result
1047+
* @param initialAccumulator supplies the accumulator for the aggregated result
1048+
* @param accumulate merges one page into the accumulator
1049+
* @param finalize converts the accumulated pages into the final result
1050+
* @param <R> the page/result type
1051+
* @param <A> the accumulator type
1052+
* @return a Mono that emits the aggregated result of all pages
1053+
*/
1054+
private <R, A> Mono<R> paginate(Function<String, Mono<R>> pageFetcher, Function<R, String> nextCursorOf,
1055+
Supplier<A> initialAccumulator, BiFunction<A, R, A> accumulate, Function<A, R> finalize) {
1056+
return Mono.defer(() -> {
1057+
PaginationGuard guard = new PaginationGuard(this.paginationConfig);
1058+
return pageFetcher.apply(McpSchema.FIRST_PAGE).expand(page -> {
1059+
String next = nextCursorOf.apply(page);
1060+
if (next == null || next.isEmpty()) {
1061+
return Mono.empty();
1062+
}
1063+
guard.beforeNextPage(next);
1064+
return pageFetcher.apply(next);
1065+
}).reduce(initialAccumulator.get(), accumulate).map(finalize);
1066+
});
1067+
}
1068+
1069+
/**
1070+
* Tracks pagination state for a single list operation and enforces the configured
1071+
* bounds. Fresh state is created per subscription so that a shared {@link Mono} can
1072+
* be subscribed multiple times without carrying stale guards.
1073+
*/
1074+
private static final class PaginationGuard {
1075+
1076+
private final Set<String> visitedCursors = new HashSet<>();
1077+
1078+
private final PaginationConfig config;
1079+
1080+
private final long startNanos = System.nanoTime();
1081+
1082+
private int pagesFetched = 1;
1083+
1084+
PaginationGuard(PaginationConfig config) {
1085+
this.config = config;
1086+
}
1087+
1088+
/**
1089+
* Validates that the next page may be fetched, throwing an
1090+
* {@link McpPaginationException} when a bound is exceeded.
1091+
* @param cursor the next cursor the server asked the client to follow
1092+
*/
1093+
void beforeNextPage(String cursor) {
1094+
if (!this.visitedCursors.add(cursor)) {
1095+
throw new McpPaginationException("Pagination loop detected: the server returned cursor '" + cursor
1096+
+ "' more than once. Aborting the list operation to avoid an endless request loop.");
1097+
}
1098+
if (this.config.maxPages() > 0 && this.pagesFetched >= this.config.maxPages()) {
1099+
throw new McpPaginationException(
1100+
"Pagination limit exceeded: the server returned more than " + this.config.maxPages()
1101+
+ " pages. Increase maxPaginationPages if this is expected for the server.");
1102+
}
1103+
if (this.config.timeout() != null
1104+
&& Duration.ofNanos(System.nanoTime() - this.startNanos).compareTo(this.config.timeout()) > 0) {
1105+
throw new McpPaginationException("Pagination timed out after " + this.config.timeout()
1106+
+ ". Increase paginationTimeout if this is expected for the server.");
1107+
}
1108+
this.pagesFetched++;
1109+
}
1110+
10331111
}
10341112

10351113
/**

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

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,10 @@ class SyncSpec {
202202

203203
private boolean applyElicitationDefaults = false; // Default to false
204204

205+
private int maxPaginationPages = 100; // Default limit
206+
207+
private Duration paginationTimeout;
208+
205209
private SyncSpec(McpClientTransport transport) {
206210
Assert.notNull(transport, "Transport must not be null");
207211
this.transport = transport;
@@ -544,6 +548,35 @@ public SyncSpec applyElicitationDefaults(boolean applyElicitationDefaults) {
544548
return this;
545549
}
546550

551+
/**
552+
* Sets the maximum number of pages the no-arg list operations (e.g.
553+
* {@link McpSyncClient#listTools()}) will follow before aborting. This protects
554+
* against servers that return an endless stream of non-empty pagination cursors,
555+
* which would otherwise cause unbounded requests, unbounded memory growth and a
556+
* permanently blocked synchronous call. A value of {@code 0} disables the
557+
* page-count limit.
558+
* @param maxPaginationPages the maximum number of pages to fetch.
559+
* @return this builder
560+
*/
561+
public SyncSpec maxPaginationPages(int maxPaginationPages) {
562+
Assert.isTrue(maxPaginationPages >= 0, "maxPaginationPages must not be negative");
563+
this.maxPaginationPages = maxPaginationPages;
564+
return this;
565+
}
566+
567+
/**
568+
* Sets the total wall-clock time budget for the no-arg list operations to fetch
569+
* all pages. When the budget is exceeded the operation aborts with an
570+
* {@link McpPaginationException}.
571+
* @param paginationTimeout the total time budget, or {@code null} for no timeout.
572+
* @return this builder
573+
*/
574+
public SyncSpec paginationTimeout(Duration paginationTimeout) {
575+
Assert.notNull(paginationTimeout, "paginationTimeout must not be null");
576+
this.paginationTimeout = paginationTimeout;
577+
return this;
578+
}
579+
547580
/**
548581
* Create an instance of {@link McpSyncClient} with the provided configurations or
549582
* sensible defaults.
@@ -558,9 +591,11 @@ public McpSyncClient build() {
558591

559592
McpClientFeatures.Async asyncFeatures = McpClientFeatures.Async.fromSync(syncFeatures);
560593

561-
return new McpSyncClient(new McpAsyncClient(transport, this.requestTimeout, this.initializationTimeout,
562-
jsonSchemaValidator != null ? jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(),
563-
asyncFeatures), this.contextProvider);
594+
return new McpSyncClient(
595+
new McpAsyncClient(transport, this.requestTimeout, this.initializationTimeout,
596+
jsonSchemaValidator != null ? jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(),
597+
asyncFeatures, new PaginationConfig(this.maxPaginationPages, this.paginationTimeout)),
598+
this.contextProvider);
564599
}
565600

566601
}
@@ -621,6 +656,10 @@ class AsyncSpec {
621656

622657
private boolean applyElicitationDefaults = false; // Default to false
623658

659+
private int maxPaginationPages = 100; // Default limit
660+
661+
private Duration paginationTimeout;
662+
624663
private AsyncSpec(McpClientTransport transport) {
625664
Assert.notNull(transport, "Transport must not be null");
626665
this.transport = transport;
@@ -950,6 +989,35 @@ public AsyncSpec applyElicitationDefaults(boolean applyElicitationDefaults) {
950989
return this;
951990
}
952991

992+
/**
993+
* Sets the maximum number of pages the no-arg list operations (e.g.
994+
* {@link McpAsyncClient#listTools()}) will follow before aborting. This protects
995+
* against servers that return an endless stream of non-empty pagination cursors,
996+
* which would otherwise cause unbounded requests, unbounded memory growth and a
997+
* permanently blocked synchronous call. A value of {@code 0} disables the
998+
* page-count limit.
999+
* @param maxPaginationPages the maximum number of pages to fetch.
1000+
* @return this builder
1001+
*/
1002+
public AsyncSpec maxPaginationPages(int maxPaginationPages) {
1003+
Assert.isTrue(maxPaginationPages >= 0, "maxPaginationPages must not be negative");
1004+
this.maxPaginationPages = maxPaginationPages;
1005+
return this;
1006+
}
1007+
1008+
/**
1009+
* Sets the total wall-clock time budget for the no-arg list operations to fetch
1010+
* all pages. When the budget is exceeded the operation aborts with an
1011+
* {@link McpPaginationException}.
1012+
* @param paginationTimeout the total time budget, or {@code null} for no timeout.
1013+
* @return this builder
1014+
*/
1015+
public AsyncSpec paginationTimeout(Duration paginationTimeout) {
1016+
Assert.notNull(paginationTimeout, "paginationTimeout must not be null");
1017+
this.paginationTimeout = paginationTimeout;
1018+
return this;
1019+
}
1020+
9531021
/**
9541022
* Create an instance of {@link McpAsyncClient} with the provided configurations
9551023
* or sensible defaults.
@@ -965,7 +1033,8 @@ public McpAsyncClient build() {
9651033
this.promptsChangeConsumers, this.loggingConsumers, this.progressConsumers,
9661034
this.elicitationCompleteConsumers, this.samplingHandler, this.formElicitationHandler,
9671035
this.urlElicitationHandler, this.enableCallToolSchemaCaching,
968-
this.applyElicitationDefaults));
1036+
this.applyElicitationDefaults),
1037+
new PaginationConfig(this.maxPaginationPages, this.paginationTimeout));
9691038
}
9701039

9711040
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
* Copyright 2026-2026 the original author or authors.
3+
*/
4+
5+
package io.modelcontextprotocol.client;
6+
7+
/**
8+
* Thrown when a no-arg list operation (e.g. {@link McpAsyncClient#listTools()}) exceeds
9+
* the configured pagination bounds. This protects the client from servers that return an
10+
* endless stream of non-empty pagination cursors, which would otherwise cause an
11+
* unbounded number of requests, unbounded memory growth, or a permanently blocked
12+
* synchronous call.
13+
*
14+
* @see McpClient.SyncSpec#maxPaginationPages(int)
15+
* @see McpClient.SyncSpec#paginationTimeout(java.time.Duration)
16+
*/
17+
public class McpPaginationException extends RuntimeException {
18+
19+
/**
20+
* Create a new {@link McpPaginationException}.
21+
* @param message the exception message
22+
*/
23+
public McpPaginationException(String message) {
24+
super(message);
25+
}
26+
27+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
* Copyright 2026-2026 the original author or authors.
3+
*/
4+
5+
package io.modelcontextprotocol.client;
6+
7+
import java.time.Duration;
8+
9+
/**
10+
* Client-side bounds applied to the no-arg list operations
11+
* ({@link McpAsyncClient#listTools()}, {@link McpAsyncClient#listResources()},
12+
* {@link McpAsyncClient#listResourceTemplates()}, {@link McpAsyncClient#listPrompts()}).
13+
*
14+
* @param maxPages the maximum number of pages to fetch across the whole list operation. A
15+
* value of {@code 0} or less disables the page-count limit.
16+
* @param timeout the total wall-clock time budget for the whole list operation, or
17+
* {@code null} for no timeout.
18+
*/
19+
record PaginationConfig(int maxPages, Duration timeout) {
20+
21+
/** Default configuration: at most 100 pages and no total timeout. */
22+
static final PaginationConfig DEFAULT = new PaginationConfig(100, null);
23+
24+
}

0 commit comments

Comments
 (0)