Skip to content

Commit d82fc22

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 d82fc22

File tree

7 files changed

+270
-111
lines changed

7 files changed

+270
-111
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: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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+
* High-level helper for simulating an MCP server over Streaming HTTP transport with Server-Sent Events (SSE),
14+
* built on top of an HTTP server using the [Mokksy](https://mokksy.dev) library.
15+
*
16+
* Provides test utilities to mock server behavior based on specific request conditions.
17+
*
18+
* @param verbose Whether to print detailed logs. Defaults to `false`.
19+
* @author Konstantin Pavlov
20+
*/
21+
internal class MockMcp(verbose: Boolean = false) {
22+
23+
private val mokksy: Mokksy = Mokksy(verbose = verbose)
24+
25+
fun checkForUnmatchedRequests() {
26+
mokksy.checkForUnmatchedRequests()
27+
}
28+
29+
val url = mokksy.baseUrl() + "/mcp"
30+
31+
@Suppress("LongParameterList")
32+
fun onJSONRPCRequest(
33+
httpMethod: HttpMethod = HttpMethod.Post,
34+
jsonRpcMethod: String,
35+
expectedSessionId: String? = null,
36+
sessionId: String,
37+
contentType: ContentType = ContentType.Application.Json,
38+
statusCode: HttpStatusCode = HttpStatusCode.OK,
39+
bodyBuilder: () -> String,
40+
) {
41+
mokksy.method(
42+
configuration = StubConfiguration(removeAfterMatch = true),
43+
httpMethod = httpMethod,
44+
requestType = JSONRPCRequest::class,
45+
) {
46+
path("/mcp")
47+
expectedSessionId?.let {
48+
containsHeader("Mcp-Session-Id", it)
49+
}
50+
bodyMatchesPredicates(
51+
{
52+
it!!.method == jsonRpcMethod
53+
},
54+
{
55+
it!!.jsonrpc == "2.0"
56+
},
57+
)
58+
} respondsWith {
59+
body = bodyBuilder.invoke()
60+
this.contentType = contentType
61+
headers += "Mcp-Session-Id" to sessionId
62+
httpStatus = statusCode
63+
}
64+
}
65+
66+
fun onSubscribeWithGet(sessionId: String, block: () -> Flow<ServerSentEvent>) {
67+
mokksy.get(name = "MCP GETs", requestType = Any::class) {
68+
path("/mcp")
69+
containsHeader("Mcp-Session-Id", sessionId)
70+
containsHeader("Accept", "text/event-stream,text/event-stream") // todo: why 2 times?
71+
containsHeader("Cache-Control", "no-store")
72+
} respondsWithSseStream {
73+
headers += "Mcp-Session-Id" to sessionId
74+
this.flow = block.invoke()
75+
}
76+
}
77+
78+
fun mockUnsubscribeRequest(sessionId: String) {
79+
mokksy.delete(
80+
configuration = StubConfiguration(removeAfterMatch = true),
81+
requestType = JSONRPCRequest::class,
82+
) {
83+
path("/mcp")
84+
containsHeader("Mcp-Session-Id", sessionId)
85+
} respondsWith {
86+
body = null
87+
}
88+
}
89+
}

0 commit comments

Comments
 (0)