Build MCP servers

How to build an MCP server in Kotlin

A notes server in Kotlin with the official SDK, built with Gradle, run over stdio and Streamable HTTP, and tested with the SDK's own client.

By
Dan Flanagan
Updated
October 14, 2026
Tested with
Kotlin 2.4.21, kotlin-sdk 0.15.0 (server and client), Ktor 3.6.0 and Gradle 9.8.0 on Java 21.0.12.1 (Corretto), macOS 26.6.2. Claude Code step not re-run.
Reading time
6 min

New to MCP? What is an MCP server? covers the basics first.

The Kotlin SDK runs on coroutines and serves HTTP through Ktor. Its logging library also prints a startup line to standard output, which on a stdio server lands in the JSON-RPC stream, so the code below turns it off. The notes server has three tools. list_notes and read_note only read, and add_note writes a file, which matters later when you decide which agents get which tools.

The SDK is Tier 3 on the official SDK list: it supports the same core protocol as the Tier 1 SDKs, with a smaller maintenance commitment. Every step below was run on the versions in the header.

Prerequisites

  • Java 21 or newer. I used Corretto 21.0.12.1.
  • No local Gradle or Kotlin install. The project uses the Gradle wrapper, which downloads the right Gradle, which in turn downloads the right Kotlin compiler.
  • Claude Code, if you want to try the server there in step 5.

Step 1: Create the project

The Kotlin SDK ships sample projects with a working Gradle wrapper. Copy one as a starting point instead of assembling the wrapper by hand:

bash
git clone --depth 1 https://github.com/modelcontextprotocol/kotlin-sdk.git
mkdir notes-server && cd notes-server
cp -r ../kotlin-sdk/samples/simple-streamable-server/gradle .
cp ../kotlin-sdk/samples/simple-streamable-server/gradlew .
chmod +x gradlew

Check the wrapper works with ./gradlew --version, which also downloads the Gradle distribution on first run. Then create settings.gradle.kts, replace the gradle/libs.versions.toml that came with the copy, and create build.gradle.kts:

kotlin
// settings.gradle.kts
rootProject.name = "notes-server"
toml
# gradle/libs.versions.toml
[versions]
kotlin = "2.4.21"
ktor = "3.6.0"
mcp-kotlin = "0.15.0"
slf4j = "2.0.20"
shadow = "9.6.1"

[libraries]
slf4j-nop = { group = "org.slf4j", name = "slf4j-nop", version.ref = "slf4j" }
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-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" }

[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
shadow = { id = "com.gradleup.shadow", version.ref = "shadow" }
kotlin
// build.gradle.kts
plugins {
    alias(libs.plugins.kotlin.jvm)
    alias(libs.plugins.shadow)
    application
}

version = "0.1.0"

repositories { mavenCentral() }

application {
    mainClass.set("dev.statio.notes.MainKt")
}

dependencies {
    implementation(libs.mcp.kotlin.server)
    runtimeOnly(libs.slf4j.nop)
}

kotlin {
    jvmToolchain(21)
}

kotlin-sdk-server and kotlin-sdk-client are separate artifacts, the same split the TypeScript SDK made. The stdio transport in step 2 needs nothing beyond the SDK itself. The Ktor entries are for step 4's HTTP server, and slf4j-nop is explained in step 2. The version line names the jar that later steps run, build/libs/notes-server-0.1.0-all.jar.

Step 2: Register the tools with addTool

Create src/main/kotlin/dev/statio/notes/Notes.kt:

kotlin
package dev.statio.notes

import io.modelcontextprotocol.kotlin.sdk.server.Server
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
import io.modelcontextprotocol.kotlin.sdk.types.ToolAnnotations
import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import java.io.File

private val notesDir: File by lazy {
    File(System.getenv("NOTES_DIR") ?: "notes").absoluteFile.apply { mkdirs() }
}

// Map a title to a file inside notesDir, and refuse anything that would escape it.
private fun notePath(title: String): File {
    val slug = title.lowercase().replace(Regex("[^a-z0-9-]+"), "-").trim('-')
    require(slug.isNotEmpty()) { "A note needs a title with at least one letter or number." }
    return File(notesDir, "$slug.md")
}

fun createNotesServer(): Server {
    val server = Server(
        Implementation(name = "notes", version = "1.0.0"),
        ServerOptions(
            capabilities = ServerCapabilities(tools = ServerCapabilities.Tools(listChanged = true)),
        ),
    )

    server.addTool(
        name = "list_notes",
        description = "List the titles of every saved note.",
        toolAnnotations = ToolAnnotations(readOnlyHint = true),
    ) {
        val titles = notesDir.listFiles { f -> f.extension == "md" }
            .orEmpty()
            .map { it.nameWithoutExtension }
            .sorted()
        CallToolResult(content = listOf(TextContent(titles.toString())))
    }

    server.addTool(
        name = "read_note",
        description = "Return the text of one note.",
        inputSchema = ToolSchema(
            properties = buildJsonObject { putJsonObject("title") { put("type", "string") } },
            required = listOf("title"),
        ),
        toolAnnotations = ToolAnnotations(readOnlyHint = true),
    ) { request ->
        val title = request.arguments?.get("title")?.jsonPrimitive?.content.orEmpty()
        val path = notePath(title)
        require(path.exists()) { "No note called '$title'." }
        CallToolResult(content = listOf(TextContent(path.readText())))
    }

    server.addTool(
        name = "add_note",
        description = "Save a note. Fails if a note with that title already exists.",
        inputSchema = ToolSchema(
            properties = buildJsonObject {
                putJsonObject("title") { put("type", "string") }
                putJsonObject("body") { put("type", "string") }
            },
            required = listOf("title", "body"),
        ),
        toolAnnotations = ToolAnnotations(readOnlyHint = false, destructiveHint = false),
    ) { request ->
        val title = request.arguments?.get("title")?.jsonPrimitive?.content.orEmpty()
        val body = request.arguments?.get("body")?.jsonPrimitive?.content.orEmpty()
        val path = notePath(title)
        require(!path.exists()) { "A note called '$title' already exists." }
        path.writeText(body)
        CallToolResult(content = listOf(TextContent("Saved ${path.nameWithoutExtension}.")))
    }

    return server
}

inputSchema is a ToolSchema, built by hand. There is no reflection step from a Kotlin data class, unlike the type hints in the Python SDK or a zod object in TypeScript. You write the JSON Schema properties and required list yourself, and the description is what the model reads when it decides what to call.

notePath turns every title into a slug before it touches the disk. ../../etc/passwd becomes etc-passwd.md inside the notes folder. A model's tool arguments can carry whatever it just read from a document or an issue, so treat them like form input on a website.

require() becomes a readable error, not a crash. addTool's handler runs inside a try/catch that turns any thrown exception into CallToolResult(isError = true, ...), message "Error executing tool $name: ${e.message}". require() throws IllegalArgumentException, which that catch treats like any other exception, so the model gets your message back. There is no dedicated ToolError type to reach for, as there is in the Python SDK. A plain exception does the job.

Annotations are hints. readOnlyHint = true tells clients the tool changes nothing. It enforces nothing: a tool marked read-only can still delete files if its code does.

Now the entry point. Create src/main/kotlin/dev/statio/notes/Main.kt:

kotlin
package dev.statio.notes

import io.modelcontextprotocol.kotlin.sdk.server.StdioServerTransport
import kotlinx.coroutines.Job
import kotlinx.coroutines.runBlocking
import kotlinx.io.asSink
import kotlinx.io.asSource
import kotlinx.io.buffered

fun main() {
    // kotlin-logging prints a banner to stdout on first use, which breaks the
    // JSON-RPC stream on stdio. Silence it before anything touches a logger.
    System.setProperty("kotlin-logging.logStartupMessage", "false")

    val server = createNotesServer()
    val transport = StdioServerTransport(
        input = System.`in`.asSource().buffered(),
        output = System.out.asSink().buffered(),
    ) {}

    runBlocking {
        val session = server.createSession(transport)
        val done = Job()
        session.onClose { done.complete() }
        done.join()
    }
}

The SDK's own logging library writes to standard output once, by default. kotlin-sdk-server pulls in kotlin-logging, and on the first call to any logger it prints kotlin-logging: initializing... active logger factory: Slf4jLoggerFactory straight to stdout, not stderr, landing in the middle of the JSON-RPC stream on the stdio transport. The ReadBuffer is resilient enough to log the bad line and recover, but don't rely on that: set the kotlin-logging.logStartupMessage system property to false before the server starts, which is why it is the first line of main(). slf4j-nop from step 1 fixes the other half: without an SLF4J provider on the classpath, SLF4J prints three warning lines to stderr on every run.

Step 3: Test it with a client

Create src/main/kotlin/dev/statio/notes/Client.kt. Add implementation(libs.mcp.kotlin.client) to the dependencies block in build.gradle.kts first:

kotlin
package dev.statio.notes

import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
import kotlinx.coroutines.runBlocking
import kotlinx.io.asSink
import kotlinx.io.asSource
import kotlinx.io.buffered

fun main() = runBlocking {
    System.setProperty("kotlin-logging.logStartupMessage", "false")

    val process = ProcessBuilder(
        "java", "-jar", "build/libs/notes-server-0.1.0-all.jar",
    ).redirectError(ProcessBuilder.Redirect.INHERIT).start()

    val transport = StdioClientTransport(
        input = process.inputStream.asSource().buffered(),
        output = process.outputStream.asSink().buffered(),
    )

    val client = Client(clientInfo = Implementation(name = "notes-check", version = "1.0.0"))
    client.connect(transport)

    println(client.listTools().tools.map { it.name })

    client.callTool(name = "add_note", arguments = mapOf("title" to "Release checklist", "body" to "Tag, build, announce."))
    val listed = client.callTool(name = "list_notes", arguments = emptyMap())
    println(listed.content.map { if (it is TextContent) it.text else it.toString() })

    val note = client.callTool(name = "read_note", arguments = mapOf("title" to "release checklist"))
    println(note.content.map { if (it is TextContent) it.text else it.toString() })

    client.close()
    process.destroy()
}

Build the jar with the Shadow plugin's shadowJar task, then run the client from it:

bash
./gradlew shadowJar
java -cp build/libs/notes-server-0.1.0-all.jar dev.statio.notes.ClientKt

The jar holds every class in the project, so java -cp can start any of them by name. java -jar starts MainKt, the application.mainClass, which is how the client launches the server. The output:

text
[list_notes, read_note, add_note]
[[release-checklist]]
[Tag, build, announce.]

tools/list came back in registration order: list_notes, read_note, add_note. The client started the jar as a subprocess and talked to it over stdin and stdout, the same way Claude Code will. A raw initialize request offering an unsupported protocol version, 2099-01-01, got the server's current version, 2025-11-25, back instead of an error, the same fallback the Go, Rust and Swift SDKs use.

Step 4: Serve it over Streamable HTTP with Ktor

Create src/main/kotlin/dev/statio/notes/Http.kt. Add implementation(platform(libs.ktor.bom)), implementation(libs.ktor.server.cio) and implementation(libs.ktor.client.cio) to build.gradle.kts first. The last one is for the HTTP client below:

kotlin
package dev.statio.notes

import io.ktor.server.cio.CIO
import io.ktor.server.engine.embeddedServer
import io.modelcontextprotocol.kotlin.sdk.server.mcpStreamableHttp

fun main() {
    System.setProperty("kotlin-logging.logStartupMessage", "false")

    val port = 3000
    println("notes server on http://127.0.0.1:$port/mcp")
    embeddedServer(CIO, host = "127.0.0.1", port = port) {
        mcpStreamableHttp {
            createNotesServer()
        }
    }.start(wait = true)
}

mcpStreamableHttp mounts the protocol at /mcp on an embedded Ktor server, one Server and session per connecting client.

Next to it, create src/main/kotlin/dev/statio/notes/HttpClient.kt to check the failure paths. It reuses the note the stdio client saved in step 3, since both transports read the same notes folder:

kotlin
package dev.statio.notes

import io.ktor.client.HttpClient
import io.ktor.client.plugins.sse.SSE
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    System.setProperty("kotlin-logging.logStartupMessage", "false")

    val httpClient = HttpClient { install(SSE) }
    val client = Client(clientInfo = Implementation(name = "notes-check", version = "1.0.0"))
    val transport = StreamableHttpClientTransport(client = httpClient, url = "http://127.0.0.1:3000/mcp")
    client.connect(transport)

    println(client.listTools().tools.map { it.name })

    val dup = client.callTool(name = "add_note", arguments = mapOf("title" to "Release checklist", "body" to "again"))
    println("${dup.isError} ${(dup.content.first() as TextContent).text}")

    val odd = client.callTool(name = "read_note", arguments = mapOf("title" to "../../etc/passwd"))
    println("${odd.isError} ${(odd.content.first() as TextContent).text}")

    client.close()
}

