From ef306c1a803bcb20a580adbf5b7b7eae47428fb5 Mon Sep 17 00:00:00 2001 From: jiwon Date: Sun, 22 Mar 2026 22:42:42 +0900 Subject: [PATCH 1/3] test(streamable-http): add missing integration tests for pagination, 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 --- .../kotlin/AbstractPromptIntegrationTest.kt | 55 ++++++++ .../kotlin/AbstractResourceIntegrationTest.kt | 61 +++++++++ .../kotlin/AbstractToolIntegrationTest.kt | 61 +++++++++ .../LoggingIntegrationTestStreamableHttp.kt | 118 ++++++++++++++++++ 4 files changed, 295 insertions(+) create mode 100644 integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/streamablehttp/LoggingIntegrationTestStreamableHttp.kt diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractPromptIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractPromptIntegrationTest.kt index d14ae88e8..c55d158d3 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractPromptIntegrationTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractPromptIntegrationTest.kt @@ -6,7 +6,11 @@ import io.kotest.matchers.string.shouldContain import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequest import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult +import io.modelcontextprotocol.kotlin.sdk.types.ListPromptsRequest +import io.modelcontextprotocol.kotlin.sdk.types.ListPromptsResult import io.modelcontextprotocol.kotlin.sdk.types.McpException +import io.modelcontextprotocol.kotlin.sdk.types.Method +import io.modelcontextprotocol.kotlin.sdk.types.PaginatedRequestParams import io.modelcontextprotocol.kotlin.sdk.types.PromptArgument import io.modelcontextprotocol.kotlin.sdk.types.PromptMessage import io.modelcontextprotocol.kotlin.sdk.types.Role @@ -697,4 +701,55 @@ abstract class AbstractPromptIntegrationTest : KotlinTestBase() { exception.message shouldBe expectedMessage } } + + @Test + fun testListPromptsPagination() = runBlocking(Dispatchers.IO) { + val pagePrefix = "paginated-prompt-" + (0 until 5).forEach { i -> + val name = "$pagePrefix$i" + server.addPrompt(name = name, description = "desc", arguments = listOf()) { _ -> + GetPromptResult(description = "desc", messages = listOf(PromptMessage(role = Role.Assistant, content = TextContent(text = name)))) + } + } + + server.sessions.forEach { (_, session) -> + session.setRequestHandler(Method.Defined.PromptsList) { request, _ -> + val all = server.prompts.values.map { it.prompt } + 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 + ListPromptsResult(prompts = page, nextCursor = next) + } + } + + val allPrompts = mutableListOf() + var currentCursor: String? = null + do { + val request = if (currentCursor == null) ListPromptsRequest() else ListPromptsRequest(PaginatedRequestParams(cursor = currentCursor)) + val response = client.listPrompts(request) + allPrompts.addAll(response.prompts) + currentCursor = response.nextCursor + } while (currentCursor != null) + + assertTrue(allPrompts.any { it.name.startsWith(pagePrefix) }) + } + + @Test + fun testListPromptsInvalidCursor() = runBlocking(Dispatchers.IO) { + server.sessions.forEach { (_, session) -> + session.setRequestHandler(Method.Defined.PromptsList) { request, _ -> + val cursor = request.cursor?.toIntOrNull() ?: throw IllegalArgumentException("Invalid cursor") + val all = server.prompts.values.map { it.prompt } + val page = all.drop(cursor).take(2) + ListPromptsResult(prompts = page, nextCursor = null) + } + } + + val exception = kotlin.test.assertFailsWith { + client.listPrompts(ListPromptsRequest(PaginatedRequestParams(cursor = "not-a-number"))) + } + + kotlin.test.assertEquals(RPCError.ErrorCode.INTERNAL_ERROR, exception.code) + } } diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractResourceIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractResourceIntegrationTest.kt index 4fc53a52c..246a087a0 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractResourceIntegrationTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractResourceIntegrationTest.kt @@ -1,7 +1,11 @@ package io.modelcontextprotocol.kotlin.sdk.integration.kotlin import io.modelcontextprotocol.kotlin.sdk.types.BlobResourceContents +import io.modelcontextprotocol.kotlin.sdk.types.ListResourcesRequest +import io.modelcontextprotocol.kotlin.sdk.types.ListResourcesResult import io.modelcontextprotocol.kotlin.sdk.types.McpException +import io.modelcontextprotocol.kotlin.sdk.types.Method +import io.modelcontextprotocol.kotlin.sdk.types.PaginatedRequestParams import io.modelcontextprotocol.kotlin.sdk.types.RPCError import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequest import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequestParams @@ -309,4 +313,61 @@ abstract class AbstractResourceIntegrationTest : KotlinTestBase() { assertTrue(result.contents.isNotEmpty(), "Result contents should not be empty") } } + + @Test + fun testListResourcesPagination() = runBlocking(Dispatchers.IO) { + val prefix = "paginated-resource-" + (0 until 6).forEach { i -> + val uri = "test://$prefix$i.txt" + server.addResource(uri = uri, name = "Name-$i", description = "desc", mimeType = "text/plain") { request -> + ReadResourceResult(contents = listOf(TextResourceContents(text = uri, uri = request.params.uri, mimeType = "text/plain"))) + } + } + + server.sessions.forEach { (_, session) -> + session.setRequestHandler(Method.Defined.ResourcesList) { request, _ -> + val all = server.resources.values.map { it.resource } + val cursor = request.cursor?.toIntOrNull() ?: 0 + val pageSize = 3 + val page = all.drop(cursor).take(pageSize) + val next = if (cursor + page.size < all.size) (cursor + page.size).toString() else null + ListResourcesResult(resources = page, nextCursor = next) + } + } + + val combinedUris = mutableListOf() + var currentCursor: String? = null + + do { + val request = if (currentCursor == null) { + ListResourcesRequest() + } else { + ListResourcesRequest(PaginatedRequestParams(cursor = currentCursor)) + } + + val response = client.listResources(request) + combinedUris += response.resources.map { it.uri } + currentCursor = response.nextCursor + } while (currentCursor != null) + + assertTrue(combinedUris.any { it.contains(prefix) }) + } + + @Test + fun testListResourcesInvalidCursor() = runBlocking(Dispatchers.IO) { + server.sessions.forEach { (_, session) -> + session.setRequestHandler(Method.Defined.ResourcesList) { request, _ -> + val cursor = request.cursor?.toIntOrNull() ?: throw IllegalArgumentException("Invalid cursor") + val all = server.resources.values.map { it.resource } + val page = all.drop(cursor).take(2) + ListResourcesResult(resources = page, nextCursor = null) + } + } + + val exception = kotlin.test.assertFailsWith { + client.listResources(ListResourcesRequest(PaginatedRequestParams(cursor = "bad"))) + } + + kotlin.test.assertEquals(RPCError.ErrorCode.INTERNAL_ERROR, exception.code) + } } diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractToolIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractToolIntegrationTest.kt index 7da82cc3a..f090c844d 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractToolIntegrationTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractToolIntegrationTest.kt @@ -6,6 +6,10 @@ import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult import io.modelcontextprotocol.kotlin.sdk.types.ContentBlock import io.modelcontextprotocol.kotlin.sdk.types.ImageContent +import io.modelcontextprotocol.kotlin.sdk.types.ListToolsRequest +import io.modelcontextprotocol.kotlin.sdk.types.ListToolsResult +import io.modelcontextprotocol.kotlin.sdk.types.Method +import io.modelcontextprotocol.kotlin.sdk.types.PaginatedRequestParams import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities import io.modelcontextprotocol.kotlin.sdk.types.TextContent import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema @@ -791,4 +795,61 @@ abstract class AbstractToolIntegrationTest : KotlinTestBase() { "Error message should indicate the tool was not found", ) } + + @Test + fun testListToolsPagination() = runBlocking(Dispatchers.IO) { + val prefix = "paginated-tool-" + (0 until 5).forEach { i -> + val name = "$prefix$i" + server.addTool(name = name, description = "desc") { request -> + CallToolResult(content = listOf(TextContent(text = name)), structuredContent = buildJsonObject { put("name", name) }) + } + } + + server.sessions.forEach { (_, session) -> + session.setRequestHandler(Method.Defined.ToolsList) { request, _ -> + 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 combinedNames = mutableListOf() + var currentCursor: String? = null + + do { + val request = if (currentCursor == null) { + ListToolsRequest() + } else { + ListToolsRequest(PaginatedRequestParams(cursor = currentCursor)) + } + + val response = client.listTools(request) + combinedNames += response.tools.map { it.name } + currentCursor = response.nextCursor + } while (currentCursor != null) + + assertTrue(combinedNames.any { it.startsWith(prefix) }) + } + + @Test + fun testListToolsInvalidCursor() = runBlocking(Dispatchers.IO) { + server.sessions.forEach { (_, session) -> + session.setRequestHandler(Method.Defined.ToolsList) { request, _ -> + val cursor = request.cursor?.toIntOrNull() ?: throw IllegalArgumentException("Invalid cursor") + val all = server.tools.values.map { it.tool } + val page = all.drop(cursor).take(2) + ListToolsResult(tools = page) + } + } + + val exception = kotlin.test.assertFailsWith { + client.listTools(ListToolsRequest(PaginatedRequestParams(cursor = "bad"))) + } + + kotlin.test.assertEquals(io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.INTERNAL_ERROR, exception.code) + } } diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/streamablehttp/LoggingIntegrationTestStreamableHttp.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/streamablehttp/LoggingIntegrationTestStreamableHttp.kt new file mode 100644 index 000000000..39046f5d1 --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/streamablehttp/LoggingIntegrationTestStreamableHttp.kt @@ -0,0 +1,118 @@ +package io.modelcontextprotocol.kotlin.sdk.integration.kotlin.streamablehttp + +import io.modelcontextprotocol.kotlin.sdk.integration.kotlin.KotlinTestBase +import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest +import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel +import io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotification +import io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotificationParams +import io.modelcontextprotocol.kotlin.sdk.types.Method +import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.junit.jupiter.api.Test + +class LoggingIntegrationTestStreamableHttp : KotlinTestBase() { + + override val transportKind = TransportKind.STREAMABLE_HTTP + + override fun configureServerCapabilities(): ServerCapabilities = ServerCapabilities( + tools = ServerCapabilities.Tools(listChanged = true), + logging = JsonObject(emptyMap()), + ) + + override fun configureServer() { + server.addTool(name = "test-notification", description = "test") { request -> + notification( + LoggingMessageNotification( + LoggingMessageNotificationParams( + level = LoggingLevel.Info, + data = JsonPrimitive("test-data-sample"), + ), + ), + ) + io.modelcontextprotocol.kotlin.sdk.types.CallToolResult(listOf(io.modelcontextprotocol.kotlin.sdk.types.TextContent("ok"))) + } + + server.addTool(name = "test-logging", description = "test") { request -> + sendLoggingMessage( + LoggingMessageNotification( + LoggingMessageNotificationParams( + level = LoggingLevel.Info, + data = JsonObject(mapOf("key" to JsonPrimitive("value"))), + ), + ), + ) + io.modelcontextprotocol.kotlin.sdk.types.CallToolResult(listOf(io.modelcontextprotocol.kotlin.sdk.types.TextContent("ok"))) + } + + server.addTool(name = "test-logging-level", description = "test") { request -> + LoggingLevel.entries.forEach { level -> + sendLoggingMessage( + LoggingMessageNotification( + LoggingMessageNotificationParams( + level = level, + data = JsonPrimitive(level.name), + ), + ), + ) + } + io.modelcontextprotocol.kotlin.sdk.types.CallToolResult(listOf(io.modelcontextprotocol.kotlin.sdk.types.TextContent("ok"))) + } + } + + @Test + fun `notification should send logging message to client`() = runBlocking { + val notificationReceived = CompletableDeferred() + client.setNotificationHandler(Method.Defined.NotificationsMessage) { + notificationReceived.complete(it) + CompletableDeferred(Unit) + } + + client.callTool(CallToolRequest(CallToolRequestParams("test-notification"))) + val received = notificationReceived.await() + kotlin.test.assertEquals(LoggingLevel.Info, received.params.level) + kotlin.test.assertEquals(JsonPrimitive("test-data-sample"), received.params.data) + } + + @Test + fun `sendLoggingMessage should send message at level`() = runBlocking { + val notificationReceived = CompletableDeferred() + client.setNotificationHandler(Method.Defined.NotificationsMessage) { + notificationReceived.complete(it) + CompletableDeferred(Unit) + } + + client.callTool(CallToolRequest(CallToolRequestParams("test-logging"))) + val received = notificationReceived.await() + kotlin.test.assertEquals(LoggingLevel.Info, received.params.level) + kotlin.test.assertEquals(JsonObject(mapOf("key" to JsonPrimitive("value"))), received.params.data) + } + + @Test + fun `sendLoggingMessage should filter messages below level`() = runBlocking { + val receivedMessages = mutableListOf() + client.setNotificationHandler(Method.Defined.NotificationsMessage) { + receivedMessages.add(it) + CompletableDeferred(Unit) + } + + client.setLoggingLevel(LoggingLevel.Warning) + + client.callTool(CallToolRequest(CallToolRequestParams("test-logging-level"))) + + val expectedLevels = LoggingLevel.entries.filter { it >= LoggingLevel.Warning } + // wait for expected notifications to arrive (transport may deliver asynchronously) + withTimeout(2000) { + while (receivedMessages.size < expectedLevels.size) { + delay(10) + } + } + kotlin.test.assertEquals(expectedLevels.size, receivedMessages.size) + kotlin.test.assertEquals(expectedLevels.toList(), receivedMessages.map { it.params.level }) + } +} From bc65aef46f79b39ae28dc83e3ba4380e8336bf4a Mon Sep 17 00:00:00 2001 From: Konstantin Pavlov <1517853+kpavlov@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:14:54 +0200 Subject: [PATCH 2/3] refactor(tests): enhance readability and type safety in integration tests - 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. --- .../kotlin/AbstractPromptIntegrationTest.kt | 23 +++++++++++++------ .../kotlin/AbstractResourceIntegrationTest.kt | 14 ++++++++--- .../kotlin/AbstractToolIntegrationTest.kt | 20 +++++++++++----- .../LoggingIntegrationTestStreamableHttp.kt | 10 ++++---- 4 files changed, 47 insertions(+), 20 deletions(-) diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractPromptIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractPromptIntegrationTest.kt index c55d158d3..4f300290d 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractPromptIntegrationTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractPromptIntegrationTest.kt @@ -13,6 +13,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.Method import io.modelcontextprotocol.kotlin.sdk.types.PaginatedRequestParams import io.modelcontextprotocol.kotlin.sdk.types.PromptArgument import io.modelcontextprotocol.kotlin.sdk.types.PromptMessage +import io.modelcontextprotocol.kotlin.sdk.types.RPCError import io.modelcontextprotocol.kotlin.sdk.types.Role import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities import io.modelcontextprotocol.kotlin.sdk.types.TextContent @@ -23,6 +24,7 @@ import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -161,8 +163,8 @@ abstract class AbstractPromptIntegrationTest : KotlinTestBase() { // validate required arguments val requiredArgs = listOf("arg1", "arg2", "arg3") for (argName in requiredArgs) { - if (request.params.arguments?.get(argName) == null) { - throw IllegalArgumentException("Missing required argument: $argName") + requireNotNull(request.params.arguments?.get(argName)) { + "Missing required argument: $argName" } } @@ -708,7 +710,10 @@ abstract class AbstractPromptIntegrationTest : KotlinTestBase() { (0 until 5).forEach { i -> val name = "$pagePrefix$i" server.addPrompt(name = name, description = "desc", arguments = listOf()) { _ -> - GetPromptResult(description = "desc", messages = listOf(PromptMessage(role = Role.Assistant, content = TextContent(text = name)))) + GetPromptResult( + description = "desc", + messages = listOf(PromptMessage(role = Role.Assistant, content = TextContent(text = name))), + ) } } @@ -726,7 +731,11 @@ abstract class AbstractPromptIntegrationTest : KotlinTestBase() { val allPrompts = mutableListOf() var currentCursor: String? = null do { - val request = if (currentCursor == null) ListPromptsRequest() else ListPromptsRequest(PaginatedRequestParams(cursor = currentCursor)) + val request = if (currentCursor == null) { + ListPromptsRequest() + } else { + ListPromptsRequest(PaginatedRequestParams(cursor = currentCursor)) + } val response = client.listPrompts(request) allPrompts.addAll(response.prompts) currentCursor = response.nextCursor @@ -739,17 +748,17 @@ abstract class AbstractPromptIntegrationTest : KotlinTestBase() { fun testListPromptsInvalidCursor() = runBlocking(Dispatchers.IO) { server.sessions.forEach { (_, session) -> session.setRequestHandler(Method.Defined.PromptsList) { request, _ -> - val cursor = request.cursor?.toIntOrNull() ?: throw IllegalArgumentException("Invalid cursor") + val cursor = requireNotNull(request.cursor?.toIntOrNull()) { "Invalid cursor" } val all = server.prompts.values.map { it.prompt } val page = all.drop(cursor).take(2) ListPromptsResult(prompts = page, nextCursor = null) } } - val exception = kotlin.test.assertFailsWith { + val exception = assertFailsWith { client.listPrompts(ListPromptsRequest(PaginatedRequestParams(cursor = "not-a-number"))) } - kotlin.test.assertEquals(RPCError.ErrorCode.INTERNAL_ERROR, exception.code) + assertEquals(RPCError.ErrorCode.INTERNAL_ERROR, exception.code) } } diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractResourceIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractResourceIntegrationTest.kt index 246a087a0..ce4cb6712 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractResourceIntegrationTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractResourceIntegrationTest.kt @@ -320,7 +320,15 @@ abstract class AbstractResourceIntegrationTest : KotlinTestBase() { (0 until 6).forEach { i -> val uri = "test://$prefix$i.txt" server.addResource(uri = uri, name = "Name-$i", description = "desc", mimeType = "text/plain") { request -> - ReadResourceResult(contents = listOf(TextResourceContents(text = uri, uri = request.params.uri, mimeType = "text/plain"))) + ReadResourceResult( + contents = listOf( + TextResourceContents( + text = uri, + uri = request.params.uri, + mimeType = "text/plain", + ), + ), + ) } } @@ -357,7 +365,7 @@ abstract class AbstractResourceIntegrationTest : KotlinTestBase() { fun testListResourcesInvalidCursor() = runBlocking(Dispatchers.IO) { server.sessions.forEach { (_, session) -> session.setRequestHandler(Method.Defined.ResourcesList) { request, _ -> - val cursor = request.cursor?.toIntOrNull() ?: throw IllegalArgumentException("Invalid cursor") + val cursor = requireNotNull(request.cursor?.toIntOrNull()) { "Invalid cursor" } val all = server.resources.values.map { it.resource } val page = all.drop(cursor).take(2) ListResourcesResult(resources = page, nextCursor = null) @@ -368,6 +376,6 @@ abstract class AbstractResourceIntegrationTest : KotlinTestBase() { client.listResources(ListResourcesRequest(PaginatedRequestParams(cursor = "bad"))) } - kotlin.test.assertEquals(RPCError.ErrorCode.INTERNAL_ERROR, exception.code) + assertEquals(RPCError.ErrorCode.INTERNAL_ERROR, exception.code) } } diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractToolIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractToolIntegrationTest.kt index f090c844d..661ad5dd7 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractToolIntegrationTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractToolIntegrationTest.kt @@ -8,8 +8,10 @@ import io.modelcontextprotocol.kotlin.sdk.types.ContentBlock import io.modelcontextprotocol.kotlin.sdk.types.ImageContent import io.modelcontextprotocol.kotlin.sdk.types.ListToolsRequest import io.modelcontextprotocol.kotlin.sdk.types.ListToolsResult +import io.modelcontextprotocol.kotlin.sdk.types.McpException import io.modelcontextprotocol.kotlin.sdk.types.Method import io.modelcontextprotocol.kotlin.sdk.types.PaginatedRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.RPCError import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities import io.modelcontextprotocol.kotlin.sdk.types.TextContent import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema @@ -802,13 +804,16 @@ abstract class AbstractToolIntegrationTest : KotlinTestBase() { (0 until 5).forEach { i -> val name = "$prefix$i" server.addTool(name = name, description = "desc") { request -> - CallToolResult(content = listOf(TextContent(text = name)), structuredContent = buildJsonObject { put("name", name) }) + CallToolResult( + content = listOf(TextContent(text = name)), + structuredContent = buildJsonObject { put("name", name) }, + ) } } server.sessions.forEach { (_, session) -> session.setRequestHandler(Method.Defined.ToolsList) { request, _ -> - val all = server.tools.values.map { it.tool } + val all = server.tools.values.map { it.tool }.sortedBy { it.name } val cursor = request.cursor?.toIntOrNull() ?: 0 val pageSize = 2 val page = all.drop(cursor).take(pageSize) @@ -832,24 +837,27 @@ abstract class AbstractToolIntegrationTest : KotlinTestBase() { currentCursor = response.nextCursor } while (currentCursor != null) - assertTrue(combinedNames.any { it.startsWith(prefix) }) + val paginatedNames = combinedNames.filter { it.startsWith(prefix) } + assertEquals(5, paginatedNames.size, "All 5 paginated tools should appear") + assertEquals(combinedNames.size, combinedNames.distinct().size, "No duplicate tools across pages") + assertEquals(server.tools.size, combinedNames.size, "Total tools should match server registry") } @Test fun testListToolsInvalidCursor() = runBlocking(Dispatchers.IO) { server.sessions.forEach { (_, session) -> session.setRequestHandler(Method.Defined.ToolsList) { request, _ -> - val cursor = request.cursor?.toIntOrNull() ?: throw IllegalArgumentException("Invalid cursor") + val cursor = requireNotNull(request.cursor?.toIntOrNull()) { "Invalid cursor" } val all = server.tools.values.map { it.tool } val page = all.drop(cursor).take(2) ListToolsResult(tools = page) } } - val exception = kotlin.test.assertFailsWith { + val exception = kotlin.test.assertFailsWith { client.listTools(ListToolsRequest(PaginatedRequestParams(cursor = "bad"))) } - kotlin.test.assertEquals(io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.INTERNAL_ERROR, exception.code) + assertEquals(RPCError.ErrorCode.INTERNAL_ERROR, exception.code) } } diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/streamablehttp/LoggingIntegrationTestStreamableHttp.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/streamablehttp/LoggingIntegrationTestStreamableHttp.kt index 39046f5d1..08034bd7a 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/streamablehttp/LoggingIntegrationTestStreamableHttp.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/streamablehttp/LoggingIntegrationTestStreamableHttp.kt @@ -3,14 +3,16 @@ package io.modelcontextprotocol.kotlin.sdk.integration.kotlin.streamablehttp import io.modelcontextprotocol.kotlin.sdk.integration.kotlin.KotlinTestBase import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult import io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel import io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotification import io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotificationParams import io.modelcontextprotocol.kotlin.sdk.types.Method import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities +import io.modelcontextprotocol.kotlin.sdk.types.TextContent import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -35,7 +37,7 @@ class LoggingIntegrationTestStreamableHttp : KotlinTestBase() { ), ), ) - io.modelcontextprotocol.kotlin.sdk.types.CallToolResult(listOf(io.modelcontextprotocol.kotlin.sdk.types.TextContent("ok"))) + CallToolResult(listOf(TextContent("ok"))) } server.addTool(name = "test-logging", description = "test") { request -> @@ -47,7 +49,7 @@ class LoggingIntegrationTestStreamableHttp : KotlinTestBase() { ), ), ) - io.modelcontextprotocol.kotlin.sdk.types.CallToolResult(listOf(io.modelcontextprotocol.kotlin.sdk.types.TextContent("ok"))) + CallToolResult(listOf(TextContent("ok"))) } server.addTool(name = "test-logging-level", description = "test") { request -> @@ -61,7 +63,7 @@ class LoggingIntegrationTestStreamableHttp : KotlinTestBase() { ), ) } - io.modelcontextprotocol.kotlin.sdk.types.CallToolResult(listOf(io.modelcontextprotocol.kotlin.sdk.types.TextContent("ok"))) + CallToolResult(listOf(TextContent("ok"))) } } From 24a1bcc184f55b0e87e5490cb856ae608117cf63 Mon Sep 17 00:00:00 2001 From: Konstantin Pavlov <1517853+kpavlov@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:02:59 +0200 Subject: [PATCH 3/3] refactor(tests): Make LoggingIntegrationTestStreamableHttp run in the same thread LoggingIntegrationTestStreamableHttp is not designed for concurrent execution --- .../streamablehttp/LoggingIntegrationTestStreamableHttp.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/streamablehttp/LoggingIntegrationTestStreamableHttp.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/streamablehttp/LoggingIntegrationTestStreamableHttp.kt index 08034bd7a..31dabd94e 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/streamablehttp/LoggingIntegrationTestStreamableHttp.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/streamablehttp/LoggingIntegrationTestStreamableHttp.kt @@ -17,7 +17,10 @@ import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import org.junit.jupiter.api.Test +import org.junit.jupiter.api.parallel.Execution +import org.junit.jupiter.api.parallel.ExecutionMode +@Execution(ExecutionMode.SAME_THREAD) class LoggingIntegrationTestStreamableHttp : KotlinTestBase() { override val transportKind = TransportKind.STREAMABLE_HTTP