diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java index 2044d8b38..cbdfae6c2 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java @@ -5,6 +5,7 @@ package io.modelcontextprotocol.server; import java.time.Duration; +import java.util.Base64; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -123,6 +124,11 @@ public class McpAsyncServer { private McpUriTemplateManagerFactory uriTemplateManagerFactory = new DefaultMcpUriTemplateManagerFactory(); + private final TypeRef PAGINATED_REQUEST_TYPE_REF = new TypeRef<>() { + }; + + private static final int PAGE_SIZE = 10; + /** * Create a new McpAsyncServer with the given transport provider and capabilities. * @param mcpTransportProvider The transport layer implementation for MCP @@ -537,9 +543,26 @@ public Mono notifyToolsListChanged() { private McpRequestHandler toolsListRequestHandler() { return (exchange, params) -> { - List tools = this.tools.stream().map(McpServerFeatures.AsyncToolSpecification::tool).toList(); + var paginatedRequest = jsonMapper.convertValue(params, PAGINATED_REQUEST_TYPE_REF); + var cursor = paginatedRequest != null ? paginatedRequest.cursor() : null; + + var mapSize = this.tools.size(); + var mapHash = this.tools.hashCode(); + + return handleCursor(cursor, mapSize, mapHash).map(requestedStartIndex -> { + var startIndex = requestedStartIndex != null ? requestedStartIndex : 0; + var endIndex = Math.min(startIndex + PAGE_SIZE, mapSize); - return Mono.just(McpSchema.ListToolsResult.builder(tools).build()); + var nextCursor = getCursor(endIndex, mapSize, mapHash); + + var resultList = this.tools.stream() + .skip(startIndex) + .limit(endIndex - startIndex) + .map(McpServerFeatures.AsyncToolSpecification::tool) + .toList(); + + return McpSchema.ListToolsResult.builder(resultList).nextCursor(nextCursor).build(); + }); }; } @@ -787,21 +810,53 @@ private McpRequestHandler resourcesUnsubscribeRequestHandler() { private McpRequestHandler resourcesListRequestHandler() { return (exchange, params) -> { - var resourceList = this.resources.values() - .stream() - .map(McpServerFeatures.AsyncResourceSpecification::resource) - .toList(); - return Mono.just(McpSchema.ListResourcesResult.builder(resourceList).build()); + var paginatedRequest = jsonMapper.convertValue(params, PAGINATED_REQUEST_TYPE_REF); + var cursor = paginatedRequest != null ? paginatedRequest.cursor() : null; + + var mapSize = this.resources.size(); + var mapHash = this.resources.hashCode(); + + return handleCursor(cursor, mapSize, mapHash).map(requestedStartIndex -> { + var startIndex = requestedStartIndex != null ? requestedStartIndex : 0; + var endIndex = Math.min(startIndex + PAGE_SIZE, mapSize); + + var nextCursor = getCursor(endIndex, mapSize, mapHash); + + var resultList = this.resources.values() + .stream() + .skip(startIndex) + .limit(endIndex - startIndex) + .map(McpServerFeatures.AsyncResourceSpecification::resource) + .toList(); + + return McpSchema.ListResourcesResult.builder(resultList).nextCursor(nextCursor).build(); + }); }; } private McpRequestHandler resourceTemplateListRequestHandler() { return (exchange, params) -> { - var resourceList = this.resourceTemplates.values() - .stream() - .map(McpServerFeatures.AsyncResourceTemplateSpecification::resourceTemplate) - .toList(); - return Mono.just(McpSchema.ListResourceTemplatesResult.builder(resourceList).build()); + var paginatedRequest = jsonMapper.convertValue(params, PAGINATED_REQUEST_TYPE_REF); + var cursor = paginatedRequest != null ? paginatedRequest.cursor() : null; + + var mapSize = this.resourceTemplates.size(); + var mapHash = this.resourceTemplates.hashCode(); + + return handleCursor(cursor, mapSize, mapHash).map(requestedStartIndex -> { + var startIndex = requestedStartIndex != null ? requestedStartIndex : 0; + var endIndex = Math.min(startIndex + PAGE_SIZE, mapSize); + + var nextCursor = getCursor(endIndex, mapSize, mapHash); + + var resultList = this.resourceTemplates.values() + .stream() + .skip(startIndex) + .limit(endIndex - startIndex) + .map(McpServerFeatures.AsyncResourceTemplateSpecification::resourceTemplate) + .toList(); + + return McpSchema.ListResourceTemplatesResult.builder(resultList).nextCursor(nextCursor).build(); + }); }; } @@ -923,17 +978,27 @@ public Mono notifyPromptsListChanged() { private McpRequestHandler promptsListRequestHandler() { return (exchange, params) -> { - // TODO: Implement pagination - // McpSchema.PaginatedRequest request = objectMapper.convertValue(params, - // new TypeReference() { - // }); + var paginatedRequest = jsonMapper.convertValue(params, PAGINATED_REQUEST_TYPE_REF); + var cursor = paginatedRequest != null ? paginatedRequest.cursor() : null; - var promptList = this.prompts.values() - .stream() - .map(McpServerFeatures.AsyncPromptSpecification::prompt) - .toList(); + var mapSize = this.prompts.size(); + var mapHash = this.prompts.hashCode(); - return Mono.just(McpSchema.ListPromptsResult.builder(promptList).build()); + return handleCursor(cursor, mapSize, mapHash).map(requestedStartIndex -> { + var startIndex = requestedStartIndex != null ? requestedStartIndex : 0; + var endIndex = Math.min(startIndex + PAGE_SIZE, mapSize); + + var nextCursor = getCursor(endIndex, mapSize, mapHash); + + var resultList = this.prompts.values() + .stream() + .skip(startIndex) + .limit(endIndex - startIndex) + .map(McpServerFeatures.AsyncPromptSpecification::prompt) + .toList(); + + return McpSchema.ListPromptsResult.builder(resultList).nextCursor(nextCursor).build(); + }); }; } @@ -1089,4 +1154,84 @@ void setProtocolVersions(List protocolVersions) { this.protocolVersions = protocolVersions; } + // --------------------------------------- + // Cursor Handling for paginated requests + // --------------------------------------- + + /** + * Handles the cursor by decoding, validating and reading the index of it. + * @param cursor the base64 representation of the cursor. + * @param mapSize the size of the map from which the values should be read. + * @param mapHash the hash of the map to compare the cursor value to. + * @return a {@link Mono} which contains the index to which the cursor points. + */ + private Mono handleCursor(String cursor, int mapSize, int mapHash) { + if (cursor == null) { + return Mono.just(0); + } + + var decodedCursor = decodeCursor(cursor); + + if (!isCursorValid(decodedCursor, mapSize, mapHash)) { + return Mono.error(McpError.builder(ErrorCodes.INVALID_PARAMS).message("Invalid cursor").build()); + } + + return Mono.just(getCursorIndex(decodedCursor)); + } + + private String getCursor(int endIndex, int mapSize, int mapHash) { + if (endIndex >= mapSize) { + return null; + } + return encodeCursor(endIndex, mapHash); + } + + private int getCursorIndex(String cursor) { + return Integer.parseInt(cursor.split(":")[0]); + } + + private boolean isCursorValid(String cursor, int maxPageSize, int currentHash) { + var cursorElements = cursor.split(":"); + + if (cursorElements.length != 2) { + logger.debug("Length of elements in cursor doesn't match expected number. Cursor: {} Actual number: {}", + cursor, cursorElements.length); + return false; + } + + int index; + int hash; + + try { + index = Integer.parseInt(cursorElements[0]); + hash = Integer.parseInt(cursorElements[1]); + } + catch (NumberFormatException e) { + logger.debug("Failed to parse cursor elements."); + return false; + } + + if (index < 0 || index > maxPageSize) { + logger.debug("Cursor boundaries are invalid."); + return false; + } + + if (hash != currentHash) { + logger.debug("Cursor not valid, anymore."); + return false; + } + + return true; + } + + private String encodeCursor(int index, int hash) { + var cursor = index + ":" + hash; + + return Base64.getEncoder().encodeToString(cursor.getBytes()); + } + + private String decodeCursor(String base64Cursor) { + return new String(Base64.getDecoder().decode(base64Cursor)); + } + } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java index 3d7054cba..4b7787cd9 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java @@ -30,6 +30,7 @@ import java.time.Duration; import java.util.ArrayList; +import java.util.Base64; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -80,6 +81,11 @@ public class McpStatelessAsyncServer { private final boolean validateToolInputs; + private final TypeRef PAGINATED_REQUEST_TYPE_REF = new TypeRef<>() { + }; + + private static final int PAGE_SIZE = 10; + McpStatelessAsyncServer(McpStatelessServerTransport mcpTransport, McpJsonMapper jsonMapper, McpStatelessServerFeatures.Async features, Duration requestTimeout, McpUriTemplateManagerFactory uriTemplateManagerFactory, JsonSchemaValidator jsonSchemaValidator, @@ -398,11 +404,27 @@ public Mono removeTool(String toolName) { } private McpStatelessRequestHandler toolsListRequestHandler() { - return (ctx, params) -> { - List tools = this.tools.stream() - .map(McpStatelessServerFeatures.AsyncToolSpecification::tool) - .toList(); - return Mono.just(McpSchema.ListToolsResult.builder(tools).build()); + return (exchange, params) -> { + var paginatedRequest = jsonMapper.convertValue(params, PAGINATED_REQUEST_TYPE_REF); + var cursor = paginatedRequest != null ? paginatedRequest.cursor() : null; + + var mapSize = this.tools.size(); + var mapHash = this.tools.hashCode(); + + return handleCursor(cursor, mapSize, mapHash).map(requestedStartIndex -> { + var startIndex = requestedStartIndex != null ? requestedStartIndex : 0; + var endIndex = Math.min(startIndex + PAGE_SIZE, mapSize); + + var nextCursor = getCursor(endIndex, mapSize, mapHash); + + var resultList = this.tools.stream() + .skip(startIndex) + .limit(endIndex - startIndex) + .map(McpStatelessServerFeatures.AsyncToolSpecification::tool) + .toList(); + + return McpSchema.ListToolsResult.builder(resultList).nextCursor(nextCursor).build(); + }); }; } @@ -561,22 +583,54 @@ public Mono removeResourceTemplate(String uriTemplate) { } private McpStatelessRequestHandler resourcesListRequestHandler() { - return (ctx, params) -> { - var resourceList = this.resources.values() - .stream() - .map(McpStatelessServerFeatures.AsyncResourceSpecification::resource) - .toList(); - return Mono.just(McpSchema.ListResourcesResult.builder(resourceList).build()); + return (exchange, params) -> { + var paginatedRequest = jsonMapper.convertValue(params, PAGINATED_REQUEST_TYPE_REF); + var cursor = paginatedRequest != null ? paginatedRequest.cursor() : null; + + var mapSize = this.resources.size(); + var mapHash = this.resources.hashCode(); + + return handleCursor(cursor, mapSize, mapHash).map(requestedStartIndex -> { + var startIndex = requestedStartIndex != null ? requestedStartIndex : 0; + var endIndex = Math.min(startIndex + PAGE_SIZE, mapSize); + + var nextCursor = getCursor(endIndex, mapSize, mapHash); + + var resultList = this.resources.values() + .stream() + .skip(startIndex) + .limit(endIndex - startIndex) + .map(McpStatelessServerFeatures.AsyncResourceSpecification::resource) + .toList(); + + return McpSchema.ListResourcesResult.builder(resultList).nextCursor(nextCursor).build(); + }); }; } private McpStatelessRequestHandler resourceTemplateListRequestHandler() { return (exchange, params) -> { - var resourceList = this.resourceTemplates.values() - .stream() - .map(AsyncResourceTemplateSpecification::resourceTemplate) - .toList(); - return Mono.just(McpSchema.ListResourceTemplatesResult.builder(resourceList).build()); + var paginatedRequest = jsonMapper.convertValue(params, PAGINATED_REQUEST_TYPE_REF); + var cursor = paginatedRequest != null ? paginatedRequest.cursor() : null; + + var mapSize = this.resourceTemplates.size(); + var mapHash = this.resourceTemplates.hashCode(); + + return handleCursor(cursor, mapSize, mapHash).map(requestedStartIndex -> { + var startIndex = requestedStartIndex != null ? requestedStartIndex : 0; + var endIndex = Math.min(startIndex + PAGE_SIZE, mapSize); + + var nextCursor = getCursor(endIndex, mapSize, mapHash); + + var resultList = this.resourceTemplates.values() + .stream() + .skip(startIndex) + .limit(endIndex - startIndex) + .map(McpStatelessServerFeatures.AsyncResourceTemplateSpecification::resourceTemplate) + .toList(); + + return McpSchema.ListResourceTemplatesResult.builder(resultList).nextCursor(nextCursor).build(); + }); }; } @@ -685,18 +739,28 @@ public Mono removePrompt(String promptName) { } private McpStatelessRequestHandler promptsListRequestHandler() { - return (ctx, params) -> { - // TODO: Implement pagination - // McpSchema.PaginatedRequest request = objectMapper.convertValue(params, - // new TypeReference() { - // }); + return (exchange, params) -> { + var paginatedRequest = jsonMapper.convertValue(params, PAGINATED_REQUEST_TYPE_REF); + var cursor = paginatedRequest != null ? paginatedRequest.cursor() : null; + + var mapSize = this.prompts.size(); + var mapHash = this.prompts.hashCode(); + + return handleCursor(cursor, mapSize, mapHash).map(requestedStartIndex -> { + var startIndex = requestedStartIndex != null ? requestedStartIndex : 0; + var endIndex = Math.min(startIndex + PAGE_SIZE, mapSize); - var promptList = this.prompts.values() - .stream() - .map(McpStatelessServerFeatures.AsyncPromptSpecification::prompt) - .toList(); + var nextCursor = getCursor(endIndex, mapSize, mapHash); - return Mono.just(McpSchema.ListPromptsResult.builder(promptList).build()); + var resultList = this.prompts.values() + .stream() + .skip(startIndex) + .limit(endIndex - startIndex) + .map(McpStatelessServerFeatures.AsyncPromptSpecification::prompt) + .toList(); + + return McpSchema.ListPromptsResult.builder(resultList).nextCursor(nextCursor).build(); + }); }; } @@ -831,4 +895,79 @@ void setProtocolVersions(List protocolVersions) { this.protocolVersions = protocolVersions; } + // --------------------------------------- + // Cursor Handling for paginated requests + // --------------------------------------- + + /** + * Handles the cursor by decoding, validating and reading the index of it. + * @param cursor the base64 representation of the cursor. + * @param mapSize the size of the map from which the values should be read. + * @param mapHash the hash of the map to compare the cursor value to. + * @return a {@link Mono} which contains the index to which the cursor points. + */ + private Mono handleCursor(String cursor, int mapSize, int mapHash) { + if (cursor == null) { + return Mono.just(0); + } + + var decodedCursor = decodeCursor(cursor); + + if (!isCursorValid(decodedCursor, mapSize, mapHash)) { + return Mono.error(McpError.builder(ErrorCodes.INVALID_PARAMS).message("Invalid cursor").build()); + } + + return Mono.just(getCursorIndex(decodedCursor)); + } + + private String getCursor(int endIndex, int mapSize, int mapHash) { + if (endIndex >= mapSize) { + return null; + } + return encodeCursor(endIndex, mapHash); + } + + private int getCursorIndex(String cursor) { + return Integer.parseInt(cursor.split(":")[0]); + } + + private boolean isCursorValid(String cursor, int maxPageSize, int currentHash) { + var cursorElements = cursor.split(":"); + + if (cursorElements.length != 2) { + logger.debug("Length of elements in cursor doesn't match expected number. Cursor: {} Actual number: {}", + cursor, cursorElements.length); + return false; + } + + int index; + int hash; + + try { + index = Integer.parseInt(cursorElements[0]); + hash = Integer.parseInt(cursorElements[1]); + } + catch (NumberFormatException e) { + logger.debug("Failed to parse cursor elements."); + return false; + } + + if (index < 0 || index > maxPageSize || hash != currentHash) { + logger.debug("Cursor boundaries are invalid."); + return false; + } + + return true; + } + + private String encodeCursor(int index, int hash) { + var cursor = index + ":" + hash; + + return Base64.getEncoder().encodeToString(cursor.getBytes()); + } + + private String decodeCursor(String base64Cursor) { + return new String(Base64.getDecoder().decode(base64Cursor)); + } + } diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java index 3e4ac4837..048f15c97 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java @@ -4,6 +4,7 @@ package io.modelcontextprotocol; +import static io.modelcontextprotocol.spec.McpSchema.ErrorCodes.INVALID_PARAMS; import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; import java.net.URI; @@ -11,6 +12,8 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; +import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -50,7 +53,6 @@ import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities; import io.modelcontextprotocol.spec.McpSchema.TextContent; import io.modelcontextprotocol.spec.McpSchema.Tool; -import io.modelcontextprotocol.util.McpJsonMapperUtils; import io.modelcontextprotocol.util.Utils; import net.javacrumbs.jsonunit.core.Option; import org.junit.jupiter.params.ParameterizedTest; @@ -62,6 +64,7 @@ import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertWith; import static org.awaitility.Awaitility.await; import static org.mockito.Mockito.mock; @@ -1086,6 +1089,167 @@ void testToolListChangeHandlingSuccess(String clientType) { } } + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "httpclient" }) + void testPaginatedListToolsSuccess(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + + List tools = new ArrayList<>(); + + for (int i = 0; i < 21; i++) { + var mock = McpSchema.Tool.builder("test-tool-" + i, EMPTY_JSON_SCHEMA) + .description("Test progress notifications") + .build(); + var spec = McpServerFeatures.SyncToolSpecification.builder() + .tool(mock) + .callHandler(buildCallToolRequestHandlerMock()) + .build(); + + tools.add(spec); + } + + var mcpServer = prepareSyncServerBuilder().capabilities(ServerCapabilities.builder().tools(true).build()) + .tools(tools) + .build(); + + try (var mcpClient = clientBuilder.build()) { + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + var returnedElements = new HashSet(); + + var hasEntries = true; + String nextCursor = null; + + while (hasEntries) { + var res = mcpClient.listTools(nextCursor); + + res.tools().forEach(e -> returnedElements.add(e.name())); // store unique + // attribute + + nextCursor = res.nextCursor(); + + if (nextCursor == null) { + hasEntries = false; + } + } + + assertThat(returnedElements.size()).isEqualTo(21); + } + + mcpServer.close(); + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "httpclient" }) + void testPaginatedListToolsCursorInvalidListChanged(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + + var pageSize = 11; + List tools = new ArrayList<>(); + + for (int i = 0; i <= pageSize; i++) { + var mock = McpSchema.Tool.builder("test-tool-" + i, EMPTY_JSON_SCHEMA) + .description("Test progress notifications") + .build(); + var spec = McpServerFeatures.SyncToolSpecification.builder() + .tool(mock) + .callHandler(buildCallToolRequestHandlerMock()) + .build(); + + tools.add(spec); + } + + var mcpServer = prepareSyncServerBuilder().capabilities(ServerCapabilities.builder().tools(true).build()) + .tools(tools) + .build(); + + try (var mcpClient = clientBuilder.build()) { + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + var res = mcpClient.listTools(null); + + // Change list + var mock = McpSchema.Tool.builder("test-tool-xyz", EMPTY_JSON_SCHEMA) + .description("Test progress notifications") + .build(); + mcpServer.addTool(new McpServerFeatures.SyncToolSpecification(mock, null)); + + assertThatThrownBy(() -> mcpClient.listTools(res.nextCursor())).isInstanceOf(McpError.class) + .hasMessage("Invalid cursor") + .satisfies(exception -> { + var error = (McpError) exception; + assertThat(error.getJsonRpcError().code()).isEqualTo(INVALID_PARAMS); + assertThat(error.getJsonRpcError().message()).isEqualTo("Invalid cursor"); + }); + + } + + mcpServer.close(); + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "httpclient" }) + void testPaginatedListToolsInvalidCursor(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + + var mock = McpSchema.Tool.builder("test-tool-xyz", EMPTY_JSON_SCHEMA) + .description("Test progress notifications") + .build(); + var spec = new McpServerFeatures.SyncToolSpecification(mock, null); + + var mcpServer = prepareSyncServerBuilder().capabilities(ServerCapabilities.builder().tools(true).build()) + .tools(spec) + .build(); + + try (var mcpClient = clientBuilder.build()) { + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + assertThatThrownBy(() -> mcpClient.listTools("INVALID")).isInstanceOf(McpError.class) + .hasMessage("Invalid cursor") + .satisfies(exception -> { + var error = (McpError) exception; + assertThat(error.getJsonRpcError().code()).isEqualTo(INVALID_PARAMS); + assertThat(error.getJsonRpcError().message()).isEqualTo("Invalid cursor"); + }); + + } + + mcpServer.close(); + } + + private BiFunction buildCallToolRequestHandlerMock() { + var callResponse = McpSchema.CallToolResult.builder() + .addContent(McpSchema.TextContent.builder("CALL RESPONSE").build()) + .build(); + + return (exchange, request) -> { + // perform a blocking call to a remote service + try { + HttpResponse response = HttpClient.newHttpClient() + .send(HttpRequest.newBuilder() + .uri(URI.create( + "https://raw.githubusercontent.com/modelcontextprotocol/java-sdk/refs/heads/main/README.md")) + .GET() + .build(), HttpResponse.BodyHandlers.ofString()); + String responseBody = response.body(); + assertThat(responseBody).isNotBlank(); + } + catch (Exception e) { + e.printStackTrace(); + } + return callResponse; + }; + } + @ParameterizedTest(name = "{0} : {displayName} ") @MethodSource("clientsForTesting") void testInitialize(String clientType) { @@ -1902,6 +2066,393 @@ void testResourceSubscription_afterUnsubscribe_noNotification(String clientType) } } + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "httpclient" }) + void testPaginatedListResourcesSuccess(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + + List resources = new ArrayList<>(); + + for (int i = 0; i < 21; i++) { + var mock = McpSchema.Resource.builder("test://static-text/" + i, "Static Text Resource") + .description("A static text resource for testing") + .mimeType("text/plain") + .build(); + var spec = new McpServerFeatures.SyncResourceSpecification(mock, null); + resources.add(spec); + } + + var mcpServer = prepareSyncServerBuilder() + .capabilities(ServerCapabilities.builder().resources(true, true).build()) + .resources(resources) + .build(); + + try (var mcpClient = clientBuilder.build()) { + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + var returnedElements = new HashSet(); + + var hasEntries = true; + String nextCursor = null; + + while (hasEntries) { + var res = mcpClient.listResources(nextCursor); + + res.resources().forEach(e -> returnedElements.add(e.uri())); + + nextCursor = res.nextCursor(); + + if (nextCursor == null) { + hasEntries = false; + } + } + + assertThat(returnedElements.size()).isEqualTo(21); + } + + mcpServer.close(); + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "httpclient" }) + void testPaginatedListResourcesCursorInvalidListChanged(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + + var pageSize = 11; + List resources = new ArrayList<>(); + + for (int i = 0; i < pageSize; i++) { + var mock = McpSchema.Resource.builder("test://static-text/" + i, "Static Text Resource") + .description("A static text resource for testing") + .mimeType("text/plain") + .build(); + var spec = new McpServerFeatures.SyncResourceSpecification(mock, null); + resources.add(spec); + } + + var mcpServer = prepareSyncServerBuilder() + .capabilities(ServerCapabilities.builder().resources(true, true).build()) + .resources(resources) + .build(); + + try (var mcpClient = clientBuilder.build()) { + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + var res = mcpClient.listResources(null); + + // Change list + var mock = McpSchema.Resource.builder("test://static-text/" + 99, "Static Text Resource") + .description("A static text resource for testing") + .mimeType("text/plain") + .build(); + var spec = new McpServerFeatures.SyncResourceSpecification(mock, null); + mcpServer.addResource(spec); + + assertThatThrownBy(() -> mcpClient.listResources(res.nextCursor())).isInstanceOf(McpError.class) + .hasMessage("Invalid cursor") + .satisfies(exception -> { + var error = (McpError) exception; + assertThat(error.getJsonRpcError().code()).isEqualTo(INVALID_PARAMS); + assertThat(error.getJsonRpcError().message()).isEqualTo("Invalid cursor"); + }); + } + + mcpServer.close(); + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "httpclient" }) + void testPaginatedListResourcesInvalidCursor(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + + var mock = McpSchema.Resource.builder("test://static-text/" + 0, "Static Text Resource") + .description("A static text resource for testing") + .mimeType("text/plain") + .build(); + var spec = new McpServerFeatures.SyncResourceSpecification(mock, null); + + var mcpServer = prepareSyncServerBuilder() + .capabilities(ServerCapabilities.builder().resources(true, true).build()) + .resources(spec) + .build(); + + try (var mcpClient = clientBuilder.build()) { + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + assertThatThrownBy(() -> mcpClient.listResources("INVALID")).isInstanceOf(McpError.class) + .hasMessage("Invalid cursor") + .satisfies(exception -> { + var error = (McpError) exception; + assertThat(error.getJsonRpcError().code()).isEqualTo(INVALID_PARAMS); + assertThat(error.getJsonRpcError().message()).isEqualTo("Invalid cursor"); + }); + } + + mcpServer.close(); + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "httpclient" }) + void testPaginatedListResourceTemplatesListSuccess(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + + List resources = new ArrayList<>(); + + for (int i = 0; i < 21; i++) { + var mock = McpSchema.ResourceTemplate.builder("test://static-text/" + i, "Static Text Resource") + .description("A static text resource for testing") + .mimeType("text/plain") + .build(); + var spec = new McpServerFeatures.SyncResourceTemplateSpecification(mock, null); + resources.add(spec); + } + + var mcpServer = prepareSyncServerBuilder() + .capabilities(ServerCapabilities.builder().resources(true, true).build()) + .resourceTemplates(resources) + .build(); + + try (var mcpClient = clientBuilder.build()) { + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + var returnedElements = new HashSet(); + + var hasEntries = true; + String nextCursor = null; + + while (hasEntries) { + var res = mcpClient.listResourceTemplates(nextCursor); + + res.resourceTemplates().forEach(e -> returnedElements.add(e.uriTemplate())); + + nextCursor = res.nextCursor(); + + if (nextCursor == null) { + hasEntries = false; + } + } + + assertThat(returnedElements.size()).isEqualTo(21); + } + + mcpServer.close(); + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "httpclient" }) + void testPaginatedListResourceTemplatesListCursorInvalidListChanged(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + + var pageSize = 11; + List resources = new ArrayList<>(); + + for (int i = 0; i < pageSize; i++) { + var mock = McpSchema.ResourceTemplate.builder("test://static-text/" + i, "Static Text Resource") + .description("A static text resource for testing") + .mimeType("text/plain") + .build(); + var spec = new McpServerFeatures.SyncResourceTemplateSpecification(mock, null); + resources.add(spec); + } + + var mcpServer = prepareSyncServerBuilder() + .capabilities(ServerCapabilities.builder().resources(true, true).build()) + .resourceTemplates(resources) + .build(); + + try (var mcpClient = clientBuilder.build()) { + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + var res = mcpClient.listResourceTemplates(null); + + // Change list + var mock = McpSchema.ResourceTemplate.builder("test://static-text/" + 99, "Static Text Resource") + .description("A static text resource for testing") + .mimeType("text/plain") + .build(); + var spec = new McpServerFeatures.SyncResourceTemplateSpecification(mock, null); + mcpServer.addResourceTemplate(spec); + + assertThatThrownBy(() -> mcpClient.listResourceTemplates(res.nextCursor())).isInstanceOf(McpError.class) + .hasMessage("Invalid cursor") + .satisfies(exception -> { + var error = (McpError) exception; + assertThat(error.getJsonRpcError().code()).isEqualTo(INVALID_PARAMS); + assertThat(error.getJsonRpcError().message()).isEqualTo("Invalid cursor"); + }); + } + + mcpServer.close(); + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "httpclient" }) + void testPaginatedListResourceTemplatesListInvalidCursor(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + + var mock = McpSchema.ResourceTemplate.builder("test://static-text/" + 0, "Static Text Resource") + .description("A static text resource for testing") + .mimeType("text/plain") + .build(); + var spec = new McpServerFeatures.SyncResourceTemplateSpecification(mock, null); + + var mcpServer = prepareSyncServerBuilder() + .capabilities(ServerCapabilities.builder().resources(true, true).build()) + .resourceTemplates(spec) + .build(); + + try (var mcpClient = clientBuilder.build()) { + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + assertThatThrownBy(() -> mcpClient.listResourceTemplates("INVALID")).isInstanceOf(McpError.class) + .hasMessage("Invalid cursor") + .satisfies(exception -> { + var error = (McpError) exception; + assertThat(error.getJsonRpcError().code()).isEqualTo(INVALID_PARAMS); + assertThat(error.getJsonRpcError().message()).isEqualTo("Invalid cursor"); + }); + } + + mcpServer.close(); + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "httpclient" }) + void testPaginatedPromptsListSuccess(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + + List prompts = new ArrayList<>(); + + for (int i = 0; i < 21; i++) { + var mock = McpSchema.Prompt.builder("Prompt " + i).build(); + var spec = new McpServerFeatures.SyncPromptSpecification(mock, null); + prompts.add(spec); + } + + var mcpServer = prepareSyncServerBuilder().capabilities(ServerCapabilities.builder().prompts(true).build()) + .prompts(prompts) + .build(); + + try (var mcpClient = clientBuilder.build()) { + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + var returnedElements = new HashSet(); + + var hasEntries = true; + String nextCursor = null; + + while (hasEntries) { + var res = mcpClient.listPrompts(nextCursor); + + res.prompts().forEach(e -> returnedElements.add(e.name())); + + nextCursor = res.nextCursor(); + + if (nextCursor == null) { + hasEntries = false; + } + } + + assertThat(returnedElements.size()).isEqualTo(21); + } + + mcpServer.close(); + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "httpclient" }) + void testPaginatedPromptsCursorInvalidListChanged(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + + var pageSize = 11; + List prompts = new ArrayList<>(); + + for (int i = 0; i < pageSize; i++) { + var mock = McpSchema.Prompt.builder("Prompt " + i).build(); + var spec = new McpServerFeatures.SyncPromptSpecification(mock, null); + prompts.add(spec); + } + + var mcpServer = prepareSyncServerBuilder().capabilities(ServerCapabilities.builder().prompts(true).build()) + .prompts(prompts) + .build(); + + try (var mcpClient = clientBuilder.build()) { + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + var res = mcpClient.listPrompts(null); + + // Change list + var mock = McpSchema.Prompt.builder("Prompt " + 99).build(); + var spec = new McpServerFeatures.SyncPromptSpecification(mock, null); + mcpServer.addPrompt(spec); + + assertThatThrownBy(() -> mcpClient.listPrompts(res.nextCursor())).isInstanceOf(McpError.class) + .hasMessage("Invalid cursor") + .satisfies(exception -> { + var error = (McpError) exception; + assertThat(error.getJsonRpcError().code()).isEqualTo(INVALID_PARAMS); + assertThat(error.getJsonRpcError().message()).isEqualTo("Invalid cursor"); + }); + } + + mcpServer.close(); + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "httpclient" }) + void testPaginatedListPromptsInvalidCursor(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + + var mock = McpSchema.Prompt.builder("Prompt").build(); + var spec = new McpServerFeatures.SyncPromptSpecification(mock, null); + + var mcpServer = prepareSyncServerBuilder().capabilities(ServerCapabilities.builder().prompts(true).build()) + .prompts(spec) + .build(); + + try (var mcpClient = clientBuilder.build()) { + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + assertThatThrownBy(() -> mcpClient.listPrompts("INVALID")).isInstanceOf(McpError.class) + .hasMessage("Invalid cursor") + .satisfies(exception -> { + var error = (McpError) exception; + assertThat(error.getJsonRpcError().code()).isEqualTo(INVALID_PARAMS); + assertThat(error.getJsonRpcError().message()).isEqualTo("Invalid cursor"); + }); + } + + mcpServer.close(); + } + private double evaluateExpression(String expression) { // Simple expression evaluator for testing return switch (expression) {