Skip to content

Commit dc4386f

Browse files
authored
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 -->
1 parent e4bd866 commit dc4386f

File tree

10 files changed

+172
-69
lines changed

10 files changed

+172
-69
lines changed

samples/kotlin-mcp-server/README.md

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,40 +18,58 @@ configurations and transport methods.
1818

1919
### Running the Server
2020

21-
The server defaults to SSE mode with Ktor plugin on port 3001. You can customize the behavior using command-line arguments.
21+
The server defaults [STDIO transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio).
2222

23-
#### Default (SSE with Ktor plugin):
23+
You can customize the behavior using command-line arguments.
24+
Logs are printed to [./build/stdout.log](./build/stdout.log)
25+
26+
#### Standard I/O mode (STDIO):
2427

2528
```bash
26-
./gradlew run
29+
./gradlew clean build
2730
```
31+
Use the [MCP inspector](https://modelcontextprotocol.io/docs/tools/inspector)
32+
to connect to MCP via STDIO (Click the "▶️ Connect" button):
2833

29-
#### Standard I/O mode:
30-
31-
```bash
32-
./gradlew run --args="--stdio"
34+
```shell
35+
npx @modelcontextprotocol/inspector --config mcp-inspector-config.json --server stdio-server
3336
```
3437

3538
#### SSE with plain configuration:
3639

40+
**NB!: 🐞 This configuration may not work ATM**
41+
3742
```bash
3843
./gradlew run --args="--sse-server 3001"
3944
```
45+
or
46+
```shell
47+
./gradlew clean build
48+
java -jar ./build/libs/kotlin-mcp-server-0.1.0-all.jar --sse-server 3001
49+
```
4050

41-
#### SSE with Ktor plugin (custom port):
51+
Use the [MCP inspector](https://modelcontextprotocol.io/docs/tools/inspector)
52+
to connect to `http://localhost:3002/` via SSE Transport (Click the "▶️ Connect" button):
53+
```shell
54+
npx @modelcontextprotocol/inspector --config mcp-inspector-config.json --server sse-server
55+
```
56+
57+
#### SSE with Ktor plugin:
4258

4359
```bash
4460
./gradlew run --args="--sse-server-ktor 3002"
4561
```
62+
or
63+
```shell
64+
./gradlew clean build
65+
java -jar ./build/libs/kotlin-mcp-server-0.1.0-all.jar --sse-server-ktor 3002
66+
```
4667

47-
### Connecting to the Server
48-
49-
For SSE servers:
50-
1. Start the server
51-
2. Use the [MCP inspector](https://modelcontextprotocol.io/docs/tools/inspector) to connect to `http://localhost:<port>/sse`
52-
53-
For STDIO servers:
54-
- Connect using an MCP client that supports STDIO transport
68+
Use the [MCP inspector](https://modelcontextprotocol.io/docs/tools/inspector)
69+
to connect to `http://localhost:3002/` via SSE transport (Click the "▶️ Connect" button):
70+
```shell
71+
npx @modelcontextprotocol/inspector --config mcp-inspector-config.json --server sse-ktor-server
72+
```
5573

5674
## Server Capabilities
5775

samples/kotlin-mcp-server/build.gradle.kts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
plugins {
22
alias(libs.plugins.kotlin.jvm)
33
alias(libs.plugins.kotlin.serialization)
4+
alias(libs.plugins.shadow)
45
application
56
}
67

@@ -15,6 +16,7 @@ dependencies {
1516
implementation(dependencies.platform(libs.ktor.bom))
1617
implementation(libs.mcp.kotlin.server)
1718
implementation(libs.ktor.server.cio)
19+
implementation(libs.ktor.server.cors)
1820
implementation(libs.slf4j.simple)
1921

2022
testImplementation(libs.mcp.kotlin.client)
@@ -28,4 +30,10 @@ tasks.test {
2830

2931
kotlin {
3032
jvmToolchain(17)
33+
compilerOptions {
34+
javaParameters = true
35+
freeCompilerArgs.addAll(
36+
"-Xdebug",
37+
)
38+
}
3139
}

samples/kotlin-mcp-server/gradle/libs.versions.toml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,18 @@ kotlin = "2.2.21"
33
ktor = "3.2.3"
44
mcp-kotlin = "0.7.4"
55
slf4j = "2.0.17"
6+
shadow = "9.2.2"
67

78
[libraries]
89
ktor-bom = { group = "io.ktor", name = "ktor-bom", version.ref = "ktor" }
9-
ktor-server-cio = { group = "io.ktor", name = "ktor-server-cio" }
1010
ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio" }
11-
mcp-kotlin-server = { group = "io.modelcontextprotocol", name = "kotlin-sdk-server", version.ref = "mcp-kotlin" }
11+
ktor-server-cio = { group = "io.ktor", name = "ktor-server-cio" }
12+
ktor-server-cors = { group = "io.ktor", name = "ktor-server-cors" }
1213
mcp-kotlin-client = { group = "io.modelcontextprotocol", name = "kotlin-sdk-client", version.ref = "mcp-kotlin" }
14+
mcp-kotlin-server = { group = "io.modelcontextprotocol", name = "kotlin-sdk-server", version.ref = "mcp-kotlin" }
1315
slf4j-simple = { group = "org.slf4j", name = "slf4j-simple", version.ref = "slf4j" }
1416

1517
[plugins]
1618
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
1719
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
20+
shadow = { id = "com.gradleup.shadow", version.ref = "shadow" }
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"mcpServers": {
3+
"stdio-server": {
4+
"command": "java",
5+
"args": [
6+
"-Dorg.slf4j.simpleLogger.defaultLogLevel=off",
7+
"-jar",
8+
"./build/libs/kotlin-mcp-server-0.1.0-all.jar"
9+
],
10+
"env": {
11+
},
12+
"note": "For SSE connections, add this URL directly in your MCP Client"
13+
},
14+
"sse-server": {
15+
"type": "sse",
16+
"url": "http://127.0.0.1:3001/sse",
17+
"note": "SSE with plain configuration"
18+
},
19+
"sse-ktor-server": {
20+
"type": "sse",
21+
"url": "http://127.0.0.1:3002/",
22+
"note": "SSE with Ktor plugin"
23+
}
24+
}
25+
}

samples/kotlin-mcp-server/src/main/kotlin/io/modelcontextprotocol/sample/server/main.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,16 @@ import kotlinx.coroutines.runBlocking
1010
* - "--sse-server-ktor <port>": Runs an SSE MCP server using Ktor plugin (default if no argument is provided).
1111
* - "--sse-server <port>": Runs an SSE MCP server with a plain configuration.
1212
*/
13-
fun main(args: Array<String>): Unit = runBlocking {
14-
val command = args.firstOrNull() ?: "--sse-server-ktor"
13+
fun main(vararg args: String): Unit = runBlocking {
14+
val command = args.firstOrNull() ?: "--stdio"
1515
val port = args.getOrNull(1)?.toIntOrNull() ?: 3001
1616
when (command) {
1717
"--stdio" -> runMcpServerUsingStdio()
18+
1819
"--sse-server-ktor" -> runSseMcpServerUsingKtorPlugin(port)
20+
1921
"--sse-server" -> runSseMcpServerWithPlainConfiguration(port)
22+
2023
else -> {
2124
error("Unknown command: $command")
2225
}

samples/kotlin-mcp-server/src/main/kotlin/io/modelcontextprotocol/sample/server/server.kt

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
package io.modelcontextprotocol.sample.server
22

3+
import io.ktor.http.HttpMethod
34
import io.ktor.http.HttpStatusCode
5+
import io.ktor.server.application.Application
46
import io.ktor.server.application.install
57
import io.ktor.server.cio.CIO
8+
import io.ktor.server.engine.EmbeddedServer
69
import io.ktor.server.engine.embeddedServer
10+
import io.ktor.server.plugins.cors.routing.CORS
711
import io.ktor.server.response.respond
812
import io.ktor.server.routing.post
913
import io.ktor.server.routing.routing
@@ -101,13 +105,13 @@ fun configureServer(): Server {
101105
}
102106

103107
fun runSseMcpServerWithPlainConfiguration(port: Int, wait: Boolean = true) {
108+
printBanner(port = port, path = "/sse")
104109
val serverSessions = ConcurrentMap<String, ServerSession>()
105-
println("Starting SSE server on port $port")
106-
println("Use inspector to connect to http://localhost:$port/sse")
107110

108111
val server = configureServer()
109112

110113
embeddedServer(CIO, host = "127.0.0.1", port = port) {
114+
installCors()
111115
install(SSE)
112116
routing {
113117
sse("/sse") {
@@ -147,15 +151,36 @@ fun runSseMcpServerWithPlainConfiguration(port: Int, wait: Boolean = true) {
147151
*
148152
* @param port The port number on which the SSE MCP server will listen for client connections.
149153
*/
150-
fun runSseMcpServerUsingKtorPlugin(port: Int, wait: Boolean = true) {
151-
println("Starting SSE server on port $port")
152-
println("Use inspector to connect to http://localhost:$port/sse")
154+
fun runSseMcpServerUsingKtorPlugin(port: Int, wait: Boolean = true): EmbeddedServer<*, *> {
155+
printBanner(port)
153156

154-
embeddedServer(CIO, host = "127.0.0.1", port = port) {
157+
val server = embeddedServer(CIO, host = "127.0.0.1", port = port) {
158+
installCors()
155159
mcp {
156160
return@mcp configureServer()
157161
}
158162
}.start(wait = wait)
163+
return server
164+
}
165+
166+
private fun printBanner(port: Int, path: String = "") {
167+
if (port == 0) {
168+
println("🎬 Starting SSE server on random port")
169+
} else {
170+
println("🎬 Starting SSE server on ${if (port > 0) "port $port" else "random port"}")
171+
println("🔍 Use MCP inspector to connect to http://localhost:$port$path")
172+
}
173+
}
174+
175+
private fun Application.installCors() {
176+
install(CORS) {
177+
allowMethod(HttpMethod.Options)
178+
allowMethod(HttpMethod.Get)
179+
allowMethod(HttpMethod.Post)
180+
allowMethod(HttpMethod.Delete)
181+
allowNonSimpleContentTypes = true
182+
anyHost() // @TODO: Don't do this in production if possible. Try to limit it.
183+
}
159184
}
160185

161186
/**
@@ -168,7 +193,7 @@ fun runMcpServerUsingStdio() {
168193
val server = configureServer()
169194
val transport = StdioServerTransport(
170195
inputStream = System.`in`.asSource().buffered(),
171-
outputStream = System.out.asSink().buffered()
196+
outputStream = System.out.asSink().buffered(),
172197
)
173198

174199
runBlocking {

samples/kotlin-mcp-server/src/main/resources/simplelogger.properties

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ org.slf4j.simpleLogger.defaultLogLevel=INFO
33
org.slf4j.simpleLogger.showThreadName=true
44
org.slf4j.simpleLogger.showDateTime=false
55

6+
org.slf4j.simpleLogger.logFile=./build/stdout.log
7+
68
# Log level for specific packages or classes
7-
org.slf4j.simpleLogger.log.io.modelcontextprotocol=TRACE
8-
org.slf4j.simpleLogger.log.io.ktor=TRACE
9+
org.slf4j.simpleLogger.log.io.modelcontextprotocol=DEBUG
10+
org.slf4j.simpleLogger.log.io.ktor=DEBUG

samples/kotlin-mcp-server/src/test/kotlin/SseServerIntegrationTest.kt

Lines changed: 1 addition & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,15 @@
1-
import io.ktor.client.HttpClient
2-
import io.ktor.client.engine.cio.CIO
3-
import io.ktor.client.plugins.sse.SSE
41
import io.modelcontextprotocol.kotlin.sdk.EmptyJsonObject
5-
import io.modelcontextprotocol.kotlin.sdk.Implementation
62
import io.modelcontextprotocol.kotlin.sdk.TextContent
73
import io.modelcontextprotocol.kotlin.sdk.client.Client
8-
import io.modelcontextprotocol.kotlin.sdk.client.mcpSseTransport
9-
import io.modelcontextprotocol.sample.server.runSseMcpServerUsingKtorPlugin
104
import kotlinx.coroutines.runBlocking
11-
import org.junit.jupiter.api.BeforeAll
125
import org.junit.jupiter.api.TestInstance
136
import kotlin.test.Test
147
import kotlin.test.assertEquals
158
import kotlin.test.assertIs
169

17-
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
1810
class SseServerIntegrationTest {
1911

20-
companion object {
21-
private const val PORT = 3002
22-
}
23-
24-
private lateinit var client: Client
25-
26-
private fun initClient(port: Int) {
27-
client = Client(
28-
Implementation(name = "test-client", version = "0.1.0"),
29-
)
30-
31-
val httpClient = HttpClient(CIO) {
32-
install(SSE)
33-
}
34-
35-
// Create a transport wrapper that captures the session ID and received messages
36-
val transport = httpClient.mcpSseTransport {
37-
url {
38-
this.host = "127.0.0.1"
39-
this.port = port
40-
}
41-
}
42-
runBlocking {
43-
client.connect(transport)
44-
}
45-
}
46-
47-
@BeforeAll
48-
fun setUp() {
49-
runSseMcpServerUsingKtorPlugin(PORT, wait = false)
50-
initClient(PORT)
51-
}
12+
private val client: Client = TestEnvironment.client
5213

5314
@Test
5415
fun `should get tools`(): Unit = runBlocking {
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import io.ktor.client.HttpClient
2+
import io.ktor.client.engine.cio.CIO
3+
import io.ktor.client.plugins.sse.SSE
4+
import io.modelcontextprotocol.kotlin.sdk.Implementation
5+
import io.modelcontextprotocol.kotlin.sdk.client.Client
6+
import io.modelcontextprotocol.kotlin.sdk.client.mcpSseTransport
7+
import io.modelcontextprotocol.sample.server.runSseMcpServerUsingKtorPlugin
8+
import kotlinx.coroutines.runBlocking
9+
import java.util.concurrent.TimeUnit
10+
11+
object TestEnvironment {
12+
13+
val server = runSseMcpServerUsingKtorPlugin(0, wait = false)
14+
val client: Client
15+
16+
init {
17+
client = runBlocking {
18+
val port = server.engine.resolvedConnectors().single().port
19+
initClient(port)
20+
}
21+
22+
Runtime.getRuntime().addShutdownHook(
23+
Thread {
24+
println("🏁 Shutting down server")
25+
server.stop(500, 700, TimeUnit.MILLISECONDS)
26+
println("☑️ Shutdown complete")
27+
},
28+
)
29+
}
30+
31+
private suspend fun initClient(port: Int): Client {
32+
val client = Client(
33+
Implementation(name = "test-client", version = "0.1.0"),
34+
)
35+
36+
val httpClient = HttpClient(CIO) {
37+
install(SSE)
38+
}
39+
40+
// Create a transport wrapper that captures the session ID and received messages
41+
val transport = httpClient.mcpSseTransport {
42+
url {
43+
this.host = "127.0.0.1"
44+
this.port = port
45+
}
46+
}
47+
client.connect(transport)
48+
return client
49+
}
50+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Level of logging for the ROOT logger: ERROR, WARN, INFO, DEBUG, TRACE (default is INFO)
2+
org.slf4j.simpleLogger.defaultLogLevel=INFO
3+
org.slf4j.simpleLogger.showThreadName=true
4+
org.slf4j.simpleLogger.showDateTime=false
5+
6+
# Log level for specific packages or classes
7+
org.slf4j.simpleLogger.log.io.modelcontextprotocol=TRACE
8+
org.slf4j.simpleLogger.log.io.ktor=TRACE

0 commit comments

Comments
 (0)