feat: Add kotlin-sdk-testing module with channel-based transport implementation (#505)

# feat: Add kotlin-sdk-testing module with channel-based transport
implementation

- Introduced `kotlin-sdk-testing` module for testing utilities in the
MCP Kotlin SDK.
- Implemented `ChannelTransport`, providing JSON-RPC transport via
Kotlin coroutines channels.

<!-- Provide a brief summary of your changes -->

## Motivation and Context
This ChannelTransport can be used for testing Server implementation
without using real transports.

## How Has This Been Tested?
Unit test

## Breaking Changes
No

## Types of changes

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [x] Documentation update

## Checklist
<!-- Go over all the following points, and put an `x` in all the boxes
that apply. -->
- [x] I have read the [MCP
Documentation](https://modelcontextprotocol.io)
- [x] My code follows the repository's style guidelines
- [x] New and existing tests pass locally
- [x] I have added appropriate error handling
- [x] I have added or updated documentation as needed
This commit is contained in:
Konstantin Pavlov 2026-02-16 14:10:56 +02:00 • committed by GitHub
commit 212cbfa325
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 669 additions and 3 deletions

View file

@ -46,6 +46,7 @@ standardized protocol interface.
* [Streamable HTTP Transport](#streamable-http-transport)
* [SSE Transport](#sse-transport)
* [WebSocket Transport](#websocket-transport)
* [ChannelTransport (testing)](#channeltransport-testing)
* [Connecting your server](#connecting-your-server)
* [Examples](#examples)
* [Documentation](#documentation)
@ -774,6 +775,12 @@ SSE Ktor plugin when you need drop-in compatibility, but prefer Streamable HTTP
`WebSocketClientTransport` plus the matching server utilities provide full-duplex, low-latency connections—useful when
you expect lots of notifications or long-running sessions behind a reverse proxy that already terminates WebSockets.
### ChannelTransport (testing)
`ChannelTransport` provides a simple, non-networked transport for testing and local development.
It uses Kotlin coroutines channels to provide a full-duplex connection between a client and server,
allowing for easy testing of MCP functionality without the need for network setup.
## Connecting your server
1. Start a sample HTTP server on port 3000:

View file

@ -11,11 +11,13 @@ dependencies {
dokka(project(":kotlin-sdk-core"))
dokka(project(":kotlin-sdk-client"))
dokka(project(":kotlin-sdk-server"))
dokka(project(":kotlin-sdk-testing"))
kover(project(":kotlin-sdk-core"))
kover(project(":kotlin-sdk-client"))
kover(project(":kotlin-sdk-server"))
"kover"(project(":integration-test"))
kover(project(":kotlin-sdk-testing"))
kover(project(":integration-test"))
}
subprojects {

View file

@ -30,6 +30,7 @@ kotlin {
jvmTest {
dependencies {
implementation(project(":test-utils"))
implementation(project(":kotlin-sdk-testing"))
}
}
}

View file

@ -0,0 +1,88 @@
package io.modelcontextprotocol.kotlin.sdk.testing
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.nulls.shouldNotBeNull
import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.server.Server
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
import io.modelcontextprotocol.kotlin.sdk.server.ServerSession
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest
import io.modelcontextprotocol.kotlin.sdk.types.InitializeResult
import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION
import io.modelcontextprotocol.kotlin.sdk.types.ListResourcesRequest
import io.modelcontextprotocol.kotlin.sdk.types.ListResourcesResult
import io.modelcontextprotocol.kotlin.sdk.types.Method
import io.modelcontextprotocol.kotlin.sdk.types.Resource
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
@OptIn(ExperimentalMcpApi::class)
class ChannelTransportTest {
@Test
fun `should connect and list resources`(): Unit = runBlocking {
val serverOptions = ServerOptions(
capabilities = ServerCapabilities(
resources = ServerCapabilities.Resources(),
),
)
val server = Server(
Implementation(name = "test server", version = "1.0"),
serverOptions,
)
val (clientTransport, serverTransport) = ChannelTransport.createLinkedPair()
val client = Client(
clientInfo = Implementation(name = "test client", version = "1.0"),
)
val serverSessionResult = CompletableDeferred<ServerSession>()
listOf(
launch {
client.connect(clientTransport)
},
launch {
serverSessionResult.complete(server.createSession(serverTransport))
},
).joinAll()
val serverSession = serverSessionResult.await()
serverSession.setRequestHandler<InitializeRequest>(Method.Defined.Initialize) { _, _ ->
InitializeResult(
protocolVersion = LATEST_PROTOCOL_VERSION,
capabilities = ServerCapabilities(
resources = ServerCapabilities.Resources(null, null),
tools = ServerCapabilities.Tools(null),
),
serverInfo = Implementation(name = "test", version = "1.0"),
)
}
serverSession.setRequestHandler<ListResourcesRequest>(Method.Defined.ResourcesList) { _, _ ->
ListResourcesResult(
resources = listOf(
Resource(
uri = "/foo/bar",
name = "foo-bar-resource",
),
),
)
}
// These should not throw
client.listResources() shouldNotBeNull {
this.resources shouldHaveSize 1
}
client.close()
server.close()
}
}

View file

@ -41,6 +41,7 @@ kotlin {
commonTest {
dependencies {
implementation(kotlin("test"))
implementation(project(":kotlin-sdk-testing"))
implementation(libs.kotest.assertions.core)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.ktor.client.logging)

View file

@ -4,7 +4,7 @@ import io.kotest.assertions.nondeterministic.eventually
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport
import io.modelcontextprotocol.kotlin.sdk.shared.AbstractClientTransport
import io.modelcontextprotocol.kotlin.sdk.types.McpException
import io.modelcontextprotocol.kotlin.sdk.types.PingRequest
import io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.CONNECTION_CLOSED
@ -16,7 +16,7 @@ import kotlin.test.Test
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
abstract class AbstractClientTransportLifecycleTest<T : AbstractTransport> {
abstract class AbstractClientTransportLifecycleTest<T : AbstractClientTransport> {
protected lateinit var transport: T

View file

@ -0,0 +1,20 @@
package io.modelcontextprotocol.kotlin.sdk.client.channel
import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi
import io.modelcontextprotocol.kotlin.sdk.client.AbstractClientTransportLifecycleTest
import io.modelcontextprotocol.kotlin.sdk.testing.ChannelTransport
import kotlin.test.Ignore
import kotlin.test.Test
@OptIn(ExperimentalMcpApi::class)
class ChannelClientTransportLifecycleTest : AbstractClientTransportLifecycleTest<ChannelTransport>() {
/**
* Dummy method to make IDE treat this class as a test
*/
@Test
@Ignore
fun dummyTest() = Unit
override fun createTransport(): ChannelTransport = ChannelTransport()
}

View file

@ -0,0 +1,67 @@
# Module kotlin-sdk-testing
`kotlin-sdk-testing` provides testing utilities for MCP client-server interactions. It eliminates the need for external
processes or network connections by offering coroutine-safe in-memory transport implementations that connect clients and servers directly.
## Typical usage
1. Create linked transport pair with `ChannelTransport.createLinkedPair()`.
2. Connect client and server concurrently using the paired transports.
3. Configure server request handlers.
4. Execute client operations and verify results.
### Complete test example
```kotlin
@OptIn(ExperimentalMcpApi::class)
@Test
fun testClientServerCommunication(): Unit = runBlocking {
val server = Server(
Implementation(name = "test server", version = "1.0"),
ServerOptions(capabilities = ServerCapabilities(resources = ServerCapabilities.Resources())),
)
val (clientTransport, serverTransport) = ChannelTransport.createLinkedPair()
val client = Client(clientInfo = Implementation(name = "test client", version = "1.0"))
val serverSessionResult = CompletableDeferred<ServerSession>()
listOf(
launch { client.connect(clientTransport) },
launch { serverSessionResult.complete(server.createSession(serverTransport)) },
).joinAll()
val serverSession = serverSessionResult.await()
serverSession.setRequestHandler<InitializeRequest>(Method.Defined.Initialize) { _, _ ->
InitializeResult(
protocolVersion = LATEST_PROTOCOL_VERSION,
capabilities = ServerCapabilities(
resources = ServerCapabilities.Resources(null, null),
tools = ServerCapabilities.Tools(null),
),
serverInfo = Implementation(name = "test", version = "1.0"),
)
}
serverSession.setRequestHandler<ListResourcesRequest>(Method.Defined.ResourcesList) { _, _ ->
ListResourcesResult(
resources = listOf(Resource(uri = "/foo/bar", name = "foo-bar-resource")),
)
}
// Test client operations
client.listResources() shouldNotBeNull {
this.resources shouldHaveSize 1
}
// Clean up resources
client.close()
server.close()
}
```
**Note**: In production tests, wrap client/server operations in try-finally blocks to ensure proper resource cleanup even if assertions fail.
Use this module for unit and integration testing of MCP implementations without managing external server processes,
network ports, or I/O streams. The channel-based transport ensures tests run instantly with full isolation between test
cases.

View file

@ -0,0 +1,26 @@
public final class io/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport : io/modelcontextprotocol/kotlin/sdk/shared/AbstractClientTransport {
public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport$Companion;
public fun <init> (Lkotlinx/coroutines/channels/Channel;Lkotlinx/coroutines/CoroutineDispatcher;)V
public synthetic fun <init> (Lkotlinx/coroutines/channels/Channel;Lkotlinx/coroutines/CoroutineDispatcher;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun <init> (Lkotlinx/coroutines/channels/SendChannel;Lkotlinx/coroutines/channels/ReceiveChannel;Lkotlinx/coroutines/CoroutineDispatcher;)V
public synthetic fun <init> (Lkotlinx/coroutines/channels/SendChannel;Lkotlinx/coroutines/channels/ReceiveChannel;Lkotlinx/coroutines/CoroutineDispatcher;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
}
public final class io/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport$Companion {
public final fun createLinkedPair (ILkotlinx/coroutines/CoroutineDispatcher;)Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport$LinkedTransports;
public static synthetic fun createLinkedPair$default (Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport$Companion;ILkotlinx/coroutines/CoroutineDispatcher;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport$LinkedTransports;
}
public final class io/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport$LinkedTransports {
public fun <init> (Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport;Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport;)V
public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport;
public final fun component2 ()Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport;
public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport;Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport;)Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport$LinkedTransports;
public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport$LinkedTransports;Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport;Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport$LinkedTransports;
public fun equals (Ljava/lang/Object;)Z
public final fun getClientTransport ()Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport;
public final fun getServerTransport ()Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

View file

@ -0,0 +1,53 @@
@file:OptIn(ExperimentalWasmDsl::class)
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
plugins {
id("mcp.multiplatform")
id("mcp.publishing")
id("mcp.dokka")
alias(libs.plugins.kotlinx.binary.compatibility.validator)
}
kotlin {
iosArm64()
iosX64()
iosSimulatorArm64()
watchosX64()
watchosArm64()
watchosSimulatorArm64()
tvosX64()
tvosArm64()
tvosSimulatorArm64()
js {
browser()
nodejs()
}
wasmJs {
browser()
nodejs()
}
sourceSets {
commonMain {
dependencies {
api(project(":kotlin-sdk-core"))
implementation(libs.kotlin.logging)
}
}
commonTest {
dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
implementation(libs.kotest.assertions.core)
}
}
jvmTest {
dependencies {
runtimeOnly(libs.slf4j.simple)
}
}
}
}

View file

@ -0,0 +1,176 @@
package io.modelcontextprotocol.kotlin.sdk.testing
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi
import io.modelcontextprotocol.kotlin.sdk.shared.AbstractClientTransport
import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
/**
* A transport implementation that uses Kotlin Coroutines Channels for asynchronous
* communication of JSON-RPC messages. This is useful in scenarios where communication is
* message-based and channels are used as the underlying mechanism for delivery.
*
* @constructor Initializes the transport with the specified [SendChannel] and [ReceiveChannel].
* @property sendChannel The channel used to send JSON-RPC messages.
* @property receiveChannel The channel used to receive JSON-RPC messages.
* @param dispatcher The coroutine dispatcher for the event loop. Defaults to [Dispatchers.Default].
*/
@ExperimentalMcpApi
public class ChannelTransport(
private val sendChannel: SendChannel<JSONRPCMessage>,
private val receiveChannel: ReceiveChannel<JSONRPCMessage>,
dispatcher: CoroutineDispatcher = Dispatchers.Default,
) : AbstractClientTransport() {
override val logger: KLogger = KotlinLogging.logger {}
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
/**
* Creates a `ChannelTransport` instance using a single channel for both sending and receiving messages.
*
* This constructor simplifies the use of `ChannelTransport` in scenarios where a single channel
* is enough for bidirectional communication. A default channel with unlimited capacity is used
* if no channel is provided.
*
* @param channel The `Channel` of `JSONRPCMessage` instances, used for both sending and receiving.
* Defaults to a channel with unlimited capacity.
* @param dispatcher The coroutine dispatcher for the event loop. Defaults to [Dispatchers.Default].
*/
public constructor(
channel: Channel<JSONRPCMessage> = Channel(UNLIMITED),
dispatcher: CoroutineDispatcher = Dispatchers.Default,
) : this(channel, channel, dispatcher)
/**
* Represents a pair of interconnected [ChannelTransport]s for bidirectional communication.
*
* Messages sent via one transport are received by the other. This is useful for
* setting up communication between a client and server without requiring external networking.
*
* @property clientTransport The transport intended for use on the client-side.
* @property serverTransport The transport intended for use on the server-side.
*/
public data class LinkedTransports(val clientTransport: ChannelTransport, val serverTransport: ChannelTransport)
public companion object {
/**
* Creates a pair of interconnected [ChannelTransport] objects for bidirectional communication.
*
* This method sets up a client and server transport using Kotlin channels, enabling messages sent
* through one transport to be received by the other. It is typically used for testing or in-memory
* communication without external networking.
*
* @param capacity The buffer capacity of the internal channels used for communication. Defaults to 256.
* @return A [LinkedTransports] instance containing the client and server transports.
*/
public fun createLinkedPair(
capacity: Int = 256,
dispatcher: CoroutineDispatcher = Dispatchers.Default,
): LinkedTransports {
val sendChannel = Channel<JSONRPCMessage>(capacity)
val receiveChannel = Channel<JSONRPCMessage>(capacity)
val clientTransport = ChannelTransport(sendChannel, receiveChannel, dispatcher)
val serverTransport = ChannelTransport(receiveChannel, sendChannel, dispatcher)
return LinkedTransports(clientTransport = clientTransport, serverTransport = serverTransport)
}
}
/**
* Starts the transport, allowing it to begin processing messages.
*
* Waits for the event loop to start before returning, ensuring the transport is
* fully initialized and ready to send/receive messages.
*
* Processes messages until the channel closes or cancellation occurs.
* Exceptions from message handlers are reported via onError but do not stop processing.
*/
override suspend fun initialize() {
logger.info { "ChannelTransport starting message processing" }
val started = CompletableDeferred<Unit>()
@Suppress("TooGenericExceptionCaught")
scope.launch(CoroutineName("ChannelTransport#${hashCode()}-event-loop")) {
try {
// Signal that event loop has started, then yield to ensure we're ready
started.complete(Unit)
yield()
for (message in receiveChannel) {
logger.debug { "Received message: ${message::class.simpleName}" }
try {
_onMessage.invoke(message)
logger.trace { "Message processed successfully: ${message::class.simpleName}" }
} catch (e: CancellationException) {
// Let cancellation propagate immediately
logger.debug { "Cancellation requested during message processing" }
throw e
} catch (e: Exception) {
// Report other errors but continue processing
logger.warn(e) { "Error processing message: ${message::class.simpleName}" }
_onError.invoke(e)
}
}
logger.info { "ChannelTransport stopped: receive channel closed" }
} catch (e: Exception) {
// Only complete exceptionally if not already completed
if (!started.isCompleted) {
started.completeExceptionally(e)
}
throw e
} finally {
close()
}
}
// Wait for the event loop to start
started.await()
}
/**
* Sends a JSON-RPC message through the transport.
*
* @param message The JSON-RPC message to send. This can be a request or a response,
* conforming to the JSON-RPC 2.0 specification.
* @param options Optional transport-specific options that provide additional context or behavior
* for the message transmission, such as associating the message with a specific
* incoming request or handling resumption tokens.
*/
override suspend fun performSend(message: JSONRPCMessage, options: TransportSendOptions?) {
logger.debug { "Sending message: ${message::class.simpleName}" }
sendChannel.send(message)
logger.trace { "Message sent successfully: ${message::class.simpleName}" }
}
/**
* Closes the transport, preventing further message transmission and processing.
*/
override suspend fun closeResources() {
logger.info { "Closing ChannelTransport" }
sendChannel.close()
if (receiveChannel !== sendChannel) {
logger.debug { "Cancelling separate receive channel" }
receiveChannel.cancel()
}
scope.cancel()
scope.coroutineContext[Job]?.join() // Wait for cleanup
logger.info { "ChannelTransport closed" }
}
}

View file

@ -0,0 +1,212 @@
package io.modelcontextprotocol.kotlin.sdk.testing
import io.kotest.assertions.nondeterministic.eventually
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest
import io.modelcontextprotocol.kotlin.sdk.types.RequestId
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ClosedSendChannelException
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalMcpApi::class)
class ChannelTransportTest {
@Test
fun `send writes to sendChannel`() = runTest {
val sendChannel = Channel<JSONRPCMessage>(Channel.UNLIMITED)
val transport = ChannelTransport(sendChannel, Channel())
transport.start()
val message = JSONRPCRequest(RequestId.NumberId(42), "test")
transport.send(message)
sendChannel.receive() shouldBe message
}
@Test
fun `start reads from receiveChannel and invokes onMessage`() = runTest {
val receiveChannel = Channel<JSONRPCMessage>(Channel.UNLIMITED)
val transport = ChannelTransport(Channel(), receiveChannel)
val messagesProcessed = CompletableDeferred<Unit>()
val received = mutableListOf<JSONRPCMessage>()
transport.onMessage { msg ->
received.add(msg)
if (received.size == 2) {
messagesProcessed.complete(Unit)
}
}
transport.start()
val msg1 = JSONRPCRequest(RequestId.NumberId(1), "method1")
val msg2 = JSONRPCRequest(RequestId.NumberId(2), "method2")
receiveChannel.send(msg1)
receiveChannel.send(msg2)
// Wait for messages to be processed
messagesProcessed.await()
received.shouldContainExactly(msg1, msg2)
}
@Test
fun `start completes when channel closes`() = runTest {
val receiveChannel = Channel<JSONRPCMessage>(Channel.UNLIMITED)
val transport = ChannelTransport(Channel(), receiveChannel)
val messageProcessed = CompletableDeferred<Unit>()
val received = mutableListOf<JSONRPCMessage>()
transport.onMessage {
received.add(it)
messageProcessed.complete(Unit)
}
transport.start()
val msg = JSONRPCRequest(RequestId.NumberId(1), "method1")
receiveChannel.send(msg)
receiveChannel.close() // Close the channel
// Wait for a message to be processed
messageProcessed.await()
received.shouldContainExactly(msg)
}
@Test
fun `close is idempotent`() = runTest {
val transport = ChannelTransport()
var closeCount = 0
transport.onClose { closeCount++ }
transport.start()
transport.close()
transport.close()
closeCount shouldBe 1
}
@Test
fun `callbacks chain not replace`() = runTest {
val receiveChannel = Channel<JSONRPCMessage>(Channel.UNLIMITED)
val transport = ChannelTransport(Channel(), receiveChannel)
val messageProcessed = CompletableDeferred<Unit>()
val calls = mutableListOf<String>()
transport.onMessage {
calls.add("first")
}
transport.onMessage {
calls.add("second")
messageProcessed.complete(Unit)
}
val startJob = backgroundScope.launch { transport.start() }
receiveChannel.send(JSONRPCRequest(RequestId.NumberId(1), "test"))
messageProcessed.await()
calls.shouldContainExactly("first", "second")
startJob.cancelAndJoin()
}
@Test
fun `secondary constructor uses single channel`() = runTest {
val channel = Channel<JSONRPCMessage>(Channel.UNLIMITED)
val transport = ChannelTransport(channel)
val messageProcessed = CompletableDeferred<Unit>()
val received = mutableListOf<JSONRPCMessage>()
transport.onMessage { msg ->
received.add(msg)
messageProcessed.complete(Unit)
}
transport.start()
val message = JSONRPCRequest(RequestId.NumberId(1), "test")
transport.send(message)
messageProcessed.await()
eventually(2.seconds) {
received.shouldContainExactly(message)
}
}
@Test
fun `send to closed channel triggers error`() = runTest {
val sendChannel = Channel<JSONRPCMessage>(Channel.UNLIMITED)
val transport = ChannelTransport(sendChannel, Channel())
transport.start()
var errorCaught = false
transport.onError {
it.shouldBeInstanceOf<ClosedSendChannelException>()
errorCaught = true
}
sendChannel.close()
try {
transport.send(JSONRPCRequest(RequestId.NumberId(1), "method"))
} catch (e: Exception) {
// send() wraps ClosedSendChannelException in McpException
e.shouldBeInstanceOf<io.modelcontextprotocol.kotlin.sdk.types.McpException>()
}
eventually(2.seconds) {
errorCaught shouldBe true
}
}
@Test
fun `exceptions in message handler do not stop processing`() = runTest {
val receiveChannel = Channel<JSONRPCMessage>(Channel.UNLIMITED)
val transport = ChannelTransport(Channel(), receiveChannel)
val allProcessed = CompletableDeferred<Unit>()
val received = mutableListOf<Int>()
val errors = mutableListOf<Throwable>()
transport.onError { errors.add(it) }
transport.onMessage { msg ->
val id = ((msg as JSONRPCRequest).id as RequestId.NumberId).value.toInt()
received.add(id)
if (id == 2) {
@Suppress("TooGenericExceptionThrown")
throw RuntimeException("Error processing message 2")
}
if (received.size == 4) {
allProcessed.complete(Unit)
}
}
transport.start()
// Send 4 messages, second one will throw
receiveChannel.send(JSONRPCRequest(RequestId.NumberId(1), "m1"))
receiveChannel.send(JSONRPCRequest(RequestId.NumberId(2), "m2"))
receiveChannel.send(JSONRPCRequest(RequestId.NumberId(3), "m3"))
receiveChannel.send(JSONRPCRequest(RequestId.NumberId(4), "m4"))
allProcessed.await()
// All messages should be processed despite error in message 2
received.shouldContainExactly(1, 2, 3, 4)
errors.size shouldBe 1
errors[0].message shouldBe "Error processing message 2"
}
}

View file

@ -0,0 +1,5 @@
## https://docs.junit.org/5.3.0-M1/user-guide/index.html#writing-tests-parallel-execution
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.config.strategy=dynamic
junit.jupiter.execution.parallel.mode.default=concurrent
junit.jupiter.execution.parallel.mode.classes.default=concurrent

View file

@ -0,0 +1,7 @@
# Level of logging for the ROOT logger: ERROR, WARN, INFO, DEBUG, TRACE (default is INFO)
org.slf4j.simpleLogger.defaultLogLevel=INFO
org.slf4j.simpleLogger.showThreadName=true
org.slf4j.simpleLogger.showDateTime=false
# Log level for specific packages or classes
org.slf4j.simpleLogger.log.io.modelcontextprotocol=DEBUG

View file

@ -22,6 +22,7 @@ include(
":kotlin-sdk-core",
":kotlin-sdk-client",
":kotlin-sdk-server",
":kotlin-sdk-testing",
":kotlin-sdk",
":integration-test",
":docs",