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.GetPromptRequest
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult 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.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.PromptArgument
import io.modelcontextprotocol.kotlin.sdk.types.PromptMessage 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.Role
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
import io.modelcontextprotocol.kotlin.sdk.types.TextContent import io.modelcontextprotocol.kotlin.sdk.types.TextContent
@ -18,7 +24,9 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.assertThrows
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
@ -697,4 +705,69 @@ abstract class AbstractPromptIntegrationTest : KotlinTestBase() {
exception.message shouldBe expectedMessage 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 package io.modelcontextprotocol.kotlin.sdk.integration.kotlin
import io.modelcontextprotocol.kotlin.sdk.types.BlobResourceContents 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.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.RPCError
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequest import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequest
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequestParams import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequestParams
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult 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.ServerCapabilities
import io.modelcontextprotocol.kotlin.sdk.types.SubscribeRequest import io.modelcontextprotocol.kotlin.sdk.types.SubscribeRequest
import io.modelcontextprotocol.kotlin.sdk.types.SubscribeRequestParams import io.modelcontextprotocol.kotlin.sdk.types.SubscribeRequestParams
@ -18,8 +23,10 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.assertThrows
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
@ -309,4 +316,76 @@ abstract class AbstractResourceIntegrationTest : KotlinTestBase() {
assertTrue(result.contents.isNotEmpty(), "Result contents should not be empty") 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.CallToolResult
import io.modelcontextprotocol.kotlin.sdk.types.ContentBlock import io.modelcontextprotocol.kotlin.sdk.types.ContentBlock
import io.modelcontextprotocol.kotlin.sdk.types.ImageContent 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.ServerCapabilities
import io.modelcontextprotocol.kotlin.sdk.types.TextContent import io.modelcontextprotocol.kotlin.sdk.types.TextContent
import io.modelcontextprotocol.kotlin.sdk.types.Tool
import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -24,7 +31,9 @@ import org.junit.jupiter.api.Test
import java.text.DecimalFormat import java.text.DecimalFormat
import java.text.DecimalFormatSymbols import java.text.DecimalFormatSymbols
import java.util.Locale import java.util.Locale
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
@ -791,4 +800,75 @@ abstract class AbstractToolIntegrationTest : KotlinTestBase() {
"Error message should indicate the tool was not found", "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)
}
} }