Skip to content

Commit de4c903

Browse files
committed
Add StreamableHttpClientTest with test infrastructure setup and dependencies
1 parent 3b56429 commit de4c903

File tree

7 files changed

+269
-3
lines changed

7 files changed

+269
-3
lines changed

buildSrc/src/main/kotlin/mcp.multiplatform.gradle.kts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,14 @@ plugins {
1313
kotlin {
1414
jvm {
1515
compilerOptions.jvmTarget = JvmTarget.JVM_1_8
16+
tasks.withType<Test> {
17+
useJUnitPlatform()
18+
}
1619
}
17-
macosX64(); macosArm64()
18-
linuxX64(); linuxArm64()
20+
macosX64()
21+
macosArm64()
22+
linuxX64()
23+
linuxArm64()
1924
mingwX64()
2025
js { nodejs() }
2126
wasmJs { nodejs() }

gradle/libs.versions.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ logging = "7.0.13"
1818
slf4j = "2.0.17"
1919
kotest = "6.0.3"
2020
awaitility = "4.3.0"
21+
mokksy = "0.5.1"
2122

2223
# Samples
2324
mcp-kotlin = "0.7.2"
@@ -41,7 +42,10 @@ kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotli
4142
kotlin-logging = { group = "io.github.oshai", name = "kotlin-logging", version.ref = "logging" }
4243

4344
# Ktor
45+
ktor-bom = { group = "io.ktor", name = "ktor-bom", version.ref = "ktor" }
4446
ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
47+
ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging"}
48+
ktor-client-apache5 = { group = "io.ktor", name = "ktor-client-apache5" }
4549
ktor-server-sse = { group = "io.ktor", name = "ktor-server-sse", version.ref = "ktor" }
4650
ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", version.ref = "ktor" }
4751
ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" }
@@ -53,6 +57,7 @@ kotest-assertions-json = { group = "io.kotest", name = "kotest-assertions-json",
5357
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
5458
ktor-client-mock = { group = "io.ktor", name = "ktor-client-mock", version.ref = "ktor" }
5559
ktor-server-test-host = { group = "io.ktor", name = "ktor-server-test-host", version.ref = "ktor" }
60+
mokksy = { group = "me.kpavlov.mokksy", name = "mokksy", version.ref = "mokksy" }
5661
slf4j-simple = { group = "org.slf4j", name = "slf4j-simple", version.ref = "slf4j" }
5762

5863
# Samples

kotlin-sdk-client/build.gradle.kts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,18 @@ kotlin {
4040
commonTest {
4141
dependencies {
4242
implementation(kotlin("test"))
43+
implementation(dependencies.platform(libs.ktor.bom))
4344
implementation(libs.ktor.client.mock)
4445
implementation(libs.kotlinx.coroutines.test)
46+
implementation(libs.ktor.client.logging)
47+
}
48+
}
49+
50+
jvmTest {
51+
dependencies {
52+
implementation(libs.mokksy)
53+
implementation(libs.awaitility)
54+
implementation(libs.ktor.client.apache5)
4555
runtimeOnly(libs.slf4j.simple)
4656
}
4757
}
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
package io.modelcontextprotocol.kotlin.sdk.client
2+
3+
import io.kotest.matchers.collections.shouldContain
4+
import io.ktor.client.HttpClient
5+
import io.ktor.client.engine.apache5.Apache5
6+
import io.ktor.client.plugins.sse.SSE
7+
import io.ktor.http.HttpStatusCode
8+
import io.ktor.sse.ServerSentEvent
9+
import io.modelcontextprotocol.kotlin.sdk.ClientCapabilities
10+
import io.modelcontextprotocol.kotlin.sdk.Implementation
11+
import io.modelcontextprotocol.kotlin.sdk.JSONRPCRequest
12+
import io.modelcontextprotocol.kotlin.sdk.Tool
13+
import kotlinx.coroutines.delay
14+
import kotlinx.coroutines.flow.flow
15+
import kotlinx.coroutines.runBlocking
16+
import kotlinx.serialization.json.buildJsonObject
17+
import kotlinx.serialization.json.put
18+
import kotlinx.serialization.json.putJsonObject
19+
import me.kpavlov.mokksy.MokksyServer
20+
import me.kpavlov.mokksy.StubConfiguration
21+
import org.junit.jupiter.api.AfterAll
22+
import org.junit.jupiter.api.TestInstance
23+
import java.util.UUID
24+
import kotlin.test.AfterTest
25+
import kotlin.test.Test
26+
import kotlin.time.Duration.Companion.milliseconds
27+
28+
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
29+
class StreamableHttpClientTest {
30+
31+
private val mokksy = MokksyServer(verbose = true)
32+
33+
@AfterTest
34+
fun afterEach() {
35+
mokksy.checkForUnmatchedRequests()
36+
}
37+
38+
@AfterAll
39+
fun afterAll() {
40+
mokksy.shutdown()
41+
}
42+
43+
@Test
44+
fun `test streamableHttpClient`(): Unit = runBlocking {
45+
val client = Client(
46+
clientInfo = Implementation(name = "sample-client", version = "1.0.0"),
47+
options = ClientOptions(
48+
capabilities = ClientCapabilities(),
49+
),
50+
)
51+
52+
val sessionId = UUID.randomUUID().toString()
53+
54+
mockPostRequest(
55+
method = "initialize",
56+
sessionId = sessionId,
57+
) {
58+
// language=json
59+
"""
60+
{
61+
"jsonrpc": "2.0",
62+
"id": 1,
63+
"result": {
64+
"capabilities": {
65+
"tools": {
66+
"listChanged": false
67+
}
68+
},
69+
"protocolVersion": "2025-03-26",
70+
"serverInfo": {
71+
"name": "Mock MCP Server",
72+
"version": "1.0.0"
73+
},
74+
"_meta": {
75+
"foo": "bar"
76+
}
77+
}
78+
}
79+
""".trimIndent()
80+
}
81+
82+
mockPostRequest(
83+
method = "notifications/initialized",
84+
sessionId = sessionId,
85+
statusCode = HttpStatusCode.Accepted,
86+
) {
87+
""
88+
}
89+
90+
mokksy.get(name = "MCP GETs", requestType = Any::class) {
91+
path("/mcp")
92+
containsHeader("Mcp-Session-Id", sessionId)
93+
containsHeader("Connection", "keep-alive")
94+
containsHeader("Cache-Control", "no-store")
95+
} respondsWithSseStream {
96+
headers += "Mcp-Session-Id" to sessionId
97+
flow =
98+
flow {
99+
delay(500.milliseconds)
100+
emit(
101+
ServerSentEvent(
102+
event = "message",
103+
id = "1",
104+
data = @Suppress("ktlint:standard:max-line-length")
105+
//language=json
106+
"""{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"upload-123","progress":50,"total":100}}""",
107+
),
108+
)
109+
delay(200.milliseconds)
110+
emit(
111+
ServerSentEvent(
112+
data = @Suppress("ktlint:standard:max-line-length")
113+
//language=json
114+
"""{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"upload-123","progress":50,"total":100}}""",
115+
),
116+
)
117+
}
118+
}
119+
120+
client.connect(
121+
StreamableHttpClientTransport(
122+
url = "http://localhost:${mokksy.port()}/mcp",
123+
client = HttpClient(Apache5) {
124+
install(SSE)
125+
},
126+
),
127+
)
128+
129+
// TODO: get notifications
130+
131+
mockPostRequest(
132+
method = "tools/list",
133+
sessionId = sessionId,
134+
) {
135+
// language=json
136+
"""
137+
{
138+
"jsonrpc": "2.0",
139+
"id": 3,
140+
"result": {
141+
"tools": [
142+
{
143+
"name": "get_weather",
144+
"title": "Weather Information Provider",
145+
"description": "Get current weather information for a location",
146+
"inputSchema": {
147+
"type": "object",
148+
"properties": {
149+
"location": {
150+
"type": "string",
151+
"description": "City name or zip code"
152+
}
153+
},
154+
"required": ["location"]
155+
},
156+
"outputSchema": {
157+
"type": "object",
158+
"properties": {
159+
"temperature": {
160+
"type": "number",
161+
"description": "Temperature, Celsius"
162+
}
163+
},
164+
"required": ["temperature"]
165+
}
166+
}
167+
]
168+
}
169+
}
170+
""".trimIndent()
171+
}
172+
173+
val listToolsResult = client.listTools()
174+
175+
listToolsResult.tools shouldContain Tool(
176+
name = "get_weather",
177+
title = "Weather Information Provider",
178+
description = "Get current weather information for a location",
179+
inputSchema = Tool.Input(
180+
properties = buildJsonObject {
181+
putJsonObject("location") {
182+
put("type", "string")
183+
put("description", "City name or zip code")
184+
}
185+
},
186+
required = listOf("location"),
187+
),
188+
outputSchema = Tool.Output(
189+
properties = buildJsonObject {
190+
putJsonObject("temperature") {
191+
put("type", "number")
192+
put("description", "Temperature, Celsius")
193+
}
194+
},
195+
required = listOf("temperature"),
196+
),
197+
annotations = null,
198+
)
199+
}
200+
201+
private fun mockPostRequest(
202+
method: String,
203+
sessionId: String,
204+
statusCode: HttpStatusCode = HttpStatusCode.OK,
205+
bodyBuilder: () -> String,
206+
) {
207+
mokksy.post(
208+
configuration = StubConfiguration(removeAfterMatch = true),
209+
requestType = JSONRPCRequest::class,
210+
) {
211+
path("/mcp")
212+
bodyMatchesPredicates(
213+
{
214+
it!!.method == method
215+
},
216+
{
217+
it!!.jsonrpc == "2.0"
218+
},
219+
)
220+
} respondsWith {
221+
body = bodyBuilder.invoke()
222+
headers += "Content-Type" to "application/json; charset=utf-8"
223+
headers += "Mcp-Session-Id" to sessionId
224+
httpStatus = statusCode
225+
}
226+
}
227+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
## https://docs.junit.org/5.3.0-M1/user-guide/index.html#writing-tests-parallel-execution
2+
junit.jupiter.execution.parallel.enabled=true
3+
junit.jupiter.execution.parallel.config.strategy=dynamic
4+
junit.jupiter.execution.parallel.mode.default=concurrent
5+
junit.jupiter.execution.parallel.mode.classes.default=concurrent
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Level of logging for the ROOT logger: ERROR, WARN, INFO, DEBUG, TRACE (default is INFO)
2+
org.slf4j.simpleLogger.defaultLogLevel=INFO
3+
4+
5+
org.slf4j.simpleLogger.showThreadName=true
6+
org.slf4j.simpleLogger.showDateTime=false
7+
8+
# Whether to enable stack traces for exceptions (true/false, default is true)
9+
org.slf4j.simpleLogger.showShortLogName=false
10+
11+
# Log level for specific packages or classes
12+
org.slf4j.simpleLogger.log.io.ktor.server=DEBUG
13+
org.slf4j.simpleLogger.log.io.modelcontextprotocol=TRACE
14+
org.slf4j.simpleLogger.log.me.kpavlov=DEBUG

kotlin-sdk-test/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ plugins {
44

55
kotlin {
66
jvm {
7-
testRuns["test"].executionTask.configure {
7+
tasks.withType<Test> {
88
useJUnitPlatform()
99
}
1010
}

0 commit comments

Comments
 (0)