
This tutorial is part of Build an MCP Server, within The MCP Guide. Want it built for you? See MCP development.
You can build an MCP server in Kotlin with the official Kotlin SDK, published on Maven Central as io.modelcontextprotocol:kotlin-sdk. You create a Server, register tools with addTool, and serve it over stdio or over HTTP with Ktor. The same code can target the JVM, Native, JS and Wasm.
This guide builds a small stdio server, adds a tool, tests it with MCP Inspector, connects it to Claude, then moves it to Streamable HTTP. It also flags what the SDK's Tier 3 status and protocol version mean for production use.
Checked against: the Kotlin SDK README, the 0.16.0 release notes and the Kotlin tab of the official Build an MCP server tutorial, on 2 November 2026, for io.modelcontextprotocol:kotlin-sdk 0.16.0. eSEOspace did not compile or run this Kotlin code; no JDK was available.
Key Takeaways
- The official Kotlin SDK is Tier 3 on the official SDK list as of November 2026, so plan for API changes between releases.
- Version 0.16.0 (27 October 2026) is the latest on GitHub and Maven Central; its newest protocol version is 2025-11-25.
- Clients that support only the 2026-07-28 spec cannot connect to it yet, per the official tutorial.
- Use
StdioServerTransportfor local servers and the KtormcpStreamableHttp()helper for remote ones; SSE is deprecated. - Never use
println()in a stdio server; log to stderr or a file. - 0.16.0 has breaking changes, including idle session expiry and renamed stdio arguments.
Prerequisites
- JDK 11 or higher. The tutorial lists it, and the README badge says "JVM 11+".
- Gradle, or IntelliJ IDEA's Kotlin project wizard.
- Node.js, to run MCP Inspector with
npx. - A client such as Claude Desktop or Claude Code.
New to MCP servers? Read how to build your first MCP server for the concepts, then return here.
Step 1: Set up Gradle
Create a project with gradle init and choose Application and Kotlin. Then use a build.gradle.kts like the one in the official tutorial:
val mcpVersion = "0.16.0"
val ktorVersion = "3.2.3"
val slf4jVersion = "2.0.17"
plugins {
kotlin("jvm") version "2.3.20"
kotlin("plugin.serialization") version "2.3.20"
id("com.gradleup.shadow") version "8.3.9"
application
}
repositories {
mavenCentral()
}
application {
mainClass.set("MainKt")
}
dependencies {
implementation("io.modelcontextprotocol:kotlin-sdk:$mcpVersion")
implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
implementation("io.ktor:ktor-client-cio:$ktorVersion")
implementation("org.slf4j:slf4j-simple:$slf4jVersion")
}
The tutorial's weather server needs the Ktor client lines to call an outside API. A server with no outbound HTTP can drop them.
The README lists three artifacts. kotlin-sdk is the umbrella package with client and server APIs. kotlin-sdk-server and kotlin-sdk-client hold one side each. It also notes that the SDK "does not add Ktor engine dependencies transitively," so you declare the engine you want, such as ktor-server-netty or CIO.
Step 2: Write a stdio server with one tool
This bookshop server follows the stdio setup in the official tutorial and the addTool pattern in the README:
import io.ktor.utils.io.streams.asInput
import io.modelcontextprotocol.kotlin.sdk.server.Server
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
import io.modelcontextprotocol.kotlin.sdk.server.StdioServerTransport
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.ToolSchema
import kotlinx.coroutines.Job
import kotlinx.coroutines.runBlocking
import kotlinx.io.asSink
import kotlinx.io.buffered
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
val catalog = mapOf("Dune" to "Frank Herbert", "Neuromancer" to "William Gibson")
fun main() {
val server = Server(
Implementation(name = "bookshop", version = "1.0.0"),
ServerOptions(
capabilities = ServerCapabilities(tools = ServerCapabilities.Tools(listChanged = true)),
),
)
server.addTool(
name = "search_books",
description = "Search the catalog by title or author. Returns matching titles.",
inputSchema = ToolSchema(
properties = buildJsonObject {
putJsonObject("query") {
put("type", "string")
put("description", "Part of a title or author name")
}
},
required = listOf("query"),
),
) { request ->
val query = request.arguments?.get("query")?.jsonPrimitive?.content?.lowercase()
?: return@addTool CallToolResult(content = listOf(TextContent("The 'query' argument is required.")))
val hits = catalog.filter { (title, author) ->
title.lowercase().contains(query) || author.lowercase().contains(query)
}.keys
CallToolResult(content = listOf(TextContent(hits.joinToString("\n").ifEmpty { "No matches." })))
}
val transport = StdioServerTransport(
System.`in`.asInput(),
System.out.asSink().buffered(),
)
runBlocking {
val session = server.createSession(transport)
val done = Job()
session.onClose { done.complete() }
done.join()
}
}
What each part does:
Server(Implementation(...), ServerOptions(...))creates the server and declares that it offers tools.addTooltakes a name, a description, aToolSchemaand a handler. The handler receives the request and returns aCallToolResult.ToolSchemaholds JSON Schema properties built with kotlinx.serialization'sbuildJsonObject. Thedescriptionstrings are all the model knows about each argument.StdioServerTransportreads stdin and writes stdout. The tutorial passes the two streams positionally, as above.createSessionstarts the session. TheJobkeepsmainalive until the client closes the connection.
The README notes that listChanged = true should only be set "if your tool catalog can change at runtime and your server will emit notifications/tools/list_changed when it does." For a fixed set of tools, you can leave it out.
Step 3: Add more tools
Call addTool again for each tool. Keep each one focused on a single job with clear argument names. The README adds that long-running tools can report progress through the request context, and tools can trigger sampling when they need the client's model.
To report a problem the model should see, return a CallToolResult with a helpful text message, as the tutorial does for missing arguments. That gives the model a chance to fix its call and try again.
Step 4: Test with MCP Inspector
Build a runnable JAR with ./gradlew build (the tutorial's shadow plugin produces a -all.jar). Then use the MCP Inspector CLI:
npx @modelcontextprotocol/inspector --cli java -jar build/libs/bookshop-all.jar \
--protocol-era legacy --method tools/list
npx @modelcontextprotocol/inspector --cli java -jar build/libs/bookshop-all.jar \
--protocol-era legacy --method tools/call \
--tool-name search_books --tool-arg query=dune
The --protocol-era legacy flag tells the Inspector to use the initialize-based handshake this SDK speaks. Run npx @modelcontextprotocol/inspector alone for the web UI. Our lesson on testing with MCP Inspector walks through it.
Step 5: Connect it to a client
For Claude Desktop, add the server under mcpServers in claude_desktop_config.json (macOS: ~/Library/Application Support/Claude/; Windows: %AppData%\Claude\):
{
"mcpServers": {
"bookshop": {
"command": "java",
"args": ["-jar", "/ABSOLUTE/PATH/TO/build/libs/bookshop-all.jar"]
}
}
}
Restart Claude Desktop after saving. The MCP config generator produces the same block for other clients.
Step 6: Serve it over Streamable HTTP with Ktor
For a remote server, the README's quickstart mounts the same Server on an embedded Ktor server:
import io.ktor.server.cio.CIO
import io.ktor.server.engine.embeddedServer
import io.modelcontextprotocol.kotlin.sdk.server.mcpStreamableHttp
fun main() {
val server = createBookshopServer() // the Server and addTool calls from step 2
embeddedServer(CIO, host = "127.0.0.1", port = 3000) {
mcpStreamableHttp {
server
}
}.start(wait = true)
}
The endpoint defaults to /mcp. Add a Ktor server engine to your dependencies first; this example imports CIO, so it needs io.ktor:ktor-server-cio. The README lists two helpers: mcpStreamableHttp() keeps sessions, and mcpStatelessStreamableHttp() does not. Both install ContentNegotiation for you, so do not install it yourself.
Connect from Claude Code with the form the README shows, claude mcp add --transport http <name> <url>, using your server's full URL, for example http://localhost:3000/mcp. If you connect from a browser-based client such as the Inspector web UI, the README says to install Ktor's CORS plugin and allow the Mcp-Session-Id and Mcp-Protocol-Version headers.
For production hosting choices, see our lesson on deploying a remote MCP server.
What changed in 0.16.0
The 0.16.0 release notes list breaking changes worth knowing before you copy older examples:
mcpStreamableHttpsessions now expire aftersessionIdleTimeout(30 minutes by default) and are capped bymaxSessions(10,000 by default). Clients of an expired session get404 Not Found.- A
GETwithout anMcp-Session-Idheader now gets400 Bad Request, so point health checks at a separate route. - APIs deprecated in 0.9.0 to 0.13.0 no longer compile.
- The named arguments of
StdioServerTransportchanged frominputStream/outputStreamtoinput/output. Positional calls still compile. - The SSE transport is deprecated in favor of Streamable HTTP.
- New experimental types for the 2026-07-28
server/discoverrequest arrived, but the notes say "the client and server do not use them yet."
Common pitfalls
- Printing to stdout. The official tutorial says never use
println()in a stdio server. Use a logger that writes to stderr or a file. - Expecting 2026-07-28 support. The SDK's newest protocol version is 2025-11-25 in 0.16.0. The tutorial says clients that support only 2026-07-28 cannot connect.
- Missing Ktor engine. The SDK does not pull in a Ktor engine. Add one explicitly.
- Old SSE examples.
Application.mcpandSseServerTransportare deprecated. UsemcpStreamableHttp. - Pinning loosely on a Tier 3 SDK. Tier 3 carries no stability promise. Pin an exact version and read release notes before upgrading.
- Relative paths in client config. Use the absolute path to your JAR.
Next step
The free MCP course covers resources, prompts and OAuth step by step. If you need a Kotlin or JVM server built for your product, our MCP development team can help. To compare Kotlin with Java and the other languages, see the official MCP SDKs overview and our Java MCP server guide.
More in the MCP Guide
Free tools: server.json Validator · MCP Error Code Lookup
Latest research: State of MCP: October 2026
Frequently asked questions
Is the Kotlin MCP SDK official?
Yes. It lives in the modelcontextprotocol GitHub organization and is listed on the official SDK page. As of November 2026 it is Tier 3, which means no minimum conformance score and no response-time commitments.
Can I use the Kotlin SDK outside the JVM?
Yes. The README says it targets JVM, Native, JS and Wasm, and you can add it to commonMain in a Kotlin Multiplatform project. A client or server still needs a transport that works on that platform.
Does the Kotlin SDK support the 2026-07-28 spec?
Not yet as of 0.16.0. Its newest supported protocol version is 2025-11-25. The release added experimental types for the new server/discover request, but the server does not use them.
Should I use the Kotlin SDK or the Java SDK on the JVM?
The Java SDK is Tier 2 and has a preview 2026-07-28 server; the Kotlin SDK is Tier 3 with coroutine-friendly APIs and Ktor helpers. Pick by your stack and how much API churn you can absorb.
Why does my Kotlin server work with ./gradlew run but not in Claude Desktop?
Gradle may print to stdout, and any stray output breaks the JSON-RPC stream. Point the client at the built JAR with java -jar and an absolute path instead.











