Skip to content

Commit 6ec6d3c

Browse files
authored
Better exception handling in StdioClientTransport (#444)
# Better exception handling in StdioClientTransport Refactor `McpException` for improved handling with convenience constructors and enhanced exception wrapping logic in `StdioClientTransport`. Update tests to use `kotest` matchers and add JUnit parameterized tests for exception handling. ## Motivation and Context #442 follow up ## How Has This Been Tested? <!-- Have you tested this in a real application? Which scenarios were tested? --> ## Breaking Changes <!-- Will users need to update their code or configurations? --> ## Types of changes <!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply: --> - [x] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update ## Checklist <!-- Go over all the following points, and put an `x` in all the boxes that apply. --> - [ ] I have read the [MCP Documentation](https://modelcontextprotocol.io) - [ ] My code follows the repository's style guidelines - [ ] New and existing tests pass locally - [ ] I have added appropriate error handling - [ ] I have added or updated documentation as needed ## Additional context <!-- Add any other context, implementation notes, or design decisions -->
1 parent bb8de71 commit 6ec6d3c

File tree

7 files changed

+121
-15
lines changed

7 files changed

+121
-15
lines changed

gradle/libs.versions.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ mockk = "1.14.6"
2222
mokksy = "0.6.2"
2323
serialization = "1.9.0"
2424
slf4j = "2.0.17"
25+
junit="6.0.1"
2526

2627
[libraries]
2728
# Plugins
@@ -58,6 +59,7 @@ mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
5859
mokksy = { group = "dev.mokksy", name = "mokksy", version.ref = "mokksy" }
5960
netty-bom = { group = "io.netty", name = "netty-bom", version.ref = "netty" }
6061
slf4j-simple = { group = "org.slf4j", name = "slf4j-simple", version.ref = "slf4j" }
62+
junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit" }
6163

6264
# Samples
6365
ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio", version.ref = "ktor" }

kotlin-sdk-client/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ kotlin {
5454
implementation(libs.awaitility)
5555
implementation(libs.ktor.client.apache5)
5656
implementation(libs.mockk)
57+
implementation(libs.junit.jupiter.params)
5758
implementation(libs.mokksy)
5859
implementation(dependencies.platform(libs.netty.bom))
5960
runtimeOnly(libs.slf4j.simple)

kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransport.kt

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
1515
import io.modelcontextprotocol.kotlin.sdk.types.McpException
1616
import io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.CONNECTION_CLOSED
1717
import io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.INTERNAL_ERROR
18+
import kotlinx.coroutines.CancellationException
1819
import kotlinx.coroutines.CoroutineName
1920
import kotlinx.coroutines.CoroutineScope
2021
import kotlinx.coroutines.Dispatchers
@@ -71,12 +72,14 @@ import kotlin.jvm.JvmOverloads
7172
* @param input The input stream where messages are received.
7273
* @param output The output stream where messages are sent.
7374
* @param error Optional error stream for stderr monitoring.
74-
* @param sendChannel Channel for outbound messages. Default: buffered channel (capacity 64).
75+
* @param sendChannel Channel for outbound messages. Default: buffered channel
76+
* (<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>).
7577
* @param classifyStderr Callback to classify stderr lines. Return [StderrSeverity.FATAL] to fail transport,
7678
* or [StderrSeverity.WARNING] / [StderrSeverity.INFO] / [StderrSeverity.DEBUG]
7779
* to log, or [StderrSeverity.IGNORE] to discard.
7880
* Default value: [StderrSeverity.DEBUG].
79-
* @see <a href="https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#stdio">MCP Specification</a>
81+
* @see <a href="https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio">MCP Specification</a>
82+
* @see [Channel.BUFFERED]
8083
*/
8184
@OptIn(ExperimentalAtomicApi::class)
8285
public class StdioClientTransport @JvmOverloads public constructor(
@@ -232,15 +235,25 @@ public class StdioClientTransport @JvmOverloads public constructor(
232235
@Suppress("TooGenericExceptionCaught", "SwallowedException")
233236
try {
234237
sendChannel.send(message)
238+
} catch (e: CancellationException) {
239+
throw e // MUST rethrow immediately - don't log, don't wrap
235240
} catch (e: ClosedSendChannelException) {
236241
logger.debug(e) { "Cannot send message: transport is closed" }
237-
throw McpException(CONNECTION_CLOSED, "Transport is closed")
242+
throw McpException(
243+
code = CONNECTION_CLOSED,
244+
message = "Transport is closed",
245+
cause = e,
246+
)
238247
} catch (e: McpException) {
239248
logger.debug(e) { "Error while sending message: ${e.message}" }
240249
throw e
241-
} catch (e: Exception) {
250+
} catch (e: Throwable) {
242251
logger.error(e) { "Error while sending message: ${e.message}" }
243-
throw McpException(INTERNAL_ERROR, "Error while sending message: ${e.message}")
252+
throw McpException(
253+
code = INTERNAL_ERROR,
254+
message = "Error while sending message: ${e.message}",
255+
cause = e,
256+
)
244257
}
245258
}
246259

kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/stdio/StdioClientTransportErrorHandlingTest.kt

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,28 @@
11
package io.modelcontextprotocol.kotlin.sdk.client.stdio
22

3+
import io.kotest.assertions.throwables.shouldThrow
34
import io.kotest.matchers.booleans.shouldBeFalse
45
import io.kotest.matchers.shouldBe
6+
import io.kotest.matchers.types.shouldBeInstanceOf
7+
import io.kotest.matchers.types.shouldBeSameInstanceAs
8+
import io.mockk.coEvery
9+
import io.mockk.mockk
510
import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport
11+
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
12+
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest
13+
import io.modelcontextprotocol.kotlin.sdk.types.McpException
14+
import io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode
15+
import kotlinx.coroutines.CancellationException
16+
import kotlinx.coroutines.channels.Channel
17+
import kotlinx.coroutines.channels.ClosedSendChannelException
618
import kotlinx.coroutines.delay
719
import kotlinx.coroutines.test.runTest
820
import kotlinx.io.Buffer
921
import kotlinx.io.writeString
22+
import org.junit.jupiter.params.ParameterizedTest
23+
import org.junit.jupiter.params.provider.Arguments
24+
import org.junit.jupiter.params.provider.MethodSource
25+
import java.util.stream.Stream
1026
import kotlin.concurrent.atomics.AtomicBoolean
1127
import kotlin.concurrent.atomics.ExperimentalAtomicApi
1228
import kotlin.test.Test
@@ -95,4 +111,66 @@ class StdioClientTransportErrorHandlingTest {
95111
// Empty input should close cleanly without error
96112
errorCalled.shouldBeFalse()
97113
}
114+
115+
companion object {
116+
@JvmStatic
117+
fun exceptions(): Stream<Arguments> = Stream.of(
118+
Arguments.of(
119+
CancellationException(),
120+
false, // should not wrap, propagate
121+
null,
122+
),
123+
Arguments.of(
124+
McpException(-1, "dummy"),
125+
false, // should not wrap, propagate
126+
null,
127+
),
128+
Arguments.of(
129+
ClosedSendChannelException("dummy"),
130+
true, // should wrap in McpException
131+
ErrorCode.CONNECTION_CLOSED,
132+
),
133+
Arguments.of(
134+
Exception(),
135+
true,
136+
ErrorCode.INTERNAL_ERROR,
137+
),
138+
Arguments.of(
139+
OutOfMemoryError(),
140+
true,
141+
ErrorCode.INTERNAL_ERROR,
142+
),
143+
144+
)
145+
}
146+
147+
@ParameterizedTest
148+
@MethodSource("exceptions")
149+
fun `Send should handle exceptions`(throwable: Throwable, shouldWrap: Boolean, expectedCode: Int?) = runTest {
150+
val sendChannel: Channel<JSONRPCMessage> = mockk(relaxed = true)
151+
152+
transport = StdioClientTransport(
153+
input = Buffer(),
154+
output = Buffer(),
155+
sendChannel = sendChannel,
156+
)
157+
158+
coEvery { sendChannel.send(any()) } throws throwable
159+
160+
transport.start()
161+
162+
// Cancel the coroutine while it's suspended in send()
163+
val exception = shouldThrow<Throwable> {
164+
transport.send(JSONRPCRequest(id = "test-1", method = "test/method"))
165+
}
166+
167+
if (shouldWrap) {
168+
exception.shouldBeInstanceOf<McpException> {
169+
it.cause shouldBeSameInstanceAs throwable
170+
it.code shouldBe expectedCode
171+
}
172+
} else {
173+
exception shouldBeSameInstanceAs throwable
174+
}
175+
}
98176
}

kotlin-sdk-core/api/kotlin-sdk-core.api

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2654,11 +2654,12 @@ public abstract interface annotation class io/modelcontextprotocol/kotlin/sdk/ty
26542654
}
26552655

26562656
public final class io/modelcontextprotocol/kotlin/sdk/types/McpException : java/lang/Exception {
2657+
public fun <init> (ILjava/lang/String;)V
26572658
public fun <init> (ILjava/lang/String;Lkotlinx/serialization/json/JsonElement;)V
2658-
public synthetic fun <init> (ILjava/lang/String;Lkotlinx/serialization/json/JsonElement;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
2659+
public fun <init> (ILjava/lang/String;Lkotlinx/serialization/json/JsonElement;Ljava/lang/Throwable;)V
2660+
public synthetic fun <init> (ILjava/lang/String;Lkotlinx/serialization/json/JsonElement;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
26592661
public final fun getCode ()I
26602662
public final fun getData ()Lkotlinx/serialization/json/JsonElement;
2661-
public fun getMessage ()Ljava/lang/String;
26622663
}
26632664

26642665
public abstract interface class io/modelcontextprotocol/kotlin/sdk/types/MediaContent : io/modelcontextprotocol/kotlin/sdk/types/ContentBlock {
Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
package io.modelcontextprotocol.kotlin.sdk.types
22

33
import kotlinx.serialization.json.JsonElement
4+
import kotlin.jvm.JvmOverloads
45

56
/**
67
* Represents an error specific to the MCP protocol.
78
*
8-
* @property code The error code.
9-
* @property message The error message.
10-
* @property data Additional error data as a JSON object.
9+
* @property code The MCP/JSON‑RPC error code.
10+
* @property data Optional additional error payload as a JSON element; `null` when not provided.
11+
* @param message The error message.
12+
* @param cause The original cause.
1113
*/
12-
public class McpException(public val code: Int, message: String, public val data: JsonElement? = null) : Exception() {
13-
override val message: String = "MCP error $code: $message"
14-
}
14+
public class McpException @JvmOverloads public constructor(
15+
public val code: Int,
16+
message: String,
17+
public val data: JsonElement? = null,
18+
cause: Throwable? = null,
19+
) : Exception("MCP error $code: $message", cause)

kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractPromptIntegrationTest.kt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package io.modelcontextprotocol.kotlin.sdk.integration.kotlin
22

3+
import io.kotest.assertions.withClue
4+
import io.kotest.matchers.string.shouldContain
35
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequest
46
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams
57
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult
@@ -391,7 +393,9 @@ abstract class AbstractPromptIntegrationTest : KotlinTestBase() {
391393
}
392394
}
393395

394-
assertTrue(exception.message.contains("requiredArg2"), "Exception should mention the missing argument")
396+
withClue("Exception should mention the missing argument") {
397+
exception.message shouldContain "requiredArg2"
398+
}
395399

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

410-
assertTrue(exception2.message.contains("requiredArg"), "Exception should mention a missing required argument")
414+
withClue("Exception should mention a missing required argument") {
415+
exception2.message shouldContain "requiredArg"
416+
}
411417

412418
// test with all required args
413419
val result = client.getPrompt(

0 commit comments

Comments
 (0)