This commit is contained in:
parent
4645ebfd17
commit
191b76863d
15 changed files with 660 additions and 87 deletions
12
.github/workflows/build.yml
vendored
12
.github/workflows/build.yml
vendored
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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})"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue