test(streamable-http): add missing integration tests for pagination, bad request, and logging (#613)

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:
- **Pagination**: Added cursor-based pagination tests for Prompts,
Resources, and Tools to `Abstract*IntegrationTest`.
- **Bad Request**: Added tests to verify that invalid cursor parameters
correctly throw an `McpException` with `INTERNAL_ERROR`.
- **Logging**: Added `LoggingIntegrationTestStreamableHttp` to
specifically test `setLevel` and logging message notifications over
Streamable HTTP.

*Note: Security (Allow/Deny) tests are intentionally excluded as
discussed in #486, pending further server auth model implementation.*

Co-authored-by: Pavel Gorgulov <devcrocod@gmail.com>
This commit is contained in:
johny 2026-04-29 02:12:28 +09:00 • committed by GitHub
commit 66d9efb599
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 232 additions and 0 deletions

View file

@ -6,9 +6,15 @@ 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.Prompt
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
@ -18,7 +24,9 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@ -697,4 +705,69 @@ abstract class AbstractPromptIntegrationTest : KotlinTestBase() {
exception.message shouldBe expectedMessage
}
}
@Test
fun testListPromptsPagination() = runBlocking(Dispatchers.IO) {
val receivedCursors = CopyOnWriteArrayList<String?>()
val page1 = listOf(Prompt(name = "p-1", description = "desc"), Prompt(name = "p-2", description = "desc"))
val page2 = listOf(Prompt(name = "p-3", description = "desc"), Prompt(name = "p-4", description = "desc"))
val page3 = listOf(Prompt(name = "p-5", description = "desc"))
server.sessions.forEach { (_, session) ->
session.setRequestHandler<ListPromptsRequest>(Method.Defined.PromptsList) { request, _ ->
receivedCursors += request.cursor
when (request.cursor) {
null -> ListPromptsResult(prompts = page1, nextCursor = "cursor-2")
"cursor-2" -> ListPromptsResult(prompts = page2, nextCursor = "cursor-3")
"cursor-3" -> ListPromptsResult(prompts = page3, nextCursor = null)
else -> error("Unexpected cursor: ${request.cursor}")
}
}
}
val collected = mutableListOf<Prompt>()
var cursor: String? = null
do {
val request = if (cursor == null) {
ListPromptsRequest()
} else {
ListPromptsRequest(PaginatedRequestParams(cursor = cursor))
}
val response = client.listPrompts(request)
collected += response.prompts
cursor = response.nextCursor
} while (cursor != null)
assertEquals(
listOf(null, "cursor-2", "cursor-3"),
receivedCursors.toList(),
"Client must forward each nextCursor into the next request",
)
assertEquals(
listOf("p-1", "p-2", "p-3", "p-4", "p-5"),
collected.map { it.name },
"Client must accumulate pages in order without duplicates or reordering",
)
}
@Test
fun testListPromptsInvalidCursor() = runBlocking(Dispatchers.IO) {
val invalidCursor = "not-a-valid-cursor"
server.sessions.forEach { (_, session) ->
session.setRequestHandler<ListPromptsRequest>(Method.Defined.PromptsList) { _, _ ->
throw McpException(
code = RPCError.ErrorCode.INVALID_PARAMS,
message = "Invalid cursor: $invalidCursor",
)
}
}
val exception = assertFailsWith<McpException> {
client.listPrompts(ListPromptsRequest(PaginatedRequestParams(cursor = invalidCursor)))
}
assertEquals(RPCError.ErrorCode.INVALID_PARAMS, exception.code)
assertEquals("Invalid cursor: $invalidCursor", exception.message)
}
}

View file

