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 only when the run is NOT on `main` branch
|
||||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
checks: write
|
||||||
|
pull-requests: write # only required if `comment: true` was enabled
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validate-pr:
|
validate-pr:
|
||||||
runs-on: macos-latest-xlarge
|
runs-on: macos-latest-xlarge
|
||||||
|
|
@ -70,12 +74,16 @@ jobs:
|
||||||
uses: mikepenz/action-junit-report@v6
|
uses: mikepenz/action-junit-report@v6
|
||||||
if: ${{ !cancelled() }} # always run even if the previous step fails
|
if: ${{ !cancelled() }} # always run even if the previous step fails
|
||||||
with:
|
with:
|
||||||
report_paths: '**/test-results/**/TEST-*.xml'
|
annotate_only: true
|
||||||
|
comment: true
|
||||||
detailed_summary: true
|
detailed_summary: true
|
||||||
flaky_summary: true
|
flaky_summary: true
|
||||||
|
group_suite: true
|
||||||
include_empty_in_summary: false
|
include_empty_in_summary: false
|
||||||
include_time_in_summary: true
|
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
|
- name: Disable Auto-Merge on Fail
|
||||||
if: failure() && github.event_name == 'pull_request'
|
if: failure() && github.event_name == 'pull_request'
|
||||||
|
|
|
||||||
|
|
@ -11,16 +11,17 @@ binaryCompatibilityValidatorPlugin = "0.18.1"
|
||||||
openapi-generator = "7.17.0"
|
openapi-generator = "7.17.0"
|
||||||
|
|
||||||
# libraries version
|
# libraries version
|
||||||
serialization = "1.9.0"
|
awaitility = "4.3.0"
|
||||||
collections-immutable = "0.4.0"
|
collections-immutable = "0.4.0"
|
||||||
coroutines = "1.10.2"
|
coroutines = "1.10.2"
|
||||||
|
kotest = "6.0.7"
|
||||||
kotlinx-io = "0.8.2"
|
kotlinx-io = "0.8.2"
|
||||||
ktor = "3.2.3"
|
ktor = "3.2.3"
|
||||||
logging = "7.0.13"
|
logging = "7.0.13"
|
||||||
slf4j = "2.0.17"
|
mockk = "1.14.6"
|
||||||
kotest = "6.0.7"
|
|
||||||
awaitility = "4.3.0"
|
|
||||||
mokksy = "0.6.2"
|
mokksy = "0.6.2"
|
||||||
|
serialization = "1.9.0"
|
||||||
|
slf4j = "2.0.17"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
# Plugins
|
# 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" }
|
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-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" }
|
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" }
|
mokksy = { group = "dev.mokksy", name = "mokksy", version.ref = "mokksy" }
|
||||||
netty-bom = { group = "io.netty", name = "netty-bom", version.ref = "netty" }
|
netty-bom = { group = "io.netty", name = "netty-bom", version.ref = "netty" }
|
||||||
slf4j-simple = { group = "org.slf4j", name = "slf4j-simple", version.ref = "slf4j" }
|
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 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;)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 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 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 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 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;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
|
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 {
|
commonTest {
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(kotlin("test"))
|
implementation(kotlin("test"))
|
||||||
implementation(libs.ktor.client.mock)
|
implementation(libs.kotest.assertions.core)
|
||||||
implementation(libs.ktor.server.websockets)
|
|
||||||
implementation(libs.kotlinx.coroutines.test)
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
implementation(libs.ktor.client.logging)
|
implementation(libs.ktor.client.logging)
|
||||||
|
implementation(libs.ktor.client.mock)
|
||||||
|
implementation(libs.ktor.server.websockets)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
jvmTest {
|
jvmTest {
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(libs.mokksy)
|
|
||||||
implementation(libs.awaitility)
|
implementation(libs.awaitility)
|
||||||
implementation(libs.ktor.client.apache5)
|
implementation(libs.ktor.client.apache5)
|
||||||
|
implementation(libs.mockk)
|
||||||
|
implementation(libs.mokksy)
|
||||||
implementation(dependencies.platform(libs.netty.bom))
|
implementation(dependencies.platform(libs.netty.bom))
|
||||||
runtimeOnly(libs.slf4j.simple)
|
runtimeOnly(libs.slf4j.simple)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,135 +1,348 @@
|
||||||
package io.modelcontextprotocol.kotlin.sdk.client
|
package io.modelcontextprotocol.kotlin.sdk.client
|
||||||
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
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.internal.IODispatcher
|
||||||
import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport
|
import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport
|
||||||
import io.modelcontextprotocol.kotlin.sdk.shared.ReadBuffer
|
import io.modelcontextprotocol.kotlin.sdk.shared.ReadBuffer
|
||||||
import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions
|
import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions
|
||||||
import io.modelcontextprotocol.kotlin.sdk.shared.serializeMessage
|
import io.modelcontextprotocol.kotlin.sdk.shared.serializeMessage
|
||||||
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
|
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.CoroutineName
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.cancelAndJoin
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.channels.ClosedSendChannelException
|
||||||
|
import kotlinx.coroutines.channels.ProducerScope
|
||||||
import kotlinx.coroutines.channels.consumeEach
|
import kotlinx.coroutines.channels.consumeEach
|
||||||
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.yield
|
||||||
import kotlinx.io.Buffer
|
import kotlinx.io.Buffer
|
||||||
|
import kotlinx.io.IOException
|
||||||
import kotlinx.io.Sink
|
import kotlinx.io.Sink
|
||||||
import kotlinx.io.Source
|
import kotlinx.io.Source
|
||||||
import kotlinx.io.buffered
|
import kotlinx.io.buffered
|
||||||
import kotlinx.io.readByteArray
|
import kotlinx.io.readByteArray
|
||||||
import kotlinx.io.writeString
|
import kotlinx.io.writeString
|
||||||
|
import kotlinx.serialization.SerializationException
|
||||||
import kotlin.concurrent.atomics.AtomicBoolean
|
import kotlin.concurrent.atomics.AtomicBoolean
|
||||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||||
import kotlin.coroutines.CoroutineContext
|
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
|
* Reads JSON-RPC messages from [input] and writes messages to [output]. Optionally monitors
|
||||||
* to an output stream.
|
* [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 input The input stream where messages are received.
|
||||||
* @param output The output stream where messages are sent.
|
* @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)
|
@OptIn(ExperimentalAtomicApi::class)
|
||||||
public class StdioClientTransport(private val input: Source, private val output: Sink) : AbstractTransport() {
|
public class StdioClientTransport @JvmOverloads public constructor(
|
||||||
private val logger = KotlinLogging.logger {}
|
private val input: Source,
|
||||||
private val ioCoroutineContext: CoroutineContext = IODispatcher
|
private val output: Sink,
|
||||||
private val scope by lazy {
|
private val error: Source? = null,
|
||||||
CoroutineScope(ioCoroutineContext + SupervisorJob())
|
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)
|
* Severity classification for stderr messages.
|
||||||
private val readBuffer = ReadBuffer()
|
*
|
||||||
|
* - [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() {
|
override suspend fun start() {
|
||||||
if (!initialized.compareAndSet(expectedValue = false, newValue = true)) {
|
if (!initialized.compareAndSet(expectedValue = false, newValue = true)) {
|
||||||
error("StdioClientTransport already started!")
|
error("StdioClientTransport already started!")
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug { "Starting StdioClientTransport..." }
|
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 eventsFlow = channelFlow {
|
||||||
val readJob = launch {
|
launch(ioCoroutineContext) {
|
||||||
logger.debug { "Read coroutine started." }
|
logger.debug { "Read stdin coroutine started." }
|
||||||
try {
|
val readBuffer = ReadBuffer() // parses bytes into JSONRPCMessage
|
||||||
input.use {
|
readSource(stream = ProcessStream.Stdin, source = input, channel = this@channelFlow) { bytes ->
|
||||||
while (isActive) {
|
readBuffer.append(bytes)
|
||||||
val buffer = Buffer()
|
do {
|
||||||
val bytesRead = input.readAtMostTo(buffer, 8192)
|
val msg = readBuffer.readMessage()
|
||||||
if (bytesRead == -1L) break
|
msg?.let { send(Event.JsonRpc(msg)) }
|
||||||
if (bytesRead > 0L) {
|
} while (msg != null)
|
||||||
readBuffer.append(buffer.readByteArray())
|
}
|
||||||
processReadBuffer()
|
}.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 {
|
// Collect events on handlerCoroutineContext (Dispatchers.Default from parent scope)
|
||||||
logger.debug { "Write coroutine started." }
|
// No flowOn necessary - collection runs in parent launch context
|
||||||
try {
|
eventsFlow
|
||||||
sendChannel.consumeEach { message ->
|
.collect { event ->
|
||||||
val json = serializeMessage(message)
|
when (event) {
|
||||||
outputStream.writeString(json)
|
is Event.JsonRpc -> {
|
||||||
outputStream.flush()
|
handleJSONRPCMessage(event.message)
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
|
||||||
if (isActive) {
|
|
||||||
_onError.invoke(e)
|
|
||||||
logger.error(e) { "Error writing to output stream" }
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
output.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
readJob.join()
|
is Event.StderrEvent -> {
|
||||||
writeJob.cancelAndJoin()
|
val errorSeverity = classifyStderr(event.message)
|
||||||
_onClose.invoke()
|
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?) {
|
override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) {
|
||||||
if (!initialized.load()) {
|
check(initialized.load()) { "Transport is not started" }
|
||||||
error("Transport 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() {
|
override suspend fun close() {
|
||||||
if (!initialized.compareAndSet(expectedValue = true, newValue = false)) {
|
if (!initialized.compareAndSet(expectedValue = true, newValue = false)) {
|
||||||
error("Transport is already closed")
|
return // Already closed
|
||||||
}
|
}
|
||||||
job?.cancelAndJoin()
|
scope.stopProcessing("Closed")
|
||||||
input.close()
|
scope.coroutineContext[Job]?.join() // Wait for all coroutines to complete
|
||||||
output.close()
|
|
||||||
readBuffer.clear()
|
|
||||||
sendChannel.close()
|
|
||||||
_onClose.invoke()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun processReadBuffer() {
|
private fun callOnCloseOnce() {
|
||||||
while (true) {
|
if (onCloseCalled.compareAndSet(expectedValue = false, newValue = true)) {
|
||||||
val msg = readBuffer.readMessage() ?: break
|
runCatching { _onClose() }
|
||||||
try {
|
}
|
||||||
_onMessage.invoke(msg)
|
}
|
||||||
} catch (e: Throwable) {
|
|
||||||
_onError.invoke(e)
|
private fun sendOutboundMessage(message: JSONRPCMessage, sink: Sink, mainScope: CoroutineScope) {
|
||||||
logger.error(e) { "Error processing message." }
|
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?,
|
resumptionToken: String?,
|
||||||
onResumptionToken: ((String) -> Unit)? = null,
|
onResumptionToken: ((String) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
|
check(initialized.load()) { "Transport is not started" }
|
||||||
logger.debug { "Client sending message via POST to $url: ${McpJson.encodeToString(message)}" }
|
logger.debug { "Client sending message via POST to $url: ${McpJson.encodeToString(message)}" }
|
||||||
|
|
||||||
// If we have a resumption token, reconnect the SSE stream with it
|
// 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
|
package io.modelcontextprotocol.kotlin.sdk.shared
|
||||||
|
|
||||||
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
|
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
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.PingRequest
|
||||||
import io.modelcontextprotocol.kotlin.sdk.types.toJSON
|
import io.modelcontextprotocol.kotlin.sdk.types.toJSON
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertFalse
|
import kotlin.test.assertFalse
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
import kotlin.test.fail
|
import kotlin.test.fail
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
abstract class BaseTransportTest {
|
abstract class BaseTransportTest {
|
||||||
|
|
||||||
|
|
@ -21,6 +23,8 @@ abstract class BaseTransportTest {
|
||||||
transport.onClose { didClose = true }
|
transport.onClose { didClose = true }
|
||||||
|
|
||||||
transport.start()
|
transport.start()
|
||||||
|
delay(1.seconds)
|
||||||
|
|
||||||
assertFalse(didClose, "Transport should not be closed immediately after start")
|
assertFalse(didClose, "Transport should not be closed immediately after start")
|
||||||
|
|
||||||
transport.close()
|
transport.close()
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,68 @@
|
||||||
package io.modelcontextprotocol.kotlin.sdk.client
|
package io.modelcontextprotocol.kotlin.sdk.client
|
||||||
|
|
||||||
import io.modelcontextprotocol.kotlin.sdk.shared.BaseTransportTest
|
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.coroutines.test.runTest
|
||||||
import kotlinx.io.asSink
|
import kotlinx.io.asSink
|
||||||
import kotlinx.io.asSource
|
import kotlinx.io.asSource
|
||||||
import kotlinx.io.buffered
|
import kotlinx.io.buffered
|
||||||
import org.junit.jupiter.api.Test
|
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() {
|
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
|
@Test
|
||||||
fun `should start then close cleanly`() = runTest {
|
fun `should start then close cleanly`() = runTest {
|
||||||
// Run process "/usr/bin/tee"
|
// Run process "/usr/bin/tee"
|
||||||
|
|
@ -16,15 +71,33 @@ class StdioClientTransportTest : BaseTransportTest() {
|
||||||
|
|
||||||
val input = process.inputStream.asSource().buffered()
|
val input = process.inputStream.asSource().buffered()
|
||||||
val output = process.outputStream.asSink().buffered()
|
val output = process.outputStream.asSink().buffered()
|
||||||
|
val error = process.errorStream.asSource().buffered()
|
||||||
|
|
||||||
val transport = StdioClientTransport(
|
val transport = StdioClientTransport(
|
||||||
input = input,
|
input = input,
|
||||||
output = output,
|
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
|
@Test
|
||||||
|
|
@ -43,7 +116,7 @@ class StdioClientTransportTest : BaseTransportTest() {
|
||||||
testTransportRead(transport)
|
testTransportRead(transport)
|
||||||
|
|
||||||
process.waitFor()
|
process.waitFor()
|
||||||
process.destroy()
|
process.destroyForcibly()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -63,6 +136,6 @@ class StdioClientTransportTest : BaseTransportTest() {
|
||||||
testTransportRead(transport)
|
testTransportRead(transport)
|
||||||
|
|
||||||
process.waitFor()
|
process.waitFor()
|
||||||
process.destroy()
|
process.destroyForcibly()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@ class KotlinClientTsServerEdgeCasesTestStdio : TsTestBase() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Timeout(60, unit = TimeUnit.SECONDS)
|
@Timeout(30, unit = TimeUnit.SECONDS)
|
||||||
fun testConcurrentRequestsOverStdio(): Unit = runBlocking(Dispatchers.IO) {
|
fun testConcurrentRequestsOverStdio(): Unit = runBlocking(Dispatchers.IO) {
|
||||||
withClientStdio { client: Client, _ ->
|
withClientStdio { client: Client, _ ->
|
||||||
val concurrentCount = 5
|
val concurrentCount = 5
|
||||||
|
|
@ -102,8 +102,7 @@ class KotlinClientTsServerEdgeCasesTestStdio : TsTestBase() {
|
||||||
val result = client.callTool("greet", arguments)
|
val result = client.callTool("greet", arguments)
|
||||||
assertNotNull(result, "Tool call result should not be null for client $i")
|
assertNotNull(result, "Tool call result should not be null for client $i")
|
||||||
|
|
||||||
val callResult = result
|
val textContent = result.content.firstOrNull { it is TextContent } as? TextContent
|
||||||
val textContent = callResult.content.firstOrNull { it is TextContent } as? TextContent
|
|
||||||
assertNotNull(textContent, "Text content should be present for client $i")
|
assertNotNull(textContent, "Text content should be present for client $i")
|
||||||
|
|
||||||
textContent.text
|
textContent.text
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue