Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 81 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ fun main(args: Array<String>) = runBlocking {

Create an MCP server that exposes a simple tool and runs on an embedded Ktor server with SSE transport:

<!--- CLEAR -->
```kotlin
import io.ktor.server.cio.CIO
import io.ktor.server.engine.embeddedServer
Expand Down Expand Up @@ -761,14 +762,89 @@ CLI tooling that spawns a helper process. No networking setup is required.

### Streamable HTTP Transport

`StreamableHttpClientTransport` and the Ktor `streamableHttpApp()` helpers expose MCP over a single HTTP endpoint with
optional JSON-only or SSE streaming responses. This is the recommended choice for remote deployments and integrates
nicely with proxies or service meshes.
`StreamableHttpClientTransport` and the Ktor `mcpStreamableHttp()` / `mcpStatelessStreamableHttp()` helpers expose MCP
over a single HTTP endpoint with optional JSON-only or SSE streaming responses. This is the recommended choice for
remote deployments and integrates nicely with proxies or service meshes. Both accept a `path` parameter (default:
`"/mcp"`) to mount the endpoint at any URL:

<!--- CLEAR -->
<!--- INCLUDE
import io.ktor.server.cio.CIO
import io.ktor.server.engine.embeddedServer
import io.modelcontextprotocol.kotlin.sdk.server.Server
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
import io.modelcontextprotocol.kotlin.sdk.server.mcpStreamableHttp
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities

private class MyServer :
Server(
serverInfo = Implementation(name = "ExampleServer", version = "1.0"),
options = ServerOptions(capabilities = ServerCapabilities()),
)

fun main() {
-->
```kotlin
embeddedServer(CIO, port = 3000) {
mcpStreamableHttp(path = "/api/mcp") {
MyServer()
}
}.start(wait = true)
```
<!--- SUFFIX
}
-->
<!--- KNIT example-server-routes-01.kt -->

### SSE Transport

Server-Sent Events remain available for backwards compatibility with older MCP clients. Use `SseServerTransport` or the
SSE Ktor plugin when you need drop-in compatibility, but prefer Streamable HTTP for new projects.
Server-Sent Events remain available for backwards compatibility with older MCP clients. Two Ktor helpers are provided:

- **`Application.mcp { }`** — installs the SSE plugin automatically and registers MCP endpoints at `/`.
- **`Route.mcp { }`** — registers MCP endpoints at the current route path; requires `install(SSE)` in the application
first. Use this to host MCP alongside other routes or under a path prefix:

<!--- CLEAR -->
<!--- INCLUDE
import io.ktor.server.application.install
import io.ktor.server.cio.CIO
import io.ktor.server.engine.embeddedServer
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.route
import io.ktor.server.routing.routing
import io.ktor.server.sse.SSE
import io.modelcontextprotocol.kotlin.sdk.server.Server
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
import io.modelcontextprotocol.kotlin.sdk.server.mcp
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities

private class MyServer :
Server(
serverInfo = Implementation(name = "ExampleServer", version = "1.0"),
options = ServerOptions(capabilities = ServerCapabilities()),
)

fun main() {
-->
```kotlin
embeddedServer(CIO, port = 3000) {
install(SSE)
routing {
route("/api/mcp") {
mcp { MyServer() }
}
}
}.start(wait = true)
```
<!--- SUFFIX
}
-->
<!--- KNIT example-server-routes-02.kt -->

Prefer Streamable HTTP for new projects.

### WebSocket Transport

Expand Down
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", v
awaitility = { group = "org.awaitility", name = "awaitility-kotlin", version.ref = "awaitility" }
kotest-assertions-core = { group = "io.kotest", name = "kotest-assertions-core", version.ref = "kotest" }
kotest-assertions-json = { group = "io.kotest", name = "kotest-assertions-json", version.ref = "kotest" }
kotest-assertions-ktor = { group = "io.kotest", name = "kotest-assertions-ktor", version.ref = "kotest" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
ktor-client-mock = { group = "io.ktor", name = "ktor-client-mock", version.ref = "ktor" }
ktor-server-test-host = { group = "io.ktor", name = "ktor-server-test-host", version.ref = "ktor" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,6 @@ import io.ktor.server.application.install
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.route
import io.ktor.server.routing.routing
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.client.SseClientTransport
Expand All @@ -22,6 +18,7 @@ import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
import io.modelcontextprotocol.kotlin.sdk.server.StdioServerTransport
import io.modelcontextprotocol.kotlin.sdk.server.StreamableHttpServerTransport
import io.modelcontextprotocol.kotlin.sdk.server.mcp
import io.modelcontextprotocol.kotlin.sdk.server.mcpStreamableHttp
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.McpJson
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
Expand Down Expand Up @@ -54,6 +51,7 @@ abstract class KotlinTestBase {

// Transport selection
protected enum class TransportKind { SSE, STDIO, STREAMABLE_HTTP }

protected open val transportKind: TransportKind = TransportKind.STDIO

// STDIO-specific fields
Expand Down Expand Up @@ -168,19 +166,7 @@ abstract class KotlinTestBase {
install(ContentNegotiation) {
json(McpJson)
}
routing {
route("/mcp") {
post {
transport.handlePostRequest(null, call)
}
get {
transport.handleGetRequest(null, call)
}
delete {
transport.handleDeleteRequest(call)
}
}
}
mcpStreamableHttp(path = "/mcp") { server }
}.start(wait = false)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import kotlin.time.Duration
* @param urlString Optional URL of the MCP server.
* @param reconnectionTime Optional duration to wait before attempting to reconnect.
* @param requestBuilder Optional lambda to configure the HTTP request.
* @return A [SSEClientTransport] configured for MCP communication.
* @return A [SseClientTransport] configured for MCP communication.
*/
public fun HttpClient.mcpSseTransport(
urlString: String? = null,
Expand Down
2 changes: 1 addition & 1 deletion kotlin-sdk-server/Module.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ that make it straightforward to expose tools, prompts, and resources to MCP clie
listeners emit list-changed notifications when the capability is enabled.
- **Transports**: Ready-to-use transports for STDIO (CLI-style hosting), Ktor Server-Sent Events (SSE) with POST
back-channel, and Ktor WebSocket. All share the common `Transport` abstraction from the SDK.
- **Ktor integration**: Extension functions (`Routing.mcp`, `Routing.mcpWebSocket`, and `Application` variants) that
- **Ktor integration**: Extension functions (`Route.mcp`, `Route.mcpWebSocket`, and `Application` variants) that
plug MCP into an existing Ktor server with minimal boilerplate.
- **Notifications**: Built-in support for resource subscription, resource-updated events, and feature list change
notifications when capabilities are set accordingly.
Expand Down
15 changes: 7 additions & 8 deletions kotlin-sdk-server/api/kotlin-sdk-server.api
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ public abstract interface class io/modelcontextprotocol/kotlin/sdk/server/EventS

public final class io/modelcontextprotocol/kotlin/sdk/server/KtorServerKt {
public static final fun mcp (Lio/ktor/server/application/Application;Lkotlin/jvm/functions/Function1;)V
public static final fun mcp (Lio/ktor/server/routing/Routing;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V
public static final fun mcp (Lio/ktor/server/routing/Routing;Lkotlin/jvm/functions/Function1;)V
public static final fun mcpStatelessStreamableHttp (Lio/ktor/server/application/Application;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;)V
public static synthetic fun mcpStatelessStreamableHttp$default (Lio/ktor/server/application/Application;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
public static final fun mcpStreamableHttp (Lio/ktor/server/application/Application;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;)V
public static synthetic fun mcpStreamableHttp$default (Lio/ktor/server/application/Application;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
public static final fun mcp (Lio/ktor/server/routing/Route;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V
public static final fun mcp (Lio/ktor/server/routing/Route;Lkotlin/jvm/functions/Function1;)V
public static final fun mcpStatelessStreamableHttp (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;)V
public static synthetic fun mcpStatelessStreamableHttp$default (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
public static final fun mcpStreamableHttp (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;)V
public static synthetic fun mcpStreamableHttp$default (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
}

public final class io/modelcontextprotocol/kotlin/sdk/server/RegisteredPrompt : io/modelcontextprotocol/kotlin/sdk/server/Feature {
Expand Down Expand Up @@ -185,9 +185,8 @@ public final class io/modelcontextprotocol/kotlin/sdk/server/WebSocketMcpKtorSer
public static final fun mcpWebSocket (Lio/ktor/server/application/Application;Lkotlin/jvm/functions/Function0;)V
public static final fun mcpWebSocket (Lio/ktor/server/routing/Route;Lio/modelcontextprotocol/kotlin/sdk/server/ServerOptions;Lkotlin/jvm/functions/Function2;)V
public static final fun mcpWebSocket (Lio/ktor/server/routing/Route;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/server/ServerOptions;Lkotlin/jvm/functions/Function2;)V
public static final fun mcpWebSocket (Lio/ktor/server/routing/Route;Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V
public static final fun mcpWebSocket (Lio/ktor/server/routing/Route;Lkotlin/jvm/functions/Function0;)V
public static final fun mcpWebSocket (Lio/ktor/server/routing/Routing;Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V
public static final fun mcpWebSocket (Lio/ktor/server/routing/Routing;Lkotlin/jvm/functions/Function0;)V
public static synthetic fun mcpWebSocket$default (Lio/ktor/server/routing/Route;Lio/modelcontextprotocol/kotlin/sdk/server/ServerOptions;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
public static synthetic fun mcpWebSocket$default (Lio/ktor/server/routing/Route;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/server/ServerOptions;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
public static final fun mcpWebSocketTransport (Lio/ktor/server/routing/Route;Ljava/lang/String;Lkotlin/jvm/functions/Function2;)V
Expand Down
10 changes: 6 additions & 4 deletions kotlin-sdk-server/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,21 @@ kotlin {
commonTest {
dependencies {
implementation(kotlin("test"))
implementation(libs.kotest.assertions.core)
implementation(libs.kotest.assertions.json)
implementation(libs.kotlinx.coroutines.test)
}
}

jvmTest {
dependencies {
implementation(libs.ktor.client.logging)
implementation(libs.ktor.server.content.negotiation)
implementation(libs.kotest.assertions.ktor)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.logging)
implementation(libs.ktor.serialization)
implementation(libs.ktor.server.content.negotiation)
implementation(libs.ktor.server.test.host)
implementation(libs.kotest.assertions.core)
implementation(libs.kotest.assertions.json)

runtimeOnly(libs.slf4j.simple)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.MissingApplicationPluginException
import io.ktor.server.application.install
import io.ktor.server.request.ApplicationRequest
import io.ktor.server.request.header
import io.ktor.server.response.respond
import io.ktor.server.routing.Routing
import io.ktor.server.routing.Route
import io.ktor.server.routing.RoutingContext
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
Expand Down Expand Up @@ -53,17 +54,33 @@ internal class TransportManager(transports: Map<String, AbstractTransport> = emp
* @param block the block of code that defines the server's behavior for the SSE session.
*/
@KtorDsl
public fun Routing.mcp(path: String, block: ServerSSESession.() -> Server) {
public fun Route.mcp(path: String, block: ServerSSESession.() -> Server) {
route(path) {
mcp(block)
}
}

/**
* Configures the Ktor Application to handle Model Context Protocol (MCP) over Server-Sent Events (SSE).
*/
* Configures the Ktor Application to handle Model Context Protocol (MCP) over Server-Sent Events (SSE).
*
* **Precondition:** the [SSE] plugin must be installed on the application before calling this function.
* Use [Application.mcp] if you want SSE to be installed automatically.
*
* @throws IllegalStateException if the [SSE] plugin is not installed.
*/
@KtorDsl
public fun Routing.mcp(block: ServerSSESession.() -> Server) {
public fun Route.mcp(block: ServerSSESession.() -> Server) {
try {
plugin(SSE)
} catch (e: MissingApplicationPluginException) {
throw IllegalStateException(
"The SSE plugin must be installed before registering MCP routes. " +
"Add `install(SSE)` to your application configuration, " +
"or use Application.mcp() which installs it automatically.",
e,
)
}

val transportManager = TransportManager()

sse {
Expand All @@ -85,7 +102,9 @@ public fun Application.mcp(block: ServerSSESession.() -> Server) {
}

@KtorDsl
@Suppress("LongParameterList")
public fun Application.mcpStreamableHttp(
path: String = "/mcp",
enableDnsRebindingProtection: Boolean = false,
allowedHosts: List<String>? = null,
allowedOrigins: List<String>? = null,
Expand All @@ -97,7 +116,7 @@ public fun Application.mcpStreamableHttp(
val transportManager = TransportManager()

routing {
route("/mcp") {
route(path) {
sse {
val transport = existingStreamableTransport(call, transportManager) ?: return@sse
transport.handleRequest(this, call)
Expand Down Expand Up @@ -126,7 +145,9 @@ public fun Application.mcpStreamableHttp(
}

@KtorDsl
@Suppress("LongParameterList")
public fun Application.mcpStatelessStreamableHttp(
path: String = "/mcp",
enableDnsRebindingProtection: Boolean = false,
allowedHosts: List<String>? = null,
allowedOrigins: List<String>? = null,
Expand All @@ -136,7 +157,7 @@ public fun Application.mcpStatelessStreamableHttp(
install(SSE)

routing {
route("/mcp") {
route(path) {
post {
mcpStatelessStreamableHttpEndpoint(
enableDnsRebindingProtection = enableDnsRebindingProtection,
Expand Down Expand Up @@ -164,7 +185,7 @@ public fun Application.mcpStatelessStreamableHttp(
}
}

internal suspend fun ServerSSESession.mcpSseEndpoint(
private suspend fun ServerSSESession.mcpSseEndpoint(
postEndpoint: String,
transportManager: TransportManager,
block: ServerSSESession.() -> Server,
Expand All @@ -185,7 +206,7 @@ internal suspend fun ServerSSESession.mcpSseEndpoint(
awaitCancellation()
}

internal fun ServerSSESession.mcpSseTransport(
private fun ServerSSESession.mcpSseTransport(
postEndpoint: String,
transportManager: TransportManager,
): SseServerTransport {
Expand All @@ -196,7 +217,7 @@ internal fun ServerSSESession.mcpSseTransport(
return transport
}

internal suspend fun RoutingContext.mcpStatelessStreamableHttpEndpoint(
private suspend fun RoutingContext.mcpStatelessStreamableHttpEndpoint(
enableDnsRebindingProtection: Boolean = false,
allowedHosts: List<String>? = null,
allowedOrigins: List<String>? = null,
Expand All @@ -221,7 +242,7 @@ internal suspend fun RoutingContext.mcpStatelessStreamableHttpEndpoint(
logger.debug { "Server connected to transport without sessionId" }
}

internal suspend fun RoutingContext.mcpPostEndpoint(transportManager: TransportManager) {
private suspend fun RoutingContext.mcpPostEndpoint(transportManager: TransportManager) {
val sessionId: String = call.request.queryParameters["sessionId"] ?: run {
call.respond(HttpStatusCode.BadRequest, "sessionId query parameter is not provided")
return
Expand Down
Loading
Loading