diff --git a/README.md b/README.md index 7df2be2..50beb54 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,7 @@ fun main(args: Array) = runBlocking { Create an MCP server that exposes a simple tool and runs on an embedded Ktor server with SSE transport: + ```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: + + + +```kotlin +embeddedServer(CIO, port = 3000) { + mcpStreamableHttp(path = "/api/mcp") { + MyServer() + } +}.start(wait = true) +``` + + ### 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: + + + +```kotlin +embeddedServer(CIO, port = 3000) { + install(SSE) + routing { + route("/api/mcp") { + mcp { MyServer() } + } + } +}.start(wait = true) +``` + + + +Prefer Streamable HTTP for new projects. ### WebSocket Transport diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 460b825..08bf61e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/KotlinTestBase.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/KotlinTestBase.kt index 1a8ffe0..f8e8740 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/KotlinTestBase.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/KotlinTestBase.kt @@ -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) } diff --git a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/KtorClient.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/KtorClient.kt index fe92e15..0c69504 100644 --- a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/KtorClient.kt +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/KtorClient.kt @@ -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, diff --git a/kotlin-sdk-server/Module.md b/kotlin-sdk-server/Module.md index 43a7359..176a398 100644 --- a/kotlin-sdk-server/Module.md +++ b/kotlin-sdk-server/Module.md @@ -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. diff --git a/kotlin-sdk-server/api/kotlin-sdk-server.api b/kotlin-sdk-server/api/kotlin-sdk-server.api index f0eb5c1..f97845b 100644 --- a/kotlin-sdk-server/api/kotlin-sdk-server.api +++ b/kotlin-sdk-server/api/kotlin-sdk-server.api @@ -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 diff --git a/kotlin-sdk-server/build.gradle.kts b/kotlin-sdk-server/build.gradle.kts index e51a643..9d0bddf 100644 --- a/kotlin-sdk-server/build.gradle.kts +++ b/kotlin-sdk-server/build.gradle.kts @@ -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) } } diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorServer.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorServer.kt index 90aee6b..5a750a5 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorServer.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorServer.kt @@ -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 = 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? = null, allowedOrigins: List? = 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? = null, allowedOrigins: List? = 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? = null, allowedOrigins: List? = 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 diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/WebSocketMcpKtorServerExtensions.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/WebSocketMcpKtorServerExtensions.kt index edd54d3..c2cb2c4 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/WebSocketMcpKtorServerExtensions.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/WebSocketMcpKtorServerExtensions.kt @@ -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, diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/AbstractKtorExtensionsTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/AbstractKtorExtensionsTest.kt new file mode 100644 index 0000000..28e21b9 --- /dev/null +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/AbstractKtorExtensionsTest.kt @@ -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) + } +} diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorApplicationExtensionsTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorApplicationExtensionsTest.kt new file mode 100644 index 0000000..05200e5 --- /dev/null +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorApplicationExtensionsTest.kt @@ -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("/") + } +} diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorRouteExtensionsTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorRouteExtensionsTest.kt new file mode 100644 index 0000000..5ba4448 --- /dev/null +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorRouteExtensionsTest.kt @@ -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 { + 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) + } +}