Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ jobs:
validate-pr:
runs-on: macos-latest-xlarge
name: Validate PR
timeout-minutes: 20
env:
JAVA_OPTS: "-Xmx8g -Dfile.encoding=UTF-8 -Djava.awt.headless=true -Dkotlin.daemon.jvm.options=-Xmx6g"
steps:
Expand All @@ -35,20 +36,20 @@ jobs:

- name: Build with Gradle
run: |-
./gradlew clean ktlintCheck build koverLog koverHtmlReport
./gradlew :kotlin-sdk-core:publishToMavenLocal :kotlin-sdk-client:publishToMavenLocal :kotlin-sdk-server:publishToMavenLocal
./gradlew --no-daemon clean ktlintCheck build koverLog koverHtmlReport
./gradlew --no-daemon :kotlin-sdk-core:publishToMavenLocal :kotlin-sdk-client:publishToMavenLocal :kotlin-sdk-server:publishToMavenLocal

- name: Build Kotlin-MCP-Client Sample
working-directory: ./samples/kotlin-mcp-client
run: ./../../gradlew clean build
run: ./../../gradlew --no-daemon clean build

- name: Build Kotlin-MCP-Server Sample
working-directory: ./samples/kotlin-mcp-server
run: ./../../gradlew clean build
run: ./../../gradlew --no-daemon clean build

- name: Build Weather-Stdio-Server Sample
working-directory: ./samples/weather-stdio-server
run: ./../../gradlew clean build
run: ./../../gradlew --no-daemon clean build

- name: Upload Reports
if: ${{ !cancelled() }}
Expand Down
47 changes: 44 additions & 3 deletions buildSrc/src/main/kotlin/mcp.multiplatform.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
@file:OptIn(ExperimentalWasmDsl::class)

import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode
import org.jetbrains.kotlin.gradle.dsl.JvmDefaultMode
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
Expand All @@ -11,11 +13,50 @@ plugins {
}

kotlin {

compilerOptions {
freeCompilerArgs =
listOf(
"-Wextra",
"-Xmulti-dollar-interpolation",
)
}

// coreLibrariesVersion = "2.0.10"

jvm {
compilerOptions.jvmTarget = JvmTarget.JVM_1_8
compilerOptions {
jvmTarget = JvmTarget.JVM_1_8
javaParameters = true
jvmDefault = JvmDefaultMode.ENABLE
freeCompilerArgs.addAll(
"-Xdebug",
)
}

tasks.withType<Test>().configureEach {

useJUnitPlatform()

maxParallelForks = Runtime.getRuntime().availableProcessors()
forkEvery = 100
testLogging {
exceptionFormat = TestExceptionFormat.SHORT
showStandardStreams = true
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for visibility

events("failed")
}
systemProperty("kotest.output.ansi", "true")
reports {
junitXml.required.set(true)
junitXml.includeSystemOutLog.set(true)
junitXml.includeSystemErrLog.set(true)
Comment on lines +51 to +52
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for visibility

}
}
}
macosX64(); macosArm64()
linuxX64(); linuxArm64()
macosX64()
macosArm64()
linuxX64()
linuxArm64()
mingwX64()
js { nodejs() }
wasmJs { nodejs() }
Expand Down
61 changes: 61 additions & 0 deletions buildSrc/src/main/kotlin/netty-convention.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension

/**
* Netty convention plugin that adds platform-specific Netty native transport libraries
* to the jvmTest source set. This is similar to how Maven handles OS-specific dependencies
* with profile activation.
*
* This plugin should be applied to any module that uses Netty for testing.
*/
val nettyVersion = "4.2.7.Final"
plugins {
id("org.gradle.base")
}

// This plugin is applied to projects that already have the kotlin multiplatform plugin applied
// It adds the Netty native transport libraries to the jvmTest source set

afterEvaluate {

extensions.findByType<KotlinMultiplatformExtension>()?.apply {
sourceSets.findByName("jvmTest")?.apply {
dependencies {
// Netty native transport libraries for different platforms
val osName = System.getProperty("os.name").lowercase()
val osArch = System.getProperty("os.arch").lowercase()

// Add the base Netty platform
implementation(project.dependencies.platform("io.netty:netty-bom:$nettyVersion"))

when {
osName.contains("linux") -> {
val archClassifier =
if (osArch.contains("aarch64")) {
"linux-aarch_64"
} else {
"linux-x86_64"
}
runtimeOnly(
"io.netty:netty-transport-native-epoll:$nettyVersion:$archClassifier",
)
}

osName.contains("mac") -> {
val archClassifier =
if (osArch.contains("aarch64")) {
"osx-aarch_64"
} else {
"osx-x86_64"
}
runtimeOnly(
"io.netty:netty-transport-native-kqueue:$nettyVersion:$archClassifier",
)
runtimeOnly(
"io.netty:netty-resolver-dns-native-macos:$nettyVersion:$archClassifier",
)
}
}
}
}
}
}
9 changes: 9 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ dokka = "2.0.0"
atomicfu = "0.29.0"
ktlint = "13.1.0"
kover = "0.9.2"
netty = "4.2.7.Final"
mavenPublish = "0.34.0"
binaryCompatibilityValidatorPlugin = "0.18.1"

Expand All @@ -18,6 +19,7 @@ logging = "7.0.13"
slf4j = "2.0.17"
kotest = "6.0.3"
awaitility = "4.3.0"
mokksy = "0.6.1"

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

# Ktor
ktor-bom = { group = "io.ktor", name = "ktor-bom", version.ref = "ktor" }
ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging"}
ktor-client-apache5 = { group = "io.ktor", name = "ktor-client-apache5" }
ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp" }
ktor-server-sse = { group = "io.ktor", name = "ktor-server-sse", version.ref = "ktor" }
ktor-server-netty = { group = "io.ktor", name = "ktor-server-netty" }
ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", version.ref = "ktor" }
ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" }

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

# Samples
ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio", version.ref = "ktor" }
Expand Down
12 changes: 12 additions & 0 deletions kotlin-sdk-client/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ plugins {
id("mcp.publishing")
id("mcp.dokka")
alias(libs.plugins.kotlinx.binary.compatibility.validator)
`netty-convention`
}

kotlin {
Expand Down Expand Up @@ -40,8 +41,19 @@ kotlin {
commonTest {
dependencies {
implementation(kotlin("test"))
implementation(dependencies.platform(libs.ktor.bom))
implementation(libs.ktor.client.mock)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.ktor.client.logging)
}
}

jvmTest {
dependencies {
implementation(libs.mokksy)
implementation(libs.awaitility)
implementation(libs.ktor.client.apache5)
implementation(dependencies.platform(libs.netty.bom))
runtimeOnly(libs.slf4j.simple)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,8 @@ public class StreamableHttpClientTransport(
) {
method = HttpMethod.Get
applyCommonHeaders(this)
accept(ContentType.Text.EventStream)
// sseSession will add ContentType.Text.EventStream automatically
accept(ContentType.Application.Json)
(resumptionToken ?: lastEventId)?.let { headers.append(MCP_RESUMPTION_TOKEN_HEADER, it) }
requestBuilder()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package io.modelcontextprotocol.kotlin.sdk.client

import dev.mokksy.mokksy.Mokksy
import dev.mokksy.mokksy.StubConfiguration
import io.ktor.http.ContentType
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.sse.ServerSentEvent
import io.modelcontextprotocol.kotlin.sdk.JSONRPCRequest
import kotlinx.coroutines.flow.Flow

/**
* High-level helper for simulating an MCP server over Streaming HTTP transport with Server-Sent Events (SSE),
* built on top of an HTTP server using the [Mokksy](https://mokksy.dev) library.
*
* Provides test utilities to mock server behavior based on specific request conditions.
*
* @param verbose Whether to print detailed logs. Defaults to `false`.
* @author Konstantin Pavlov
*/
internal class MockMcp(verbose: Boolean = false) {

private val mokksy: Mokksy = Mokksy(verbose = verbose)

fun checkForUnmatchedRequests() {
mokksy.checkForUnmatchedRequests()
}

val url = mokksy.baseUrl() + "/mcp"

@Suppress("LongParameterList")
fun onJSONRPCRequest(
httpMethod: HttpMethod = HttpMethod.Post,
jsonRpcMethod: String,
expectedSessionId: String? = null,
sessionId: String,
contentType: ContentType = ContentType.Application.Json,
statusCode: HttpStatusCode = HttpStatusCode.OK,
bodyBuilder: () -> String,
) {
mokksy.method(
configuration = StubConfiguration(removeAfterMatch = true),
httpMethod = httpMethod,
requestType = JSONRPCRequest::class,
) {
path("/mcp")
expectedSessionId?.let {
containsHeader("Mcp-Session-Id", it)
}
bodyMatchesPredicates(
{
it!!.method == jsonRpcMethod
},
{
it!!.jsonrpc == "2.0"
},
)
} respondsWith {
body = bodyBuilder.invoke()
this.contentType = contentType
headers += "Mcp-Session-Id" to sessionId
httpStatus = statusCode
}
}

fun onSubscribeWithGet(sessionId: String, block: () -> Flow<ServerSentEvent>) {
mokksy.get(name = "MCP GETs", requestType = Any::class) {
path("/mcp")
containsHeader("Mcp-Session-Id", sessionId)
containsHeader("Accept", "application/json,text/event-stream")
containsHeader("Cache-Control", "no-store")
} respondsWithSseStream {
headers += "Mcp-Session-Id" to sessionId
this.flow = block.invoke()
}
}

fun mockUnsubscribeRequest(sessionId: String) {
mokksy.delete(
configuration = StubConfiguration(removeAfterMatch = true),
requestType = JSONRPCRequest::class,
) {
path("/mcp")
containsHeader("Mcp-Session-Id", sessionId)
} respondsWith {
body = null
}
}
}
Loading
Loading