Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ mockk = "1.14.6"
mokksy = "0.6.2"
serialization = "1.9.0"
slf4j = "2.0.17"
junit="6.0.1"

[libraries]
# Plugins
Expand Down Expand Up @@ -58,6 +59,7 @@ mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
mokksy = { group = "dev.mokksy", name = "mokksy", version.ref = "mokksy" }
netty-bom = { group = "io.netty", name = "netty-bom", version.ref = "netty" }
slf4j-simple = { group = "org.slf4j", name = "slf4j-simple", version.ref = "slf4j" }
junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit" }

# Samples
ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio", version.ref = "ktor" }
Expand Down
1 change: 1 addition & 0 deletions kotlin-sdk-client/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ kotlin {
implementation(libs.awaitility)
implementation(libs.ktor.client.apache5)
implementation(libs.mockk)
implementation(libs.junit.jupiter.params)
implementation(libs.mokksy)
implementation(dependencies.platform(libs.netty.bom))
runtimeOnly(libs.slf4j.simple)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.types.McpException
import io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.CONNECTION_CLOSED
import io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.INTERNAL_ERROR
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
Expand Down Expand Up @@ -71,12 +72,14 @@ import kotlin.jvm.JvmOverloads
* @param input The input stream where messages are received.
* @param output The output stream where messages are sent.
* @param error Optional error stream for stderr monitoring.
* @param sendChannel Channel for outbound messages. Default: buffered channel (capacity 64).
* @param sendChannel Channel for outbound messages. Default: buffered channel
* (<a jref="https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.channels/-channel/-factory/-b-u-f-f-e-r-e-d.html">implementation-default capacity</a>).
* @param classifyStderr Callback to classify stderr lines. Return [StderrSeverity.FATAL] to fail transport,
* or [StderrSeverity.WARNING] / [StderrSeverity.INFO] / [StderrSeverity.DEBUG]
* to log, or [StderrSeverity.IGNORE] to discard.
* Default value: [StderrSeverity.DEBUG].
* @see <a href="https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#stdio">MCP Specification</a>
* @see <a href="https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio">MCP Specification</a>
* @see [Channel.BUFFERED]
*/
@OptIn(ExperimentalAtomicApi::class)
public class StdioClientTransport @JvmOverloads public constructor(
Expand Down Expand Up @@ -232,15 +235,25 @@ public class StdioClientTransport @JvmOverloads public constructor(
@Suppress("TooGenericExceptionCaught", "SwallowedException")
try {
sendChannel.send(message)
} catch (e: CancellationException) {
throw e // MUST rethrow immediately - don't log, don't wrap
} catch (e: ClosedSendChannelException) {
logger.debug(e) { "Cannot send message: transport is closed" }
throw McpException(CONNECTION_CLOSED, "Transport is closed")
throw McpException(
code = CONNECTION_CLOSED,
message = "Transport is closed",
cause = e,
)
} catch (e: McpException) {
logger.debug(e) { "Error while sending message: ${e.message}" }
throw e
} catch (e: Exception) {
} catch (e: Throwable) {
logger.error(e) { "Error while sending message: ${e.message}" }
throw McpException(INTERNAL_ERROR, "Error while sending message: ${e.message}")
throw McpException(
code = INTERNAL_ERROR,
message = "Error while sending message: ${e.message}",
cause = e,
)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
package io.modelcontextprotocol.kotlin.sdk.client.stdio

import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.booleans.shouldBeFalse
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import io.kotest.matchers.types.shouldBeSameInstanceAs
import io.mockk.coEvery
import io.mockk.mockk
import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest
import io.modelcontextprotocol.kotlin.sdk.types.McpException
import io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ClosedSendChannelException
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import kotlinx.io.Buffer
import kotlinx.io.writeString
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.MethodSource
import java.util.stream.Stream
import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.test.Test
Expand Down Expand Up @@ -95,4 +111,66 @@ class StdioClientTransportErrorHandlingTest {
// Empty input should close cleanly without error
errorCalled.shouldBeFalse()
}

companion object {
@JvmStatic
fun exceptions(): Stream<Arguments> = Stream.of(
Arguments.of(
CancellationException(),
false, // should not wrap, propagate
null,
),
Arguments.of(
McpException(-1, "dummy"),
false, // should not wrap, propagate
null,
),
Arguments.of(
ClosedSendChannelException("dummy"),
true, // should wrap in McpException
ErrorCode.CONNECTION_CLOSED,
),
Arguments.of(
Exception(),
true,
ErrorCode.INTERNAL_ERROR,
),
Arguments.of(
OutOfMemoryError(),
true,
ErrorCode.INTERNAL_ERROR,
),

)
}

@ParameterizedTest
@MethodSource("exceptions")
fun `Send should handle exceptions`(throwable: Throwable, shouldWrap: Boolean, expectedCode: Int?) = runTest {
val sendChannel: Channel<JSONRPCMessage> = mockk(relaxed = true)

transport = StdioClientTransport(
input = Buffer(),
output = Buffer(),
sendChannel = sendChannel,
)

coEvery { sendChannel.send(any()) } throws throwable

transport.start()

// Cancel the coroutine while it's suspended in send()
val exception = shouldThrow<Throwable> {
transport.send(JSONRPCRequest(id = "test-1", method = "test/method"))
}

if (shouldWrap) {
exception.shouldBeInstanceOf<McpException> {
it.cause shouldBeSameInstanceAs throwable
it.code shouldBe expectedCode
}
} else {
exception shouldBeSameInstanceAs throwable
}
}
}
5 changes: 3 additions & 2 deletions kotlin-sdk-core/api/kotlin-sdk-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -2654,11 +2654,12 @@ public abstract interface annotation class io/modelcontextprotocol/kotlin/sdk/ty
}

public final class io/modelcontextprotocol/kotlin/sdk/types/McpException : java/lang/Exception {
public fun <init> (ILjava/lang/String;)V
public fun <init> (ILjava/lang/String;Lkotlinx/serialization/json/JsonElement;)V
public synthetic fun <init> (ILjava/lang/String;Lkotlinx/serialization/json/JsonElement;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun <init> (ILjava/lang/String;Lkotlinx/serialization/json/JsonElement;Ljava/lang/Throwable;)V
public synthetic fun <init> (ILjava/lang/String;Lkotlinx/serialization/json/JsonElement;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getCode ()I
public final fun getData ()Lkotlinx/serialization/json/JsonElement;
public fun getMessage ()Ljava/lang/String;
}

public abstract interface class io/modelcontextprotocol/kotlin/sdk/types/MediaContent : io/modelcontextprotocol/kotlin/sdk/types/ContentBlock {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
package io.modelcontextprotocol.kotlin.sdk.types

import kotlinx.serialization.json.JsonElement
import kotlin.jvm.JvmOverloads

/**
* Represents an error specific to the MCP protocol.
*
* @property code The error code.
* @property message The error message.
* @property data Additional error data as a JSON object.
* @property code The MCP/JSON‑RPC error code.
* @property data Optional additional error payload as a JSON element; `null` when not provided.
* @param message The error message.
* @param cause The original cause.
*/
public class McpException(public val code: Int, message: String, public val data: JsonElement? = null) : Exception() {
override val message: String = "MCP error $code: $message"
}
public class McpException @JvmOverloads public constructor(
public val code: Int,
message: String,
public val data: JsonElement? = null,
cause: Throwable? = null,
) : Exception("MCP error $code: $message", cause)
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package io.modelcontextprotocol.kotlin.sdk.integration.kotlin

import io.kotest.assertions.withClue
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
Expand Down Expand Up @@ -391,7 +393,9 @@ abstract class AbstractPromptIntegrationTest : KotlinTestBase() {
}
}

assertTrue(exception.message.contains("requiredArg2"), "Exception should mention the missing argument")
withClue("Exception should mention the missing argument") {
exception.message shouldContain "requiredArg2"
}

// test with no args
val exception2 = assertThrows<McpException> {
Expand All @@ -407,7 +411,9 @@ abstract class AbstractPromptIntegrationTest : KotlinTestBase() {
}
}

assertTrue(exception2.message.contains("requiredArg"), "Exception should mention a missing required argument")
withClue("Exception should mention a missing required argument") {
exception2.message shouldContain "requiredArg"
}

// test with all required args
val result = client.getPrompt(
Expand Down
Loading