Skip to content

Commit d60e8b6

Browse files
authored
Add StreamableHttpClientTest with test infrastructure setup and dependencies (#316)
Add `StreamableHttpClientTest` with test infrastructure setup and dependencies <!-- Provide a brief summary of your changes --> ## Motivation and Context To test Streaming HTTP Transport ## How Has This Been Tested? Integration test added ## Breaking Changes No, only tests ## Types of changes <!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply: --> - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [x] Tests ## Checklist <!-- Go over all the following points, and put an `x` in all the boxes that apply. --> - [x] I have read the [MCP Documentation](https://modelcontextprotocol.io) - [x] My code follows the repository's style guidelines - [x] New and existing tests pass locally - [ ] I have added appropriate error handling - [ ] I have added or updated documentation as needed ## Additional context <!-- Add any other context, implementation notes, or design decisions -->
1 parent 3b56429 commit d60e8b6

File tree

11 files changed

+446
-10
lines changed

11 files changed

+446
-10
lines changed

.github/workflows/build.yml

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ jobs:
1616
validate-pr:
1717
runs-on: macos-latest-xlarge
1818
name: Validate PR
19+
timeout-minutes: 20
1920
env:
2021
JAVA_OPTS: "-Xmx8g -Dfile.encoding=UTF-8 -Djava.awt.headless=true -Dkotlin.daemon.jvm.options=-Xmx6g"
2122
steps:
@@ -35,20 +36,20 @@ jobs:
3536

3637
- name: Build with Gradle
3738
run: |-
38-
./gradlew clean ktlintCheck build koverLog koverHtmlReport
39-
./gradlew :kotlin-sdk-core:publishToMavenLocal :kotlin-sdk-client:publishToMavenLocal :kotlin-sdk-server:publishToMavenLocal
39+
./gradlew --no-daemon clean ktlintCheck build koverLog koverHtmlReport
40+
./gradlew --no-daemon :kotlin-sdk-core:publishToMavenLocal :kotlin-sdk-client:publishToMavenLocal :kotlin-sdk-server:publishToMavenLocal
4041
4142
- name: Build Kotlin-MCP-Client Sample
4243
working-directory: ./samples/kotlin-mcp-client
43-
run: ./../../gradlew clean build
44+
run: ./../../gradlew --no-daemon clean build
4445

4546
- name: Build Kotlin-MCP-Server Sample
4647
working-directory: ./samples/kotlin-mcp-server
47-
run: ./../../gradlew clean build
48+
run: ./../../gradlew --no-daemon clean build
4849

4950
- name: Build Weather-Stdio-Server Sample
5051
working-directory: ./samples/weather-stdio-server
51-
run: ./../../gradlew clean build
52+
run: ./../../gradlew --no-daemon clean build
5253

5354
- name: Upload Reports
5455
if: ${{ !cancelled() }}

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

Lines changed: 44 additions & 3 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,11 +13,50 @@ 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
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+
39+
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+
}
54+
}
1655
}
17-
macosX64(); macosArm64()
18-
linuxX64(); linuxArm64()
56+
macosX64()
57+
macosArm64()
58+
linuxX64()
59+
linuxArm64()
1960
mingwX64()
2061
js { nodejs() }
2162
wasmJs { nodejs() }
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: 9 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

@@ -18,6 +19,7 @@ logging = "7.0.13"
1819
slf4j = "2.0.17"
1920
kotest = "6.0.3"
2021
awaitility = "4.3.0"
22+
mokksy = "0.6.1"
2123

2224
# Samples
2325
mcp-kotlin = "0.7.2"
@@ -41,8 +43,13 @@ kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotli
4143
kotlin-logging = { group = "io.github.oshai", name = "kotlin-logging", version.ref = "logging" }
4244

4345
# Ktor
46+
ktor-bom = { group = "io.ktor", name = "ktor-bom", version.ref = "ktor" }
4447
ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
48+
ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging"}
49+
ktor-client-apache5 = { group = "io.ktor", name = "ktor-client-apache5" }
50+
ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp" }
4551
ktor-server-sse = { group = "io.ktor", name = "ktor-server-sse", version.ref = "ktor" }
52+
ktor-server-netty = { group = "io.ktor", name = "ktor-server-netty" }
4653
ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", version.ref = "ktor" }
4754
ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" }
4855

@@ -53,7 +60,9 @@ kotest-assertions-json = { group = "io.kotest", name = "kotest-assertions-json",
5360
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
5461
ktor-client-mock = { group = "io.ktor", name = "ktor-client-mock", version.ref = "ktor" }
5562
ktor-server-test-host = { group = "io.ktor", name = "ktor-server-test-host", version.ref = "ktor" }
63+
mokksy = { group = "dev.mokksy", name = "mokksy", version.ref = "mokksy" }
5664
slf4j-simple = { group = "org.slf4j", name = "slf4j-simple", version.ref = "slf4j" }
65+
netty-bom = { group = "io.netty", name = "netty-bom", version.ref = "netty" }
5766

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

kotlin-sdk-client/build.gradle.kts

Lines changed: 12 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 {
@@ -40,8 +41,19 @@ kotlin {
4041
commonTest {
4142
dependencies {
4243
implementation(kotlin("test"))
44+
implementation(dependencies.platform(libs.ktor.bom))
4345
implementation(libs.ktor.client.mock)
4446
implementation(libs.kotlinx.coroutines.test)
47+
implementation(libs.ktor.client.logging)
48+
}
49+
}
50+
51+
jvmTest {
52+
dependencies {
53+
implementation(libs.mokksy)
54+
implementation(libs.awaitility)
55+
implementation(libs.ktor.client.apache5)
56+
implementation(dependencies.platform(libs.netty.bom))
4557
runtimeOnly(libs.slf4j.simple)
4658
}
4759
}

kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTransport.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,8 @@ public class StreamableHttpClientTransport(
228228
) {
229229
method = HttpMethod.Get
230230
applyCommonHeaders(this)
231-
accept(ContentType.Text.EventStream)
231+
// sseSession will add ContentType.Text.EventStream automatically
232+
accept(ContentType.Application.Json)
232233
(resumptionToken ?: lastEventId)?.let { headers.append(MCP_RESUMPTION_TOKEN_HEADER, it) }
233234
requestBuilder()
234235
}
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", "application/json,text/event-stream")
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)