Skip to content
Draft
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
1 change: 1 addition & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ jobs:
:kotlin-sdk:compileKotlinJvm \
:integration-test:compileKotlinJvm \
:conformance-test:testClasses \
detekt \
-Pkotlin.incremental=false \
--no-daemon --stacktrace --rerun-tasks

Expand Down
8 changes: 8 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
plugins {
id("mcp.dokka")
alias(libs.plugins.detekt)
alias(libs.plugins.ktlint)
alias(libs.plugins.kover)
}
Expand All @@ -18,6 +19,13 @@ dependencies {
subprojects {
apply(plugin = "org.jlleitschuh.gradle.ktlint")
apply(plugin = "org.jetbrains.kotlinx.kover")
apply(plugin = "dev.detekt")

detekt {
config = files("$rootDir/detekt.yml")
buildUponDefaultConfig = true
ignoreFailures = false // to fix issues and enable
}
}

dokka {
Expand Down
4 changes: 4 additions & 0 deletions buildSrc/src/main/kotlin/mcp.multiplatform.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,7 @@ kotlin {
explicitApi = ExplicitApiMode.Strict
jvmToolchain(21)
}

tasks.named("detekt").configure {
dependsOn("detektMainJvm", "detektTestJvm")
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ private const val MESSAGE_QUEUE_CAPACITY = 256
private fun isInitializeRequest(json: JsonElement): Boolean =
json is JsonObject && json["method"]?.jsonPrimitive?.contentOrNull == "initialize"

@Suppress("CyclomaticComplexMethod", "LongMethod")
fun main(args: Array<String>) {
val port = args.getOrNull(0)?.toIntOrNull() ?: 3000

Expand Down Expand Up @@ -236,6 +237,7 @@ fun main(args: Array<String>) {
}.start(wait = true)
}

@Suppress("LongMethod")
private fun createConformanceServer(): Server {
val server = Server(
Implementation(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.DynamicTest
import org.junit.jupiter.api.TestFactory
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.fail
import java.io.BufferedReader
import java.io.InputStreamReader
import java.lang.management.ManagementFactory
Expand All @@ -24,6 +25,7 @@ enum class TransportType {
WEBSOCKET,
}

@Suppress("ForbiddenComment")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ConformanceTest {

Expand Down Expand Up @@ -158,7 +160,7 @@ class ConformanceTest {
}
}
serverProcess?.destroyForcibly()
throw IllegalStateException(
error(
"Server failed to start within $DEFAULT_SERVER_STARTUP_TIMEOUT_SECONDS seconds. " +
"Check if port $serverPort is available.$errorInfo",
)
Expand Down Expand Up @@ -307,8 +309,9 @@ class ConformanceTest {
"$capitalizedType conformance test [$transportType] '$scenario' timed out after $timeoutSeconds seconds"
}
process.destroyForcibly()
throw AssertionError(
"❌ $capitalizedType conformance test [$transportType] '$scenario' timed out after $timeoutSeconds seconds",
fail(
"❌ $capitalizedType conformance test [$transportType] '$scenario' " +
"timed out after $timeoutSeconds seconds",
)
}

Expand All @@ -319,8 +322,9 @@ class ConformanceTest {
logger.error {
"$capitalizedType conformance test [$transportType] '$scenario' failed with exit code: $exitCode"
}
throw AssertionError(
"❌ $capitalizedType conformance test [$transportType] '$scenario' failed (exit code: $exitCode). Check test output above for details.",
fail(
"❌ $capitalizedType conformance test [$transportType] '$scenario' failed (exit code: $exitCode). " +
"Check test output above for details.",
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class WebSocketClientTransport(override val session: WebSocketSession) : WebSock
}
}

@Suppress("LongMethod")
fun main(args: Array<String>) {
require(args.isNotEmpty()) {
"Server WebSocket URL must be provided as an argument"
Expand Down
7 changes: 7 additions & 0 deletions detekt.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
config:
validation: true
warningsAsErrors: true

complexity:
LongMethod:
excludes: ['**Test.kt']
12 changes: 7 additions & 5 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
[versions]
# plugins version
kotlin = "2.2.21"
dokka = "2.1.0"
atomicfu = "0.29.0"
ktlint = "14.0.1"
binaryCompatibilityValidatorPlugin = "0.18.1"
detekt = "2.0.0-alpha.1"
dokka = "2.1.0"
kotlin = "2.2.21"
kover = "0.9.4"
netty = "4.2.9.Final"
ktlint = "14.0.1"
mavenPublish = "0.35.0"
binaryCompatibilityValidatorPlugin = "0.18.1"
netty = "4.2.9.Final"
openapi-generator = "7.18.0"

# libraries version
Expand Down Expand Up @@ -69,6 +70,7 @@ ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio", version.ref = "
ktor-server-cio = { group = "io.ktor", name = "ktor-server-cio", version.ref = "ktor" }

[plugins]
detekt = { id = "dev.detekt", version.ref = "detekt" }
kotlinx-binary-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binaryCompatibilityValidatorPlugin" }
kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" }
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ abstract class TsTestBase {
if (sharedSseServer == null || !sharedSseServer!!.isAlive) {
sharedSsePort = io.modelcontextprotocol.kotlin.test.utils.findFreePort()
val server = TypeScriptServer(typescriptDir)
sharedSseServer = server.startSse(sharedSsePort)
sharedSseServer = server.startSseServer(sharedSsePort)
println("Shared TypeScript SSE server started on port $sharedSsePort")

Runtime.getRuntime().addShutdownHook(
Expand Down Expand Up @@ -161,7 +161,7 @@ abstract class TsTestBase {
// ===== STDIO client + server helpers =====
protected fun startTypeScriptServerStdio(): Process {
val server = TypeScriptServer(typescriptDir)
return server.startStdio()
return server.startStdioServer()
}

protected suspend fun newClientStdio(process: Process): Client {
Expand Down Expand Up @@ -193,7 +193,7 @@ abstract class TsTestBase {
// ===== Helpers to run TypeScript client over STDIO against Kotlin server over STDIO =====
protected fun runStdioClient(vararg args: String): String = tsClient.use { client ->
// Start Node stdio client (it will speak MCP over its stdout/stdin)
val process = client.startStdio(args.toList(), log = false)
val process = client.startStdioClient(args.toList(), log = false)

// Create Kotlin server and attach stdio transport to the process streams
val server: Server = KotlinServerForTsClient().createMcpServer()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class TypeScriptClient(private val typescriptDir: File) : AutoCloseable {
* @param log Whether to automatically log process output/error
* @return The started Process
*/
fun startSse(arguments: List<String>, log: Boolean = true): Process {
fun startSseClient(arguments: List<String>, log: Boolean = true): Process {
val scriptPath = File(typescriptDir, "client/sse-client.ts").absolutePath
val proc = TypeScriptRunner.run(
typescriptDir = typescriptDir,
Expand All @@ -42,7 +42,7 @@ class TypeScriptClient(private val typescriptDir: File) : AutoCloseable {
* @param log Whether to automatically log process output/error
* @return The started Process
*/
fun startStdio(arguments: List<String>, log: Boolean = true): Process {
fun startStdioClient(arguments: List<String>, log: Boolean = true): Process {
val scriptPath = File(typescriptDir, "client/stdio-client.ts").absolutePath
val proc = TypeScriptRunner.run(
typescriptDir = typescriptDir,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ class TypeScriptServer(private val typescriptDir: File) {
* @return The started Process
* @throws IllegalStateException if the server fails to start within timeout
*/
fun startSse(port: Int): Process {
fun startSseServer(port: Int): Process {
killProcessOnPort(port)
val serverPath = File(typescriptDir, "server/sse-server.ts").absolutePath
val proc = TypeScriptRunner.run(
Expand All @@ -68,7 +68,7 @@ class TypeScriptServer(private val typescriptDir: File) {
*
* @return The started Process
*/
fun startStdio(): Process {
fun startStdioServer(): Process {
val serverPath = File(typescriptDir, "server/stdio-server.ts").absolutePath
val proc = TypeScriptRunner.run(
typescriptDir = typescriptDir,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class TsClientKotlinServerTestSse : AbstractTsClientKotlinServerTest() {
}

override fun runClient(vararg args: String): String = tsClient.use { client ->
val process = client.startSse(listOf(serverUrl) + args.toList(), log = true)
val process = client.startSseClient(listOf(serverUrl) + args.toList(), log = true)
val output = StringBuilder()
process.inputStream.bufferedReader().useLines { lines ->
lines.forEach { line ->
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@file:Suppress("DEPRECATION")

package io.modelcontextprotocol.kotlin.sdk

import kotlinx.serialization.json.JsonPrimitive
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@file:Suppress("DEPRECATION")

package io.modelcontextprotocol.kotlin.sdk

import io.modelcontextprotocol.kotlin.sdk.shared.McpJson
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@file:Suppress("DEPRECATION")

package io.modelcontextprotocol.kotlin.sdk

import io.kotest.assertions.json.shouldEqualJson
Expand Down Expand Up @@ -419,7 +421,7 @@ class OldSchemaToolSerializationTest {
name: String = "get_weather",
title: String? = null,
outputSchema: String? = null,
@Suppress("LocalVariableName") _meta: String? = null,
meta: String? = null,
): String {
val stringBuilder = StringBuilder()

Expand Down Expand Up @@ -466,7 +468,7 @@ class OldSchemaToolSerializationTest {
.appendLine(",")
.append(
"""
"_meta": ${_meta ?: "{}"}
"_meta": ${meta ?: "{}"}
""".trimIndent(),
)

Expand All @@ -481,7 +483,7 @@ class OldSchemaToolSerializationTest {
name: String = "get_weather",
title: String? = null,
outputSchema: ToolSchema? = null,
@Suppress("LocalVariableName") _meta: JsonObject? = null,
meta: JsonObject? = null,
): io.modelcontextprotocol.kotlin.sdk.types.Tool = Tool(
name = name,
title = title,
Expand All @@ -500,7 +502,7 @@ class OldSchemaToolSerializationTest {
required = listOf("location"),
),
outputSchema = outputSchema,
_meta = _meta ?: EmptyJsonObject,
_meta = meta ?: EmptyJsonObject,
)

//endregion Private Methods
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@file:Suppress("DEPRECATION")

package io.modelcontextprotocol.kotlin.sdk

import io.modelcontextprotocol.kotlin.sdk.shared.McpJson
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@file:Suppress("DEPRECATION")

package io.modelcontextprotocol.kotlin.sdk.models

import io.kotest.matchers.shouldBe
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@file:Suppress("DEPRECATION")

package io.modelcontextprotocol.kotlin.sdk.shared

import io.ktor.utils.io.charsets.Charsets
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,17 +154,25 @@ class ProtocolTest {
}

private class TestProtocol : Protocol(null) {
override fun assertCapabilityForMethod(method: Method) {}
override fun assertNotificationCapability(method: Method) {}
override fun assertRequestHandlerCapability(method: Method) {}
override fun assertCapabilityForMethod(method: Method) {
// noop
}
override fun assertNotificationCapability(method: Method) {
// noop
}
override fun assertRequestHandlerCapability(method: Method) {
// noop
}
}

private class RecordingTransport : Transport {
private val sentMessages = Channel<JSONRPCMessage>(Channel.UNLIMITED)
private var onMessageCallback: (suspend (JSONRPCMessage) -> Unit)? = null
private var onCloseCallback: (() -> Unit)? = null

override suspend fun start() {}
override suspend fun start() {
// noop
}

override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) {
sentMessages.send(message)
Expand All @@ -178,7 +186,9 @@ private class RecordingTransport : Transport {
onCloseCallback = block
}

override fun onError(block: (Throwable) -> Unit) {}
override fun onError(block: (Throwable) -> Unit) {
// noop
}

override fun onMessage(block: suspend (JSONRPCMessage) -> Unit) {
onMessageCallback = block
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,17 @@ private data class SessionContext(val session: ServerSSESession?, val call: Appl
* @param enableJsonResponse If true, the server will return JSON responses instead of starting an SSE stream.
* This can be useful for simple request/response scenarios without streaming.
* Default is false (SSE streams are preferred).
* @param enableDnsRebindingProtection Enable DNS rebinding protection (requires allowedHosts and/or allowedOrigins to be configured).
* @param enableDnsRebindingProtection Enable DNS rebinding protection (requires allowedHosts
* and/or allowedOrigins to be configured).
* Default is false for backwards compatibility.
* @param allowedHosts List of allowed host header values for DNS rebinding protection.
* If not specified, host validation is disabled.
* @param allowedOrigins List of allowed origin header values for DNS rebinding protection.
* If not specified, origin validation is disabled.
* @param eventStore Event store for resumability support
* If provided, resumability will be enabled, allowing clients to reconnect and resume messages
* @param retryIntervalMillis Retry interval (in milliseconds) advertised via SSE priming events to hint the client when to reconnect.
* @param retryIntervalMillis Retry interval (in milliseconds) advertised via SSE priming events to hint the client
* when to reconnect.
* Applies only when an [eventStore] is configured. Defaults to `null` (no retry hint).
*/
@OptIn(ExperimentalUuidApi::class, ExperimentalAtomicApi::class)
Expand Down Expand Up @@ -107,6 +109,7 @@ public class StreamableHttpServerTransport(

private companion object {
const val STANDALONE_SSE_STREAM_ID = "_GET_stream"
private const val ONE_MEGABYTE = 1024 * 1024
}

/**
Expand Down Expand Up @@ -145,7 +148,8 @@ public class StreamableHttpServerTransport(

override suspend fun start() {
check(started.compareAndSet(expectedValue = false, newValue = true)) {
"StreamableHttpServerTransport already started! If using Server class, note that connect() calls start() automatically."
"StreamableHttpServerTransport already started! If using Server class, " +
"note that connect() calls start() automatically."
}
}

Expand Down Expand Up @@ -585,7 +589,7 @@ public class StreamableHttpServerTransport(
call.reject(
HttpStatusCode.PayloadTooLarge,
RPCError.ErrorCode.INVALID_REQUEST,
"Invalid Request: message size exceeds maximum of ${MAXIMUM_MESSAGE_SIZE / (1024 * 1024)} MB",
"Invalid Request: message size exceeds maximum of ${MAXIMUM_MESSAGE_SIZE / ONE_MEGABYTE} MB",
)
return null
}
Expand All @@ -595,7 +599,7 @@ public class StreamableHttpServerTransport(
call.reject(
HttpStatusCode.PayloadTooLarge,
RPCError.ErrorCode.INVALID_REQUEST,
"Invalid Request: message size exceeds maximum of ${MAXIMUM_MESSAGE_SIZE / (1024 * 1024)} MB",
"Invalid Request: message size exceeds maximum of ${MAXIMUM_MESSAGE_SIZE / (ONE_MEGABYTE)} MB",
)
return null
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@file:Suppress("DEPRECATION")

package io.modelcontextprotocol.kotlin.sdk.server

import io.modelcontextprotocol.kotlin.sdk.InitializedNotification
Expand Down
Loading
Loading