Update Kotlin-MCP-Server with STDIO support, logging & CORS (#371)

Update Kotlin-MCP-Server with STDIO support, logging & CORS

### Summary
- Added support for STDIO transport mode (default behavior).
- Configured CORS and migrated SSE server to Ktor's Netty engine.
- 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.

## How Has This Been Tested?
Unit tests, tested locally (see README.md in sample)
**NB! sse plain configuration is not working at the moment**

## Breaking Changes
No

## Types of changes
<!-- What types of changes does your code introduce? Put an `x` in all
the boxes that apply: -->
- [x] 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

## Checklist
<!-- Go over all the following points, and put an `x` in all the boxes
that apply. -->
- [ ] I have read the [MCP
Documentation](https://modelcontextprotocol.io)
- [ ] My code follows the repository's style guidelines
- [ ] 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
-->
This commit is contained in:
Konstantin Pavlov 2025-11-06 18:18:04 +02:00 • committed by GitHub
commit dc4386f0f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 172 additions and 69 deletions

View file

@ -10,13 +10,16 @@ import kotlinx.coroutines.runBlocking
* - "--sse-server-ktor <port>": Runs an SSE MCP server using Ktor plugin (default if no argument is provided).
* - "--sse-server <port>": Runs an SSE MCP server with a plain configuration.
*/
fun main(args: Array<String>): 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")
}

View file

@ -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<String, ServerSession>()
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 {

View file

@ -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

View file

@ -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 {

View file

@ -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
}
}

View file

@ -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