# 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
12 changed files with 373 additions and 70 deletions
86
README.md
86
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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