Rebuild the jar and start the server:

bash
./gradlew shadowJar
java -cp build/libs/notes-server-0.1.0-all.jar dev.statio.notes.HttpKt

It listens on http://127.0.0.1:3000/mcp, and by default only answers requests whose Host or Origin header names localhost or a loopback address. That blocks DNS rebinding, where a page on another domain points itself at 127.0.0.1 to drive your local server from your browser. A forged Host is rejected before the request reaches any tool:

bash
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:3000/mcp \
  -H "Content-Type: application/json" -H "Host: evil.example" \
  -d '{"jsonrpc":"2.0","method":"initialize"}'
text
403

Leave the server running and run the client in a second terminal:

bash
java -cp build/libs/notes-server-0.1.0-all.jar dev.statio.notes.HttpClientKt
text
[list_notes, read_note, add_note]
true Error executing tool add_note: A note called 'Release checklist' already exists.
true Error executing tool read_note: No note called '../../etc/passwd'.

Both come back as isError = true with a readable message, the same shape as the stdio run, so the model sees what went wrong and can try something else. The path trying to climb out of the folder found nothing, because it was looked up as etc-passwd.md inside it.

The server binds to 127.0.0.1, so only your machine can reach it. Put authentication in front of it before it goes anywhere public, or it will run its tools for anyone who finds the URL.

