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

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