# 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:
parent
b2878ed406
commit
bb79e200c4
5 changed files with 373 additions and 70 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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("/")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue