From c70cc60b09c63ddd51fa004f57a707f401c4a10c Mon Sep 17 00:00:00 2001 From: Konstantin Pavlov <1517853+kpavlov@users.noreply.github.com> Date: Thu, 6 Nov 2025 14:27:36 +0200 Subject: [PATCH] Update Kotlin-MCP-Server with STDIO support, logging improvements, and CORS configuration ### Summary - Added support for STDIO transport mode (default behavior). - Configured CORS. - Redirected logs to `./build/stdout.log` and updated log levels. - New `TestEnvironment` setup for integration testing. - Enhanced documentation with examples for server execution and MCP Inspector usage. - Introduced shadow JAR plugin for building runnable artifacts. ### Motivation and Context Improves server versatility and developer experience by enabling STDIO and refining setup. --- samples/kotlin-mcp-server/README.md | 50 +++++++++++++------ samples/kotlin-mcp-server/build.gradle.kts | 8 +++ .../gradle/libs.versions.toml | 7 ++- .../mcp-inspector-config.json | 25 ++++++++++ .../sample/server/main.kt | 7 ++- .../sample/server/server.kt | 39 ++++++++++++--- .../main/resources/simplelogger.properties | 6 ++- .../test/kotlin/SseServerIntegrationTest.kt | 41 +-------------- .../src/test/kotlin/TestEnvironment.kt | 50 +++++++++++++++++++ .../test/resources/simplelogger.properties | 8 +++ 10 files changed, 172 insertions(+), 69 deletions(-) create mode 100644 samples/kotlin-mcp-server/mcp-inspector-config.json create mode 100644 samples/kotlin-mcp-server/src/test/kotlin/TestEnvironment.kt create mode 100644 samples/kotlin-mcp-server/src/test/resources/simplelogger.properties diff --git a/samples/kotlin-mcp-server/README.md b/samples/kotlin-mcp-server/README.md index 13a2976e..fa56b66d 100644 --- a/samples/kotlin-mcp-server/README.md +++ b/samples/kotlin-mcp-server/README.md @@ -18,40 +18,58 @@ configurations and transport methods. ### Running the Server -The server defaults to SSE mode with Ktor plugin on port 3001. You can customize the behavior using command-line arguments. +The server defaults [STDIO transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio). -#### Default (SSE with Ktor plugin): +You can customize the behavior using command-line arguments. +Logs are printed to [./build/stdout.log](./build/stdout.log) + +#### Standard I/O mode (STDIO): ```bash -./gradlew run +./gradlew clean build ``` +Use the [MCP inspector](https://modelcontextprotocol.io/docs/tools/inspector) +to connect to MCP via STDIO (Click the "▶️ Connect" button): -#### Standard I/O mode: - -```bash -./gradlew run --args="--stdio" +```shell +npx @modelcontextprotocol/inspector --config mcp-inspector-config.json --server stdio-server ``` #### SSE with plain configuration: +**NB!: 🐞 This configuration may not work ATM** + ```bash ./gradlew run --args="--sse-server 3001" ``` +or +```shell +./gradlew clean build +java -jar ./build/libs/kotlin-mcp-server-0.1.0-all.jar --sse-server 3001 +``` -#### SSE with Ktor plugin (custom port): +Use the [MCP inspector](https://modelcontextprotocol.io/docs/tools/inspector) +to connect to `http://localhost:3002/` via SSE Transport (Click the "▶️ Connect" button): +```shell +npx @modelcontextprotocol/inspector --config mcp-inspector-config.json --server sse-server +``` + +#### SSE with Ktor plugin: ```bash ./gradlew run --args="--sse-server-ktor 3002" ``` +or +```shell +./gradlew clean build +java -jar ./build/libs/kotlin-mcp-server-0.1.0-all.jar --sse-server-ktor 3002 +``` -### Connecting to the Server - -For SSE servers: -1. Start the server -2. Use the [MCP inspector](https://modelcontextprotocol.io/docs/tools/inspector) to connect to `http://localhost:/sse` - -For STDIO servers: -- Connect using an MCP client that supports STDIO transport +Use the [MCP inspector](https://modelcontextprotocol.io/docs/tools/inspector) +to connect to `http://localhost:3002/` via SSE transport (Click the "▶️ Connect" button): +```shell +npx @modelcontextprotocol/inspector --config mcp-inspector-config.json --server sse-ktor-server +``` ## Server Capabilities diff --git a/samples/kotlin-mcp-server/build.gradle.kts b/samples/kotlin-mcp-server/build.gradle.kts index 11cfbc1a..90bb9c95 100644 --- a/samples/kotlin-mcp-server/build.gradle.kts +++ b/samples/kotlin-mcp-server/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.shadow) application } @@ -15,6 +16,7 @@ dependencies { implementation(dependencies.platform(libs.ktor.bom)) implementation(libs.mcp.kotlin.server) implementation(libs.ktor.server.cio) + implementation(libs.ktor.server.cors) implementation(libs.slf4j.simple) testImplementation(libs.mcp.kotlin.client) @@ -28,4 +30,10 @@ tasks.test { kotlin { jvmToolchain(17) + compilerOptions { + javaParameters = true + freeCompilerArgs.addAll( + "-Xdebug", + ) + } } diff --git a/samples/kotlin-mcp-server/gradle/libs.versions.toml b/samples/kotlin-mcp-server/gradle/libs.versions.toml index 016241d0..0f6ab78f 100644 --- a/samples/kotlin-mcp-server/gradle/libs.versions.toml +++ b/samples/kotlin-mcp-server/gradle/libs.versions.toml @@ -3,15 +3,18 @@ kotlin = "2.2.21" ktor = "3.2.3" mcp-kotlin = "0.7.4" slf4j = "2.0.17" +shadow = "9.2.2" [libraries] ktor-bom = { group = "io.ktor", name = "ktor-bom", version.ref = "ktor" } -ktor-server-cio = { group = "io.ktor", name = "ktor-server-cio" } ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio" } -mcp-kotlin-server = { group = "io.modelcontextprotocol", name = "kotlin-sdk-server", version.ref = "mcp-kotlin" } +ktor-server-cio = { group = "io.ktor", name = "ktor-server-cio" } +ktor-server-cors = { group = "io.ktor", name = "ktor-server-cors" } mcp-kotlin-client = { group = "io.modelcontextprotocol", name = "kotlin-sdk-client", version.ref = "mcp-kotlin" } +mcp-kotlin-server = { group = "io.modelcontextprotocol", name = "kotlin-sdk-server", version.ref = "mcp-kotlin" } slf4j-simple = { group = "org.slf4j", name = "slf4j-simple", version.ref = "slf4j" } [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +shadow = { id = "com.gradleup.shadow", version.ref = "shadow" } diff --git a/samples/kotlin-mcp-server/mcp-inspector-config.json b/samples/kotlin-mcp-server/mcp-inspector-config.json new file mode 100644 index 00000000..e23ef690 --- /dev/null +++ b/samples/kotlin-mcp-server/mcp-inspector-config.json @@ -0,0 +1,25 @@ +{ + "mcpServers": { + "stdio-server": { + "command": "java", + "args": [ + "-Dorg.slf4j.simpleLogger.defaultLogLevel=off", + "-jar", + "./build/libs/kotlin-mcp-server-0.1.0-all.jar" + ], + "env": { + }, + "note": "For SSE connections, add this URL directly in your MCP Client" + }, + "sse-server": { + "type": "sse", + "url": "http://127.0.0.1:3001/sse", + "note": "SSE with plain configuration" + }, + "sse-ktor-server": { + "type": "sse", + "url": "http://127.0.0.1:3002/", + "note": "SSE with Ktor plugin" + } + } +} diff --git a/samples/kotlin-mcp-server/src/main/kotlin/io/modelcontextprotocol/sample/server/main.kt b/samples/kotlin-mcp-server/src/main/kotlin/io/modelcontextprotocol/sample/server/main.kt index d01def55..ebe7c0d9 100644 --- a/samples/kotlin-mcp-server/src/main/kotlin/io/modelcontextprotocol/sample/server/main.kt +++ b/samples/kotlin-mcp-server/src/main/kotlin/io/modelcontextprotocol/sample/server/main.kt @@ -10,13 +10,16 @@ import kotlinx.coroutines.runBlocking * - "--sse-server-ktor ": Runs an SSE MCP server using Ktor plugin (default if no argument is provided). * - "--sse-server ": Runs an SSE MCP server with a plain configuration. */ -fun main(args: Array): Unit = runBlocking { - val command = args.firstOrNull() ?: "--sse-server-ktor" +fun main(vararg args: String): Unit = runBlocking { + val command = args.firstOrNull() ?: "--stdio" val port = args.getOrNull(1)?.toIntOrNull() ?: 3001 when (command) { "--stdio" -> runMcpServerUsingStdio() + "--sse-server-ktor" -> runSseMcpServerUsingKtorPlugin(port) + "--sse-server" -> runSseMcpServerWithPlainConfiguration(port) + else -> { error("Unknown command: $command") } diff --git a/samples/kotlin-mcp-server/src/main/kotlin/io/modelcontextprotocol/sample/server/server.kt b/samples/kotlin-mcp-server/src/main/kotlin/io/modelcontextprotocol/sample/server/server.kt index 4cca92ca..fc82c5d5 100644 --- a/samples/kotlin-mcp-server/src/main/kotlin/io/modelcontextprotocol/sample/server/server.kt +++ b/samples/kotlin-mcp-server/src/main/kotlin/io/modelcontextprotocol/sample/server/server.kt @@ -1,9 +1,13 @@ package io.modelcontextprotocol.sample.server +import io.ktor.http.HttpMethod import io.ktor.http.HttpStatusCode +import io.ktor.server.application.Application import io.ktor.server.application.install import io.ktor.server.cio.CIO +import io.ktor.server.engine.EmbeddedServer import io.ktor.server.engine.embeddedServer +import io.ktor.server.plugins.cors.routing.CORS import io.ktor.server.response.respond import io.ktor.server.routing.post import io.ktor.server.routing.routing @@ -101,13 +105,13 @@ fun configureServer(): Server { } fun runSseMcpServerWithPlainConfiguration(port: Int, wait: Boolean = true) { + printBanner(port = port, path = "/sse") val serverSessions = ConcurrentMap() - println("Starting SSE server on port $port") - println("Use inspector to connect to http://localhost:$port/sse") val server = configureServer() embeddedServer(CIO, host = "127.0.0.1", port = port) { + installCors() install(SSE) routing { sse("/sse") { @@ -147,15 +151,36 @@ fun runSseMcpServerWithPlainConfiguration(port: Int, wait: Boolean = true) { * * @param port The port number on which the SSE MCP server will listen for client connections. */ -fun runSseMcpServerUsingKtorPlugin(port: Int, wait: Boolean = true) { - println("Starting SSE server on port $port") - println("Use inspector to connect to http://localhost:$port/sse") +fun runSseMcpServerUsingKtorPlugin(port: Int, wait: Boolean = true): EmbeddedServer<*, *> { + printBanner(port) - embeddedServer(CIO, host = "127.0.0.1", port = port) { + val server = embeddedServer(CIO, host = "127.0.0.1", port = port) { + installCors() mcp { return@mcp configureServer() } }.start(wait = wait) + return server +} + +private fun printBanner(port: Int, path: String = "") { + if (port == 0) { + println("🎬 Starting SSE server on random port") + } else { + println("🎬 Starting SSE server on ${if (port > 0) "port $port" else "random port"}") + println("🔍 Use MCP inspector to connect to http://localhost:$port$path") + } +} + +private fun Application.installCors() { + install(CORS) { + allowMethod(HttpMethod.Options) + allowMethod(HttpMethod.Get) + allowMethod(HttpMethod.Post) + allowMethod(HttpMethod.Delete) + allowNonSimpleContentTypes = true + anyHost() // @TODO: Don't do this in production if possible. Try to limit it. + } } /** @@ -168,7 +193,7 @@ fun runMcpServerUsingStdio() { val server = configureServer() val transport = StdioServerTransport( inputStream = System.`in`.asSource().buffered(), - outputStream = System.out.asSink().buffered() + outputStream = System.out.asSink().buffered(), ) runBlocking { diff --git a/samples/kotlin-mcp-server/src/main/resources/simplelogger.properties b/samples/kotlin-mcp-server/src/main/resources/simplelogger.properties index 2012bec2..ba9c7d80 100644 --- a/samples/kotlin-mcp-server/src/main/resources/simplelogger.properties +++ b/samples/kotlin-mcp-server/src/main/resources/simplelogger.properties @@ -3,6 +3,8 @@ org.slf4j.simpleLogger.defaultLogLevel=INFO org.slf4j.simpleLogger.showThreadName=true org.slf4j.simpleLogger.showDateTime=false +org.slf4j.simpleLogger.logFile=./build/stdout.log + # Log level for specific packages or classes -org.slf4j.simpleLogger.log.io.modelcontextprotocol=TRACE -org.slf4j.simpleLogger.log.io.ktor=TRACE +org.slf4j.simpleLogger.log.io.modelcontextprotocol=DEBUG +org.slf4j.simpleLogger.log.io.ktor=DEBUG diff --git a/samples/kotlin-mcp-server/src/test/kotlin/SseServerIntegrationTest.kt b/samples/kotlin-mcp-server/src/test/kotlin/SseServerIntegrationTest.kt index 2c0532c4..8399173a 100644 --- a/samples/kotlin-mcp-server/src/test/kotlin/SseServerIntegrationTest.kt +++ b/samples/kotlin-mcp-server/src/test/kotlin/SseServerIntegrationTest.kt @@ -1,54 +1,15 @@ -import io.ktor.client.HttpClient -import io.ktor.client.engine.cio.CIO -import io.ktor.client.plugins.sse.SSE import io.modelcontextprotocol.kotlin.sdk.EmptyJsonObject -import io.modelcontextprotocol.kotlin.sdk.Implementation import io.modelcontextprotocol.kotlin.sdk.TextContent import io.modelcontextprotocol.kotlin.sdk.client.Client -import io.modelcontextprotocol.kotlin.sdk.client.mcpSseTransport -import io.modelcontextprotocol.sample.server.runSseMcpServerUsingKtorPlugin import kotlinx.coroutines.runBlocking -import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.TestInstance import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs -@TestInstance(TestInstance.Lifecycle.PER_CLASS) class SseServerIntegrationTest { - companion object { - private const val PORT = 3002 - } - - private lateinit var client: Client - - private fun initClient(port: Int) { - client = Client( - Implementation(name = "test-client", version = "0.1.0"), - ) - - val httpClient = HttpClient(CIO) { - install(SSE) - } - - // Create a transport wrapper that captures the session ID and received messages - val transport = httpClient.mcpSseTransport { - url { - this.host = "127.0.0.1" - this.port = port - } - } - runBlocking { - client.connect(transport) - } - } - - @BeforeAll - fun setUp() { - runSseMcpServerUsingKtorPlugin(PORT, wait = false) - initClient(PORT) - } + private val client: Client = TestEnvironment.client @Test fun `should get tools`(): Unit = runBlocking { diff --git a/samples/kotlin-mcp-server/src/test/kotlin/TestEnvironment.kt b/samples/kotlin-mcp-server/src/test/kotlin/TestEnvironment.kt new file mode 100644 index 00000000..6d457ddc --- /dev/null +++ b/samples/kotlin-mcp-server/src/test/kotlin/TestEnvironment.kt @@ -0,0 +1,50 @@ +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.sse.SSE +import io.modelcontextprotocol.kotlin.sdk.Implementation +import io.modelcontextprotocol.kotlin.sdk.client.Client +import io.modelcontextprotocol.kotlin.sdk.client.mcpSseTransport +import io.modelcontextprotocol.sample.server.runSseMcpServerUsingKtorPlugin +import kotlinx.coroutines.runBlocking +import java.util.concurrent.TimeUnit + +object TestEnvironment { + + val server = runSseMcpServerUsingKtorPlugin(0, wait = false) + val client: Client + + init { + client = runBlocking { + val port = server.engine.resolvedConnectors().single().port + initClient(port) + } + + Runtime.getRuntime().addShutdownHook( + Thread { + println("🏁 Shutting down server") + server.stop(500, 700, TimeUnit.MILLISECONDS) + println("☑️ Shutdown complete") + }, + ) + } + + private suspend fun initClient(port: Int): Client { + val client = Client( + Implementation(name = "test-client", version = "0.1.0"), + ) + + val httpClient = HttpClient(CIO) { + install(SSE) + } + + // Create a transport wrapper that captures the session ID and received messages + val transport = httpClient.mcpSseTransport { + url { + this.host = "127.0.0.1" + this.port = port + } + } + client.connect(transport) + return client + } +} diff --git a/samples/kotlin-mcp-server/src/test/resources/simplelogger.properties b/samples/kotlin-mcp-server/src/test/resources/simplelogger.properties new file mode 100644 index 00000000..2012bec2 --- /dev/null +++ b/samples/kotlin-mcp-server/src/test/resources/simplelogger.properties @@ -0,0 +1,8 @@ +# 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 + +# Log level for specific packages or classes +org.slf4j.simpleLogger.log.io.modelcontextprotocol=TRACE +org.slf4j.simpleLogger.log.io.ktor=TRACE