@ -1,11 +1,16 @@
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
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult
import io.modelcontextprotocol.kotlin.sdk.types.Resource
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
import io.modelcontextprotocol.kotlin.sdk.types.SubscribeRequest
import io.modelcontextprotocol.kotlin.sdk.types.SubscribeRequestParams
@ -18,8 +23,10 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@ -309,4 +316,76 @@ abstract class AbstractResourceIntegrationTest : KotlinTestBase() {
assertTrue(result.contents.isNotEmpty(), "Result contents should not be empty")
}
}
@Test
fun testListResourcesPagination() = runBlocking(Dispatchers.IO) {
val receivedCursors = CopyOnWriteArrayList<String?>()
val page1 = listOf(
Resource(uri = "test://r-1", name = "r-1"),
Resource(uri = "test://r-2", name = "r-2"),
Resource(uri = "test://r-3", name = "r-3"),
)
val page2 = listOf(
Resource(uri = "test://r-4", name = "r-4"),
Resource(uri = "test://r-5", name = "r-5"),
)
val page3 = listOf(Resource(uri = "test://r-6", name = "r-6"))
server.sessions.forEach { (_, session) ->
session.setRequestHandler<ListResourcesRequest>(Method.Defined.ResourcesList) { request, _ ->
receivedCursors += request.cursor
when (request.cursor) {
null -> ListResourcesResult(resources = page1, nextCursor = "cursor-2")
"cursor-2" -> ListResourcesResult(resources = page2, nextCursor = "cursor-3")
"cursor-3" -> ListResourcesResult(resources = page3, nextCursor = null)
else -> error("Unexpected cursor: ${request.cursor}")
}
}
}
val collected = mutableListOf<Resource>()
var cursor: String? = null
do {
val request = if (cursor == null) {
ListResourcesRequest()
} else {
ListResourcesRequest(PaginatedRequestParams(cursor = cursor))
}
val response = client.listResources(request)
collected += response.resources
cursor = response.nextCursor
} while (cursor != null)
assertEquals(
listOf(null, "cursor-2", "cursor-3"),
receivedCursors.toList(),
"Client must forward each nextCursor into the next request",
)
assertEquals(
listOf("test://r-1", "test://r-2", "test://r-3", "test://r-4", "test://r-5", "test://r-6"),
collected.map { it.uri },
"Client must accumulate pages in order without duplicates or reordering",
)
}
@Test
fun testListResourcesInvalidCursor() = runBlocking(Dispatchers.IO) {
val invalidCursor = "not-a-valid-cursor"
server.sessions.forEach { (_, session) ->
session.setRequestHandler<ListResourcesRequest>(Method.Defined.ResourcesList) { _, _ ->
throw McpException(
code = RPCError.ErrorCode.INVALID_PARAMS,
message = "Invalid cursor: $invalidCursor",
)
}
}
val exception = assertFailsWith<McpException> {
client.listResources(ListResourcesRequest(PaginatedRequestParams(cursor = invalidCursor)))
}
assertEquals(RPCError.ErrorCode.INVALID_PARAMS, exception.code)
assertEquals("Invalid cursor: $invalidCursor", exception.message)
}
}

View file

@ -6,8 +6,15 @@ 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.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.Tool
import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@ -24,7 +31,9 @@ import org.junit.jupiter.api.Test
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.util.Locale
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@ -791,4 +800,75 @@ abstract class AbstractToolIntegrationTest : KotlinTestBase() {
"Error message should indicate the tool was not found",
)
}
@Test
fun testListToolsPagination() = runBlocking(Dispatchers.IO) {
val receivedCursors = CopyOnWriteArrayList<String?>()
val page1 = listOf(
Tool(name = "t-1", inputSchema = ToolSchema()),
Tool(name = "t-2", inputSchema = ToolSchema()),
)
val page2 = listOf(
Tool(name = "t-3", inputSchema = ToolSchema()),
Tool(name = "t-4", inputSchema = ToolSchema()),
)
val page3 = listOf(Tool(name = "t-5", inputSchema = ToolSchema()))
server.sessions.forEach { (_, session) ->
session.setRequestHandler<ListToolsRequest>(Method.Defined.ToolsList) { request, _ ->
receivedCursors += request.cursor
when (request.cursor) {
null -> ListToolsResult(tools = page1, nextCursor = "cursor-2")
"cursor-2" -> ListToolsResult(tools = page2, nextCursor = "cursor-3")
"cursor-3" -> ListToolsResult(tools = page3, nextCursor = null)
else -> error("Unexpected cursor: ${request.cursor}")
}
}
}
val collected = mutableListOf<Tool>()
var cursor: String? = null
do {
val request = if (cursor == null) {
ListToolsRequest()
} else {
ListToolsRequest(PaginatedRequestParams(cursor = cursor))
}
val response = client.listTools(request)
collected += response.tools
cursor = response.nextCursor
} while (cursor != null)
assertEquals(
listOf(null, "cursor-2", "cursor-3"),
receivedCursors.toList(),
"Client must forward each nextCursor into the next request",
)
assertEquals(
listOf("t-1", "t-2", "t-3", "t-4", "t-5"),
collected.map { it.name },
"Client must accumulate pages in order without duplicates or reordering",
)
}
@Test
fun testListToolsInvalidCursor() = runBlocking(Dispatchers.IO) {
val invalidCursor = "not-a-valid-cursor"
server.sessions.forEach { (_, session) ->
session.setRequestHandler<ListToolsRequest>(Method.Defined.ToolsList) { _, _ ->
throw McpException(
code = RPCError.ErrorCode.INVALID_PARAMS,
message = "Invalid cursor: $invalidCursor",
)
}
}
val exception = assertFailsWith<McpException> {
client.listTools(ListToolsRequest(PaginatedRequestParams(cursor = invalidCursor)))
}
assertEquals(RPCError.ErrorCode.INVALID_PARAMS, exception.code)
assertEquals("Invalid cursor: $invalidCursor", exception.message)
}
}