diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e2d28699..5c722e1c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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: @@ -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() }} diff --git a/buildSrc/src/main/kotlin/mcp.multiplatform.gradle.kts b/buildSrc/src/main/kotlin/mcp.multiplatform.gradle.kts index e1157168..0a57e98d 100644 --- a/buildSrc/src/main/kotlin/mcp.multiplatform.gradle.kts +++ b/buildSrc/src/main/kotlin/mcp.multiplatform.gradle.kts @@ -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 { @@ -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().configureEach { + + useJUnitPlatform() + + maxParallelForks = Runtime.getRuntime().availableProcessors() + forkEvery = 100 + testLogging { + exceptionFormat = TestExceptionFormat.SHORT + showStandardStreams = true + events("failed") + } + systemProperty("kotest.output.ansi", "true") + reports { + junitXml.required.set(true) + junitXml.includeSystemOutLog.set(true) + junitXml.includeSystemErrLog.set(true) + } + } } - macosX64(); macosArm64() - linuxX64(); linuxArm64() + macosX64() + macosArm64() + linuxX64() + linuxArm64() mingwX64() js { nodejs() } wasmJs { nodejs() } diff --git a/buildSrc/src/main/kotlin/netty-convention.gradle.kts b/buildSrc/src/main/kotlin/netty-convention.gradle.kts new file mode 100644 index 00000000..920baf06 --- /dev/null +++ b/buildSrc/src/main/kotlin/netty-convention.gradle.kts @@ -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()?.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", + ) + } + } + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5bb80c86..86886f77 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" @@ -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" @@ -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" } @@ -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" } diff --git a/kotlin-sdk-client/build.gradle.kts b/kotlin-sdk-client/build.gradle.kts index 7ba3de25..7db177cd 100644 --- a/kotlin-sdk-client/build.gradle.kts +++ b/kotlin-sdk-client/build.gradle.kts @@ -7,6 +7,7 @@ plugins { id("mcp.publishing") id("mcp.dokka") alias(libs.plugins.kotlinx.binary.compatibility.validator) + `netty-convention` } kotlin { @@ -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) } } diff --git a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTransport.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTransport.kt index 50f3a510..71a89f68 100644 --- a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTransport.kt +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTransport.kt @@ -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() } diff --git a/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/MockMcp.kt b/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/MockMcp.kt new file mode 100644 index 00000000..d6fcaed5 --- /dev/null +++ b/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/MockMcp.kt @@ -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) { + 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 + } + } +} diff --git a/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTest.kt b/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTest.kt new file mode 100644 index 00000000..6e730c30 --- /dev/null +++ b/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTest.kt @@ -0,0 +1,202 @@ +package io.modelcontextprotocol.kotlin.sdk.client + +import io.kotest.matchers.collections.shouldContain +import io.ktor.client.HttpClient +import io.ktor.client.engine.apache5.Apache5 +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logging +import io.ktor.client.plugins.sse.SSE +import io.ktor.http.HttpStatusCode +import io.ktor.sse.ServerSentEvent +import io.modelcontextprotocol.kotlin.sdk.ClientCapabilities +import io.modelcontextprotocol.kotlin.sdk.Implementation +import io.modelcontextprotocol.kotlin.sdk.Tool +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import org.junit.jupiter.api.TestInstance +import java.util.UUID +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.time.Duration.Companion.milliseconds + +/** + * Integration tests for the `StreamableHttpClientTransport` implementation + * using the [Mokksy](https://mokksy.dev) library + * to simulate Streaming HTTP with server-sent events (SSE). + * @author Konstantin Pavlov + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class StreamableHttpClientTest { + + // start mokksy on random port + private val mockMcp: MockMcp = MockMcp(verbose = true) + + @AfterTest + fun afterEach() { + mockMcp.checkForUnmatchedRequests() + } + + @Test + @Suppress("LongMethod") + fun `test streamableHttpClient`(): Unit = runBlocking { + val client = Client( + clientInfo = Implementation(name = "sample-client", version = "1.0.0"), + options = ClientOptions( + capabilities = ClientCapabilities(), + ), + ) + + val sessionId = UUID.randomUUID().toString() + + mockMcp.onJSONRPCRequest( + jsonRpcMethod = "initialize", + sessionId = sessionId, + ) { + // language=json + """ + { + "jsonrpc": "2.0", + "id": 1, + "result": { + "capabilities": { + "tools": { + "listChanged": false + } + }, + "protocolVersion": "2025-03-26", + "serverInfo": { + "name": "Mock MCP Server", + "version": "1.0.0" + }, + "_meta": { + "foo": "bar" + } + } + } + """.trimIndent() + } + + mockMcp.onJSONRPCRequest( + jsonRpcMethod = "notifications/initialized", + expectedSessionId = sessionId, + sessionId = sessionId, + statusCode = HttpStatusCode.Accepted, + ) { + "" + } + + mockMcp.onSubscribeWithGet(sessionId) { + flow { + delay(500.milliseconds) + emit( + ServerSentEvent( + event = "message", + id = "1", + data = @Suppress("MaxLineLength") + //language=json + """{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"upload-123","progress":50,"total":100}}""", + ), + ) + delay(200.milliseconds) + emit( + ServerSentEvent( + data = @Suppress("MaxLineLength") + //language=json + """{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"upload-123","progress":50,"total":100}}""", + ), + ) + } + } + + client.connect( + StreamableHttpClientTransport( + url = mockMcp.url, + client = HttpClient(Apache5) { + install(SSE) + install(Logging) { + level = LogLevel.ALL + } + }, + ), + ) + + // TODO: how to get notifications via Client API? + + mockMcp.onJSONRPCRequest( + jsonRpcMethod = "tools/list", + sessionId = sessionId, + ) { + // language=json + """ + { + "jsonrpc": "2.0", + "id": 3, + "result": { + "tools": [ + { + "name": "get_weather", + "title": "Weather Information Provider", + "description": "Get current weather information for a location", + "inputSchema": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "City name or zip code" + } + }, + "required": ["location"] + }, + "outputSchema": { + "type": "object", + "properties": { + "temperature": { + "type": "number", + "description": "Temperature, Celsius" + } + }, + "required": ["temperature"] + } + } + ] + } + } + """.trimIndent() + } + + val listToolsResult = client.listTools() + + listToolsResult.tools shouldContain Tool( + name = "get_weather", + title = "Weather Information Provider", + description = "Get current weather information for a location", + inputSchema = Tool.Input( + properties = buildJsonObject { + putJsonObject("location") { + put("type", "string") + put("description", "City name or zip code") + } + }, + required = listOf("location"), + ), + outputSchema = Tool.Output( + properties = buildJsonObject { + putJsonObject("temperature") { + put("type", "number") + put("description", "Temperature, Celsius") + } + }, + required = listOf("temperature"), + ), + annotations = null, + ) + + mockMcp.mockUnsubscribeRequest(sessionId = sessionId) + + client.close() + } +} diff --git a/kotlin-sdk-client/src/jvmTest/resources/junit-platform.properties b/kotlin-sdk-client/src/jvmTest/resources/junit-platform.properties new file mode 100644 index 00000000..9af79875 --- /dev/null +++ b/kotlin-sdk-client/src/jvmTest/resources/junit-platform.properties @@ -0,0 +1,6 @@ +## https://docs.junit.org/5.3.0-M1/user-guide/index.html#writing-tests-parallel-execution +junit.jupiter.execution.parallel.enabled=true +junit.jupiter.execution.parallel.config.strategy=dynamic +junit.jupiter.execution.parallel.mode.default=concurrent +junit.jupiter.execution.parallel.mode.classes.default=concurrent +junit.jupiter.execution.timeout.default=2m diff --git a/kotlin-sdk-client/src/jvmTest/resources/simplelogger.properties b/kotlin-sdk-client/src/jvmTest/resources/simplelogger.properties new file mode 100644 index 00000000..506c4365 --- /dev/null +++ b/kotlin-sdk-client/src/jvmTest/resources/simplelogger.properties @@ -0,0 +1,14 @@ +# Level of logging for the ROOT logger: ERROR, WARN, INFO, DEBUG, TRACE (default is INFO) +org.slf4j.simpleLogger.defaultLogLevel=INFO + + +org.slf4j.simpleLogger.showThreadName=true +org.slf4j.simpleLogger.showDateTime=false + +# Whether to enable stack traces for exceptions (true/false, default is true) +org.slf4j.simpleLogger.showShortLogName=false + +# Log level for specific packages or classes +org.slf4j.simpleLogger.log.io.ktor.server=DEBUG +org.slf4j.simpleLogger.log.io.modelcontextprotocol=TRACE +org.slf4j.simpleLogger.log.dev.mokksy=DEBUG diff --git a/kotlin-sdk-test/build.gradle.kts b/kotlin-sdk-test/build.gradle.kts index 012619b9..b51398b3 100644 --- a/kotlin-sdk-test/build.gradle.kts +++ b/kotlin-sdk-test/build.gradle.kts @@ -4,7 +4,7 @@ plugins { kotlin { jvm { - testRuns["test"].executionTask.configure { + tasks.withType { useJUnitPlatform() } }