Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.function.Supplier;

import io.modelcontextprotocol.client.LifecycleInitializer.Initialization;
import io.modelcontextprotocol.json.TypeRef;
Expand Down Expand Up @@ -185,6 +189,12 @@ public class McpAsyncClient {

private final boolean applyElicitationDefaults;

/**
* Bounds applied to the no-arg list operations to protect against unbounded
* pagination from misbehaving servers.
*/
private final PaginationConfig paginationConfig;

/**
* Create a new McpAsyncClient with the given transport and session request-response
* timeout.
Expand All @@ -196,7 +206,8 @@ public class McpAsyncClient {
* schemas.
*/
McpAsyncClient(McpClientTransport transport, Duration requestTimeout, Duration initializationTimeout,
JsonSchemaValidator jsonSchemaValidator, McpClientFeatures.Async features) {
JsonSchemaValidator jsonSchemaValidator, McpClientFeatures.Async features,
PaginationConfig paginationConfig) {

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

// Request Handlers
Map<String, RequestHandler<?>> requestHandlers = new HashMap<>();
Expand Down Expand Up @@ -731,13 +743,11 @@ private McpSchema.CallToolResult validateToolResult(String toolName, McpSchema.C
* @return A Mono that emits the list of all tools result
*/
public Mono<McpSchema.ListToolsResult> listTools() {
return this.listTools(McpSchema.FIRST_PAGE).expand(result -> {
String next = result.nextCursor();
return (next != null && !next.isEmpty()) ? this.listTools(next) : Mono.empty();
}).reduce(new ArrayList<McpSchema.Tool>(), (accumulated, result) -> {
accumulated.addAll(result.tools());
return accumulated;
}).map(all -> McpSchema.ListToolsResult.builder(Collections.unmodifiableList(all)).build());
return paginate(this::listTools, McpSchema.ListToolsResult::nextCursor, ArrayList<McpSchema.Tool>::new,
(all, result) -> {
all.addAll(result.tools());
return all;
}, all -> McpSchema.ListToolsResult.builder(Collections.unmodifiableList(all)).build());
}

/**
Expand Down Expand Up @@ -818,13 +828,11 @@ private NotificationHandler asyncToolsChangeNotificationHandler(
* @see #readResource(McpSchema.Resource)
*/
public Mono<McpSchema.ListResourcesResult> listResources() {
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());
return paginate(this::listResources, McpSchema.ListResourcesResult::nextCursor,
ArrayList<McpSchema.Resource>::new, (all, result) -> {
all.addAll(result.resources());
return all;
}, all -> McpSchema.ListResourcesResult.builder(Collections.unmodifiableList(all)).build());
}

/**
Expand Down Expand Up @@ -904,13 +912,11 @@ public Mono<McpSchema.ReadResourceResult> readResource(McpSchema.ReadResourceReq
* @see McpSchema.ListResourceTemplatesResult
*/
public Mono<McpSchema.ListResourceTemplatesResult> listResourceTemplates() {
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());
return paginate(this::listResourceTemplates, McpSchema.ListResourceTemplatesResult::nextCursor,
ArrayList<McpSchema.ResourceTemplate>::new, (all, result) -> {
all.addAll(result.resourceTemplates());
return all;
}, all -> McpSchema.ListResourceTemplatesResult.builder(Collections.unmodifiableList(all)).build());
}

/**
Expand Down Expand Up @@ -1023,13 +1029,85 @@ private NotificationHandler asyncResourcesUpdatedNotificationHandler(
* @see #getPrompt(GetPromptRequest)
*/
public Mono<ListPromptsResult> listPrompts() {
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());
return paginate(this::listPrompts, ListPromptsResult::nextCursor, ArrayList<McpSchema.Prompt>::new,
(all, result) -> {
all.addAll(result.prompts());
return all;
}, all -> McpSchema.ListPromptsResult.builder(Collections.unmodifiableList(all)).build());
}

/**
* Fetches every page of a paginated list operation, accumulating the pages into a
* single result, while enforcing the client's pagination bounds. A server that
* returns an endless stream of non-empty cursors is stopped with an
* {@link McpPaginationException} once the configured page limit, cursor-repetition
* guard or total timeout is hit.
* @param pageFetcher fetches a single page for a given cursor
* @param nextCursorOf extracts the next cursor from a page result
* @param initialAccumulator supplies the accumulator for the aggregated result
* @param accumulate merges one page into the accumulator
* @param finalize converts the accumulated pages into the final result
* @param <R> the page/result type
* @param <A> the accumulator type
* @return a Mono that emits the aggregated result of all pages
*/
private <R, A> Mono<R> paginate(Function<String, Mono<R>> pageFetcher, Function<R, String> nextCursorOf,
Supplier<A> initialAccumulator, BiFunction<A, R, A> accumulate, Function<A, R> finalize) {
return Mono.defer(() -> {
PaginationGuard guard = new PaginationGuard(this.paginationConfig);
return pageFetcher.apply(McpSchema.FIRST_PAGE).expand(page -> {
String next = nextCursorOf.apply(page);
if (next == null || next.isEmpty()) {
return Mono.empty();
}
guard.beforeNextPage(next);
return pageFetcher.apply(next);
}).reduce(initialAccumulator.get(), accumulate).map(finalize);
});
}