Step 5: Add it to Claude Code

From the project folder, register the stdio version for this project:

bash
claude mcp add --scope project notes -- java -jar "$PWD/build/libs/notes-server-0.1.0-all.jar"

That writes a .mcp.json file in the folder, and claude mcp get notes shows it as pending approval. Claude Code asks before running a project server, because .mcp.json gets committed and cloning a repository shouldn't run a command on your machine. Start claude in the folder, approve it, and ask it to save a note.

To use the HTTP version instead, with the server from step 4 running:

bash
claude mcp add --scope project --transport http notes http://127.0.0.1:3000/mcp

Claude Code reads its servers when a session starts, so open a new session after adding one.

Step 6: Put it behind Statio

Statio is a gateway that grants tools one at a time, so an agent that looks things up can get list_notes and read_note without add_note. It checks the grant on every call and records a refused one, which MCP audit logs covers.

The setup is in Connect a client and Permissions, and MCP permissions compares the other ways to limit an agent's tools.

Conclusion

You now have a Kotlin MCP server running over stdio or Streamable HTTP and set up for Claude Code. addTool takes a JSON Schema you write by hand, and any exception a handler throws comes back as an error the model can read. On stdio, keep standard output for the protocol. The SDK's logging library prints its startup line there unless you turn it off, which is why every main() here sets kotlin-logging.logStartupMessage first.

Where to go next: