fix(stdio-client)!: Stdio- and StreamableHttpClientTransport should throw McpException on send when not ready (#487)

This commit is contained in:
Konstantin Pavlov 2026-01-21 08:54:35 +02:00 • committed by GitHub
commit 4d505025b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 35 additions and 7 deletions

View file

@ -989,7 +989,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@ -1824,7 +1823,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View file

@ -230,8 +230,18 @@ public class StdioClientTransport @JvmOverloads public constructor(
}
override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) {
check(initialized.load()) { "Transport is not started" }
check(!onCloseCalled.load()) { "Transport is closed" }
if (!initialized.load()) {
throw McpException(
code = CONNECTION_CLOSED,
message = "Transport is not started",
)
}
if (onCloseCalled.load()) {
throw McpException(
code = CONNECTION_CLOSED,
message = "Transport is closed",
)
}
@Suppress("TooGenericExceptionCaught", "SwallowedException")
try {
sendChannel.send(message)

View file

@ -27,7 +27,9 @@ import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse
import io.modelcontextprotocol.kotlin.sdk.types.McpException
import io.modelcontextprotocol.kotlin.sdk.types.McpJson
import io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.CONNECTION_CLOSED
import io.modelcontextprotocol.kotlin.sdk.types.RequestId
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineName
@ -105,7 +107,12 @@ public class StreamableHttpClientTransport(
resumptionToken: String?,
onResumptionToken: ((String) -> Unit)? = null,
) {
check(initialized.load()) { "Transport is not started" }
if (!initialized.load()) {
throw McpException(
code = CONNECTION_CLOSED,
message = "Transport is not started",
)
}
logger.debug { "Client sending message via POST to $url: ${McpJson.encodeToString(message)}" }
// If we have a resumption token, reconnect the SSE stream with it

View file

@ -4,7 +4,9 @@ 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.types.McpException
import io.modelcontextprotocol.kotlin.sdk.types.PingRequest
import io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.CONNECTION_CLOSED
import io.modelcontextprotocol.kotlin.sdk.types.toJSON
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
@ -46,10 +48,11 @@ abstract class AbstractClientTransportLifecycleTest<T : AbstractTransport> {
fun `should throw when sending before start`() = runTest {
val transport = createTransport()
val exception = shouldThrow<IllegalStateException> {
val exception = shouldThrow<McpException> {
transport.send(PingRequest().toJSON())
}
exception.message shouldContain "not started"
exception.code shouldBe CONNECTION_CLOSED
}
@Test

View file

@ -6,6 +6,7 @@ import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import io.kotest.matchers.types.shouldBeSameInstanceAs
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
@ -151,7 +152,16 @@ class StdioClientTransportErrorHandlingTest {
fun `Send should handle exceptions`(throwable: Throwable, shouldWrap: Boolean, expectedCode: Int?) = runTest {
val sendChannel: Channel<JSONRPCMessage> = mockk(relaxed = true)
val stdin = createNonEmptyBuffer { "id: 1\ndata:\n" }
// Create a stdin Source that never returns EOF to prevent transport from closing
// Use mockk since Source is a sealed interface
val stdin: kotlinx.io.Source = mockk(relaxed = true)
every { stdin.readAtMostTo(any<Buffer>(), any()) } coAnswers {
// Return 0 to indicate no data available (but not EOF)
// This keeps the transport alive without blocking
delay(10) // Small delay to prevent busy-waiting
0L
}
transport = StdioClientTransport(
input = stdin,
output = Buffer(),