fix!: Migrate Ktor extension functions from Routing to Route (#237) (#531)

# Migrate Ktor extension functions from `Routing` to `Route` (#237)

- Replaced `Routing` with `Route` in MCP-related functions for better
composability in nested contexts.
- Updated tests to verify compatibility with nested routes and subpaths.
- Deprecated legacy signatures and removed redundant overloads.

## How Has This Been Tested?
Unit test

## Breaking Changes
`Routing.mcp(...) `extension was replaced with `Route.mcp(...)`

## Types of changes

- [X] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [x] 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. -->
- [x] I have read the [MCP
Documentation](https://modelcontextprotocol.io)
- [x] 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
Based on #314 by @IainHull but that PR is too outdated.
This commit is contained in:
Konstantin Pavlov 2026-02-18 16:43:37 +02:00 • committed by GitHub
commit bb79e200c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 373 additions and 70 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
@ -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 {
@ -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,
@ -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)
@ -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,
@ -136,7 +157,7 @@ public fun Application.mcpStatelessStreamableHttp(
install(SSE)
routing {
route("/mcp") {
route(path) {
post {
mcpStatelessStreamableHttpEndpoint(
enableDnsRebindingProtection = enableDnsRebindingProtection,
@ -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,
@ -185,7 +206,7 @@ internal suspend fun ServerSSESession.mcpSseEndpoint(
awaitCancellation()
}
internal fun ServerSSESession.mcpSseTransport(
private fun ServerSSESession.mcpSseTransport(
postEndpoint: String,
transportManager: TransportManager,
): SseServerTransport {
@ -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,
@ -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

View file

@ -6,7 +6,6 @@ import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.routing.Route
import io.ktor.server.routing.Routing
import io.ktor.server.routing.routing
import io.ktor.server.websocket.WebSocketServerSession
import io.ktor.server.websocket.WebSockets
@ -25,7 +24,7 @@ private val logger = KotlinLogging.logger {}
* Configures the Ktor Application to handle Model Context Protocol (MCP) over WebSocket.
*/
@KtorDsl
public fun Routing.mcpWebSocket(block: () -> Server) {
public fun Route.mcpWebSocket(block: () -> Server) {
webSocket {
mcpWebSocketEndpoint(block)
}
@ -35,7 +34,7 @@ public fun Routing.mcpWebSocket(block: () -> Server) {
* Configures the Ktor Application to handle Model Context Protocol (MCP) over WebSocket.
*/
@KtorDsl
public fun Routing.mcpWebSocket(path: String, block: () -> Server) {
public fun Route.mcpWebSocket(path: String, block: () -> Server) {
webSocket(path) {
mcpWebSocketEndpoint(block)
}
@ -89,7 +88,7 @@ private fun createMcpTransport(webSocketSession: WebSocketServerSession): WebSoc
*/
@Deprecated(
"Use mcpWebSocket with a lambda that returns a Server instance instead",
ReplaceWith("Routing.mcpWebSocket"),
ReplaceWith(""),
DeprecationLevel.WARNING,
)
public fun Route.mcpWebSocket(options: ServerOptions? = null, handler: suspend Server.() -> Unit = {}) {
@ -98,17 +97,6 @@ public fun Route.mcpWebSocket(options: ServerOptions? = null, handler: suspend S
}
}
@Deprecated(
"Use mcpWebSocket with a lambda that returns a Server instance instead",
ReplaceWith("Routing.mcpWebSocket"),
DeprecationLevel.WARNING,
)
public fun Route.mcpWebSocket(block: () -> Server) {
webSocket {
block().createSession(createMcpTransport(this))
}
}
/**
* Registers a WebSocket route at the specified [path] that establishes an MCP server session.
*
@ -118,7 +106,7 @@ public fun Route.mcpWebSocket(block: () -> Server) {
*/
@Deprecated(
"Use mcpWebSocket with a path and a lambda that returns a Server instance instead",
ReplaceWith("Routing.mcpWebSocket"),
ReplaceWith(""),
DeprecationLevel.WARNING,
)
public fun Route.mcpWebSocket(path: String, options: ServerOptions? = null, handler: suspend Server.() -> Unit = {}) {
@ -134,7 +122,7 @@ public fun Route.mcpWebSocket(path: String, options: ServerOptions? = null, hand
*/
@Deprecated(
"Use mcpWebSocket with a lambda that returns a Server instance instead",
ReplaceWith("Routing.mcpWebSocket"),
ReplaceWith(""),
DeprecationLevel.WARNING,
)
public fun Route.mcpWebSocketTransport(handler: suspend WebSocketMcpServerTransport.() -> Unit = {}) {
@ -166,12 +154,7 @@ public fun Route.mcpWebSocketTransport(path: String, handler: suspend WebSocketM
}
}
@Deprecated(
"Use mcpWebSocket with a lambda that returns a Server instance instead",
ReplaceWith("mcpWebSocket"),
DeprecationLevel.WARNING,
)
private suspend fun Route.createMcpServer(
private suspend fun createMcpServer(
session: WebSocketServerSession,
options: ServerOptions?,
handler: suspend Server.() -> Unit,

View file

@ -0,0 +1,68 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.kotest.assertions.ktor.client.shouldHaveContentType
import io.kotest.assertions.ktor.client.shouldHaveStatus
import io.ktor.client.HttpClient
import io.ktor.client.request.post
import io.ktor.client.request.prepareGet
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.utils.io.readUTF8Line
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
@Suppress("AbstractClassCanBeConcreteClass")
abstract class AbstractKtorExtensionsTest {
protected val sseContentType = ContentType("text", "event-stream")
protected fun testServer() = Server(
serverInfo = Implementation(name = "test-server", version = "1.0.0"),
options = ServerOptions(capabilities = ServerCapabilities()),
)
/**
* Asserts that both MCP transport endpoints are registered at [path]:
* - GET returns 200 with `text/event-stream` content type (SSE endpoint)
* - POST with a valid MCP payload and session returns 202 Accepted
* - POST without a sessionId returns 400 Bad Request
*/
protected suspend fun HttpClient.assertMcpEndpointsAt(path: String) {
prepareGet(path).execute { response ->
response.shouldHaveStatus(HttpStatusCode.OK)
response.shouldHaveContentType(sseContentType)
// Extract sessionId from the SSE "endpoint" event
val channel = response.bodyAsChannel()
var eventName: String? = null
var sessionId: String? = null
while (sessionId == null && !channel.isClosedForRead) {
val line = channel.readUTF8Line() ?: break
when {
line.startsWith("event:") -> eventName = line.substringAfter("event:").trim()
line.startsWith("data:") && eventName == "endpoint" -> {
val data = line.substringAfter("data:").trim()
sessionId = data.substringAfter("sessionId=").ifEmpty { null }
}
}
}
requireNotNull(sessionId) { "sessionId not found in SSE endpoint event" }
// POST a valid JSON-RPC ping while the SSE connection is alive
val postResponse = post("$path?sessionId=$sessionId") {
contentType(ContentType.Application.Json)
setBody("""{"jsonrpc":"2.0","id":1,"method":"ping"}""")
}
postResponse.shouldHaveStatus(HttpStatusCode.Accepted)
}
// POST without sessionId returns 400 Bad Request
post(path).shouldHaveStatus(HttpStatusCode.BadRequest)
}
}

View file

@ -0,0 +1,42 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.kotest.assertions.ktor.client.shouldHaveStatus
import io.kotest.matchers.shouldBe
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import kotlin.test.Test
/**
* Integration tests for Ktor Application.mcp() extension.
*
* Verifies that Application.mcp() installs SSE automatically and registers
* MCP endpoints at the application root, without requiring explicit install(SSE).
*/
class KtorApplicationExtensionsTest : AbstractKtorExtensionsTest() {
/**
* Verifies that Application.mcp() does not interfere with other routes
* added to the same application.
*/
@Test
fun `Application mcp should installs SSE and coexist with other routes`() = testApplication {
application {
mcp { testServer() }
routing {
get("/health") { call.respondText("healthy") }
}
}
val healthResponse = client.get("/health")
healthResponse.shouldHaveStatus(HttpStatusCode.OK)
healthResponse.bodyAsText() shouldBe "healthy"
client.assertMcpEndpointsAt("/")
}
}

View file

@ -0,0 +1,125 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.kotest.assertions.ktor.client.shouldHaveStatus
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.install
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.ktor.server.testing.testApplication
import kotlin.test.Test
import kotlin.test.assertFailsWith
/**
* Integration tests for Ktor Route.mcp() extensions.
*
* Verifies issue #237: Route.mcp() should work on subpaths and with middleware.
* The key issue was that Routing.mcp() registered at top-level, preventing use on subpaths.
* Now Route.mcp() allows registration on any route path.
*/
class KtorRouteExtensionsTest : AbstractKtorExtensionsTest() {
/**
* Verifies that Route.mcp() throws immediately at route registration time
* when the SSE plugin has not been installed, rather than failing silently on the first request.
*/
@Test
fun `Route mcp should throw at registration time if SSE plugin is not installed`() {
val exception = assertFailsWith<IllegalStateException> {
testApplication {
application {
// Intentionally omit install(SSE)
routing {
mcp { testServer() }
}
}
client.get("/")
}
}
exception.message shouldContain "SSE"
exception.message shouldContain "install"
}
/**
* Verifies that Route.mcp() registers SSE and POST endpoints on the subpath,
* not at root, and does not disturb sibling routes.
*/
@Test
fun `Route mcp should register SSE and POST endpoints at the given subpath`() = testApplication {
application {
install(SSE)
routing {
get("/") { call.respondText("root") }
route("/api/mcp") {
get("/test") { call.respondText("test-endpoint") }
mcp { testServer() }
}
}
}
// Sibling routes are unaffected
val rootResponse = client.get("/")
rootResponse.shouldHaveStatus(HttpStatusCode.OK)
rootResponse.bodyAsText() shouldBe "root"
val testResponse = client.get("/api/mcp/test")
testResponse.shouldHaveStatus(HttpStatusCode.OK)
testResponse.bodyAsText() shouldBe "test-endpoint"
client.assertMcpEndpointsAt("/api/mcp")
}
/**
* Verifies that Route.mcp() registers endpoints at the correct fully-qualified
* path when nested inside multiple route() calls.
*/
@Test
fun `Route mcp should register endpoints at the full nested path`() = testApplication {
application {
install(SSE)
routing {
route("/v1") {
route("/services") {
route("/mcp") {
mcp { testServer() }
}
}
}
}
}
client.assertMcpEndpointsAt("/v1/services/mcp")
}
/**
* Verifies that mcp(path) registers SSE and POST endpoints at the sub-path
* relative to the enclosing route, not at the root.
*/
@Test
fun `Route mcp with path parameter should register endpoints at the resolved path`() = testApplication {
application {
install(SSE)
routing {
route("/api") {
mcp("/mcp-endpoint") { testServer() }
}
}
}
client.assertMcpEndpointsAt("/api/mcp-endpoint")
// Root /api is not an MCP endpoint
client.post("/api").shouldHaveStatus(HttpStatusCode.NotFound)
}
}