/**
* Tracks pagination state for a single list operation and enforces the configured
* bounds. Fresh state is created per subscription so that a shared {@link Mono} can
* be subscribed multiple times without carrying stale guards.
*/
private static final class PaginationGuard {

private final Set<String> visitedCursors = new HashSet<>();

private final PaginationConfig config;

private final long startNanos = System.nanoTime();

private int pagesFetched = 1;

PaginationGuard(PaginationConfig config) {
this.config = config;
}

/**
* Validates that the next page may be fetched, throwing an
* {@link McpPaginationException} when a bound is exceeded.
* @param cursor the next cursor the server asked the client to follow
*/
void beforeNextPage(String cursor) {
if (!this.visitedCursors.add(cursor)) {
throw new McpPaginationException("Pagination loop detected: the server returned cursor '" + cursor
+ "' more than once. Aborting the list operation to avoid an endless request loop.");
}
if (this.config.maxPages() > 0 && this.pagesFetched >= this.config.maxPages()) {
throw new McpPaginationException(
"Pagination limit exceeded: the server returned more than " + this.config.maxPages()
+ " pages. Increase maxPaginationPages if this is expected for the server.");
}
if (this.config.timeout() != null
&& Duration.ofNanos(System.nanoTime() - this.startNanos).compareTo(this.config.timeout()) > 0) {
throw new McpPaginationException("Pagination timed out after " + this.config.timeout()
+ ". Increase paginationTimeout if this is expected for the server.");
}
this.pagesFetched++;
}

}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,10 @@ class SyncSpec {

private boolean applyElicitationDefaults = false; // Default to false

private int maxPaginationPages = 100; // Default limit

private Duration paginationTimeout;

private SyncSpec(McpClientTransport transport) {
Assert.notNull(transport, "Transport must not be null");
this.transport = transport;
Expand Down Expand Up @@ -544,6 +548,35 @@ public SyncSpec applyElicitationDefaults(boolean applyElicitationDefaults) {
return this;
}

/**
* Sets the maximum number of pages the no-arg list operations (e.g.
* {@link McpSyncClient#listTools()}) will follow before aborting. This protects
* against servers that return an endless stream of non-empty pagination cursors,
* which would otherwise cause unbounded requests, unbounded memory growth and a
* permanently blocked synchronous call. A value of {@code 0} disables the
* page-count limit.
* @param maxPaginationPages the maximum number of pages to fetch.
* @return this builder
*/
public SyncSpec maxPaginationPages(int maxPaginationPages) {
Assert.isTrue(maxPaginationPages >= 0, "maxPaginationPages must not be negative");
this.maxPaginationPages = maxPaginationPages;
return this;
}

/**
* Sets the total wall-clock time budget for the no-arg list operations to fetch
* all pages. When the budget is exceeded the operation aborts with an
* {@link McpPaginationException}.
* @param paginationTimeout the total time budget, or {@code null} for no timeout.
* @return this builder
*/
public SyncSpec paginationTimeout(Duration paginationTimeout) {
Assert.notNull(paginationTimeout, "paginationTimeout must not be null");
this.paginationTimeout = paginationTimeout;
return this;
}

/**
* Create an instance of {@link McpSyncClient} with the provided configurations or
* sensible defaults.
Expand All @@ -558,9 +591,11 @@ public McpSyncClient build() {

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

return new McpSyncClient(new McpAsyncClient(transport, this.requestTimeout, this.initializationTimeout,
jsonSchemaValidator != null ? jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(),
asyncFeatures), this.contextProvider);
return new McpSyncClient(
new McpAsyncClient(transport, this.requestTimeout, this.initializationTimeout,
jsonSchemaValidator != null ? jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(),
asyncFeatures, new PaginationConfig(this.maxPaginationPages, this.paginationTimeout)),
this.contextProvider);
}

}
Expand Down Expand Up @@ -621,6 +656,10 @@ class AsyncSpec {

private boolean applyElicitationDefaults = false; // Default to false

private int maxPaginationPages = 100; // Default limit

private Duration paginationTimeout;

private AsyncSpec(McpClientTransport transport) {
Assert.notNull(transport, "Transport must not be null");
this.transport = transport;
Expand Down Expand Up @@ -950,6 +989,35 @@ public AsyncSpec applyElicitationDefaults(boolean applyElicitationDefaults) {
return this;
}

/**
* Sets the maximum number of pages the no-arg list operations (e.g.
* {@link McpAsyncClient#listTools()}) will follow before aborting. This protects
* against servers that return an endless stream of non-empty pagination cursors,
* which would otherwise cause unbounded requests, unbounded memory growth and a
* permanently blocked synchronous call. A value of {@code 0} disables the
* page-count limit.
* @param maxPaginationPages the maximum number of pages to fetch.
* @return this builder
*/
public AsyncSpec maxPaginationPages(int maxPaginationPages) {
Assert.isTrue(maxPaginationPages >= 0, "maxPaginationPages must not be negative");
this.maxPaginationPages = maxPaginationPages;
return this;
}

/**
* Sets the total wall-clock time budget for the no-arg list operations to fetch
* all pages. When the budget is exceeded the operation aborts with an
* {@link McpPaginationException}.
* @param paginationTimeout the total time budget, or {@code null} for no timeout.
* @return this builder
*/
public AsyncSpec paginationTimeout(Duration paginationTimeout) {
Assert.notNull(paginationTimeout, "paginationTimeout must not be null");
this.paginationTimeout = paginationTimeout;
return this;
}

/**
* Create an instance of {@link McpAsyncClient} with the provided configurations
* or sensible defaults.
Expand All @@ -965,7 +1033,8 @@ public McpAsyncClient build() {
this.promptsChangeConsumers, this.loggingConsumers, this.progressConsumers,
this.elicitationCompleteConsumers, this.samplingHandler, this.formElicitationHandler,
this.urlElicitationHandler, this.enableCallToolSchemaCaching,
this.applyElicitationDefaults));
this.applyElicitationDefaults),
new PaginationConfig(this.maxPaginationPages, this.paginationTimeout));
}

}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* Copyright 2026-2026 the original author or authors.
*/

package io.modelcontextprotocol.client;

/**
* Thrown when a no-arg list operation (e.g. {@link McpAsyncClient#listTools()}) exceeds
* the configured pagination bounds. This protects the client from servers that return an
* endless stream of non-empty pagination cursors, which would otherwise cause an
* unbounded number of requests, unbounded memory growth, or a permanently blocked
* synchronous call.
*
* @see McpClient.SyncSpec#maxPaginationPages(int)
* @see McpClient.SyncSpec#paginationTimeout(java.time.Duration)
*/
public class McpPaginationException extends RuntimeException {

/**
* Create a new {@link McpPaginationException}.
* @param message the exception message
*/
public McpPaginationException(String message) {
super(message);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Copyright 2026-2026 the original author or authors.
*/

package io.modelcontextprotocol.client;

import java.time.Duration;

/**
* Client-side bounds applied to the no-arg list operations
* ({@link McpAsyncClient#listTools()}, {@link McpAsyncClient#listResources()},
* {@link McpAsyncClient#listResourceTemplates()}, {@link McpAsyncClient#listPrompts()}).
*
* @param maxPages the maximum number of pages to fetch across the whole list operation. A
* value of {@code 0} or less disables the page-count limit.
* @param timeout the total wall-clock time budget for the whole list operation, or
* {@code null} for no timeout.
*/
record PaginationConfig(int maxPages, Duration timeout) {

/** Default configuration: at most 100 pages and no total timeout. */
static final PaginationConfig DEFAULT = new PaginationConfig(100, null);

}
Loading