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
5 changed files with 373 additions and 70 deletions

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