diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java index 3509b760b..3a8c80eee 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java @@ -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; @@ -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. @@ -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"); @@ -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> requestHandlers = new HashMap<>(); @@ -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 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(), (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::new, + (all, result) -> { + all.addAll(result.tools()); + return all; + }, all -> McpSchema.ListToolsResult.builder(Collections.unmodifiableList(all)).build()); } /** @@ -818,13 +828,11 @@ private NotificationHandler asyncToolsChangeNotificationHandler( * @see #readResource(McpSchema.Resource) */ public Mono 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(), (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::new, (all, result) -> { + all.addAll(result.resources()); + return all; + }, all -> McpSchema.ListResourcesResult.builder(Collections.unmodifiableList(all)).build()); } /** @@ -904,13 +912,11 @@ public Mono readResource(McpSchema.ReadResourceReq * @see McpSchema.ListResourceTemplatesResult */ public Mono 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(), (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::new, (all, result) -> { + all.addAll(result.resourceTemplates()); + return all; + }, all -> McpSchema.ListResourceTemplatesResult.builder(Collections.unmodifiableList(all)).build()); } /** @@ -1023,13 +1029,85 @@ private NotificationHandler asyncResourcesUpdatedNotificationHandler( * @see #getPrompt(GetPromptRequest) */ public Mono 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(), (accumulated, result) -> { - accumulated.addAll(result.prompts()); - return accumulated; - }).map(all -> McpSchema.ListPromptsResult.builder(Collections.unmodifiableList(all)).build()); + return paginate(this::listPrompts, ListPromptsResult::nextCursor, ArrayList::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 the page/result type + * @param the accumulator type + * @return a Mono that emits the aggregated result of all pages + */ + private Mono paginate(Function> pageFetcher, Function nextCursorOf, + Supplier initialAccumulator, BiFunction accumulate, Function 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 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++; + } + } /** diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java index 1af4eea1b..ae0136036 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java @@ -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; @@ -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. @@ -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); } } @@ -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; @@ -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. @@ -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)); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpPaginationException.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpPaginationException.java new file mode 100644 index 000000000..9c335adf5 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpPaginationException.java @@ -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); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/PaginationConfig.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/PaginationConfig.java new file mode 100644 index 000000000..66a310fee --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/PaginationConfig.java @@ -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); + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientPaginationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientPaginationTests.java new file mode 100644 index 000000000..60433ef47 --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientPaginationTests.java @@ -0,0 +1,283 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.client; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; + +import io.modelcontextprotocol.json.TypeRef; +import io.modelcontextprotocol.spec.McpClientTransport; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.ProtocolVersions; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests for the pagination bounds applied to the no-arg list operations + * ({@link McpAsyncClient#listTools()}, {@link McpAsyncClient#listResources()}, ...). + */ +class McpAsyncClientPaginationTests { + + private static final McpSchema.Implementation MOCK_SERVER_INFO = McpSchema.Implementation + .builder("test-server", "1.0.0") + .build(); + + private static final McpSchema.ServerCapabilities MOCK_SERVER_CAPABILITIES = McpSchema.ServerCapabilities.builder() + .tools(true) + .resources(true, false) + .build(); + + private static final McpSchema.InitializeResult MOCK_INIT_RESULT = McpSchema.InitializeResult + .builder(ProtocolVersions.MCP_2024_11_05, MOCK_SERVER_CAPABILITIES, MOCK_SERVER_INFO) + .build(); + + private static final Map EMPTY_INPUT_SCHEMA = Map.of("type", "object"); + + /** + * Describes how a mocked server pages through tools/resources. + */ + private interface PaginatedServer { + + /** + * Returns the next cursor to hand back for a page requested with the given + * cursor, or {@code null} to signal the end of the list. + * @param cursor the cursor of the incoming request, or {@code null} for the first + * page. + * @return the next cursor, or {@code null} to end pagination. + */ + String nextCursorFor(String cursor); + + /** + * Optional artificial latency per page request. + * @return the delay to apply per page request. + */ + default Duration pageDelay() { + return Duration.ZERO; + } + + } + + private McpClientTransport createPaginatedTransport(PaginatedServer server, AtomicInteger toolsRequests, + AtomicInteger resourcesRequests) { + return new McpClientTransport() { + + Function, Mono> handler; + + @Override + public Mono connect( + Function, Mono> handler) { + this.handler = handler; + return Mono.empty(); + } + + @Override + public Mono closeGracefully() { + return Mono.empty(); + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + if (!(message instanceof McpSchema.JSONRPCRequest request)) { + return Mono.empty(); + } + + McpSchema.JSONRPCResponse response; + if (McpSchema.METHOD_INITIALIZE.equals(request.method())) { + response = McpSchema.JSONRPCResponse.result(request.id(), MOCK_INIT_RESULT); + } + else if (McpSchema.METHOD_TOOLS_LIST.equals(request.method())) { + toolsRequests.incrementAndGet(); + String cursor = cursorOf(request); + String next = server.nextCursorFor(cursor); + McpSchema.Tool tool = McpSchema.Tool.builder("tool-" + labelFor(cursor), EMPTY_INPUT_SCHEMA) + .build(); + response = McpSchema.JSONRPCResponse.result(request.id(), + McpSchema.ListToolsResult.builder(List.of(tool)).nextCursor(next).build()); + } + else if (McpSchema.METHOD_RESOURCES_LIST.equals(request.method())) { + resourcesRequests.incrementAndGet(); + String cursor = cursorOf(request); + String next = server.nextCursorFor(cursor); + McpSchema.Resource resource = McpSchema.Resource + .builder("resource-" + labelFor(cursor), "test://resource-" + labelFor(cursor)) + .build(); + response = McpSchema.JSONRPCResponse.result(request.id(), + McpSchema.ListResourcesResult.builder(List.of(resource)).nextCursor(next).build()); + } + else { + return Mono.empty(); + } + + Mono responseMono = Mono.just(response); + if (!server.pageDelay().isZero()) { + responseMono = responseMono.delayElement(server.pageDelay()); + } + return responseMono.flatMap(r -> handler.apply(Mono.just(r))).then(); + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return JSON_MAPPER.convertValue(data, typeRef); + } + + private String cursorOf(McpSchema.JSONRPCRequest request) { + return request.params() instanceof McpSchema.PaginatedRequest paginated ? paginated.cursor() : null; + } + + private String labelFor(String cursor) { + return cursor != null ? cursor : "first"; + } + + }; + } + + @Test + void listToolsAggregatesAllPagesUntilNullCursor() { + AtomicInteger toolsRequests = new AtomicInteger(); + AtomicInteger resourcesRequests = new AtomicInteger(); + PaginatedServer server = new PaginatedServer() { + @Override + public String nextCursorFor(String cursor) { + if (cursor == null) { + return "p1"; + } + if ("p1".equals(cursor)) { + return "p2"; + } + return null; + } + }; + McpAsyncClient client = McpClient.async(createPaginatedTransport(server, toolsRequests, resourcesRequests)) + .build(); + + StepVerifier.create(client.initialize()).expectNextMatches(result -> true).verifyComplete(); + + StepVerifier.create(client.listTools()).assertNext(result -> { + assertThat(result.tools()).hasSize(3); + assertThat(result.nextCursor()).isNull(); + }).verifyComplete(); + + assertThat(toolsRequests.get()).isEqualTo(3); + } + + @Test + void listToolsStopsOnDuplicateCursor() { + AtomicInteger toolsRequests = new AtomicInteger(); + AtomicInteger resourcesRequests = new AtomicInteger(); + // server keeps asking to follow the same cursor forever + PaginatedServer server = cursor -> "p1"; + McpAsyncClient client = McpClient.async(createPaginatedTransport(server, toolsRequests, resourcesRequests)) + .build(); + + client.initialize().block(); + + StepVerifier.create(client.listTools()).expectErrorSatisfies(error -> { + assertThat(error).isInstanceOf(McpPaginationException.class); + assertThat(error.getMessage()).contains("more than once"); + }).verify(); + } + + @Test + void listToolsStopsAfterMaxPaginationPages() { + AtomicInteger toolsRequests = new AtomicInteger(); + AtomicInteger resourcesRequests = new AtomicInteger(); + // server returns a fresh cursor every time, never terminating + AtomicInteger counter = new AtomicInteger(); + PaginatedServer server = cursor -> "page-" + counter.incrementAndGet(); + McpAsyncClient client = McpClient.async(createPaginatedTransport(server, toolsRequests, resourcesRequests)) + .maxPaginationPages(3) + .build(); + + client.initialize().block(); + + StepVerifier.create(client.listTools()).expectErrorSatisfies(error -> { + assertThat(error).isInstanceOf(McpPaginationException.class); + assertThat(error.getMessage()).contains("more than 3"); + }).verify(); + assertThat(toolsRequests.get()).isEqualTo(3); + } + + @Test + void listToolsStopsAfterPaginationTimeout() { + AtomicInteger toolsRequests = new AtomicInteger(); + AtomicInteger resourcesRequests = new AtomicInteger(); + AtomicInteger counter = new AtomicInteger(); + PaginatedServer server = new PaginatedServer() { + @Override + public String nextCursorFor(String cursor) { + return "page-" + counter.incrementAndGet(); + } + + @Override + public Duration pageDelay() { + return Duration.ofMillis(150); + } + }; + McpAsyncClient client = McpClient.async(createPaginatedTransport(server, toolsRequests, resourcesRequests)) + .paginationTimeout(Duration.ofMillis(200)) + .build(); + + client.initialize().block(); + + StepVerifier.create(client.listTools()).expectErrorSatisfies(error -> { + assertThat(error).isInstanceOf(McpPaginationException.class); + assertThat(error.getMessage()).contains("timed out"); + }).verify(); + } + + @Test + void listResourcesAggregatesAllPagesUntilNullCursor() { + AtomicInteger toolsRequests = new AtomicInteger(); + AtomicInteger resourcesRequests = new AtomicInteger(); + PaginatedServer server = new PaginatedServer() { + @Override + public String nextCursorFor(String cursor) { + if (cursor == null) { + return "r1"; + } + if ("r1".equals(cursor)) { + return "r2"; + } + return null; + } + }; + McpAsyncClient client = McpClient.async(createPaginatedTransport(server, toolsRequests, resourcesRequests)) + .build(); + + client.initialize().block(); + + StepVerifier.create(client.listResources()).assertNext(result -> { + assertThat(result.resources()).hasSize(3); + }).verifyComplete(); + + assertThat(resourcesRequests.get()).isEqualTo(3); + } + + @Test + void syncClientListToolsThrowsMcpPaginationException() { + AtomicInteger toolsRequests = new AtomicInteger(); + AtomicInteger resourcesRequests = new AtomicInteger(); + AtomicInteger counter = new AtomicInteger(); + PaginatedServer server = cursor -> "page-" + counter.incrementAndGet(); + McpSyncClient client = McpClient.sync(createPaginatedTransport(server, toolsRequests, resourcesRequests)) + .maxPaginationPages(2) + .build(); + + client.initialize(); + + assertThatThrownBy(client::listTools).isInstanceOf(McpPaginationException.class) + .hasMessageContaining("more than 2"); + assertThat(toolsRequests.get()).isEqualTo(2); + } + +}