Skip to content

Commit 8373929

Browse files
committed
Update build configuration
- Add Netty convention plugin - Update Kotlin compiler and junit platform settings - Mock DELETE /mcp in StreamableHttpClientTest; Refactor Mokksy calls
1 parent c96b2d9 commit 8373929

File tree

6 files changed

+174
-45
lines changed

6 files changed

+174
-45
lines changed

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

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
@file:OptIn(ExperimentalWasmDsl::class)
22

3+
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
34
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
45
import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode
6+
import org.jetbrains.kotlin.gradle.dsl.JvmDefaultMode
57
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
68

79
plugins {
@@ -11,10 +13,44 @@ plugins {
1113
}
1214

1315
kotlin {
16+
17+
compilerOptions {
18+
freeCompilerArgs =
19+
listOf(
20+
"-Wextra",
21+
"-Xmulti-dollar-interpolation",
22+
)
23+
}
24+
25+
coreLibrariesVersion = "2.0.10"
26+
1427
jvm {
15-
compilerOptions.jvmTarget = JvmTarget.JVM_1_8
16-
tasks.withType<Test> {
28+
compilerOptions {
29+
jvmTarget = JvmTarget.JVM_1_8
30+
javaParameters = true
31+
jvmDefault = JvmDefaultMode.ENABLE
32+
freeCompilerArgs.addAll(
33+
"-Xdebug",
34+
)
35+
}
36+
37+
tasks.withType<Test>().configureEach {
38+
1739
useJUnitPlatform()
40+
41+
maxParallelForks = Runtime.getRuntime().availableProcessors()
42+
forkEvery = 100
43+
testLogging {
44+
exceptionFormat = TestExceptionFormat.SHORT
45+
showStandardStreams = true
46+
events("failed")
47+
}
48+
systemProperty("kotest.output.ansi", "true")
49+
reports {
50+
junitXml.required.set(true)
51+
junitXml.includeSystemOutLog.set(true)
52+
junitXml.includeSystemErrLog.set(true)
53+
}
1854
}
1955
}
2056
macosX64()
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
2+
3+
/**
4+
* Netty convention plugin that adds platform-specific Netty native transport libraries
5+
* to the jvmTest source set. This is similar to how Maven handles OS-specific dependencies
6+
* with profile activation.
7+
*
8+
* This plugin should be applied to any module that uses Netty for testing.
9+
*/
10+
val nettyVersion = "4.2.7.Final"
11+
plugins {
12+
id("org.gradle.base")
13+
}
14+
15+
// This plugin is applied to projects that already have the kotlin multiplatform plugin applied
16+
// It adds the Netty native transport libraries to the jvmTest source set
17+
18+
afterEvaluate {
19+
20+
extensions.findByType<KotlinMultiplatformExtension>()?.apply {
21+
sourceSets.findByName("jvmTest")?.apply {
22+
dependencies {
23+
// Netty native transport libraries for different platforms
24+
val osName = System.getProperty("os.name").lowercase()
25+
val osArch = System.getProperty("os.arch").lowercase()
26+
27+
// Add the base Netty platform
28+
implementation(project.dependencies.platform("io.netty:netty-bom:$nettyVersion"))
29+
30+
when {
31+
osName.contains("linux") -> {
32+
val archClassifier =
33+
if (osArch.contains("aarch64")) {
34+
"linux-aarch_64"
35+
} else {
36+
"linux-x86_64"
37+
}
38+
runtimeOnly(
39+
"io.netty:netty-transport-native-epoll:$nettyVersion:$archClassifier",
40+
)
41+
}
42+
43+
osName.contains("mac") -> {
44+
val archClassifier =
45+
if (osArch.contains("aarch64")) {
46+
"osx-aarch_64"
47+
} else {
48+
"osx-x86_64"
49+
}
50+
runtimeOnly(
51+
"io.netty:netty-transport-native-kqueue:$nettyVersion:$archClassifier",
52+
)
53+
runtimeOnly(
54+
"io.netty:netty-resolver-dns-native-macos:$nettyVersion:$archClassifier",
55+
)
56+
}
57+
}
58+
}
59+
}
60+
}
61+
}

gradle/libs.versions.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ dokka = "2.0.0"
55
atomicfu = "0.29.0"
66
ktlint = "13.1.0"
77
kover = "0.9.2"
8+
netty = "4.2.7.Final"
89
mavenPublish = "0.34.0"
910
binaryCompatibilityValidatorPlugin = "0.18.1"
1011

@@ -48,6 +49,7 @@ ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging"}
4849
ktor-client-apache5 = { group = "io.ktor", name = "ktor-client-apache5" }
4950
ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp" }
5051
ktor-server-sse = { group = "io.ktor", name = "ktor-server-sse", version.ref = "ktor" }
52+
ktor-server-netty = { group = "io.ktor", name = "ktor-server-netty" }
5153
ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", version.ref = "ktor" }
5254
ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" }
5355

@@ -60,6 +62,7 @@ ktor-client-mock = { group = "io.ktor", name = "ktor-client-mock", version.ref =
6062
ktor-server-test-host = { group = "io.ktor", name = "ktor-server-test-host", version.ref = "ktor" }
6163
mokksy = { group = "dev.mokksy", name = "mokksy", version.ref = "mokksy" }
6264
slf4j-simple = { group = "org.slf4j", name = "slf4j-simple", version.ref = "slf4j" }
65+
netty-bom = { group = "io.netty", name = "netty-bom", version.ref = "netty" }
6366

6467
# Samples
6568
ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio", version.ref = "ktor" }

kotlin-sdk-client/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ plugins {
77
id("mcp.publishing")
88
id("mcp.dokka")
99
alias(libs.plugins.kotlinx.binary.compatibility.validator)
10+
`netty-convention`
1011
}
1112

1213
kotlin {
@@ -52,6 +53,7 @@ kotlin {
5253
implementation(libs.mokksy)
5354
implementation(libs.awaitility)
5455
implementation(libs.ktor.client.apache5)
56+
implementation(dependencies.platform(libs.netty.bom))
5557
runtimeOnly(libs.slf4j.simple)
5658
}
5759
}

kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTest.kt

Lines changed: 69 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import io.ktor.client.engine.apache5.Apache5
88
import io.ktor.client.plugins.logging.LogLevel
99
import io.ktor.client.plugins.logging.Logging
1010
import io.ktor.client.plugins.sse.SSE
11+
import io.ktor.http.ContentType
12+
import io.ktor.http.HttpMethod
1113
import io.ktor.http.HttpStatusCode
1214
import io.ktor.sse.ServerSentEvent
1315
import io.modelcontextprotocol.kotlin.sdk.ClientCapabilities
@@ -27,6 +29,12 @@ import kotlin.test.AfterTest
2729
import kotlin.test.Test
2830
import kotlin.time.Duration.Companion.milliseconds
2931

32+
/**
33+
* Integration tests for the `StreamableHttpClientTransport` implementation
34+
* using the [Mokksy](https://mokksy.dev) library
35+
* to simulate Streaming HTTP with server-sent events (SSE).
36+
* @author Konstantin Pavlov
37+
*/
3038
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
3139
class StreamableHttpClientTest {
3240

@@ -43,6 +51,7 @@ class StreamableHttpClientTest {
4351
}
4452

4553
@Test
54+
@Suppress("LongMethod")
4655
fun `test streamableHttpClient`(): Unit = runBlocking {
4756
val client = Client(
4857
clientInfo = Implementation(name = "sample-client", version = "1.0.0"),
@@ -53,7 +62,7 @@ class StreamableHttpClientTest {
5362

5463
val sessionId = UUID.randomUUID().toString()
5564

56-
mockPostRequest(
65+
mockRequest(
5766
method = "initialize",
5867
sessionId = sessionId,
5968
) {
@@ -81,7 +90,7 @@ class StreamableHttpClientTest {
8190
""".trimIndent()
8291
}
8392

84-
mockPostRequest(
93+
mockRequest(
8594
method = "notifications/initialized",
8695
sessionId = sessionId,
8796
statusCode = HttpStatusCode.Accepted,
@@ -103,15 +112,15 @@ class StreamableHttpClientTest {
103112
ServerSentEvent(
104113
event = "message",
105114
id = "1",
106-
data = @Suppress("ktlint:standard:max-line-length")
115+
data = @Suppress("MaxLineLength")
107116
//language=json
108117
"""{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"upload-123","progress":50,"total":100}}""",
109118
),
110119
)
111120
delay(200.milliseconds)
112121
emit(
113122
ServerSentEvent(
114-
data = @Suppress("ktlint:standard:max-line-length")
123+
data = @Suppress("MaxLineLength")
115124
//language=json
116125
"""{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"upload-123","progress":50,"total":100}}""",
117126
),
@@ -121,7 +130,7 @@ class StreamableHttpClientTest {
121130

122131
client.connect(
123132
StreamableHttpClientTransport(
124-
url = "http://localhost:${mokksy.port()}/mcp",
133+
url = mokksy.baseUrl() + "/mcp",
125134
client = HttpClient(Apache5) {
126135
install(SSE)
127136
install(Logging) {
@@ -133,45 +142,45 @@ class StreamableHttpClientTest {
133142

134143
// TODO: get notifications
135144

136-
mockPostRequest(
145+
mockRequest(
137146
method = "tools/list",
138147
sessionId = sessionId,
139148
) {
140149
// language=json
141150
"""
142-
{
143-
"jsonrpc": "2.0",
144-
"id": 3,
145-
"result": {
146-
"tools": [
147-
{
148-
"name": "get_weather",
149-
"title": "Weather Information Provider",
150-
"description": "Get current weather information for a location",
151-
"inputSchema": {
152-
"type": "object",
153-
"properties": {
154-
"location": {
155-
"type": "string",
156-
"description": "City name or zip code"
157-
}
158-
},
159-
"required": ["location"]
160-
},
161-
"outputSchema": {
162-
"type": "object",
163-
"properties": {
164-
"temperature": {
165-
"type": "number",
166-
"description": "Temperature, Celsius"
167-
}
168-
},
169-
"required": ["temperature"]
170-
}
171-
}
172-
]
173-
}
174-
}
151+
{
152+
"jsonrpc": "2.0",
153+
"id": 3,
154+
"result": {
155+
"tools": [
156+
{
157+
"name": "get_weather",
158+
"title": "Weather Information Provider",
159+
"description": "Get current weather information for a location",
160+
"inputSchema": {
161+
"type": "object",
162+
"properties": {
163+
"location": {
164+
"type": "string",
165+
"description": "City name or zip code"
166+
}
167+
},
168+
"required": ["location"]
169+
},
170+
"outputSchema": {
171+
"type": "object",
172+
"properties": {
173+
"temperature": {
174+
"type": "number",
175+
"description": "Temperature, Celsius"
176+
}
177+
},
178+
"required": ["temperature"]
179+
}
180+
}
181+
]
182+
}
183+
}
175184
""".trimIndent()
176185
}
177186

@@ -203,16 +212,34 @@ class StreamableHttpClientTest {
203212
),
204213
annotations = null,
205214
)
215+
216+
mockDisconnect(sessionId = sessionId)
217+
}
218+
219+
fun mockDisconnect(sessionId: String) {
220+
mokksy.delete(
221+
configuration = StubConfiguration(removeAfterMatch = true),
222+
requestType = JSONRPCRequest::class,
223+
) {
224+
path("/mcp")
225+
containsHeader("Mcp-Session-Id", sessionId)
226+
} respondsWith {
227+
body = null
228+
}
206229
}
207230

208-
private fun mockPostRequest(
231+
@Suppress("LongParameterList")
232+
private fun mockRequest(
233+
httpMethod: HttpMethod = HttpMethod.Post,
209234
method: String,
210235
sessionId: String,
236+
contentType: ContentType = ContentType.Application.Json,
211237
statusCode: HttpStatusCode = HttpStatusCode.OK,
212238
bodyBuilder: () -> String,
213239
) {
214-
mokksy.post(
240+
mokksy.method(
215241
configuration = StubConfiguration(removeAfterMatch = true),
242+
httpMethod = httpMethod,
216243
requestType = JSONRPCRequest::class,
217244
) {
218245
path("/mcp")
@@ -226,7 +253,7 @@ class StreamableHttpClientTest {
226253
)
227254
} respondsWith {
228255
body = bodyBuilder.invoke()
229-
headers += "Content-Type" to "application/json; charset=utf-8"
256+
this.contentType = contentType
230257
headers += "Mcp-Session-Id" to sessionId
231258
httpStatus = statusCode
232259
}

kotlin-sdk-client/src/jvmTest/resources/junit-platform.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ junit.jupiter.execution.parallel.enabled=true
33
junit.jupiter.execution.parallel.config.strategy=dynamic
44
junit.jupiter.execution.parallel.mode.default=concurrent
55
junit.jupiter.execution.parallel.mode.classes.default=concurrent
6-
junit.jupiter.execution.timeout.default=90s
6+
junit.jupiter.execution.timeout.default=2m

0 commit comments

Comments
 (0)