Skip to content

Commit f79acd4

Browse files
committed
Add/fix StdioClientTransport tests
1 parent 095cf98 commit f79acd4

File tree

3 files changed

+193
-1
lines changed

3 files changed

+193
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package io.modelcontextprotocol.kotlin.sdk.client.stdio
2+
3+
import io.kotest.assertions.throwables.shouldThrow
4+
import io.kotest.matchers.shouldBe
5+
import io.kotest.matchers.string.shouldContain
6+
import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport
7+
import io.modelcontextprotocol.kotlin.sdk.types.PingRequest
8+
import io.modelcontextprotocol.kotlin.sdk.types.toJSON
9+
import kotlinx.coroutines.delay
10+
import kotlinx.coroutines.test.runTest
11+
import kotlinx.io.Buffer
12+
import kotlin.test.BeforeTest
13+
import kotlin.test.Test
14+
import kotlin.time.Duration.Companion.milliseconds
15+
16+
class StdioClientTransportLifecycleTest {
17+
18+
private lateinit var transport: StdioClientTransport
19+
20+
@BeforeTest
21+
fun beforeEach() {
22+
transport = createTransport()
23+
}
24+
25+
@Test
26+
fun `should throw when started twice`() = runTest {
27+
transport.start()
28+
29+
val exception = shouldThrow<IllegalStateException> {
30+
transport.start()
31+
}
32+
exception.message shouldContain "already started"
33+
}
34+
35+
@Test
36+
fun `should be idempotent when closed twice`() = runTest {
37+
val transport = createTransport()
38+
39+
transport.start()
40+
transport.close()
41+
42+
// Second close should not throw
43+
transport.close()
44+
}
45+
46+
@Test
47+
fun `should throw when sending before start`() = runTest {
48+
val transport = createTransport()
49+
50+
val exception = shouldThrow<IllegalStateException> {
51+
transport.send(PingRequest().toJSON())
52+
}
53+
exception.message shouldContain "not started"
54+
}
55+
56+
@Test
57+
fun `should throw when sending after close`() = runTest {
58+
val transport = createTransport()
59+
60+
transport.start()
61+
delay(50.milliseconds)
62+
transport.close()
63+
64+
shouldThrow<Exception> {
65+
transport.send(PingRequest().toJSON())
66+
}
67+
}
68+
69+
@Test
70+
fun `should call onClose exactly once`() = runTest {
71+
val transport = createTransport()
72+
73+
var closeCallCount = 0
74+
transport.onClose { closeCallCount++ }
75+
76+
transport.start()
77+
delay(50.milliseconds)
78+
79+
// Multiple close attempts
80+
transport.close()
81+
transport.close()
82+
83+
closeCallCount shouldBe 1
84+
}
85+
86+
private fun createTransport(): StdioClientTransport {
87+
val inputBuffer = Buffer()
88+
val outputBuffer = Buffer()
89+
return StdioClientTransport(
90+
input = inputBuffer,
91+
output = outputBuffer,
92+
)
93+
}
94+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package io.modelcontextprotocol.kotlin.sdk.client.stdio
2+
3+
import io.kotest.matchers.booleans.shouldBeFalse
4+
import io.kotest.matchers.shouldBe
5+
import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport
6+
import kotlinx.coroutines.delay
7+
import kotlinx.coroutines.test.runTest
8+
import kotlinx.io.Buffer
9+
import kotlinx.io.writeString
10+
import kotlin.concurrent.atomics.AtomicBoolean
11+
import kotlin.concurrent.atomics.ExperimentalAtomicApi
12+
import kotlin.test.Test
13+
import kotlin.time.Duration.Companion.milliseconds
14+
15+
/**
16+
* Tests for StdioClientTransport error handling: EOF, IO errors, and edge cases.
17+
*/
18+
class StdioClientTransportErrorHandlingTest {
19+
20+
private lateinit var transport: StdioClientTransport
21+
22+
@OptIn(ExperimentalAtomicApi::class)
23+
@Test
24+
fun `should continue on stderr EOF`() = runTest {
25+
val stderrBuffer = Buffer()
26+
// Empty stderr = immediate EOF
27+
28+
val inputBuffer = Buffer()
29+
inputBuffer.writeString("""data: {"jsonrpc":"2.0","method":"ping","id":1}\n\n""")
30+
val outputBuffer = Buffer()
31+
32+
transport = StdioClientTransport(
33+
input = inputBuffer,
34+
output = outputBuffer,
35+
error = stderrBuffer,
36+
)
37+
38+
val closeCalled = AtomicBoolean(false)
39+
transport.onClose { closeCalled.store(true) }
40+
41+
transport.start()
42+
delay(200.milliseconds)
43+
44+
// Stderr EOF should not close transport
45+
closeCalled.load() shouldBe false
46+
47+
transport.close()
48+
closeCalled.load() shouldBe true
49+
}
50+
51+
@Test
52+
fun `should call onClose exactly once on error scenarios`() = runTest {
53+
val stderrBuffer = Buffer()
54+
stderrBuffer.write("FATAL: critical error\n".encodeToByteArray())
55+
56+
val inputBuffer = Buffer()
57+
val outputBuffer = Buffer()
58+
59+
var closeCallCount = 0
60+
61+
transport = StdioClientTransport(
62+
input = inputBuffer,
63+
output = outputBuffer,
64+
error = stderrBuffer,
65+
classifyStderr = { StdioClientTransport.StderrSeverity.FATAL },
66+
)
67+
68+
transport.onClose { closeCallCount++ }
69+
70+
transport.start()
71+
delay(100.milliseconds)
72+
73+
// Explicit close after error already closed it
74+
transport.close()
75+
76+
closeCallCount shouldBe 1
77+
}
78+
79+
@Test
80+
fun `should handle empty input gracefully`() = runTest {
81+
val inputBuffer = Buffer()
82+
val outputBuffer = Buffer()
83+
84+
transport = StdioClientTransport(
85+
input = inputBuffer,
86+
output = outputBuffer,
87+
)
88+
89+
var errorCalled = false
90+
transport.onError { errorCalled = true }
91+
92+
transport.start()
93+
delay(100.milliseconds)
94+
95+
// Empty input should close cleanly without error
96+
errorCalled.shouldBeFalse()
97+
}
98+
}

kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransportTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ class StdioClientTransportTest : BaseTransportTest() {
3737
error = stderr,
3838
) {
3939
println("💥Ah-oh!, error: \"$it\"")
40-
true
40+
StdioClientTransport.StderrSeverity.FATAL
4141
}
4242

4343
val client = Client(

0 commit comments

Comments
 (0)