test(streamable-http): add missing integration tests for pagination, bad request, and logging#613
Conversation
There was a problem hiding this comment.
Pull request overview
Adds missing Streamable HTTP integration coverage to close remaining non-security scenarios for listing pagination, invalid cursor handling, and logging message delivery/filtering.
Changes:
- Add cursor-based pagination integration tests for prompts/resources/tools list endpoints.
- Add invalid cursor (“bad request”) tests asserting server exceptions propagate as
McpExceptionwithINTERNAL_ERROR. - Add Streamable HTTP–specific logging integration tests for
setLoggingLeveland log message notifications.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
| integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/streamablehttp/LoggingIntegrationTestStreamableHttp.kt | New Streamable HTTP logging integration tests (notifications + level filtering). |
| integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractToolIntegrationTest.kt | Adds tools list pagination + invalid cursor integration coverage. |
| integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractResourceIntegrationTest.kt | Adds resources list pagination + invalid cursor integration coverage. |
| integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractPromptIntegrationTest.kt | Adds prompts list pagination + invalid cursor integration coverage. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| val all = server.tools.values.map { it.tool } | ||
| val cursor = request.cursor?.toIntOrNull() ?: 0 | ||
| val pageSize = 2 | ||
| val page = all.drop(cursor).take(pageSize) | ||
| val next = if (cursor + page.size < all.size) (cursor + page.size).toString() else null | ||
| ListToolsResult(tools = page, nextCursor = next) | ||
| } | ||
| } | ||
|
|
||
| val first = client.listTools() | ||
| assertTrue(first.tools.isNotEmpty()) | ||
| val next = first.nextCursor | ||
| assertNotNull(next) | ||
|
|
||
| val second = client.listTools(ListToolsRequest(PaginatedRequestParams(cursor = next))) | ||
| assertTrue(second.tools.isNotEmpty()) | ||
|
|
||
| val combinedNames = (first.tools + second.tools).map { it.name } | ||
| assertTrue(combinedNames.any { it.startsWith(prefix) }) | ||
| } |
There was a problem hiding this comment.
The pagination handler builds all from server.tools.values, which comes from an immutable persistent map (no guaranteed iteration order). Because the test only fetches two pages, the newly added paginated-tool-* entries may not appear in those pages, making this test potentially flaky. Consider sorting all by tool name and/or filtering to the prefix tools (or paging until nextCursor == null) so the assertions are deterministic.
| val exception = org.junit.jupiter.api.assertThrows<io.modelcontextprotocol.kotlin.sdk.types.McpException> { | ||
| runBlocking { | ||
| client.listTools(ListToolsRequest(PaginatedRequestParams(cursor = "bad"))) | ||
| } |
There was a problem hiding this comment.
This test is already running inside runBlocking(Dispatchers.IO); wrapping the suspending client.listTools(...) call in an additional runBlocking { ... } inside assertThrows is redundant and can make failures/hangs harder to diagnose. Prefer using a suspending assertion (e.g., assertFailsWith) inside the existing runBlocking, or drop the outer runBlocking and keep a single runBlocking inside assertThrows.
| val first = client.listResources() | ||
| assertTrue(first.resources.isNotEmpty()) | ||
| val next = first.nextCursor | ||
| assertNotNull(next) | ||
|
|
||
| val second = client.listResources(ListResourcesRequest(PaginatedRequestParams(cursor = next))) | ||
| assertTrue(second.resources.isNotEmpty()) | ||
|
|
||
| val combinedUris = (first.resources + second.resources).map { it.uri } |
There was a problem hiding this comment.
The pagination handler uses server.resources.values (backed by a persistent map) as the source list. Iteration order isn’t guaranteed, and since the test only fetches two pages, the paginated-resource-* entries might not land in those pages, which can make the test flaky. Sort/filter the resources list (e.g., by uri/name) and/or page until nextCursor == null before asserting.
| val first = client.listResources() | |
| assertTrue(first.resources.isNotEmpty()) | |
| val next = first.nextCursor | |
| assertNotNull(next) | |
| val second = client.listResources(ListResourcesRequest(PaginatedRequestParams(cursor = next))) | |
| assertTrue(second.resources.isNotEmpty()) | |
| val combinedUris = (first.resources + second.resources).map { it.uri } | |
| val combinedUris = mutableListOf<String>() | |
| var cursor: String? = null | |
| do { | |
| val request = if (cursor == null) { | |
| ListResourcesRequest() | |
| } else { | |
| ListResourcesRequest(PaginatedRequestParams(cursor = cursor)) | |
| } | |
| val response = client.listResources(request) | |
| combinedUris += response.resources.map { it.uri } | |
| cursor = response.nextCursor | |
| } while (cursor != null) | |
| assertTrue(combinedUris.isNotEmpty()) |
| val exception = org.junit.jupiter.api.assertThrows<McpException> { | ||
| runBlocking { | ||
| client.listResources(ListResourcesRequest(PaginatedRequestParams(cursor = "bad"))) | ||
| } |
There was a problem hiding this comment.
This test runs in runBlocking(Dispatchers.IO) already; the additional nested runBlocking { client.listResources(...) } inside assertThrows is unnecessary. Consider using a suspending assertion within the current runBlocking scope, or remove the outer runBlocking and keep a single runBlocking around the suspending call.
| val nextCursor = first.nextCursor | ||
| assertNotNull(nextCursor) | ||
|
|
||
| val second = client.listPrompts(ListPromptsRequest(PaginatedRequestParams(cursor = nextCursor))) | ||
| assertTrue(second.prompts.isNotEmpty()) | ||
|
|
||
| val combined = first.prompts + second.prompts | ||
| assertTrue(combined.any { it.name.startsWith(pagePrefix) }) |
There was a problem hiding this comment.
all is built from server.prompts.values (persistent map iteration order is not guaranteed). Because the test only fetches two pages, the added paginated-prompt-* prompts may not be present in first + second, making this test potentially flaky. Sort/filter the prompts list (e.g., by name) and/or iterate through pages until nextCursor == null before asserting.
| val nextCursor = first.nextCursor | |
| assertNotNull(nextCursor) | |
| val second = client.listPrompts(ListPromptsRequest(PaginatedRequestParams(cursor = nextCursor))) | |
| assertTrue(second.prompts.isNotEmpty()) | |
| val combined = first.prompts + second.prompts | |
| assertTrue(combined.any { it.name.startsWith(pagePrefix) }) | |
| val allPrompts = mutableListOf<io.modelcontextprotocol.kotlin.sdk.types.Prompt>() | |
| allPrompts.addAll(first.prompts) | |
| var cursor = first.nextCursor | |
| while (cursor != null) { | |
| val page = client.listPrompts(ListPromptsRequest(PaginatedRequestParams(cursor = cursor))) | |
| allPrompts.addAll(page.prompts) | |
| cursor = page.nextCursor | |
| } | |
| assertTrue(allPrompts.any { it.name.startsWith(pagePrefix) }) |
| val exception = org.junit.jupiter.api.assertThrows<McpException> { | ||
| runBlocking { | ||
| client.listPrompts(ListPromptsRequest(PaginatedRequestParams(cursor = "not-a-number"))) | ||
| } |
There was a problem hiding this comment.
This test already uses runBlocking(Dispatchers.IO); the nested runBlocking { client.listPrompts(...) } inside assertThrows is redundant. Prefer a single runBlocking (either outer or inner) and a suspending assertion so the test structure is simpler and avoids blocking an extra thread.
| } | ||
|
|
||
| server.addTool(name = "test-logging-level", description = "test") { request -> | ||
| LoggingLevel.values().forEach { level -> |
There was a problem hiding this comment.
LoggingLevel.values() allocates a new array each call; elsewhere in the codebase tests use LoggingLevel.entries, which is allocation-free and the preferred Kotlin API. Consider switching to entries for consistency and to avoid unnecessary allocations.
| LoggingLevel.values().forEach { level -> | |
| LoggingLevel.entries.forEach { level -> |
6735788 to
af97f37
Compare
84d33e7 to
d839b42
Compare
❌ 1 Tests Failed:
View the top 1 failed test(s) by shortest run time
To view more test analytics, go to the Test Analytics Dashboard |
fa786e1 to
45a3348
Compare
| kotlin.test.assertEquals(LoggingLevel.Info, received.params.level) | ||
| kotlin.test.assertEquals(JsonPrimitive("test-data-sample"), received.params.data) |
There was a problem hiding this comment.
Doesn’t this duplicate ClientConnectionLoggingTest?
| kotlin.test.assertEquals(LoggingLevel.Info, received.params.level) | ||
| kotlin.test.assertEquals(JsonObject(mapOf("key" to JsonPrimitive("value"))), received.params.data) |
| kotlin.test.assertEquals(expectedLevels.size, receivedMessages.size) | ||
| kotlin.test.assertEquals(expectedLevels.toList(), receivedMessages.map { it.params.level }) |
| } | ||
| } | ||
|
|
||
| val exception = kotlin.test.assertFailsWith<McpException> { |
| } | ||
| } | ||
|
|
||
| val exception = kotlin.test.assertFailsWith<McpException> { |
| } | ||
| } | ||
|
|
||
| val allPrompts = mutableListOf<io.modelcontextprotocol.kotlin.sdk.types.Prompt>() |
| currentCursor = response.nextCursor | ||
| } while (currentCursor != null) | ||
|
|
||
| assertTrue(allPrompts.any { it.name.startsWith(pagePrefix) }) |
…bad request, and logging - Add cursor-based pagination tests for Prompts, Resources, and Tools with full page traversal until nextCursor is null - Add invalid cursor tests using assertFailsWith (no nested runBlocking) - Add LoggingIntegrationTestStreamableHttp for setLevel and notification tests - Use LoggingLevel.entries instead of values() for allocation-free iteration
…ests - Replaced `if` checks with `requireNotNull` for argument validation. - Updated formatting for response generation methods to improve clarity. - Sorted tools by name in `listTools` handler for consistent pagination testing. - Replaced `kotlin.test` prefix with direct imports for assertions.
… same thread LoggingIntegrationTestStreamableHttp is not designed for concurrent execution
45a3348 to
24a1bcc
Compare
This PR adds the remaining non-security integration tests for the Streamable HTTP transport as outlined in #183 and left over from #486.
Changes included:
Abstract*IntegrationTest.McpExceptionwithINTERNAL_ERROR.LoggingIntegrationTestStreamableHttpto specifically testsetLeveland logging message notifications over Streamable HTTP.Note: Security (Allow/Deny) tests are intentionally excluded as discussed in #486, pending further server auth model implementation.