#107 Monitor STDERR and refactor StdioClientTransport to use flows (#442)

This commit is contained in:
Konstantin Pavlov 2025-12-04 09:18:32 +02:00 • committed by GitHub
commit 191b76863d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 660 additions and 87 deletions

View file

@ -12,6 +12,10 @@ concurrency:
# Cancel only when the run is NOT on `main` branch
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
permissions:
checks: write
pull-requests: write # only required if `comment: true` was enabled
jobs:
validate-pr:
runs-on: macos-latest-xlarge
@ -70,12 +74,16 @@ jobs:
uses: mikepenz/action-junit-report@v6
if: ${{ !cancelled() }} # always run even if the previous step fails
with:
report_paths: '**/test-results/**/TEST-*.xml'
annotate_only: true
comment: true
detailed_summary: true
flaky_summary: true
group_suite: true
include_empty_in_summary: false
include_time_in_summary: true
annotate_only: true
report_paths: '**/test-results/**/TEST-*.xml'
truncate_stack_traces: false
updateComment: true
- name: Disable Auto-Merge on Fail
if: failure() && github.event_name == 'pull_request'

View file

@ -11,16 +11,17 @@ binaryCompatibilityValidatorPlugin = "0.18.1"
openapi-generator = "7.17.0"
# libraries version
serialization = "1.9.0"
awaitility = "4.3.0"
collections-immutable = "0.4.0"
coroutines = "1.10.2"
kotest = "6.0.7"
kotlinx-io = "0.8.2"
ktor = "3.2.3"
logging = "7.0.13"
slf4j = "2.0.17"
kotest = "6.0.7"
awaitility = "4.3.0"
mockk = "1.14.6"
mokksy = "0.6.2"
serialization = "1.9.0"
slf4j = "2.0.17"
[libraries]
# Plugins
@ -53,6 +54,7 @@ kotest-assertions-json = { group = "io.kotest", name = "kotest-assertions-json",
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
ktor-client-mock = { group = "io.ktor", name = "ktor-client-mock", version.ref = "ktor" }
ktor-server-test-host = { group = "io.ktor", name = "ktor-server-test-host", version.ref = "ktor" }
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" }

View file

@ -71,12 +71,28 @@ public final class io/modelcontextprotocol/kotlin/sdk/client/SseClientTransport
}
public final class io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransport : io/modelcontextprotocol/kotlin/sdk/shared/AbstractTransport {
public static final field BUFFER_SIZE J
public fun <init> (Lkotlinx/io/Source;Lkotlinx/io/Sink;)V
public fun <init> (Lkotlinx/io/Source;Lkotlinx/io/Sink;Lkotlinx/io/Source;)V
public fun <init> (Lkotlinx/io/Source;Lkotlinx/io/Sink;Lkotlinx/io/Source;Lkotlinx/coroutines/channels/Channel;)V
public fun <init> (Lkotlinx/io/Source;Lkotlinx/io/Sink;Lkotlinx/io/Source;Lkotlinx/coroutines/channels/Channel;Lkotlin/jvm/functions/Function1;)V
public synthetic fun <init> (Lkotlinx/io/Source;Lkotlinx/io/Sink;Lkotlinx/io/Source;Lkotlinx/coroutines/channels/Channel;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public fun send (Lio/modelcontextprotocol/kotlin/sdk/types/JSONRPCMessage;Lio/modelcontextprotocol/kotlin/sdk/shared/TransportSendOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public fun start (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
}
public final class io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransport$StderrSeverity : java/lang/Enum {
public static final field DEBUG Lio/modelcontextprotocol/kotlin/sdk/client/StdioClientTransport$StderrSeverity;
public static final field FATAL Lio/modelcontextprotocol/kotlin/sdk/client/StdioClientTransport$StderrSeverity;
public static final field IGNORE Lio/modelcontextprotocol/kotlin/sdk/client/StdioClientTransport$StderrSeverity;
public static final field INFO Lio/modelcontextprotocol/kotlin/sdk/client/StdioClientTransport$StderrSeverity;
public static final field WARNING Lio/modelcontextprotocol/kotlin/sdk/client/StdioClientTransport$StderrSeverity;
public static fun getEntries ()Lkotlin/enums/EnumEntries;
public static fun valueOf (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/client/StdioClientTransport$StderrSeverity;
public static fun values ()[Lio/modelcontextprotocol/kotlin/sdk/client/StdioClientTransport$StderrSeverity;
}
public final class io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTransport : io/modelcontextprotocol/kotlin/sdk/shared/AbstractTransport {
public synthetic fun <init> (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/time/Duration;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public synthetic fun <init> (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/time/Duration;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/internal/DefaultConstructorMarker;)V

View file

@ -41,18 +41,20 @@ kotlin {
commonTest {
dependencies {
implementation(kotlin("test"))
implementation(libs.ktor.client.mock)
implementation(libs.ktor.server.websockets)
implementation(libs.kotest.assertions.core)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.ktor.client.logging)
implementation(libs.ktor.client.mock)
implementation(libs.ktor.server.websockets)
}
}
jvmTest {
dependencies {
implementation(libs.mokksy)
implementation(libs.awaitility)
implementation(libs.ktor.client.apache5)
implementation(libs.mockk)
implementation(libs.mokksy)
implementation(dependencies.platform(libs.netty.bom))
runtimeOnly(libs.slf4j.simple)
}

View file

@ -1,135 +1,348 @@
package io.modelcontextprotocol.kotlin.sdk.client
import io.github.oshai.kotlinlogging.KotlinLogging
import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport.StderrSeverity.DEBUG
import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport.StderrSeverity.FATAL
import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport.StderrSeverity.IGNORE
import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport.StderrSeverity.INFO
import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport.StderrSeverity.WARNING
import io.modelcontextprotocol.kotlin.sdk.internal.IODispatcher
import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport
import io.modelcontextprotocol.kotlin.sdk.shared.ReadBuffer
import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions
import io.modelcontextprotocol.kotlin.sdk.shared.serializeMessage
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.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ClosedSendChannelException
import kotlinx.coroutines.channels.ProducerScope
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import kotlinx.io.Buffer
import kotlinx.io.IOException
import kotlinx.io.Sink
import kotlinx.io.Source
import kotlinx.io.buffered
import kotlinx.io.readByteArray
import kotlinx.io.writeString
import kotlinx.serialization.SerializationException
import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.coroutines.CoroutineContext
import kotlin.jvm.JvmOverloads
/**
* A transport implementation for JSON-RPC communication that leverages standard input and output streams.
* A transport implementation for JSON-RPC communication over standard I/O streams.
*
* This class reads from an input stream to process incoming JSON-RPC messages and writes JSON-RPC messages
* to an output stream.
* Reads JSON-RPC messages from [input] and writes messages to [output]. Optionally monitors
* [error] stream for stderr output with configurable severity handling.
*
* ## Usage Example
* ```kotlin
* val process = ProcessBuilder("mcp-server").start()
*
* val transport = StdioClientTransport(
* input = process.inputStream.asSource().buffered(),
* output = process.outputStream.asSink().buffered(),
* error = process.errorStream.asSource().buffered()
* ) { stderrLine ->
* when {
* stderrLine.contains("error", ignoreCase = true) -> StderrSeverity.FATAL
* stderrLine.contains("warning", ignoreCase = true) -> StderrSeverity.WARNING
* else -> StderrSeverity.INFO
* }
* }
*
* transport.start()
* ```
*
* @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 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>
*/
@OptIn(ExperimentalAtomicApi::class)
public class StdioClientTransport(private val input: Source, private val output: Sink) : AbstractTransport() {
private val logger = KotlinLogging.logger {}
private val ioCoroutineContext: CoroutineContext = IODispatcher
private val scope by lazy {
CoroutineScope(ioCoroutineContext + SupervisorJob())
public class StdioClientTransport @JvmOverloads public constructor(
private val input: Source,
private val output: Sink,
private val error: Source? = null,
private val sendChannel: Channel<JSONRPCMessage> = Channel(Channel.BUFFERED),
private val classifyStderr: (String) -> StderrSeverity = { DEBUG },
) : AbstractTransport() {
private companion object {
/**
* Buffer size for I/O operations.
* 8KB is optimal for most systems (matches default page size).
*/
const val BUFFER_SIZE = 8 * 1024L
private val logger = KotlinLogging.logger {}
}
private var job: Job? = null
private val initialized: AtomicBoolean = AtomicBoolean(false)
private val sendChannel = Channel<JSONRPCMessage>(Channel.UNLIMITED)
private val readBuffer = ReadBuffer()
/**
* Severity classification for stderr messages.
*
* - [FATAL]: Calls error handler and terminates transport.
* - [WARNING]: Logs at WARN level, transport continues.
* - [INFO]: Logs at INFO level, transport continues.
* - [DEBUG]: Logs at DEBUG level, transport continues.
* - [IGNORE]: Discards message silently, transport continues.
*/
public enum class StderrSeverity { FATAL, WARNING, INFO, DEBUG, IGNORE }
private val ioCoroutineContext: CoroutineContext = IODispatcher
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val initialized = AtomicBoolean(false)
private val onCloseCalled = AtomicBoolean(false)
override suspend fun start() {
if (!initialized.compareAndSet(expectedValue = false, newValue = true)) {
error("StdioClientTransport already started!")
}
logger.debug { "Starting StdioClientTransport..." }
val outputStream = output.buffered()
// Producers run on IODispatcher for I/O
// Collector runs on Default dispatcher for message handling
scope.launch(CoroutineName("StdioClientTransport.IO#${hashCode()}")) {
var writeJob: Job? = null
val mainScope = this
try {
// Explicitly use ioCoroutineContext for I/O operations
writeJob = launch(ioCoroutineContext) {
logger.debug { "Write coroutine started." }
output.buffered().use { sink ->
sendChannel.consumeEach { message ->
sendOutboundMessage(message, sink, mainScope)
yield() // Giving other coroutines a chance to run
}
}
}
job = scope.launch(CoroutineName("StdioClientTransport.IO#${hashCode()}")) {
val readJob = launch {
logger.debug { "Read coroutine started." }
try {
input.use {
while (isActive) {
val buffer = Buffer()
val bytesRead = input.readAtMostTo(buffer, 8192)
if (bytesRead == -1L) break
if (bytesRead > 0L) {
readBuffer.append(buffer.readByteArray())
processReadBuffer()
val eventsFlow = channelFlow {
launch(ioCoroutineContext) {
logger.debug { "Read stdin coroutine started." }
val readBuffer = ReadBuffer() // parses bytes into JSONRPCMessage
readSource(stream = ProcessStream.Stdin, source = input, channel = this@channelFlow) { bytes ->
readBuffer.append(bytes)
do {
val msg = readBuffer.readMessage()
msg?.let { send(Event.JsonRpc(msg)) }
} while (msg != null)
}
}.invokeOnCompletion {
logger.debug(it) { "Read stdin coroutine finished." }
}
error?.let { source ->
launch(ioCoroutineContext) {
logger.debug { "Read stderr coroutine started." }
readSource(
stream = ProcessStream.Stderr,
source = source,
channel = this@channelFlow,
) { bytes ->
val str = bytes.decodeToString()
send(Event.StderrEvent(str))
}
}
}
} catch (e: Exception) {
_onError.invoke(e)
logger.error(e) { "Error reading from input stream" }
}
}
val writeJob = launch {
logger.debug { "Write coroutine started." }
try {
sendChannel.consumeEach { message ->
val json = serializeMessage(message)
outputStream.writeString(json)
outputStream.flush()
}
} catch (e: Throwable) {
if (isActive) {
_onError.invoke(e)
logger.error(e) { "Error writing to output stream" }
}
} finally {
output.close()
}
}
// Collect events on handlerCoroutineContext (Dispatchers.Default from parent scope)
// No flowOn necessary - collection runs in parent launch context
eventsFlow
.collect { event ->
when (event) {
is Event.JsonRpc -> {
handleJSONRPCMessage(event.message)
}
readJob.join()
writeJob.cancelAndJoin()
_onClose.invoke()
is Event.StderrEvent -> {
val errorSeverity = classifyStderr(event.message)
when (errorSeverity) {
FATAL -> {
runCatching {
_onError(
McpException(INTERNAL_ERROR, "Message in StdErr: ${event.message}"),
)
}
stopProcessing("Fatal STDERR message received")
}
WARNING -> {
logger.warn { "STDERR message received: ${event.message}" }
}
INFO -> {
logger.info { "STDERR message received: ${event.message}" }
}
DEBUG -> {
logger.debug { "STDERR message received: ${event.message}" }
}
IGNORE -> {
// do nothing
}
}
}
is Event.EOFEvent -> {
if (event.stream == ProcessStream.Stdin) {
stopProcessing("EOF in ${event.stream}")
}
}
is Event.IOErrorEvent -> {
runCatching { _onError(event.cause) }
stopProcessing("IO Error", event.cause)
}
}
}
} finally {
// Wait for write job to complete before closing, matching old implementation
writeJob?.cancelAndJoin()
logger.debug { "Transport coroutine completed, calling onClose" }
callOnCloseOnce()
}
}
}
override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) {
if (!initialized.load()) {
error("Transport not started")
check(initialized.load()) { "Transport is not started" }
check(!onCloseCalled.load()) { "Transport is closed" }
@Suppress("TooGenericExceptionCaught", "SwallowedException")
try {
sendChannel.send(message)
} catch (e: ClosedSendChannelException) {
logger.debug(e) { "Cannot send message: transport is closed" }
throw McpException(CONNECTION_CLOSED, "Transport is closed")
} catch (e: McpException) {
logger.debug(e) { "Error while sending message: ${e.message}" }
throw e
} catch (e: Exception) {
logger.error(e) { "Error while sending message: ${e.message}" }
throw McpException(INTERNAL_ERROR, "Error while sending message: ${e.message}")
}
sendChannel.send(message)
}
override suspend fun close() {
if (!initialized.compareAndSet(expectedValue = true, newValue = false)) {
error("Transport is already closed")
return // Already closed
}
job?.cancelAndJoin()
input.close()
output.close()
readBuffer.clear()
sendChannel.close()
_onClose.invoke()
scope.stopProcessing("Closed")
scope.coroutineContext[Job]?.join() // Wait for all coroutines to complete
}
private suspend fun processReadBuffer() {
while (true) {
val msg = readBuffer.readMessage() ?: break
try {
_onMessage.invoke(msg)
} catch (e: Throwable) {
_onError.invoke(e)
logger.error(e) { "Error processing message." }
private fun callOnCloseOnce() {
if (onCloseCalled.compareAndSet(expectedValue = false, newValue = true)) {
runCatching { _onClose() }
}
}
private fun sendOutboundMessage(message: JSONRPCMessage, sink: Sink, mainScope: CoroutineScope) {
try {
val json = serializeMessage(message)
sink.writeString(json)
sink.flush()
} catch (e: SerializationException) {
logger.warn(e) { "Can't serialize message" }
runCatching { _onError(McpException(INTERNAL_ERROR, "Serialization error")) }
mainScope.stopProcessing("Can't serialize message", e)
} catch (e: IOException) {
logger.warn(e) { "Can't send message" }
runCatching { _onError(McpException(CONNECTION_CLOSED, "Can't send message. Connection closed")) }
mainScope.stopProcessing("Write I/O failed", e)
}
}
private suspend fun handleJSONRPCMessage(msg: JSONRPCMessage) {
@Suppress("TooGenericExceptionCaught")
try {
_onMessage.invoke(msg)
} catch (e: Throwable) {
logger.error(e) { "Error processing message." }
runCatching { _onError.invoke(e) }
}
}
private fun CoroutineScope.stopProcessing(reason: String, cause: Throwable? = null) {
sendChannel.close() // Stop accepting new messages
callOnCloseOnce()
cancel(reason, cause) // cancel current coroutine context
}
private suspend fun CoroutineScope.readSource(
stream: ProcessStream,
source: Source,
channel: ProducerScope<Event>,
bytesConsumer: suspend (ByteArray) -> Unit,
) {
val buffer = Buffer()
try {
source.use { source ->
while (isActive) {
val bytesRead = source.readAtMostTo(buffer, BUFFER_SIZE)
if (bytesRead == -1L) {
logger.debug { "EOF reached in $stream" }
channel.send(Event.EOFEvent(stream))
break
}
if (bytesRead > 0L) {
val bytes = buffer.readByteArray()
buffer.clear()
bytesConsumer.invoke(bytes)
}
yield() // Giving other coroutines a chance to run
}
}
} catch (exception: IOException) {
logger.debug(exception) { "IOException while reading stream" }
channel.send(Event.IOErrorEvent(stream, exception))
} finally {
buffer.clear()
}
}
private enum class ProcessStream { Stdin, Stderr, Stdout }
/**
* Represents an event in the communication process.
*
* Events are a sealed hierarchy of different types of communication signals
* between processes. These events are used to manage and interpret information
* or errors generated during the operation of the associated transport.
*/
private sealed interface Event {
data class JsonRpc(val message: JSONRPCMessage) : Event
data class StderrEvent(val message: String) : Event
data class EOFEvent(val stream: ProcessStream) : Event
data class IOErrorEvent(val stream: ProcessStream, val cause: Throwable) : Event {
override fun toString(): String = "IOErrorEvent(stream=$stream, cause=${cause.message})"
}
}
}

View file

@ -105,6 +105,7 @@ public class StreamableHttpClientTransport(
resumptionToken: String?,
onResumptionToken: ((String) -> Unit)? = null,
) {
check(initialized.load()) { "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

@ -0,0 +1,86 @@
package io.modelcontextprotocol.kotlin.sdk.client
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.PingRequest
import io.modelcontextprotocol.kotlin.sdk.types.toJSON
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.time.Duration.Companion.milliseconds
abstract class AbstractClientTransportLifecycleTest<T : AbstractTransport> {
protected lateinit var transport: T
@BeforeTest
fun beforeEach() {
transport = createTransport()
}
@Test
fun `should throw when started twice`() = runTest {
transport.start()
val exception = shouldThrow<IllegalStateException> {
transport.start()
}
exception.message shouldContain "already started"
}
@Test
fun `should be idempotent when closed twice`() = runTest {
val transport = createTransport()
transport.start()
transport.close()
// Second close should not throw
transport.close()
}
@Test
fun `should throw when sending before start`() = runTest {
val transport = createTransport()
val exception = shouldThrow<IllegalStateException> {
transport.send(PingRequest().toJSON())
}
exception.message shouldContain "not started"
}
@Test
fun `should throw when sending after close`() = runTest {
val transport = createTransport()
transport.start()
delay(50.milliseconds)
transport.close()
shouldThrow<Exception> {
transport.send(PingRequest().toJSON())
}
}
@Test
fun `should call onClose exactly once`() = runTest {
val transport = createTransport()
var closeCallCount = 0
transport.onClose { closeCallCount++ }
transport.start()
delay(50.milliseconds)
// Multiple close attempts
transport.close()
transport.close()
closeCallCount shouldBe 1
}
protected abstract fun createTransport(): T
}

View file

@ -0,0 +1,26 @@
package io.modelcontextprotocol.kotlin.sdk.client.stdio
import io.modelcontextprotocol.kotlin.sdk.client.AbstractClientTransportLifecycleTest
import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport
import kotlinx.io.Buffer
import kotlin.test.Ignore
import kotlin.test.Test
class StdioClientTransportLifecycleTest : AbstractClientTransportLifecycleTest<StdioClientTransport>() {
/**
* Dummy method to make IDE treat this class as a test
*/
@Test
@Ignore
fun dummyTest() = Unit
override fun createTransport(): StdioClientTransport {
val inputBuffer = Buffer()
val outputBuffer = Buffer()
return StdioClientTransport(
input = inputBuffer,
output = outputBuffer,
)
}
}

View file

@ -0,0 +1,423 @@
package io.modelcontextprotocol.kotlin.sdk.client.streamable.http
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.MockRequestHandler
import io.ktor.client.engine.mock.respond
import io.ktor.client.plugins.sse.SSE
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.http.content.TextContent
import io.ktor.http.headersOf
import io.ktor.utils.io.ByteReadChannel
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
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.McpJson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlin.test.Ignore
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.seconds
class StreamableHttpClientTransportTest {
private fun createTransport(handler: MockRequestHandler): StreamableHttpClientTransport {
val mockEngine = MockEngine(handler)
val httpClient = HttpClient(mockEngine) {
install(SSE) {
reconnectionTime = 1.seconds
}
}
return StreamableHttpClientTransport(httpClient, url = "http://localhost:8080/mcp")
}
@Test
fun testSendJsonRpcMessage() = runTest {
val message = JSONRPCRequest(
id = "test-id",
method = "test",
params = buildJsonObject { },
)
val transport = createTransport { request ->
assertEquals(HttpMethod.Post, request.method)
assertEquals("http://localhost:8080/mcp", request.url.toString())
assertEquals(ContentType.Application.Json, request.body.contentType)
val body = (request.body as TextContent).text
val decodedMessage = McpJson.decodeFromString<JSONRPCMessage>(body)
assertEquals(message, decodedMessage)
respond(
content = "",
status = HttpStatusCode.Accepted,
)
}
transport.start()
transport.send(message)
transport.close()
}
@Test
fun testStoreSessionId() = runTest {
val initMessage = JSONRPCRequest(
id = "test-id",
method = "initialize",
params = buildJsonObject {
put(
"clientInfo",
buildJsonObject {
put("name", JsonPrimitive("test-client"))
put("version", JsonPrimitive("1.0"))
},
)
put("protocolVersion", JsonPrimitive("2025-06-18"))
},
)
val transport = createTransport { request ->
when (val msg = McpJson.decodeFromString<JSONRPCMessage>((request.body as TextContent).text)) {
is JSONRPCRequest if msg.method == "initialize" -> respond(
content = "",
status = HttpStatusCode.OK,
headers = headersOf("mcp-session-id", "test-session-id"),
)
is JSONRPCNotification if msg.method == "test" -> {
assertEquals("test-session-id", request.headers["mcp-session-id"])
respond(
content = "",
status = HttpStatusCode.Accepted,
)
}
else -> error("Unexpected message: $msg")
}
}
transport.start()
transport.send(initMessage)
assertEquals("test-session-id", transport.sessionId)
transport.send(JSONRPCNotification(method = "test"))
transport.close()
}
@Test
fun testTerminateSession() = runTest {
// transport.sessionId = "test-session-id"
val transport = createTransport { request ->
assertEquals(HttpMethod.Delete, request.method)
assertEquals("test-session-id", request.headers["mcp-session-id"])
respond(
content = "",
status = HttpStatusCode.OK,
)
}
transport.start()
transport.terminateSession()
assertNull(transport.sessionId)
transport.close()
}
@Test
fun testTerminateSessionHandle405() = runTest {
// transport.sessionId = "test-session-id"
val transport = createTransport { request ->
assertEquals(HttpMethod.Delete, request.method)
respond(
content = "",
status = HttpStatusCode.MethodNotAllowed,
)
}
transport.start()
// Should not throw for 405
transport.terminateSession()
// Session ID should still be cleared
assertNull(transport.sessionId)
transport.close()
}
@Test
fun testProtocolVersionHeader() = runTest {
val transport = createTransport { request ->
assertEquals("2025-06-18", request.headers["mcp-protocol-version"])
respond(
content = "",
status = HttpStatusCode.Accepted,
)
}
transport.protocolVersion = "2025-06-18"
transport.start()
transport.send(JSONRPCNotification(method = "test"))
transport.close()
}
// Engine doesn't support SSECapability: https://youtrack.jetbrains.com/issue/KTOR-8177/MockEngine-Add-SSE-support
@Ignore
@Test
fun testNotificationSchemaE2E() = runTest {
val receivedMessages = mutableListOf<JSONRPCMessage>()
var sseStarted = false
val transport = createTransport { request ->
when (request.method) {
HttpMethod.Post if request.body.toString().contains("notifications/initialized") -> {
respond(
content = "",
status = HttpStatusCode.Accepted,
headers = headersOf("mcp-session-id", "notification-test-session"),
)
}
// Handle SSE connection
HttpMethod.Get -> {
sseStarted = true
val sseContent = buildString {
// Server sends various notifications
appendLine("event: message")
appendLine("id: 1")
appendLine(
"""data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"upload-123","progress":50,"total":100}}""",
)
appendLine()
appendLine("event: message")
appendLine("id: 2")
appendLine("""data: {"jsonrpc":"2.0","method":"notifications/resources/list_changed"}""")
appendLine()
appendLine("event: message")
appendLine("id: 3")
appendLine("""data: {"jsonrpc":"2.0","method":"notifications/tools/list_changed"}""")
appendLine()
}
respond(
content = ByteReadChannel(sseContent),
status = HttpStatusCode.OK,
headers = headersOf(
HttpHeaders.ContentType,
ContentType.Text.EventStream.toString(),
),
)
}
// Handle regular notifications
HttpMethod.Post -> {
respond(
content = "",
status = HttpStatusCode.Accepted,
)
}
else -> respond("", HttpStatusCode.OK)
}
}
transport.onMessage { message ->
receivedMessages.add(message)
}
transport.start()
// Test 1: Send initialized notification to trigger SSE
val initializedNotification = JSONRPCNotification(
method = "notifications/initialized",
params = buildJsonObject {
put("protocolVersion", JsonPrimitive("1.0"))
put(
"capabilities",
buildJsonObject {
put("tools", JsonPrimitive(true))
put("resources", JsonPrimitive(true))
},
)
},
)
transport.send(initializedNotification)
// Verify SSE was triggered
assertTrue(sseStarted, "SSE should start after initialized notification")
// Test 2: Verify received notifications
assertEquals(3, receivedMessages.size)
assertTrue(receivedMessages.all { it is JSONRPCNotification })
val notifications = receivedMessages.filterIsInstance<JSONRPCNotification>()
// Verify progress notification
val progressNotif = notifications[0]
assertEquals("notifications/progress", progressNotif.method)
val progressParams = progressNotif.params as JsonObject
assertEquals("upload-123", (progressParams["progressToken"] as JsonPrimitive).content)
assertEquals(50, (progressParams["progress"] as JsonPrimitive).content.toInt())
// Verify list changed notifications
assertEquals("notifications/resources/list_changed", notifications[1].method)
assertEquals("notifications/tools/list_changed", notifications[2].method)
// Test 3: Send various client notifications
val clientNotifications = listOf(
JSONRPCNotification(
method = "notifications/progress",
params = buildJsonObject {
put("progressToken", JsonPrimitive("download-456"))
put("progress", JsonPrimitive(75))
},
),
JSONRPCNotification(
method = "notifications/cancelled",
params = buildJsonObject {
put("requestId", JsonPrimitive("req-789"))
put("reason", JsonPrimitive("user_cancelled"))
},
),
JSONRPCNotification(
method = "notifications/message",
params = buildJsonObject {
put("level", JsonPrimitive("info"))
put("message", JsonPrimitive("Operation completed"))
put(
"data",
buildJsonObject {
put("duration", JsonPrimitive(1234))
},
)
},
),
)
// Send all client notifications
clientNotifications.forEach { notification ->
transport.send(notification)
}
// Verify session ID is maintained
assertEquals("notification-test-session", transport.sessionId)
transport.close()
}
// Engine doesn't support SSECapability: https://youtrack.jetbrains.com/issue/KTOR-8177/MockEngine-Add-SSE-support
@Ignore
@Test
fun testNotificationWithResumptionToken() = runTest {
var resumptionTokenReceived: String? = null
var lastEventIdSent: String? = null
val transport = createTransport { request ->
// Capture Last-Event-ID header
lastEventIdSent = request.headers["Last-Event-ID"]
when (request.method) {
HttpMethod.Get -> {
val sseContent = buildString {
appendLine("event: message")
appendLine("id: resume-100")
appendLine(
"""data: {"jsonrpc":"2.0","method":"notifications/resumed","params":{"fromToken":"$lastEventIdSent"}}""",
)
appendLine()
}
respond(
content = ByteReadChannel(sseContent),
status = HttpStatusCode.OK,
headers = headersOf(
HttpHeaders.ContentType,
ContentType.Text.EventStream.toString(),
),
)
}
else -> respond("", HttpStatusCode.Accepted)
}
}
transport.start()
// Send notification with resumption token
transport.send(
message = JSONRPCNotification(
method = "notifications/test",
params = buildJsonObject {
put("data", JsonPrimitive("test-data"))
},
),
resumptionToken = "previous-token-99",
onResumptionToken = { token ->
resumptionTokenReceived = token
},
)
// Wait for response
delay(1.seconds)
// Verify resumption token was sent in header
assertEquals("previous-token-99", lastEventIdSent)
// Verify new resumption token was received
assertEquals("resume-100", resumptionTokenReceived)
transport.close()
}
@Test
fun testClientConnectWithInvalidJson() = runTest {
// Transport under test: respond with invalid JSON for the initialize request
val transport = createTransport { _ ->
respond(
"this is not valid json",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()),
)
}
val client = Client(
clientInfo = Implementation(
name = "test-client",
version = "1.0",
),
)
try {
// Real time-keeping is needed; otherwise Protocol will always throw TimeoutCancellationException in tests
assertFailsWith<IllegalStateException>(
message = "Expected client.connect to fail on invalid JSON response",
) {
withContext(Dispatchers.Default.limitedParallelism(1)) {
withTimeout(5.seconds) {
client.connect(transport)
}
}
}
} finally {
transport.close()
}
}
}

View file

@ -0,0 +1,43 @@
package io.modelcontextprotocol.kotlin.sdk.client.streamable.http
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.plugins.sse.SSE
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import io.modelcontextprotocol.kotlin.sdk.client.AbstractClientTransportLifecycleTest
import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport
import kotlin.test.Ignore
import kotlin.test.Test
import kotlin.time.Duration.Companion.seconds
class StreamingHttpClientTransportLifecycleTest :
AbstractClientTransportLifecycleTest<StreamableHttpClientTransport>() {
/**
* Dummy method to make IDE treat this class as a test
*/
@Test
@Ignore
fun dummyTest() = Unit
override fun createTransport(): StreamableHttpClientTransport {
val mockEngine = MockEngine {
respond(
"this is not valid json",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()),
)
}
val httpClient = HttpClient(mockEngine) {
install(SSE) {
reconnectionTime = 1.seconds
}
}
return StreamableHttpClientTransport(httpClient, url = "http://localhost:8080/mcp")
}
}

View file

@ -0,0 +1,98 @@
package io.modelcontextprotocol.kotlin.sdk.client.stdio
import io.kotest.matchers.booleans.shouldBeFalse
import io.kotest.matchers.shouldBe
import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import kotlinx.io.Buffer
import kotlinx.io.writeString
import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.test.Test
import kotlin.time.Duration.Companion.milliseconds
/**
* Tests for StdioClientTransport error handling: EOF, IO errors, and edge cases.
*/
class StdioClientTransportErrorHandlingTest {
private lateinit var transport: StdioClientTransport
@OptIn(ExperimentalAtomicApi::class)
@Test
fun `should continue on stderr EOF`() = runTest {
val stderrBuffer = Buffer()
// Empty stderr = immediate EOF
val inputBuffer = Buffer()
inputBuffer.writeString("""data: {"jsonrpc":"2.0","method":"ping","id":1}\n\n""")
val outputBuffer = Buffer()
transport = StdioClientTransport(
input = inputBuffer,
output = outputBuffer,
error = stderrBuffer,
)
val closeCalled = AtomicBoolean(false)
transport.onClose { closeCalled.store(true) }
transport.start()
delay(200.milliseconds)
// Stderr EOF should not close transport
closeCalled.load() shouldBe false
transport.close()
closeCalled.load() shouldBe true
}
@Test
fun `should call onClose exactly once on error scenarios`() = runTest {
val stderrBuffer = Buffer()
stderrBuffer.write("FATAL: critical error\n".encodeToByteArray())
val inputBuffer = Buffer()
val outputBuffer = Buffer()
var closeCallCount = 0
transport = StdioClientTransport(
input = inputBuffer,
output = outputBuffer,
error = stderrBuffer,
classifyStderr = { StdioClientTransport.StderrSeverity.FATAL },
)
transport.onClose { closeCallCount++ }
transport.start()
delay(100.milliseconds)
// Explicit close after error already closed it
transport.close()
closeCallCount shouldBe 1
}
@Test
fun `should handle empty input gracefully`() = runTest {
val inputBuffer = Buffer()
val outputBuffer = Buffer()
transport = StdioClientTransport(
input = inputBuffer,
output = outputBuffer,
)
var errorCalled = false
transport.onError { errorCalled = true }
transport.start()
delay(100.milliseconds)
// Empty input should close cleanly without error
errorCalled.shouldBeFalse()
}
}

View file

@ -1,6 +1,6 @@
package io.modelcontextprotocol.kotlin.sdk.shared
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
import kotlinx.coroutines.CompletableDeferred
/**

View file

@ -5,10 +5,12 @@ import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.types.PingRequest
import io.modelcontextprotocol.kotlin.sdk.types.toJSON
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.delay
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.test.fail
import kotlin.time.Duration.Companion.seconds
abstract class BaseTransportTest {
@ -21,6 +23,8 @@ abstract class BaseTransportTest {
transport.onClose { didClose = true }
transport.start()
delay(1.seconds)
assertFalse(didClose, "Transport should not be closed immediately after start")
transport.close()

View file

@ -1,13 +1,68 @@
package io.modelcontextprotocol.kotlin.sdk.client
import io.modelcontextprotocol.kotlin.sdk.shared.BaseTransportTest
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.McpException
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import kotlinx.io.asSink
import kotlinx.io.asSource
import kotlinx.io.buffered
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Timeout
import org.junit.jupiter.api.assertThrows
import java.util.concurrent.TimeUnit
import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.test.fail
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@Timeout(30, unit = TimeUnit.SECONDS)
class StdioClientTransportTest : BaseTransportTest() {
@Test
fun `handle stdio error`(): Unit = runBlocking {
val processBuilder = if (System.getProperty("os.name").lowercase().contains("win")) {
ProcessBuilder("cmd", "/c", "pause 1 && echo simulated error 1>&2 && exit 1")
} else {
ProcessBuilder("sh", "-c", "sleep 1 && echo 'simulated error' >&2 && exit 1")
}
val process = processBuilder.start()
val stdin = process.inputStream.asSource().buffered()
val stdout = process.outputStream.asSink().buffered()
val stderr = process.errorStream.asSource().buffered()
val transport = StdioClientTransport(
input = stdin,
output = stdout,
error = stderr,
) {
println("💥Ah-oh!, error: \"$it\"")
StdioClientTransport.StderrSeverity.FATAL
}
val client = Client(
clientInfo = Implementation(
name = "test-client",
version = "1.0",
),
)
// The error in stderr should cause connecting to fail
assertThrows<McpException> {
client.connect(transport)
}
process.destroyForcibly()
}
@OptIn(ExperimentalAtomicApi::class)
@Test
fun `should start then close cleanly`() = runTest {
// Run process "/usr/bin/tee"
@ -16,15 +71,33 @@ class StdioClientTransportTest : BaseTransportTest() {
val input = process.inputStream.asSource().buffered()
val output = process.outputStream.asSink().buffered()
val error = process.errorStream.asSource().buffered()
val transport = StdioClientTransport(
input = input,
output = output,
error = error,
)
testTransportOpenClose(transport)
transport.onError { error ->
fail("Unexpected error: $error")
}
process.destroy()
val didClose = AtomicBoolean(false)
transport.onClose { didClose.store(true) }
transport.start()
delay(1.seconds)
assertFalse(didClose.load(), "Transport should not be closed immediately after start")
// Destroy process BEFORE close() to unblock stdin reader
process.destroyForcibly()
delay(100.milliseconds) // Give time for EOF to propagate
transport.close()
assertTrue(didClose.load(), "Transport should be closed after close() call")
}
@Test
@ -43,7 +116,7 @@ class StdioClientTransportTest : BaseTransportTest() {
testTransportRead(transport)
process.waitFor()
process.destroy()
process.destroyForcibly()
}
@Test
@ -63,6 +136,6 @@ class StdioClientTransportTest : BaseTransportTest() {
testTransportRead(transport)
process.waitFor()
process.destroy()
process.destroyForcibly()
}
}

View file

@ -89,7 +89,7 @@ class KotlinClientTsServerEdgeCasesTestStdio : TsTestBase() {
}
@Test
@Timeout(60, unit = TimeUnit.SECONDS)
@Timeout(30, unit = TimeUnit.SECONDS)
fun testConcurrentRequestsOverStdio(): Unit = runBlocking(Dispatchers.IO) {
withClientStdio { client: Client, _ ->
val concurrentCount = 5
@ -102,8 +102,7 @@ class KotlinClientTsServerEdgeCasesTestStdio : TsTestBase() {
val result = client.callTool("greet", arguments)
assertNotNull(result, "Tool call result should not be null for client $i")
val callResult = result
val textContent = callResult.content.firstOrNull { it is TextContent } as? TextContent
val textContent = result.content.firstOrNull { it is TextContent } as? TextContent
assertNotNull(textContent, "Text content should be present for client $i")
textContent.text