Skip to content

Commit a75f379

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 a75f379

File tree

7 files changed

+270
-108
lines changed

7 files changed

+270
-108
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
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package io.modelcontextprotocol.kotlin.sdk.client
2+
3+
import dev.mokksy.mokksy.Mokksy
4+
import dev.mokksy.mokksy.StubConfiguration
5+
import io.ktor.http.ContentType
6+
import io.ktor.http.HttpMethod
7+
import io.ktor.http.HttpStatusCode
8+
import io.ktor.sse.ServerSentEvent
9+
import io.modelcontextprotocol.kotlin.sdk.JSONRPCRequest
10+
import kotlinx.coroutines.flow.Flow
11+
12+
/**
13+
* A mocking utility class designed to facilitate testing of server interactions by simulating various
14+
* HTTP requests and responses.
15+
*
16+
* It uses the [Mokksy](https://mokksy.dev) library
17+
* to simulate Streaming HTTP server with server-sent events (SSE).
18+
*
19+
* @constructor Creates an instance of `MockMcp` with an optional `Mokksy` instance.
20+
*
21+
* @property verbose A flag indicating whether detailed logs should be printed. Defaults to false.
22+
* @author Konstantin Pavlov
23+
*/
24+
internal class MockMcp(verbose: Boolean = false) {
25+
26+
private val mokksy: Mokksy = Mokksy(verbose = verbose)
27+
28+
fun checkForUnmatchedRequests() {
29+
mokksy.checkForUnmatchedRequests()
30+
}
31+
32+
val url = mokksy.baseUrl() + "/mcp"
33+
34+
@Suppress("LongParameterList")
35+
internal fun onJSONRPCRequest(
36+
httpMethod: HttpMethod = HttpMethod.Post,
37+
method: String,
38+
expectedSessionId: String? = null,
39+
sessionId: String,
40+
contentType: ContentType = ContentType.Application.Json,
41+
statusCode: HttpStatusCode = HttpStatusCode.OK,
42+
bodyBuilder: () -> String,
43+
) {
44+
mokksy.method(
45+
configuration = StubConfiguration(removeAfterMatch = true),
46+
httpMethod = httpMethod,
47+
requestType = JSONRPCRequest::class,
48+
) {
49+
path("/mcp")
50+
expectedSessionId?.let {
51+
containsHeader("Mcp-Session-Id", it)
52+
}
53+
bodyMatchesPredicates(
54+
{
55+
it!!.method == method
56+
},
57+
{
58+
it!!.jsonrpc == "2.0"
59+
},
60+
)
61+
} respondsWith {
62+
body = bodyBuilder.invoke()
63+
this.contentType = contentType
64+
headers += "Mcp-Session-Id" to sessionId
65+
httpStatus = statusCode
66+
}
67+
}
68+
69+
fun onSubscribeWithGet(sessionId: String, block: () -> Flow<ServerSentEvent>) {
70+
mokksy.get(name = "MCP GETs", requestType = Any::class) {
71+
path("/mcp")
72+
containsHeader("Mcp-Session-Id", sessionId)
73+
containsHeader("Accept", "text/event-stream,text/event-stream") // todo: why 2 times?
74+
containsHeader("Cache-Control", "no-store")
75+
} respondsWithSseStream {
76+
headers += "Mcp-Session-Id" to sessionId
77+
this.flow = block.invoke()
78+
}
79+
}
80+
81+
fun mockUnsubscribeRequest(sessionId: String) {
82+
mokksy.delete(
83+
configuration = StubConfiguration(removeAfterMatch = true),
84+
requestType = JSONRPCRequest::class,
85+
) {
86+
path("/mcp")
87+
containsHeader("Mcp-Session-Id", sessionId)
88+
} respondsWith {
89+
body = null
90+
}
91+
}
92+
}

0 commit comments

Comments
 (0)